1mod records;
60mod stream;
61
62pub use records::MarginForcedLiquidation;
63
64use super::commission::{CommissionRates, fetch_commission_rates};
65use super::restatement::{SharedStreamClock, StreamClock, restatement_time};
66use super::rules::RulesLoader;
67use super::server_time::{ServerTime, SigningClock, default_server_time_sync, read_server_time};
68use super::shared::{
69 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
70 FILL_RECOVERY_TIMEOUT_SECS, MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing,
71 PlacementResponse, RateLimitTracker, RequestKind, SharedDedupCache, TrailingOrderError,
72 UnrecoveredFills, WeightPool, WireInt, answer_time, binance_filled_qty,
73 classify_order_kind_tif, classify_rest_order_error, classify_rest_query_error,
74 convert_ended_order, convert_open_order_listing, convert_open_order_owned_symbol,
75 dedup_key_from_event, drop_after, gap_failed, gap_time, is_duplicate, is_unknown_order,
76 new_dedup_cache, optional_decimal, placed_order_state, recovered_order_cids,
77 recovered_order_totals, response_decode_error, rest_call_with_retry, rest_read_with_retry,
78 trailing_params, unreadable_answer,
79};
80use crate::rules::{OrderRules, PreSendRules};
81use crate::{
82 AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
83 InstrumentBalanceSnapshot, IsolatedInstrumentState, IsolatedMarginRisk, UnindexedAccountEvent,
84 UnindexedAccountSnapshot,
85 balance::{AssetBalance, Balance},
86 borrow_capacity::BorrowCapacity,
87 client::{
88 BorrowCapacityClient, ExecutionClient, OrderStatusClient, ShortabilityClient,
89 order_recovery::{
90 KnownLiveOrders, MAX_KNOWN_LIVE_ORDERS, OpenListing, OrderLookup,
91 SharedKnownLiveOrders, UncheckedOrders, fetch_ended_by_key, recover_ended_orders,
92 },
93 venue_clock::HostMoment,
94 },
95 error::{ApiError, OrderError, UnindexedClientError},
96 margin::{MarginBorrow, MarginEffect, MarginPlacement},
97 order::{
98 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
99 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
100 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
101 state::{Cancelled, Open, OrderState, UnindexedOrderState},
102 },
103 position::PositionReport,
104 shortability::Shortability,
105 trade::{AssetFees, TimeSource, Trade, TradeId, TradesRead},
106};
107use binance_sdk::{
108 common::{config::ConfigurationRestApi, constants::MARGIN_TRADING_REST_API_PROD_URL},
109 margin_trading::{
110 MarginTradingRestApi,
111 rest_api::{
112 GetAllCrossMarginPairsParams, GetAllIsolatedMarginSymbolParams,
113 GetAllMarginAssetsParams, GetDelistScheduleParams,
114 GetFutureHourlyInterestRateIsIsolatedEnum, GetFutureHourlyInterestRateParams,
115 MarginAccountCancelOrderIsIsolatedEnum, MarginAccountCancelOrderParams,
116 MarginAccountNewOrderIsIsolatedEnum, MarginAccountNewOrderNewOrderRespTypeEnum,
117 MarginAccountNewOrderParams, MarginAccountNewOrderSideEffectTypeEnum,
118 MarginAccountNewOrderSideEnum, MarginAccountNewOrderTimeInForceEnum,
119 MarginAccountNewOrderTypeEnum, QueryCrossMarginAccountDetailsParams,
120 QueryCrossMarginAccountDetailsResponseUserAssetsInner,
121 QueryIsolatedMarginAccountInfoParams,
122 QueryIsolatedMarginAccountInfoResponseAssetsInner,
123 QueryMarginAccountsOpenOrdersIsIsolatedEnum, QueryMarginAccountsOpenOrdersParams,
124 QueryMarginAccountsOrderIsIsolatedEnum, QueryMarginAccountsOrderParams,
125 QueryMarginAccountsTradeListIsIsolatedEnum, QueryMarginAccountsTradeListParams,
126 QueryMarginAccountsTradeListResponseInner, QueryMaxBorrowParams, RestApi,
127 },
128 },
129};
130use chrono::{DateTime, TimeDelta, TimeZone, Utc};
131use fnv::FnvHashSet;
132use futures::stream::BoxStream;
133use rust_decimal::Decimal;
134use rustrade_instrument::{
135 Side,
136 asset::name::AssetNameExchange,
137 exchange::ExchangeId,
138 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
139};
140use serde::Deserialize;
141use smol_str::format_smolstr;
142use std::{
143 collections::{BTreeMap, HashMap},
144 str::FromStr,
145 sync::{
146 Arc,
147 atomic::{AtomicUsize, Ordering},
148 },
149 time::Duration,
150};
151use stream::{MarginStream, STREAM_URL, SapiListenKeys, StreamAccount};
152use tokio::sync::mpsc;
153use tracing::{debug, error, info, warn};
154
155fn binance_side_effect(effect: MarginEffect) -> MarginAccountNewOrderSideEffectTypeEnum {
166 match effect {
167 MarginEffect::NoSideEffect => MarginAccountNewOrderSideEffectTypeEnum::NoSideEffect,
168 MarginEffect::Borrow => MarginAccountNewOrderSideEffectTypeEnum::MarginBuy,
169 MarginEffect::Repay => MarginAccountNewOrderSideEffectTypeEnum::AutoRepay,
170 }
171}
172
173#[derive(Clone, Deserialize)]
183pub struct BinanceMarginConfig {
184 api_key: String,
187 secret_key: String,
188 pub testnet: bool,
194 #[serde(default)]
201 pub is_isolated: bool,
202 #[serde(default)]
214 pub isolated_symbols: Vec<InstrumentNameExchange>,
215 #[serde(default)]
220 pub margin_effect: MarginEffect,
221 #[serde(default = "crate::margin::default_auto_repay_at_cancel")]
228 pub auto_repay_at_cancel: bool,
229 #[serde(default)]
235 pub pre_send_rules: PreSendRules,
236 #[serde(default = "crate::client::binance::server_time::default_server_time_sync")]
239 pub server_time_sync: bool,
240}
241
242impl std::fmt::Debug for BinanceMarginConfig {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 f.debug_struct("BinanceMarginConfig")
246 .field("api_key", &"***")
247 .field("secret_key", &"***")
248 .field("testnet", &self.testnet)
249 .field("is_isolated", &self.is_isolated)
250 .field("isolated_symbols", &self.isolated_symbols)
251 .field("margin_effect", &self.margin_effect)
252 .field("auto_repay_at_cancel", &self.auto_repay_at_cancel)
253 .field("pre_send_rules", &self.pre_send_rules)
254 .field("server_time_sync", &self.server_time_sync)
255 .finish()
256 }
257}
258
259impl BinanceMarginConfig {
260 pub fn new(
271 api_key: String,
272 secret_key: String,
273 testnet: bool,
274 is_isolated: bool,
275 isolated_symbols: Vec<InstrumentNameExchange>,
276 margin_effect: MarginEffect,
277 auto_repay_at_cancel: bool,
278 ) -> Self {
279 Self {
280 api_key,
281 secret_key,
282 testnet,
283 is_isolated,
284 isolated_symbols,
285 margin_effect,
286 auto_repay_at_cancel,
287 pre_send_rules: PreSendRules::default(),
288 server_time_sync: default_server_time_sync(),
289 }
290 }
291
292 #[must_use]
294 pub fn with_pre_send_rules(mut self, pre_send_rules: PreSendRules) -> Self {
295 self.pre_send_rules = pre_send_rules;
296 self
297 }
298
299 #[must_use]
302 pub fn with_server_time_sync(mut self, server_time_sync: bool) -> Self {
303 self.server_time_sync = server_time_sync;
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)]
499pub struct BinanceMargin {
500 config: Arc<BinanceMarginConfig>,
501 rest: Arc<RestApi>,
503 rest_config: Arc<ConfigurationRestApi>,
508 rate_limiter: Arc<RateLimitTracker>,
510 known_live: SharedKnownLiveOrders,
513 rules: Arc<RulesLoader>,
516 spot_rest: Arc<binance_sdk::spot::rest_api::RestApi>,
519 spot_rate_limiter: Arc<RateLimitTracker>,
520}
521
522impl std::fmt::Debug for BinanceMargin {
523 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
524 f.debug_struct("BinanceMargin")
525 .field("testnet", &self.config.testnet)
526 .field("is_isolated", &self.config.is_isolated)
527 .field("margin_effect", &self.config.margin_effect)
528 .finish_non_exhaustive()
529 }
530}
531
532impl BinanceMargin {
533 pub fn recall_order_kinds(&self, kinds: impl IntoIterator<Item = (ClientOrderId, OrderKind)>) {
560 let kinds: Vec<_> = kinds.into_iter().collect();
561 let ignored = self.known_live.lock().recall(kinds);
563 if ignored > 0 {
564 warn!(
565 ignored,
566 limit = MAX_KNOWN_LIVE_ORDERS,
567 "Binance margin: recalled order kinds past the limit are ignored"
568 );
569 }
570 }
571
572 pub async fn load_rules(
580 &self,
581 instruments: &[InstrumentNameExchange],
582 ) -> Result<HashMap<InstrumentNameExchange, OrderRules>, UnindexedClientError> {
583 self.rules.load(instruments).await
584 }
585
586 pub async fn fetch_next_hourly_interest_rates(
608 &self,
609 assets: &[AssetNameExchange],
610 ) -> Result<HashMap<AssetNameExchange, Decimal>, UnindexedClientError> {
611 let mut seen = FnvHashSet::default();
612 let unique: Vec<&str> = assets
613 .iter()
614 .map(|asset| asset.name().as_str())
615 .filter(|name| seen.insert(*name))
616 .collect();
617 let is_isolated = self.config.is_isolated;
618 let mut rates = HashMap::with_capacity(unique.len());
619 for chunk in unique.chunks(HOURLY_RATE_ASSETS_PER_REQUEST) {
620 let requested = chunk.join(",");
621 let response =
622 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
623 let requested = requested.clone();
624 Box::pin(async move {
625 let params = GetFutureHourlyInterestRateParams::builder(
626 requested,
627 IsolatedFlag::from_flag(is_isolated),
628 )
629 .build()?;
630 rest.get_future_hourly_interest_rate(params).await
631 })
632 })
633 .await
634 .map_err(|e| classify_rest_query_error(&e, None))?;
635 for listed in response.data().await.map_err(response_decode_error)? {
636 let Some(asset) = listed.asset.filter(|asset| chunk.contains(&asset.as_str()))
637 else {
638 continue;
639 };
640 let rate = optional_decimal(
641 "nextHourlyInterestRate",
642 listed.next_hourly_interest_rate.as_deref(),
643 )?;
644 if let Some(rate) = rate {
645 rates.insert(AssetNameExchange::new(asset), rate);
646 }
647 }
648 }
649 Ok(rates)
650 }
651
652 pub async fn fetch_commission_rates(
665 &self,
666 instrument: &InstrumentNameExchange,
667 ) -> Result<CommissionRates, UnindexedClientError> {
668 fetch_commission_rates(&self.spot_rest, &self.spot_rate_limiter, instrument).await
669 }
670
671 pub fn host_clock_lead(&self) -> Option<TimeDelta> {
679 self.rate_limiter.host_clock_lead()
680 }
681
682 pub async fn open_margin_order(
731 &self,
732 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
733 ) -> MarginPlacement {
734 let instrument = request.key.instrument.clone();
735 let side = request.state.side;
736 let price = request.state.price;
737 let quantity = request.state.quantity;
738 let kind = request.state.kind;
739 let time_in_force = request.state.time_in_force;
740 let cid = request.key.cid.clone();
741
742 let order_key = OrderKey::new(
743 ExchangeId::BinanceMargin,
744 instrument.clone(),
745 request.key.strategy.clone(),
746 cid.clone(),
747 );
748
749 let inactive = |state: UnindexedOrderState| MarginPlacement {
752 order: Order {
753 key: order_key.clone(),
754 side,
755 price,
756 quantity,
757 kind,
758 time_in_force,
759 state,
760 },
761 borrowed: None,
762 };
763
764 let params = match build_new_order_params(
765 instrument.name().to_string(),
766 side,
767 price,
768 quantity,
769 kind,
770 time_in_force,
771 cid.0.to_string(),
772 request
773 .state
774 .margin_effect
775 .unwrap_or(self.config.margin_effect),
776 self.config.auto_repay_at_cancel,
777 self.config.is_isolated,
778 ) {
779 Ok(params) => params,
780 Err(BuildOrderError::Unsupported) => {
781 return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
782 format!(
783 "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
784 ),
785 )));
786 }
787 Err(BuildOrderError::Trailing(refused)) => {
788 return inactive(OrderState::inactive(refused.into_order_error()));
789 }
790 Err(BuildOrderError::Build(msg)) => {
791 error!(%msg, "BinanceMargin failed to build new order params");
792 return inactive(OrderState::inactive(OrderError::Rejected(
793 ApiError::OrderRejected(msg),
794 )));
795 }
796 };
797
798 if let Err(refused) = self
799 .rules
800 .check(instrument.name(), side, kind, price, quantity)
801 .await
802 {
803 return inactive(OrderState::inactive(refused));
804 }
805
806 let mut sending = None;
811 let placement = async {
813 let query = new_order_query(params);
816 let response = match rest_call_with_retry(
817 &self.rest_config,
818 &self.rate_limiter,
819 RequestKind::Order,
820 |cfg| {
821 sending.get_or_insert_with(|| self.known_live.sending(&cid, kind));
822 let query = query.clone();
823 Box::pin(async move {
824 binance_sdk::common::utils::send_request::<NewOrderAnswer>(
825 &cfg,
826 ORDER_PATH,
827 reqwest::Method::POST,
828 query,
829 BTreeMap::new(),
830 None,
831 true,
832 )
833 .await
834 })
835 },
836 )
837 .await
838 {
839 Ok(response) => response,
840 Err(e) => {
841 return inactive(OrderState::inactive(classify_rest_order_error(
842 &e,
843 &instrument,
844 )));
845 }
846 };
847
848 let data = match response.data().await {
849 Ok(data) => data,
850 Err(e) => {
851 return inactive(OrderState::inactive(unreadable_answer(
852 ExchangeId::BinanceMargin,
853 &e.to_string(),
854 )));
855 }
856 };
857
858 let time_exchange = answer_time(
859 ExchangeId::BinanceMargin,
860 &instrument,
861 "open_order",
862 data.transact_time,
863 );
864
865 let exchange_order_id = match data.order_id {
866 Some(id) => OrderId(format_smolstr!("{id}")),
867 None => {
868 return inactive(OrderState::inactive(unreadable_answer(
869 ExchangeId::BinanceMargin,
870 "open_order answer has no orderId",
871 )));
872 }
873 };
874
875 let borrowed = match (data.margin_buy_borrow_amount, data.margin_buy_borrow_asset) {
876 (Some(amount), Some(asset)) => Some(MarginBorrow::new(asset, amount)),
877 (None, None) => None,
878 (Some(_), None) | (None, Some(_)) => {
879 return inactive(OrderState::inactive(unreadable_answer(
880 ExchangeId::BinanceMargin,
881 "open_order answer has a borrowed amount or asset without the other",
882 )));
883 }
884 };
885
886 let state = placed_order_state(
889 ExchangeId::BinanceMargin,
890 &instrument,
891 quantity,
892 PlacementResponse {
893 status: data.status.as_deref(),
894 order_id: exchange_order_id,
895 time_exchange,
896 executed_qty: data.executed_qty.as_deref(),
897 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
898 },
899 );
900 self.known_live
901 .lock()
902 .placed(&order_key, quantity, kind, &state);
903
904 MarginPlacement {
905 order: Order {
906 key: order_key.clone(),
908 side,
909 price,
910 quantity,
911 kind,
912 time_in_force,
913 state,
914 },
915 borrowed,
916 }
917 }
918 .await;
919 if let Some(sending) = sending {
920 sending.sent(&placement.order.state);
921 }
922 placement
923 }
924
925 fn remember_live<'a>(
927 &self,
928 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
929 ) {
930 let mut known = self.known_live.lock();
931 for order in orders {
932 known.live(&order.key, order.quantity, order.kind, &order.state);
933 }
934 }
935
936 #[allow(clippy::expect_used)] fn build_rest_config(
946 config: &BinanceMarginConfig,
947 signing: Option<&SigningClock>,
948 ) -> ConfigurationRestApi {
949 let mut builder = ConfigurationRestApi::builder()
950 .api_key(config.api_key.clone())
951 .api_secret(config.secret_key.clone())
952 .base_path(MARGIN_TRADING_REST_API_PROD_URL);
953 if let Some(signing) = signing {
954 builder = builder.timestamp_provider(signing.provider());
955 }
956 builder
957 .build()
958 .expect("failed to build Binance margin REST configuration")
959 }
960
961 fn effective_isolated_set(
974 &self,
975 instruments: &[InstrumentNameExchange],
976 ) -> Vec<InstrumentNameExchange> {
977 if instruments.is_empty() {
978 return self.config.isolated_symbols.clone();
979 }
980 instruments
981 .iter()
982 .filter(|inst| {
983 let in_set = self.config.isolated_symbols.contains(*inst);
984 if !in_set {
985 warn!(
986 instrument = %inst.name(),
987 "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
988 );
989 }
990 in_set
991 })
992 .cloned()
993 .collect()
994 }
995
996 async fn isolated_account_snapshot(
1000 &self,
1001 instruments: &[InstrumentNameExchange],
1002 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1003 use futures::{StreamExt as _, TryStreamExt as _};
1004
1005 let effective = self.effective_isolated_set(instruments);
1006 if effective.is_empty() {
1007 return Ok(AccountSnapshot::new(
1010 ExchangeId::BinanceMargin,
1011 Vec::new(),
1012 Vec::new(),
1013 ));
1014 }
1015
1016 let mut balances_by_symbol = HashMap::with_capacity(effective.len());
1019 let reads = fetch_isolated_margin_account_info(
1020 self.rest.clone(),
1021 self.rate_limiter.clone(),
1022 effective.clone(),
1023 )
1024 .await?;
1025 for read in reads {
1026 balances_by_symbol.extend(convert_isolated_margin_assets(
1027 read.assets,
1028 self.rate_limiter.balance_read_time(read.sent),
1029 ));
1030 }
1031
1032 let instrument_snapshots: Vec<_> =
1035 futures::stream::iter(effective.into_iter().map(|instrument| {
1036 fetch_margin_open_orders_for_instrument(
1037 self.rest.clone(),
1038 self.rate_limiter.clone(),
1039 self.known_live.clone(),
1040 instrument,
1041 true,
1042 RequestKind::Query,
1043 )
1044 }))
1045 .buffer_unordered(8)
1046 .map(|result| {
1047 let (inst, listing) = result?;
1048 self.remember_live(&listing.orders);
1049 let wrapped = listing
1050 .orders
1051 .into_iter()
1052 .map(active_order_snapshot)
1053 .collect();
1054 let isolated = balances_by_symbol.remove(&inst);
1055 if isolated.is_none() {
1056 warn!(
1060 instrument = %inst.name(),
1061 "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
1062 );
1063 }
1064 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
1065 inst,
1066 wrapped,
1067 listing.complete,
1068 PositionReport::Unreported,
1069 isolated,
1070 ))
1071 })
1072 .try_collect()
1073 .await?;
1074
1075 Ok(AccountSnapshot::new(
1078 ExchangeId::BinanceMargin,
1079 Vec::new(),
1080 instrument_snapshots,
1081 ))
1082 }
1083}
1084
1085impl ExecutionClient for BinanceMargin {
1086 const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
1087
1088 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
1091 &[InstrumentKindDiscriminant::Spot];
1092 type Config = BinanceMarginConfig;
1093 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1094
1095 fn new(config: Self::Config) -> Self {
1108 if config.testnet {
1109 warn!(
1110 "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
1111 using production endpoints"
1112 );
1113 }
1114 assert!(
1115 !(config.is_isolated && config.isolated_symbols.is_empty()),
1116 "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
1117 );
1118 let signing = config.server_time_sync.then(SigningClock::default);
1123 let rest_config = Self::build_rest_config(&config, signing.as_ref());
1124 let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
1125 let spot_rest = Arc::new(binance_sdk::spot::SpotRestApi::production(
1130 rest_config.clone(),
1131 ));
1132 let server_time =
1136 signing.map(|signing| ServerTime::new(signing, read_server_time(spot_rest.clone())));
1137 let spot_rate_limiter =
1138 Arc::new(RateLimitTracker::new(WeightPool::Spot).with_server_time(server_time.clone()));
1139 let rules = Arc::new(RulesLoader::new(
1140 config.pre_send_rules,
1141 spot_rest.clone(),
1142 spot_rate_limiter.clone(),
1143 ));
1144 let rest_config = Arc::new(rest_config);
1145 Self {
1146 config: Arc::new(config),
1147 rest,
1148 rest_config,
1149 rate_limiter: Arc::new(
1150 RateLimitTracker::new(WeightPool::Sapi).with_server_time(server_time),
1151 ),
1152 known_live: KnownLiveOrders::shared(ExchangeId::BinanceMargin),
1153 rules,
1154 spot_rest,
1155 spot_rate_limiter,
1156 }
1157 }
1158
1159 async fn open_order(
1164 &self,
1165 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1166 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1167 self.open_margin_order(request).await.order
1168 }
1169
1170 async fn cancel_order(
1184 &self,
1185 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1186 ) -> UnindexedOrderResponseCancel {
1187 let instrument = request.key.instrument.clone();
1188 let key = OrderKey {
1189 exchange: request.key.exchange,
1190 instrument: instrument.clone(),
1191 strategy: request.key.strategy.clone(),
1192 cid: request.key.cid.clone(),
1193 };
1194
1195 let params = match build_cancel_order_params(
1196 instrument.name().to_string(),
1197 request.state.id.as_ref().and_then(VenueOrderId::assigned),
1198 &request.key.cid,
1199 self.config.is_isolated,
1200 ) {
1201 Ok(p) => p,
1202 Err(e) => {
1203 error!(%e, "BinanceMargin failed to build cancel order params");
1204 return UnindexedOrderResponseCancel {
1205 key,
1206 state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
1207 };
1208 }
1209 };
1210
1211 let query = cancel_order_query(params);
1214 let response = match rest_call_with_retry(
1215 &self.rest_config,
1216 &self.rate_limiter,
1217 RequestKind::Order,
1218 |cfg| {
1219 let query = query.clone();
1220 Box::pin(async move {
1221 binance_sdk::common::utils::send_request::<CancelOrderAnswer>(
1222 &cfg,
1223 ORDER_PATH,
1224 reqwest::Method::DELETE,
1225 query,
1226 BTreeMap::new(),
1227 None,
1228 true,
1229 )
1230 .await
1231 })
1232 },
1233 )
1234 .await
1235 {
1236 Ok(response) => response,
1237 Err(e) => {
1238 return UnindexedOrderResponseCancel {
1239 key,
1240 state: Err(classify_rest_order_error(&e, &instrument)),
1241 };
1242 }
1243 };
1244
1245 let data = match response.data().await {
1246 Ok(data) => data,
1247 Err(e) => {
1248 return UnindexedOrderResponseCancel {
1249 key,
1250 state: Err(unreadable_answer(ExchangeId::BinanceMargin, &e.to_string())),
1251 };
1252 }
1253 };
1254
1255 let time_exchange = answer_time(
1256 ExchangeId::BinanceMargin,
1257 &instrument,
1258 "cancel_order",
1259 data.transact_time,
1260 );
1261
1262 let exchange_order_id = match data.order_id {
1263 Some(WireInt::Int(id)) => OrderId(format_smolstr!("{id}")),
1264 Some(WireInt::Unreadable(raw)) => {
1265 return UnindexedOrderResponseCancel {
1266 key,
1267 state: Err(unreadable_answer(
1268 ExchangeId::BinanceMargin,
1269 &format!("cancel answer has an unreadable orderId: {raw}"),
1270 )),
1271 };
1272 }
1273 None => {
1274 return UnindexedOrderResponseCancel {
1275 key,
1276 state: Err(unreadable_answer(
1277 ExchangeId::BinanceMargin,
1278 "cancel answer has no orderId",
1279 )),
1280 };
1281 }
1282 };
1283
1284 let filled_qty = binance_filled_qty(
1285 ExchangeId::BinanceMargin,
1286 &exchange_order_id,
1287 data.executed_qty.as_deref(),
1288 );
1289
1290 self.known_live.lock().ended(&key.cid);
1291 UnindexedOrderResponseCancel {
1292 key,
1293 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
1294 }
1295 }
1296
1297 async fn account_snapshot(
1325 &self,
1326 assets: &[AssetNameExchange],
1327 instruments: &[InstrumentNameExchange],
1328 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1329 if self.config.is_isolated {
1330 return self.isolated_account_snapshot(instruments).await;
1331 }
1332 let (response, sent) =
1333 rest_read_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1334 Box::pin(async move {
1335 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
1336 rest.query_cross_margin_account_details(params).await
1337 })
1338 })
1339 .await
1340 .map_err(|e| classify_rest_query_error(&e, None))?;
1341
1342 let account = response.data().await.map_err(response_decode_error)?;
1343
1344 let balances = filter_and_convert_margin_balances(
1345 account.user_assets.unwrap_or_default(),
1346 assets,
1347 self.rate_limiter.balance_read_time(sent),
1348 );
1349
1350 use futures::{StreamExt as _, TryStreamExt as _};
1355 let instrument_snapshots: Vec<_> =
1356 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1357 fetch_margin_open_orders_for_instrument(
1358 self.rest.clone(),
1359 self.rate_limiter.clone(),
1360 self.known_live.clone(),
1361 instrument,
1362 false,
1364 RequestKind::Query,
1365 )
1366 }))
1367 .buffer_unordered(8)
1368 .map(|result| {
1369 let (inst, listing) = result?;
1370 self.remember_live(&listing.orders);
1371 let wrapped = listing
1372 .orders
1373 .into_iter()
1374 .map(active_order_snapshot)
1375 .collect();
1376 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
1377 inst,
1378 wrapped,
1379 listing.complete,
1380 PositionReport::Unreported,
1381 None,
1382 ))
1383 })
1384 .try_collect()
1385 .await?;
1386
1387 Ok(AccountSnapshot::new(
1388 ExchangeId::BinanceMargin,
1389 balances,
1390 instrument_snapshots,
1391 ))
1392 }
1393
1394 async fn fetch_balances(
1408 &self,
1409 assets: &[AssetNameExchange],
1410 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1411 if self.config.is_isolated {
1412 return Ok(Vec::new());
1413 }
1414 let (response, sent) =
1415 rest_read_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1416 Box::pin(async move {
1417 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
1418 rest.query_cross_margin_account_details(params).await
1419 })
1420 })
1421 .await
1422 .map_err(|e| classify_rest_query_error(&e, None))?;
1423
1424 let account = response.data().await.map_err(response_decode_error)?;
1425
1426 Ok(filter_and_convert_margin_balances(
1427 account.user_assets.unwrap_or_default(),
1428 assets,
1429 self.rate_limiter.balance_read_time(sent),
1430 ))
1431 }
1432
1433 async fn fetch_open_orders(
1448 &self,
1449 instruments: &[InstrumentNameExchange],
1450 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1451 use futures::{StreamExt as _, TryStreamExt as _};
1452
1453 if self.config.is_isolated {
1457 let effective = self.effective_isolated_set(instruments);
1458 let capacity = effective.len();
1459 return futures::stream::iter(effective.into_iter().map(|instrument| {
1460 fetch_margin_open_orders_for_instrument(
1461 self.rest.clone(),
1462 self.rate_limiter.clone(),
1463 self.known_live.clone(),
1464 instrument,
1465 true,
1466 RequestKind::Query,
1467 )
1468 }))
1469 .buffer_unordered(8)
1470 .try_fold(
1471 Vec::with_capacity(capacity),
1472 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
1473 (_, listing)| async move {
1474 acc.extend(listing.orders);
1475 Ok(acc)
1476 },
1477 )
1478 .await
1479 .inspect(|orders| self.remember_live(orders));
1480 }
1481
1482 if instruments.is_empty() {
1485 let orders = fetch_margin_all_open_orders(
1486 self.rest.clone(),
1487 self.rate_limiter.clone(),
1488 self.known_live.clone(),
1489 false,
1490 )
1491 .await?;
1492 self.remember_live(&orders);
1493 return Ok(orders);
1494 }
1495 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1496 fetch_margin_open_orders_for_instrument(
1497 self.rest.clone(),
1498 self.rate_limiter.clone(),
1499 self.known_live.clone(),
1500 instrument,
1501 false,
1502 RequestKind::Query,
1503 )
1504 }))
1505 .buffer_unordered(8)
1506 .try_fold(
1507 Vec::with_capacity(instruments.len()),
1508 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1509 acc.extend(listing.orders);
1510 Ok(acc)
1511 },
1512 )
1513 .await
1514 .inspect(|orders| self.remember_live(orders))
1515 }
1516
1517 async fn fetch_trades(
1546 &self,
1547 start: DateTime<Utc>,
1548 end: DateTime<Utc>,
1549 instruments: &[InstrumentNameExchange],
1550 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1551 use futures::StreamExt as _;
1552
1553 if start > end {
1554 return Ok(TradesRead::complete(Vec::new()));
1555 }
1556
1557 let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1560 self.effective_isolated_set(instruments)
1561 } else {
1562 instruments.to_vec()
1563 };
1564 if effective.is_empty() {
1565 debug!(
1568 is_isolated = self.config.is_isolated,
1569 "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1570 );
1571 return Ok(TradesRead::complete(Vec::new()));
1572 }
1573 let span = MyTradesFrom::Span {
1575 start: start.timestamp_millis(),
1576 end: end.timestamp_millis(),
1577 };
1578 let is_isolated = self.config.is_isolated;
1579 let mut all_trades = Vec::new();
1580
1581 let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1584 let rest = self.rest.clone();
1585 let rate_limiter = self.rate_limiter.clone();
1586 async move {
1587 let pages = paginate_margin_my_trades(
1588 &rest,
1589 &rate_limiter,
1590 &inst,
1591 span,
1592 is_isolated,
1593 RequestKind::Query,
1594 )
1595 .await?;
1596 Ok::<_, UnindexedClientError>((inst, pages))
1597 }
1598 }))
1599 .buffer_unordered(8);
1600 while let Some(result) = stream.next().await {
1601 let (instrument, trades_data) = result?;
1602 for t in trades_data {
1603 if let Some(trade) = convert_margin_trade(&t, &instrument)
1604 && (start..=end).contains(&trade.time_exchange)
1605 {
1606 all_trades.push(trade);
1607 }
1608 }
1609 }
1610
1611 Ok(TradesRead::complete(all_trades))
1612 }
1613
1614 async fn account_stream(
1774 &self,
1775 assets: &[AssetNameExchange],
1779 instruments: &[InstrumentNameExchange],
1780 ) -> Result<Self::AccountStream, UnindexedClientError> {
1781 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1782
1783 let (account, instruments) = if self.config.is_isolated {
1784 let symbols = self.config.isolated_symbols.clone();
1785 let assets: Vec<_> = fetch_isolated_margin_account_info(
1788 self.rest.clone(),
1789 self.rate_limiter.clone(),
1790 symbols.clone(),
1791 )
1792 .await?
1793 .into_iter()
1794 .flat_map(|read| read.assets)
1795 .collect();
1796 let mut base_quote = build_base_quote_map(&assets);
1797 let pairs = symbols
1798 .iter()
1799 .map(|symbol| {
1800 let assets = base_quote.remove(symbol);
1801 if assets.is_none() {
1802 warn!(
1803 symbol = %symbol.name(),
1804 "BinanceMargin isolated: account info returned no base/quote for \
1805 configured symbol — its live per-pair balance frames will be dropped \
1806 (fills/orders unaffected; balances available via account_snapshot)"
1807 );
1808 }
1809 (symbol.clone(), assets)
1810 })
1811 .collect();
1812 (StreamAccount::Isolated { pairs }, symbols)
1813 } else {
1814 (StreamAccount::Cross, instruments.to_vec())
1815 };
1816
1817 let stream = MarginStream {
1818 keys: SapiListenKeys {
1819 rest: self.rest.clone(),
1820 rate_limiter: self.rate_limiter.clone(),
1821 },
1822 url: STREAM_URL.to_owned(),
1823 rest: self.rest.clone(),
1824 rate_limiter: self.rate_limiter.clone(),
1825 known: self.known_live.clone(),
1826 account,
1827 instruments,
1828 assets: assets.to_vec(),
1829 clock: StreamClock::shared(self.rate_limiter.signing_clock()),
1830 tx,
1831 dedup: new_dedup_cache(),
1832 };
1833 let session = stream.open().await?;
1836 let handle = tokio::spawn(stream.run(session));
1837
1838 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1839 let guarded_stream = AbortOnDropStream::new(rx_stream, handle);
1840 Ok(futures::StreamExt::boxed(guarded_stream))
1841 }
1842}
1843
1844impl OrderStatusClient for BinanceMargin {
1861 async fn fetch_ended_orders(
1862 &self,
1863 orders: &[UnindexedOrderKey],
1864 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1865 fetch_ended_by_key(orders, |key| {
1866 let recorded_kind = self.known_live.lock().kind_of(&key.cid);
1867 fetch_margin_order_lookup(
1868 self.rest.clone(),
1869 self.rate_limiter.clone(),
1870 key,
1871 self.config.is_isolated,
1872 RequestKind::Query,
1873 recorded_kind,
1874 )
1875 })
1876 .await
1877 }
1878}
1879
1880const HOURS_PER_YEAR: u32 = 24 * 365;
1886
1887const HOURLY_RATE_ASSETS_PER_REQUEST: usize = 20;
1889
1890impl ShortabilityClient for BinanceMargin {
1891 async fn fetch_shortability(
1918 &self,
1919 instrument: &InstrumentNameExchange,
1920 ) -> Result<Shortability, UnindexedClientError> {
1921 let pair = self.fetch_margin_pair(instrument).await?;
1922 let (borrowable, hourly_rate, delisting_at) = tokio::try_join!(
1923 self.fetch_asset_borrowable(&pair.base),
1924 self.fetch_next_hourly_interest_rate(&pair.base),
1925 self.fetch_delisting_at(instrument, &pair),
1926 )?;
1927
1928 let mut shortability = Shortability::new();
1929 if let Some(delisting_at) = delisting_at {
1930 shortability = shortability.with_delisting_at(delisting_at);
1931 }
1932 let not_delisted = Some(delisting_at.is_none_or(|delisting_at| delisting_at > Utc::now()));
1936 let flags = [
1937 pair.is_margin_trade,
1938 pair.is_sell_allowed,
1939 borrowable,
1940 not_delisted,
1941 ];
1942 if flags.contains(&Some(false)) {
1943 shortability = shortability.with_shortable(false);
1944 } else if flags.iter().all(|flag| *flag == Some(true)) {
1945 shortability = shortability.with_shortable(true);
1946 }
1947 if let Some(hourly_rate) = hourly_rate {
1948 shortability = shortability.with_fee_rate(hourly_rate * Decimal::from(HOURS_PER_YEAR));
1949 }
1950 Ok(shortability)
1951 }
1952}
1953
1954impl BorrowCapacityClient for BinanceMargin {
1955 async fn fetch_borrow_capacity(
1972 &self,
1973 instrument: &InstrumentNameExchange,
1974 side: Side,
1975 ) -> Result<BorrowCapacity, UnindexedClientError> {
1976 let pair = self.fetch_margin_pair(instrument).await?;
1977 let asset = match side {
1978 Side::Buy => pair.quote,
1979 Side::Sell => pair.base,
1980 };
1981 let isolated_symbol = self
1982 .config
1983 .is_isolated
1984 .then(|| instrument.name().to_string());
1985 let response =
1986 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1987 let asset = asset.name().to_string();
1988 let isolated_symbol = isolated_symbol.clone();
1989 Box::pin(async move {
1990 let params = QueryMaxBorrowParams::builder(asset)
1991 .isolated_symbol(isolated_symbol)
1992 .build()?;
1993 rest.query_max_borrow(params).await
1994 })
1995 })
1996 .await
1997 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
1998 let max_borrow = response.data().await.map_err(response_decode_error)?;
1999
2000 let mut capacity = BorrowCapacity::new(asset);
2001 if let Some(amount) = optional_decimal("amount", max_borrow.amount.as_deref())? {
2002 capacity = capacity.with_borrowable_now(amount);
2003 }
2004 if let Some(limit) = optional_decimal("borrowLimit", max_borrow.borrow_limit.as_deref())? {
2005 capacity = capacity.with_account_limit(limit);
2006 }
2007 Ok(capacity)
2008 }
2009}
2010
2011#[derive(Debug)]
2013struct MarginPair {
2014 base: AssetNameExchange,
2015 quote: AssetNameExchange,
2016 is_margin_trade: Option<bool>,
2017 is_sell_allowed: Option<bool>,
2018}
2019
2020impl BinanceMargin {
2021 async fn fetch_margin_pair(
2025 &self,
2026 instrument: &InstrumentNameExchange,
2027 ) -> Result<MarginPair, UnindexedClientError> {
2028 let symbol = instrument.name().to_string();
2029 let listed = if self.config.is_isolated {
2030 let response =
2031 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
2032 let symbol = symbol.clone();
2033 Box::pin(async move {
2034 let params = GetAllIsolatedMarginSymbolParams::builder()
2035 .symbol(symbol)
2036 .build()?;
2037 rest.get_all_isolated_margin_symbol(params).await
2038 })
2039 })
2040 .await
2041 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
2042 response
2043 .data()
2044 .await
2045 .map_err(response_decode_error)?
2046 .into_iter()
2047 .find(|pair| pair.symbol.as_deref() == Some(symbol.as_str()))
2048 .map(|pair| {
2049 (
2050 pair.base,
2051 pair.quote,
2052 pair.is_margin_trade,
2053 pair.is_sell_allowed,
2054 )
2055 })
2056 } else {
2057 let response =
2058 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
2059 let symbol = symbol.clone();
2060 Box::pin(async move {
2061 let params = GetAllCrossMarginPairsParams::builder()
2062 .symbol(symbol)
2063 .build()?;
2064 rest.get_all_cross_margin_pairs(params).await
2065 })
2066 })
2067 .await
2068 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
2069 response
2070 .data()
2071 .await
2072 .map_err(response_decode_error)?
2073 .into_iter()
2074 .find(|pair| pair.symbol.as_deref() == Some(symbol.as_str()))
2075 .map(|pair| {
2076 (
2077 pair.base,
2078 pair.quote,
2079 pair.is_margin_trade,
2080 pair.is_sell_allowed,
2081 )
2082 })
2083 };
2084 let margin = if self.config.is_isolated {
2085 "isolated"
2086 } else {
2087 "cross"
2088 };
2089 let Some((base, quote, is_margin_trade, is_sell_allowed)) = listed else {
2090 return Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(
2091 instrument.clone(),
2092 format!("Binance lists no {margin} margin pair {symbol}"),
2093 )));
2094 };
2095 let (Some(base), Some(quote)) = (base, quote) else {
2096 return Err(UnindexedClientError::Internal(format!(
2097 "Binance lists {margin} margin pair {symbol} without its base or quote asset"
2098 )));
2099 };
2100 Ok(MarginPair {
2101 base: AssetNameExchange::new(base),
2102 quote: AssetNameExchange::new(quote),
2103 is_margin_trade,
2104 is_sell_allowed,
2105 })
2106 }
2107
2108 async fn fetch_asset_borrowable(
2111 &self,
2112 asset: &AssetNameExchange,
2113 ) -> Result<Option<bool>, UnindexedClientError> {
2114 let name = asset.name().to_string();
2115 let response =
2116 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
2117 let name = name.clone();
2118 Box::pin(async move {
2119 let params = GetAllMarginAssetsParams::builder().asset(name).build()?;
2120 rest.get_all_margin_assets(params).await
2121 })
2122 })
2123 .await
2124 .map_err(|e| classify_rest_query_error(&e, None))?;
2125 Ok(response
2126 .data()
2127 .await
2128 .map_err(response_decode_error)?
2129 .into_iter()
2130 .find(|listed| listed.asset_name.as_deref() == Some(name.as_str()))
2131 .and_then(|listed| listed.is_borrowable))
2132 }
2133
2134 async fn fetch_delisting_at(
2139 &self,
2140 instrument: &InstrumentNameExchange,
2141 pair: &MarginPair,
2142 ) -> Result<Option<DateTime<Utc>>, UnindexedClientError> {
2143 let response =
2144 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
2145 Box::pin(async move {
2146 let params = GetDelistScheduleParams::builder().build()?;
2147 rest.get_delist_schedule(params).await
2148 })
2149 })
2150 .await
2151 .map_err(|e| classify_rest_query_error(&e, None))?;
2152 let schedule = response.data().await.map_err(response_decode_error)?;
2153
2154 let symbol = instrument.name().as_str();
2155 let (base, quote) = (pair.base.name().as_str(), pair.quote.name().as_str());
2156 let mut earliest: Option<DateTime<Utc>> = None;
2157 for entry in schedule {
2158 let scheduled = if self.config.is_isolated {
2159 entry
2160 .isolated_margin_symbols
2161 .iter()
2162 .flatten()
2163 .any(|listed| listed == symbol)
2164 } else {
2165 entry
2166 .cross_margin_assets
2167 .iter()
2168 .flatten()
2169 .any(|listed| listed == base || listed == quote)
2170 };
2171 if !scheduled {
2172 continue;
2173 }
2174 let delisting_at = entry
2176 .delist_time
2177 .and_then(DateTime::from_timestamp_millis)
2178 .ok_or_else(|| {
2179 UnindexedClientError::Internal(format!(
2180 "decoding Binance response: delist-schedule lists {symbol} with delistTime \
2181 {:?}",
2182 entry.delist_time
2183 ))
2184 })?;
2185 earliest = Some(earliest.map_or(delisting_at, |earliest| earliest.min(delisting_at)));
2186 }
2187 Ok(earliest)
2188 }
2189
2190 async fn fetch_next_hourly_interest_rate(
2193 &self,
2194 asset: &AssetNameExchange,
2195 ) -> Result<Option<Decimal>, UnindexedClientError> {
2196 let mut rates = self
2197 .fetch_next_hourly_interest_rates(std::slice::from_ref(asset))
2198 .await?;
2199 Ok(rates.remove(asset))
2200 }
2201}
2202
2203async fn recover_margin_fills(
2229 rest: &Arc<RestApi>,
2230 rate_limiter: &Arc<RateLimitTracker>,
2231 unrecovered: &mut UnrecoveredFills,
2232 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2233 dedup: &SharedDedupCache,
2234 known: &SharedKnownLiveOrders,
2235 is_isolated: bool,
2236) {
2237 use futures::StreamExt as _;
2238
2239 let due = unrecovered.due(tokio::time::Instant::now());
2240 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
2241 return;
2242 };
2243 info!(
2244 gaps = due.len(),
2245 oldest = %gap_time(oldest),
2246 is_isolated,
2247 "BinanceMargin recovering fills missed while disconnected"
2248 );
2249
2250 let mut settled = vec![false; due.len()];
2253 let started = AtomicUsize::new(0);
2254 let recovery = async {
2255 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
2256 let mut recovered = 0u32;
2257 let mut duplicates = 0u32;
2258
2259 let mut stream =
2260 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
2261 started.store(index + 1, Ordering::Relaxed);
2262 let rest = rest.clone();
2263 let rl = rate_limiter.clone();
2264 async move {
2265 let span = MyTradesFrom::Span {
2266 start: gap.start_ms,
2267 end: gap.end_ms,
2268 };
2269 let raw = match paginate_margin_my_trades(
2270 &rest,
2271 &rl,
2272 &inst,
2273 span,
2274 is_isolated,
2275 RequestKind::Essential,
2276 )
2277 .await
2278 {
2279 Ok(pages) => pages,
2280 Err(e) => return (index, Err(e)),
2281 };
2282 let totals = recovered_order_totals(
2286 ExchangeId::BinanceMargin,
2287 &inst,
2288 &raw,
2289 order_executions_deadline,
2290 |order_id| {
2291 paginate_margin_my_trades(
2292 &rest,
2293 &rl,
2294 &inst,
2295 MyTradesFrom::Order(order_id),
2296 is_isolated,
2297 RequestKind::Essential,
2298 )
2299 },
2300 )
2301 .await;
2302 let cids = recovered_order_cids(
2305 ExchangeId::BinanceMargin,
2306 &inst,
2307 &raw,
2308 order_executions_deadline,
2309 |order_id| fetch_margin_order_cid(&rest, &rl, &inst, order_id, is_isolated),
2310 )
2311 .await;
2312 let trades = raw
2313 .iter()
2314 .filter_map(|t| {
2315 let mut trade = convert_margin_trade(t, &inst)?;
2316 trade.order_filled_quantity =
2317 t.id.and_then(|id| totals.get(&id).copied());
2318 trade.client_order_id =
2319 t.order_id.and_then(|id| cids.get(&id).cloned());
2320 Some(trade)
2321 })
2322 .collect::<Vec<_>>();
2323 (index, Ok(trades))
2324 }
2325 }))
2326 .buffer_unordered(8);
2327 while let Some((index, result)) = stream.next().await {
2328 let (inst, gap) = &due[index];
2329 settled[index] = true;
2330 let trades = match result {
2331 Ok(trades) => trades,
2332 Err(e) => {
2333 gap_failed(
2334 unrecovered,
2335 tx,
2336 ExchangeId::BinanceMargin,
2337 inst,
2338 gap,
2339 FillRecoveryFailure::Request(e.to_string()),
2340 );
2341 continue;
2342 }
2343 };
2344 for trade in trades {
2345 let event = UnindexedAccountEvent::new(
2346 ExchangeId::BinanceMargin,
2347 AccountEventKind::Trade(trade),
2348 );
2349 if let Some(key) = dedup_key_from_event(&event)
2350 && is_duplicate(dedup, key)
2351 {
2352 duplicates += 1;
2353 continue;
2354 }
2355 let sent = {
2358 let mut known = known.lock();
2359 known.observe(&event);
2360 tx.send(event)
2361 };
2362 if sent.is_err() {
2363 debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2364 return;
2365 }
2366 recovered += 1;
2367 }
2368 unrecovered.recovered(inst, gap);
2369 }
2370 info!(
2371 recovered,
2372 duplicates, is_isolated, "BinanceMargin fill recovery complete"
2373 );
2374 };
2375 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2379 .await
2380 .is_err()
2381 {
2382 let started = started.load(Ordering::Relaxed);
2383 for ((inst, gap), _) in due[..started]
2384 .iter()
2385 .zip(&settled)
2386 .filter(|(_, settled)| !**settled)
2387 {
2388 gap_failed(
2389 unrecovered,
2390 tx,
2391 ExchangeId::BinanceMargin,
2392 inst,
2393 gap,
2394 FillRecoveryFailure::TimedOut {
2395 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2396 },
2397 );
2398 }
2399 }
2400}
2401
2402fn open_reconnect_recovery(
2407 unrecovered: &mut UnrecoveredFills,
2408 unchecked: &mut UncheckedOrders,
2409 known: &SharedKnownLiveOrders,
2410 instruments: &[InstrumentNameExchange],
2411 start: DateTime<Utc>,
2412 end: DateTime<Utc>,
2413) {
2414 unrecovered.open(instruments, start, end);
2415 let held = known.lock().instruments_among(instruments);
2416 unchecked.open(held);
2417}
2418
2419async fn read_margin_restatement(
2429 rest: &Arc<RestApi>,
2430 rate_limiter: &Arc<RateLimitTracker>,
2431 is_isolated: bool,
2432 pairs: &[InstrumentNameExchange],
2433 assets: &[AssetNameExchange],
2434 clock: &SharedStreamClock,
2435 floor: Option<DateTime<Utc>>,
2436) -> Result<Vec<UnindexedAccountEvent>, UnindexedClientError> {
2437 let event = |kind| UnindexedAccountEvent::new(ExchangeId::BinanceMargin, kind);
2438 if is_isolated {
2439 let reads =
2440 fetch_isolated_margin_account_info(rest.clone(), rate_limiter.clone(), pairs.to_vec())
2441 .await?;
2442 let clock = clock.lock();
2443 return Ok(reads
2444 .into_iter()
2445 .flat_map(|read| {
2446 let time = restatement_time(&clock, read.sent, floor);
2447 convert_isolated_margin_assets(read.assets, time)
2448 })
2449 .flat_map(|(pair, state)| {
2450 [state.base, state.quote].map(|balance| {
2451 event(AccountEventKind::InstrumentBalanceSnapshot(
2452 InstrumentBalanceSnapshot::new(pair.clone(), balance),
2453 ))
2454 })
2455 })
2456 .collect());
2457 }
2458 let (response, sent) = rest_read_with_retry(rest, rate_limiter, RequestKind::Query, |rest| {
2459 Box::pin(async move {
2460 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
2461 rest.query_cross_margin_account_details(params).await
2462 })
2463 })
2464 .await
2465 .map_err(|e| classify_rest_query_error(&e, None))?;
2466 let account = response.data().await.map_err(response_decode_error)?;
2467 let time = restatement_time(&clock.lock(), sent, floor);
2468 Ok(
2469 filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets, time)
2470 .into_iter()
2471 .map(|balance| {
2472 event(AccountEventKind::BalanceSnapshot(
2473 rustrade_integration::collection::snapshot::Snapshot(balance),
2474 ))
2475 })
2476 .collect(),
2477 )
2478}
2479
2480fn build_base_quote_map(
2488 assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
2489) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
2490 let mut map = HashMap::with_capacity(assets.len());
2491 for entry in assets {
2492 let (Some(symbol), Some(base), Some(quote)) = (
2493 entry.symbol.as_deref(),
2494 entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
2495 entry
2496 .quote_asset
2497 .as_deref()
2498 .and_then(|q| q.asset.as_deref()),
2499 ) else {
2500 warn!(
2501 "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
2502 skipping base/quote map entry"
2503 );
2504 continue;
2505 };
2506 map.insert(
2507 InstrumentNameExchange::new(symbol),
2508 (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
2509 );
2510 }
2511 map
2512}
2513
2514async fn fetch_margin_open_orders_for_instrument(
2521 rest: Arc<RestApi>,
2522 rate_limiter: Arc<RateLimitTracker>,
2523 known: SharedKnownLiveOrders,
2524 instrument: InstrumentNameExchange,
2525 is_isolated: bool,
2526 kind: RequestKind,
2527) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
2528 let symbol_str = instrument.name().to_string();
2530 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2531 let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
2532 let sym = symbol_str.clone();
2533 let isolated = isolated.clone();
2534 Box::pin(async move {
2535 let params = QueryMarginAccountsOpenOrdersParams::builder()
2536 .symbol(sym)
2537 .is_isolated(isolated)
2538 .build()?;
2539 rest.query_margin_accounts_open_orders(params).await
2540 })
2541 })
2542 .await
2543 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
2544
2545 let orders_data = response.data().await.map_err(response_decode_error)?;
2546
2547 let listing = convert_open_order_listing(
2548 &orders_data,
2549 ExchangeId::BinanceMargin,
2550 &instrument,
2551 &|cid| known.lock().kind_of(cid),
2552 );
2553
2554 Ok((instrument, listing))
2555}
2556
2557async fn fetch_margin_all_open_orders(
2564 rest: Arc<RestApi>,
2565 rate_limiter: Arc<RateLimitTracker>,
2566 known: SharedKnownLiveOrders,
2567 is_isolated: bool,
2568) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
2569 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2570 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
2571 let isolated = isolated.clone();
2572 Box::pin(async move {
2573 let params = QueryMarginAccountsOpenOrdersParams::builder()
2574 .is_isolated(isolated)
2575 .build()?;
2576 rest.query_margin_accounts_open_orders(params).await
2577 })
2578 })
2579 .await
2580 .map_err(|e| classify_rest_query_error(&e, None))?;
2581
2582 let orders_data = response.data().await.map_err(response_decode_error)?;
2583
2584 let orders = orders_data
2585 .into_iter()
2586 .filter_map(|o| {
2587 convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin, &|cid| {
2588 known.lock().kind_of(cid)
2589 })
2590 })
2591 .collect();
2592
2593 Ok(orders)
2594}
2595
2596async fn fetch_margin_order_lookup(
2603 rest: Arc<RestApi>,
2604 rate_limiter: Arc<RateLimitTracker>,
2605 key: UnindexedOrderKey,
2606 is_isolated: bool,
2607 kind: RequestKind,
2608 recorded_kind: Option<OrderKind>,
2609) -> Result<OrderLookup, UnindexedClientError> {
2610 let symbol = key.instrument.name().to_string();
2612 let cid = key.cid.0.to_string();
2613 let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
2614 let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
2615 let (symbol, cid, isolated) = (symbol.clone(), cid.clone(), isolated.clone());
2616 Box::pin(async move {
2617 let params = QueryMarginAccountsOrderParams::builder(symbol)
2618 .orig_client_order_id(cid)
2619 .is_isolated(isolated)
2620 .build()?;
2621 rest.query_margin_accounts_order(params).await
2622 })
2623 })
2624 .await
2625 {
2626 Ok(response) => response,
2627 Err(e) if is_unknown_order(&e) => {
2628 debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceMargin does not know this order");
2629 return Ok(OrderLookup::Unknown);
2630 }
2631 Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
2632 };
2633
2634 let row = response.data().await.map_err(response_decode_error)?;
2635
2636 Ok(
2637 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, recorded_kind)
2638 .map_or(OrderLookup::NotEnded, |order| {
2639 OrderLookup::Ended(Box::new(order))
2640 }),
2641 )
2642}
2643
2644async fn fetch_margin_order_cid(
2649 rest: &Arc<RestApi>,
2650 rate_limiter: &RateLimitTracker,
2651 instrument: &InstrumentNameExchange,
2652 order_id: i64,
2653 is_isolated: bool,
2654) -> Result<Option<ClientOrderId>, UnindexedClientError> {
2655 let symbol = instrument.name().to_string();
2656 let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
2657 let response = match rest_call_with_retry(rest, rate_limiter, RequestKind::Essential, |rest| {
2658 let (symbol, isolated) = (symbol.clone(), isolated.clone());
2659 Box::pin(async move {
2660 let params = QueryMarginAccountsOrderParams::builder(symbol)
2661 .order_id(order_id)
2662 .is_isolated(isolated)
2663 .build()?;
2664 rest.query_margin_accounts_order(params).await
2665 })
2666 })
2667 .await
2668 {
2669 Ok(response) => response,
2670 Err(e) if is_unknown_order(&e) => {
2671 debug!(%instrument, order_id, error = %e, "BinanceMargin does not know this order");
2672 return Ok(None);
2673 }
2674 Err(e) => return Err(classify_rest_query_error(&e, Some(instrument))),
2675 };
2676 let row = response.data().await.map_err(response_decode_error)?;
2677 Ok(row
2678 .client_order_id
2679 .filter(|cid| !cid.is_empty())
2680 .map(ClientOrderId::new))
2681}
2682
2683async fn listed_margin_open_cids(
2686 rest: Arc<RestApi>,
2687 rate_limiter: Arc<RateLimitTracker>,
2688 known: SharedKnownLiveOrders,
2689 instruments: Vec<InstrumentNameExchange>,
2690 is_isolated: bool,
2691) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
2692 let mut listed = FnvHashSet::default();
2693 for instrument in instruments {
2694 let (_, listing) = fetch_margin_open_orders_for_instrument(
2695 rest.clone(),
2696 rate_limiter.clone(),
2697 known.clone(),
2698 instrument,
2699 is_isolated,
2700 RequestKind::Essential,
2701 )
2702 .await?;
2703 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
2704 }
2705 Ok(listed)
2706}
2707
2708async fn recover_margin_ended_orders(
2711 rest: &Arc<RestApi>,
2712 rate_limiter: &Arc<RateLimitTracker>,
2713 known: &SharedKnownLiveOrders,
2714 unchecked: &mut UncheckedOrders,
2715 unrecovered: &UnrecoveredFills,
2716 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2717 is_isolated: bool,
2718) {
2719 recover_ended_orders(
2720 ExchangeId::BinanceMargin,
2721 known,
2722 unchecked,
2723 unrecovered,
2724 tx,
2725 OpenListing::PerInstrument,
2726 |instruments| {
2727 listed_margin_open_cids(
2728 rest.clone(),
2729 rate_limiter.clone(),
2730 known.clone(),
2731 instruments,
2732 is_isolated,
2733 )
2734 },
2735 |key| {
2736 let recorded_kind = known.lock().kind_of(&key.cid);
2738 fetch_margin_order_lookup(
2739 rest.clone(),
2740 rate_limiter.clone(),
2741 key,
2742 is_isolated,
2743 RequestKind::Essential,
2744 recorded_kind,
2745 )
2746 },
2747 )
2748 .await;
2749}
2750
2751const MARGIN_MY_TRADES_WINDOW_MS: i64 = 23 * 60 * 60 * 1000;
2757
2758#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2761enum MarginTradesQuery {
2762 Window { start: i64, end: i64 },
2764 FromId(i64),
2766 Order(i64),
2768 OrderFromId { order_id: i64, from_id: i64 },
2770}
2771
2772impl MarginTradesQuery {
2773 fn first(from: MyTradesFrom, now_ms: i64) -> Option<Self> {
2778 match from {
2779 MyTradesFrom::Span { start, .. } => {
2780 (start <= now_ms).then(|| Self::window(start, now_ms))
2781 }
2782 MyTradesFrom::Order(order_id) => Some(Self::Order(order_id)),
2783 }
2784 }
2785
2786 fn window(start: i64, now_ms: i64) -> Self {
2788 Self::Window {
2789 start,
2790 end: start.saturating_add(MARGIN_MY_TRADES_WINDOW_MS).min(now_ms),
2791 }
2792 }
2793
2794 fn next(self, page_len: usize, last_id: Option<i64>, now_ms: i64) -> Option<Self> {
2797 let full = page_len >= BINANCE_MAX_TRADES;
2798 let after_last = || last_id?.checked_add(1);
2799 match self {
2800 Self::Window { end, .. } if page_len == 0 => {
2805 (end < now_ms).then(|| Self::window(end, now_ms))
2806 }
2807 Self::Window { end, .. } if end >= now_ms && !full => None,
2809 Self::Window { .. } => after_last().map(Self::FromId),
2812 Self::FromId(_) if full => after_last().map(Self::FromId),
2813 Self::Order(order_id) | Self::OrderFromId { order_id, .. } if full => {
2814 after_last().map(|from_id| Self::OrderFromId { order_id, from_id })
2815 }
2816 Self::FromId(_) | Self::Order(_) | Self::OrderFromId { .. } => None,
2817 }
2818 }
2819}
2820
2821async fn paginate_margin_my_trades(
2845 rest: &Arc<RestApi>,
2846 rate_limiter: &Arc<RateLimitTracker>,
2847 instrument: &InstrumentNameExchange,
2848 from: MyTradesFrom,
2849 is_isolated: bool,
2850 kind: RequestKind,
2851) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
2852 let symbol_str = instrument.name().to_string();
2854 let isolated = QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(is_isolated);
2855 #[allow(clippy::cast_possible_wrap)]
2860 let limit = BINANCE_MAX_TRADES as i64;
2861 let now_ms = match from {
2864 MyTradesFrom::Span { end, .. } => end.min(
2865 Utc::now()
2866 .timestamp_millis()
2867 .saturating_add(MARGIN_MY_TRADES_WINDOW_MS),
2868 ),
2869 MyTradesFrom::Order(_) => Utc::now().timestamp_millis(),
2870 };
2871 let mut all_pages = Vec::new();
2872 let mut requests = 0u32;
2873 let mut next = MarginTradesQuery::first(from, now_ms);
2874 while let Some(query) = next {
2875 requests += 1;
2876 let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
2877 let sym = symbol_str.clone();
2878 let isolated = isolated.clone();
2879 Box::pin(async move {
2880 let builder = QueryMarginAccountsTradeListParams::builder(sym)
2881 .is_isolated(isolated)
2882 .limit(limit);
2883 let params = match query {
2884 MarginTradesQuery::Window { start, end } => {
2885 builder.start_time(start).end_time(end)
2886 }
2887 MarginTradesQuery::FromId(id) => builder.from_id(id),
2888 MarginTradesQuery::Order(order_id) => builder.order_id(order_id),
2889 MarginTradesQuery::OrderFromId { order_id, from_id } => {
2890 builder.order_id(order_id).from_id(from_id)
2891 }
2892 }
2893 .build()?;
2894 rest.query_margin_accounts_trade_list(params).await
2895 })
2896 })
2897 .await
2898 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
2899
2900 let mut page = response.data().await.map_err(response_decode_error)?;
2901
2902 let page_len = page.len();
2903 let last_id = page.last().and_then(|t| t.id);
2904 let past_end = match from {
2906 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
2907 MyTradesFrom::Order(_) => false,
2908 };
2909 all_pages.extend(page);
2910 if past_end {
2911 break;
2912 }
2913
2914 if page_len > 0 && last_id.is_none() {
2915 warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, cannot page past it");
2916 break;
2917 }
2918 next = query.next(page_len, last_id, now_ms);
2919 }
2920 debug!(
2921 %instrument,
2922 requests,
2923 trades = all_pages.len(),
2924 "BinanceMargin paginate_my_trades: walk complete"
2925 );
2926 Ok(all_pages)
2927}
2928
2929fn convert_margin_balance_entry(
2939 b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
2940 time: DateTime<Utc>,
2941) -> Option<AssetBalance<AssetNameExchange>> {
2942 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
2943 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2944 Some(v) => v,
2945 None => {
2946 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
2947 return None;
2948 }
2949 };
2950 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2951 Some(v) => v,
2952 None => {
2953 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
2954 return None;
2955 }
2956 };
2957 let borrowed = b
2962 .borrowed
2963 .as_deref()
2964 .and_then(|s| Decimal::from_str(s).ok())
2965 .unwrap_or(Decimal::ZERO);
2966 let interest = b
2967 .interest
2968 .as_deref()
2969 .and_then(|s| Decimal::from_str(s).ok())
2970 .unwrap_or(Decimal::ZERO);
2971 Some(AssetBalance::new(
2972 asset_name,
2973 Balance::new_margin(free + locked, free, borrowed, interest),
2974 time,
2975 ))
2976}
2977
2978fn filter_and_convert_margin_balances(
2982 user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
2983 assets: &[AssetNameExchange],
2984 time: DateTime<Utc>,
2985) -> Vec<AssetBalance<AssetNameExchange>> {
2986 if assets.is_empty() {
2988 return user_assets
2989 .into_iter()
2990 .filter_map(|b| convert_margin_balance_entry(b, time))
2991 .collect();
2992 }
2993 if assets.len() <= 16 {
2995 return user_assets
2996 .into_iter()
2997 .filter_map(|b| {
2998 let asset_name_str = b.asset.as_deref()?;
2999 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
3000 return None;
3001 }
3002 convert_margin_balance_entry(b, time)
3003 })
3004 .collect();
3005 }
3006 use std::collections::HashSet;
3007 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
3008 user_assets
3009 .into_iter()
3010 .filter_map(|b| {
3011 let asset_name_str = b.asset.as_deref()?;
3012 if !asset_set.contains(asset_name_str) {
3013 return None;
3014 }
3015 convert_margin_balance_entry(b, time)
3016 })
3017 .collect()
3018}
3019
3020fn active_order_snapshot(
3027 o: Order<ExchangeId, InstrumentNameExchange, Open>,
3028) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
3029{
3030 Order {
3031 key: o.key,
3032 side: o.side,
3033 price: o.price,
3034 quantity: o.quantity,
3035 kind: o.kind,
3036 time_in_force: o.time_in_force,
3037 state: OrderState::active(o.state),
3038 }
3039}
3040
3041fn convert_isolated_asset_balance(
3051 asset: Option<&str>,
3052 free: Option<&str>,
3053 locked: Option<&str>,
3054 borrowed: Option<&str>,
3055 interest: Option<&str>,
3056 time: DateTime<Utc>,
3057) -> Option<AssetBalance<AssetNameExchange>> {
3058 let asset_name = AssetNameExchange::new(asset?);
3059 let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
3060 Some(v) => v,
3061 None => {
3062 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
3063 return None;
3064 }
3065 };
3066 let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
3067 Some(v) => v,
3068 None => {
3069 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
3070 return None;
3071 }
3072 };
3073 let borrowed = borrowed
3074 .and_then(|s| Decimal::from_str(s).ok())
3075 .unwrap_or(Decimal::ZERO);
3076 let interest = interest
3077 .and_then(|s| Decimal::from_str(s).ok())
3078 .unwrap_or(Decimal::ZERO);
3079 Some(AssetBalance::new(
3080 asset_name,
3081 Balance::new_margin(free + locked, free, borrowed, interest),
3082 time,
3083 ))
3084}
3085
3086fn convert_isolated_margin_assets(
3095 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
3096 time: DateTime<Utc>,
3097) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
3098 let mut map = HashMap::with_capacity(assets.len());
3099 for entry in assets {
3100 let Some(symbol) = entry.symbol.as_deref() else {
3101 warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
3102 continue;
3103 };
3104 let instrument = InstrumentNameExchange::new(symbol);
3105
3106 let Some(base_raw) = entry.base_asset.as_deref() else {
3107 warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
3108 continue;
3109 };
3110 let Some(quote_raw) = entry.quote_asset.as_deref() else {
3111 warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
3112 continue;
3113 };
3114
3115 let Some(base) = convert_isolated_asset_balance(
3116 base_raw.asset.as_deref(),
3117 base_raw.free.as_deref(),
3118 base_raw.locked.as_deref(),
3119 base_raw.borrowed.as_deref(),
3120 base_raw.interest.as_deref(),
3121 time,
3122 ) else {
3123 warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
3124 continue;
3125 };
3126 let Some(quote) = convert_isolated_asset_balance(
3127 quote_raw.asset.as_deref(),
3128 quote_raw.free.as_deref(),
3129 quote_raw.locked.as_deref(),
3130 quote_raw.borrowed.as_deref(),
3131 quote_raw.interest.as_deref(),
3132 time,
3133 ) else {
3134 warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
3135 continue;
3136 };
3137
3138 let risk = IsolatedMarginRisk {
3139 margin_level: entry
3140 .margin_level
3141 .as_deref()
3142 .and_then(|s| Decimal::from_str(s).ok()),
3143 margin_ratio: entry
3144 .margin_ratio
3145 .as_deref()
3146 .and_then(|s| Decimal::from_str(s).ok()),
3147 liquidation_price: entry
3148 .liquidate_price
3149 .as_deref()
3150 .and_then(|s| Decimal::from_str(s).ok()),
3151 };
3152
3153 map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
3154 }
3155 map
3156}
3157
3158fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
3161 symbols
3162 .chunks(5)
3163 .map(|chunk| {
3164 chunk
3165 .iter()
3166 .map(|s| s.name().as_str())
3167 .collect::<Vec<_>>()
3168 .join(",")
3169 })
3170 .collect()
3171}
3172
3173struct IsolatedAccountRead {
3176 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
3177 sent: HostMoment,
3178}
3179
3180async fn fetch_isolated_margin_account_info(
3186 rest: Arc<RestApi>,
3187 rate_limiter: Arc<RateLimitTracker>,
3188 symbols: Vec<InstrumentNameExchange>,
3189) -> Result<Vec<IsolatedAccountRead>, UnindexedClientError> {
3190 use futures::{StreamExt as _, TryStreamExt as _};
3191
3192 let chunks = chunk_symbols(&symbols);
3193
3194 futures::stream::iter(chunks.into_iter().map(|symbols_param| {
3195 let rest = rest.clone();
3196 let rate_limiter = rate_limiter.clone();
3197 async move {
3198 let (response, sent) =
3199 rest_read_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
3200 let symbols_param = symbols_param.clone();
3201 Box::pin(async move {
3202 let params = QueryIsolatedMarginAccountInfoParams::builder()
3203 .symbols(symbols_param)
3204 .build()?;
3205 rest.query_isolated_margin_account_info(params).await
3206 })
3207 })
3208 .await
3209 .map_err(|e| classify_rest_query_error(&e, None))?;
3210
3211 let info = response.data().await.map_err(response_decode_error)?;
3212 Ok::<_, UnindexedClientError>(IsolatedAccountRead {
3213 assets: info.assets.unwrap_or_default(),
3214 sent,
3215 })
3216 }
3217 }))
3218 .buffer_unordered(8)
3219 .try_collect()
3220 .await
3221}
3222
3223fn convert_margin_trade(
3230 t: &QueryMarginAccountsTradeListResponseInner,
3231 instrument: &InstrumentNameExchange,
3232) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3233 let trade_id_raw = match t.id {
3234 Some(id) => id,
3235 None => {
3236 warn!(%instrument, "BinanceMargin trade missing id");
3237 return None;
3238 }
3239 };
3240 let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
3241 let order_id = match t.order_id {
3242 Some(id) => OrderId(format_smolstr!("{id}")),
3243 None => {
3244 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
3245 return None;
3246 }
3247 };
3248 let side = match t.is_buyer {
3249 Some(true) => Side::Buy,
3250 Some(false) => Side::Sell,
3251 None => {
3252 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
3253 return None;
3254 }
3255 };
3256 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3257 Some(v) => v,
3258 None => {
3259 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
3260 return None;
3261 }
3262 };
3263 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3264 Some(v) => v,
3265 None => {
3266 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
3267 return None;
3268 }
3269 };
3270 let commission = t
3271 .commission
3272 .as_deref()
3273 .and_then(|s| Decimal::from_str(s).ok())
3274 .unwrap_or(Decimal::ZERO);
3275 let (time_exchange, time_source) = match t
3278 .time
3279 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
3280 {
3281 Some(ts) => (ts, TimeSource::Venue),
3282 None => {
3283 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
3284 (Utc::now(), TimeSource::Host)
3285 }
3286 };
3287
3288 let fee_asset = t
3293 .commission_asset
3294 .as_deref()
3295 .map(AssetNameExchange::from)
3296 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
3297
3298 Some(
3299 Trade::new(
3300 trade_id,
3301 order_id,
3302 instrument.clone(),
3303 StrategyId::unknown(), time_exchange,
3305 side,
3306 price,
3307 quantity,
3308 None,
3310 AssetFees::new(fee_asset, commission, None),
3311 )
3312 .with_time_source(time_source),
3313 )
3314}
3315
3316#[derive(Debug)]
3322enum BuildOrderError {
3323 Unsupported,
3325 Build(String),
3327 Trailing(TrailingOrderError),
3329}
3330
3331trait IsolatedFlag {
3342 fn from_flag(is_isolated: bool) -> Self;
3344}
3345
3346macro_rules! impl_isolated_flag {
3351 ($($t:ty),* $(,)?) => {
3352 $(
3353 impl IsolatedFlag for $t {
3354 fn from_flag(is_isolated: bool) -> Self {
3355 if is_isolated { Self::True } else { Self::False }
3356 }
3357 }
3358 )*
3359 };
3360}
3361
3362impl_isolated_flag!(
3363 MarginAccountNewOrderIsIsolatedEnum,
3364 MarginAccountCancelOrderIsIsolatedEnum,
3365 QueryMarginAccountsOpenOrdersIsIsolatedEnum,
3366 QueryMarginAccountsOrderIsIsolatedEnum,
3367 QueryMarginAccountsTradeListIsIsolatedEnum,
3368 GetFutureHourlyInterestRateIsIsolatedEnum,
3369);
3370
3371fn build_cancel_order_params(
3379 symbol: String,
3380 id: Option<&OrderId>,
3381 cid: &ClientOrderId,
3382 is_isolated: bool,
3383) -> Result<MarginAccountCancelOrderParams, String> {
3384 let mut builder = MarginAccountCancelOrderParams::builder(symbol).is_isolated(
3385 MarginAccountCancelOrderIsIsolatedEnum::from_flag(is_isolated),
3386 );
3387
3388 match id {
3389 Some(order_id) => match order_id.0.parse::<i64>() {
3390 Ok(id) => builder = builder.order_id(id),
3391 Err(_) => {
3392 error!(
3394 order_id = %order_id.0,
3395 "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
3396 );
3397 builder = builder.orig_client_order_id(cid.0.to_string());
3398 }
3399 },
3400 None => builder = builder.orig_client_order_id(cid.0.to_string()),
3401 }
3402
3403 builder.build().map_err(|e| e.to_string())
3404}
3405
3406#[allow(clippy::too_many_arguments)] fn build_new_order_params(
3415 symbol: String,
3416 side: Side,
3417 price: Option<Decimal>,
3418 quantity: Decimal,
3419 kind: OrderKind,
3420 time_in_force: TimeInForce,
3421 new_client_order_id: String,
3422 margin_effect: MarginEffect,
3423 auto_repay_at_cancel: bool,
3424 is_isolated: bool,
3425) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3426 let binance_side = match side {
3427 Side::Buy => MarginAccountNewOrderSideEnum::Buy,
3428 Side::Sell => MarginAccountNewOrderSideEnum::Sell,
3429 };
3430
3431 let (binance_type, binance_tif) =
3432 convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
3433
3434 let mut builder = MarginAccountNewOrderParams::builder(symbol, binance_side, binance_type)
3436 .quantity(quantity)
3437 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::from_flag(is_isolated))
3438 .side_effect_type(binance_side_effect(margin_effect))
3439 .new_client_order_id(new_client_order_id)
3440 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
3441
3442 if margin_effect == MarginEffect::Borrow {
3445 builder = builder.auto_repay_at_cancel(auto_repay_at_cancel);
3446 }
3447
3448 if let Some(tif) = binance_tif {
3449 builder = builder.time_in_force(tif);
3450 }
3451
3452 if let Some(trailing) = trailing_params(&kind, price).map_err(BuildOrderError::Trailing)? {
3455 builder = builder.trailing_delta(i64::from(trailing.trailing_delta));
3456 if let Some(limit) = trailing.price {
3457 builder = builder.price(limit);
3458 }
3459 if let Some(activation_price) = trailing.stop_price {
3460 builder = builder.stop_price(activation_price);
3461 }
3462 }
3463
3464 match kind {
3468 OrderKind::Limit => {
3469 builder = builder.price(price);
3470 }
3471 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
3472 builder = builder.stop_price(trigger_price);
3473 }
3474 OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
3475 builder = builder.price(price).stop_price(trigger_price);
3476 }
3477 OrderKind::TrailingStop { .. }
3480 | OrderKind::TrailingStopFixedLimit { .. }
3481 | OrderKind::Market
3482 | OrderKind::TrailingStopLimit { .. }
3483 | OrderKind::ConditionalUnresolved { .. } => {}
3484 }
3485
3486 builder
3487 .build()
3488 .map_err(|e| BuildOrderError::Build(e.to_string()))
3489}
3490
3491const ORDER_PATH: &str = "/sapi/v1/margin/order";
3494
3495fn new_order_query(params: MarginAccountNewOrderParams) -> BTreeMap<String, serde_json::Value> {
3504 use serde_json::json;
3505
3506 let MarginAccountNewOrderParams {
3507 symbol,
3508 side,
3509 r#type,
3510 is_isolated,
3511 quantity,
3512 quote_order_qty,
3513 price,
3514 stop_price,
3515 new_client_order_id,
3516 iceberg_qty,
3517 new_order_resp_type,
3518 side_effect_type,
3519 time_in_force,
3520 self_trade_prevention_mode,
3521 trailing_delta,
3522 auto_repay_at_cancel,
3523 recv_window,
3524 } = params;
3525
3526 let mut query = BTreeMap::new();
3527 let mut insert = |key: &str, value: Option<serde_json::Value>| {
3528 if let Some(value) = value {
3529 query.insert(key.to_string(), value);
3530 }
3531 };
3532 insert("symbol", Some(json!(symbol)));
3533 insert("isIsolated", is_isolated.map(|v| json!(v)));
3534 insert("side", Some(json!(side)));
3535 insert("type", Some(json!(r#type)));
3536 insert("quantity", quantity.map(|v| json!(v)));
3537 insert("quoteOrderQty", quote_order_qty.map(|v| json!(v)));
3538 insert("price", price.map(|v| json!(v)));
3539 insert("stopPrice", stop_price.map(|v| json!(v)));
3540 insert("newClientOrderId", new_client_order_id.map(|v| json!(v)));
3541 insert("icebergQty", iceberg_qty.map(|v| json!(v)));
3542 insert("newOrderRespType", new_order_resp_type.map(|v| json!(v)));
3543 insert("sideEffectType", side_effect_type.map(|v| json!(v)));
3544 insert("timeInForce", time_in_force.map(|v| json!(v)));
3545 insert(
3546 "selfTradePreventionMode",
3547 self_trade_prevention_mode.map(|v| json!(v)),
3548 );
3549 insert("trailingDelta", trailing_delta.map(|v| json!(v)));
3550 insert("autoRepayAtCancel", auto_repay_at_cancel.map(|v| json!(v)));
3551 insert("recvWindow", recv_window.map(|v| json!(v)));
3552 query
3553}
3554
3555fn cancel_order_query(
3563 params: MarginAccountCancelOrderParams,
3564) -> BTreeMap<String, serde_json::Value> {
3565 use serde_json::json;
3566
3567 let MarginAccountCancelOrderParams {
3568 symbol,
3569 is_isolated,
3570 order_id,
3571 orig_client_order_id,
3572 new_client_order_id,
3573 recv_window,
3574 } = params;
3575
3576 let mut query = BTreeMap::new();
3577 let mut insert = |key: &str, value: Option<serde_json::Value>| {
3578 if let Some(value) = value {
3579 query.insert(key.to_string(), value);
3580 }
3581 };
3582 insert("symbol", Some(json!(symbol)));
3583 insert("isIsolated", is_isolated.map(|v| json!(v)));
3584 insert("orderId", order_id.map(|v| json!(v)));
3585 insert("origClientOrderId", orig_client_order_id.map(|v| json!(v)));
3586 insert("newClientOrderId", new_client_order_id.map(|v| json!(v)));
3587 insert("recvWindow", recv_window.map(|v| json!(v)));
3588 query
3589}
3590
3591#[derive(Debug, Deserialize)]
3595#[serde(rename_all = "camelCase")]
3596struct CancelOrderAnswer {
3597 order_id: Option<WireInt>,
3600 transact_time: Option<i64>,
3601 executed_qty: Option<String>,
3602}
3603
3604#[derive(Debug, Deserialize)]
3609#[serde(rename_all = "camelCase")]
3610struct NewOrderAnswer {
3611 order_id: Option<i64>,
3612 transact_time: Option<i64>,
3613 status: Option<String>,
3615 executed_qty: Option<String>,
3617 cummulative_quote_qty: Option<String>,
3618 margin_buy_borrow_amount: Option<Decimal>,
3620 margin_buy_borrow_asset: Option<AssetNameExchange>,
3622}
3623
3624fn convert_order_kind_tif_margin(
3635 kind: OrderKind,
3636 tif: TimeInForce,
3637) -> Option<(
3638 MarginAccountNewOrderTypeEnum,
3639 Option<MarginAccountNewOrderTimeInForceEnum>,
3640)> {
3641 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
3642 let margin_type = match binance_type {
3643 BinanceOrderType::Market => MarginAccountNewOrderTypeEnum::Market,
3644 BinanceOrderType::Limit => MarginAccountNewOrderTypeEnum::Limit,
3645 BinanceOrderType::LimitMaker => MarginAccountNewOrderTypeEnum::LimitMaker,
3646 BinanceOrderType::StopLoss => MarginAccountNewOrderTypeEnum::StopLoss,
3647 BinanceOrderType::StopLossLimit => MarginAccountNewOrderTypeEnum::StopLossLimit,
3648 BinanceOrderType::TakeProfit => MarginAccountNewOrderTypeEnum::TakeProfit,
3649 BinanceOrderType::TakeProfitLimit => MarginAccountNewOrderTypeEnum::TakeProfitLimit,
3650 };
3651 let margin_tif = binance_tif.map(|t| match t {
3652 BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
3653 BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
3654 BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
3655 });
3656 Some((margin_type, margin_tif))
3657}
3658
3659#[cfg(test)]
3660#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3662 use super::*;
3663 use crate::client::binance::shared::convert_open_order;
3664 use crate::order::state::ActiveOrderState;
3665 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOpenOrdersResponseInner;
3666
3667 #[test]
3668 fn margin_effect_default_is_no_side_effect() {
3669 assert_eq!(MarginEffect::default(), MarginEffect::NoSideEffect);
3670 }
3671
3672 #[test]
3673 fn margin_effect_wire_values() {
3674 for (effect, wire) in [
3675 (MarginEffect::NoSideEffect, "NO_SIDE_EFFECT"),
3676 (MarginEffect::Borrow, "MARGIN_BUY"),
3677 (MarginEffect::Repay, "AUTO_REPAY"),
3678 ] {
3679 assert_eq!(binance_side_effect(effect).as_str(), wire);
3680 }
3681 }
3682
3683 #[test]
3684 fn config_debug_redacts_secrets() {
3685 let config = BinanceMarginConfig::new(
3686 "my_api_key".to_string(),
3687 "my_secret_key".to_string(),
3688 false,
3689 false,
3690 Vec::new(),
3691 MarginEffect::default(),
3692 true,
3693 );
3694 let debug = format!("{config:?}");
3695 assert!(!debug.contains("my_api_key"));
3696 assert!(!debug.contains("my_secret_key"));
3697 assert!(debug.contains("***"));
3698 }
3699
3700 #[test]
3701 fn cross_margin_uses_common_case_defaults() {
3702 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
3703 assert!(!config.testnet);
3704 assert!(!config.is_isolated);
3705 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
3706 assert!(config.auto_repay_at_cancel);
3707 assert_eq!(config.api_key(), "k");
3708 }
3709
3710 #[test]
3711 fn config_deserializes_with_defaults() {
3712 let config: BinanceMarginConfig =
3715 serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
3716 .expect("deserialize");
3717 assert!(!config.is_isolated);
3718 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
3719 assert!(config.auto_repay_at_cancel);
3720 }
3721
3722 #[test]
3723 fn config_deserializes_explicit_margin_effect() {
3724 let config: BinanceMarginConfig = serde_json::from_str(
3725 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"margin_effect":"borrow","auto_repay_at_cancel":false}"#,
3726 )
3727 .expect("deserialize");
3728 assert!(config.is_isolated);
3729 assert_eq!(config.margin_effect, MarginEffect::Borrow);
3730 assert!(!config.auto_repay_at_cancel);
3731 }
3732
3733 #[test]
3734 fn config_refuses_the_retired_combined_effect() {
3735 let parsed = serde_json::from_str::<BinanceMarginConfig>(
3737 r#"{"api_key":"k","secret_key":"s","testnet":false,"margin_effect":"auto_borrow_repay"}"#,
3738 );
3739 assert!(parsed.is_err());
3740 }
3741
3742 #[test]
3743 fn isolated_ctor_sets_symbols_and_flag() {
3744 let symbols = vec![
3745 InstrumentNameExchange::new("BTCUSDT"),
3746 InstrumentNameExchange::new("ETHUSDT"),
3747 ];
3748 let config =
3749 BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
3750 assert!(config.is_isolated);
3751 assert_eq!(config.isolated_symbols, symbols);
3752 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
3753 }
3754
3755 #[test]
3756 fn isolated_config_with_symbols_constructs() {
3757 let config = BinanceMarginConfig::isolated(
3759 "k".to_string(),
3760 "s".to_string(),
3761 vec![InstrumentNameExchange::new("BTCUSDT")],
3762 );
3763 let _client = BinanceMargin::new(config); }
3765
3766 #[test]
3767 #[should_panic(expected = "non-empty isolated_symbols")]
3768 fn isolated_empty_symbols_panics_at_new() {
3769 let config = BinanceMarginConfig::new(
3771 "k".to_string(),
3772 "s".to_string(),
3773 false,
3774 true,
3775 Vec::new(),
3776 MarginEffect::default(),
3777 true,
3778 );
3779 let _ = BinanceMargin::new(config);
3780 }
3781
3782 #[test]
3783 #[should_panic(expected = "non-empty isolated_symbols")]
3784 fn isolated_empty_symbols_panics_via_deserialize_path() {
3785 let config: BinanceMarginConfig = serde_json::from_str(
3788 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
3789 )
3790 .expect("deserialize");
3791 assert!(config.isolated_symbols.is_empty());
3792 let _ = BinanceMargin::new(config);
3793 }
3794
3795 #[test]
3796 fn isolated_flag_maps_mode() {
3797 assert_eq!(
3800 MarginAccountNewOrderIsIsolatedEnum::from_flag(true).as_str(),
3801 "TRUE"
3802 );
3803 assert_eq!(
3804 MarginAccountNewOrderIsIsolatedEnum::from_flag(false).as_str(),
3805 "FALSE"
3806 );
3807 assert_eq!(
3808 MarginAccountCancelOrderIsIsolatedEnum::from_flag(true).as_str(),
3809 "TRUE"
3810 );
3811 assert_eq!(
3812 MarginAccountCancelOrderIsIsolatedEnum::from_flag(false).as_str(),
3813 "FALSE"
3814 );
3815 assert_eq!(
3816 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(true).as_str(),
3817 "TRUE"
3818 );
3819 assert_eq!(
3820 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(false).as_str(),
3821 "FALSE"
3822 );
3823 assert_eq!(
3824 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(true).as_str(),
3825 "TRUE"
3826 );
3827 assert_eq!(
3828 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(false).as_str(),
3829 "FALSE"
3830 );
3831 }
3832
3833 #[test]
3838 fn cancel_params_cross_with_parseable_order_id() {
3839 let p = build_cancel_order_params(
3840 "BTCUSDT".to_string(),
3841 Some(&OrderId::new("12345")),
3842 &ClientOrderId::new("cid-1"),
3843 false,
3844 )
3845 .expect("build");
3846 assert_eq!(p.symbol, "BTCUSDT");
3847 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("FALSE"));
3848 assert_eq!(p.order_id, Some(12345));
3849 assert_eq!(p.orig_client_order_id, None);
3850 }
3851
3852 #[test]
3853 fn cancel_params_isolated_is_config_driven() {
3854 let p = build_cancel_order_params(
3855 "BTCUSDT".to_string(),
3856 Some(&OrderId::new("12345")),
3857 &ClientOrderId::new("cid-1"),
3858 true,
3859 )
3860 .expect("build");
3861 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("TRUE"));
3862 assert_eq!(p.order_id, Some(12345));
3863 }
3864
3865 #[test]
3866 fn cancel_params_non_i64_order_id_falls_back_to_cid() {
3867 let p = build_cancel_order_params(
3868 "BTCUSDT".to_string(),
3869 Some(&OrderId::new("not-an-i64")),
3870 &ClientOrderId::new("cid-1"),
3871 false,
3872 )
3873 .expect("build");
3874 assert_eq!(p.order_id, None);
3875 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3876 }
3877
3878 #[test]
3879 fn cancel_params_absent_order_id_uses_cid() {
3880 let p = build_cancel_order_params(
3881 "BTCUSDT".to_string(),
3882 None,
3883 &ClientOrderId::new("cid-1"),
3884 false,
3885 )
3886 .expect("build");
3887 assert_eq!(p.order_id, None);
3888 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3889 }
3890
3891 use crate::order::TrailingOffsetType;
3896
3897 fn params(
3899 side: Side,
3900 price: Option<Decimal>,
3901 kind: OrderKind,
3902 tif: TimeInForce,
3903 margin_effect: MarginEffect,
3904 ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3905 build_new_order_params(
3906 "BTCUSDT".to_string(),
3907 side,
3908 price,
3909 Decimal::from(2),
3910 kind,
3911 tif,
3912 "cid-1".to_string(),
3913 margin_effect,
3914 true,
3915 false, )
3917 }
3918
3919 fn gtc() -> TimeInForce {
3920 TimeInForce::GoodUntilCancelled { post_only: false }
3921 }
3922
3923 #[test]
3924 fn new_order_limit_maps_core_fields() {
3925 let p = params(
3926 Side::Buy,
3927 Some(Decimal::from(50_000)),
3928 OrderKind::Limit,
3929 gtc(),
3930 MarginEffect::Borrow,
3931 )
3932 .expect("build");
3933
3934 assert_eq!(p.symbol, "BTCUSDT");
3935 assert_eq!(p.side.as_str(), "BUY");
3936 assert_eq!(p.r#type.as_str(), "LIMIT");
3937 assert_eq!(p.quantity, Some(Decimal::from(2)));
3938 assert_eq!(p.price, Some(Decimal::from(50_000)));
3939 assert_eq!(p.stop_price, None);
3940 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
3941 assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
3942 assert_eq!(
3944 p.new_order_resp_type.as_ref().map(|r| r.as_str()),
3945 Some("FULL")
3946 );
3947 }
3948
3949 #[test]
3950 fn new_order_is_isolated_is_config_driven() {
3951 let cross = build_new_order_params(
3953 "BTCUSDT".to_string(),
3954 Side::Sell,
3955 Some(Decimal::from(10)),
3956 Decimal::from(2),
3957 OrderKind::Limit,
3958 gtc(),
3959 "cid-1".to_string(),
3960 MarginEffect::Borrow,
3961 true,
3962 false,
3963 )
3964 .expect("build");
3965 assert_eq!(
3966 cross.is_isolated.as_ref().map(|i| i.as_str()),
3967 Some("FALSE")
3968 );
3969 assert_eq!(cross.side.as_str(), "SELL");
3970
3971 let isolated = build_new_order_params(
3973 "BTCUSDT".to_string(),
3974 Side::Sell,
3975 Some(Decimal::from(10)),
3976 Decimal::from(2),
3977 OrderKind::Limit,
3978 gtc(),
3979 "cid-1".to_string(),
3980 MarginEffect::Borrow,
3981 true,
3982 true,
3983 )
3984 .expect("build");
3985 assert_eq!(
3986 isolated.is_isolated.as_ref().map(|i| i.as_str()),
3987 Some("TRUE")
3988 );
3989 }
3990
3991 #[test]
3992 fn side_effect_and_auto_repay_gating() {
3993 let build = |effect, auto_repay_at_cancel| {
3994 build_new_order_params(
3995 "BTCUSDT".to_string(),
3996 Side::Buy,
3997 Some(Decimal::from(1)),
3998 Decimal::from(2),
3999 OrderKind::Limit,
4000 gtc(),
4001 "cid-1".to_string(),
4002 effect,
4003 auto_repay_at_cancel,
4004 false,
4005 )
4006 .expect("build")
4007 };
4008 let wire = |params: &MarginAccountNewOrderParams| {
4009 params
4010 .side_effect_type
4011 .as_ref()
4012 .map(|s| s.as_str().to_owned())
4013 };
4014
4015 let borrow = build(MarginEffect::Borrow, true);
4017 assert_eq!(wire(&borrow).as_deref(), Some("MARGIN_BUY"));
4018 assert_eq!(borrow.auto_repay_at_cancel, Some(true));
4019 let borrow_off = build(MarginEffect::Borrow, false);
4020 assert_eq!(borrow_off.auto_repay_at_cancel, Some(false));
4021
4022 let repay = build(MarginEffect::Repay, true);
4024 assert_eq!(wire(&repay).as_deref(), Some("AUTO_REPAY"));
4025 assert_eq!(repay.auto_repay_at_cancel, None);
4026 let none = build(MarginEffect::NoSideEffect, true);
4027 assert_eq!(wire(&none).as_deref(), Some("NO_SIDE_EFFECT"));
4028 assert_eq!(none.auto_repay_at_cancel, None);
4029 }
4030
4031 #[test]
4032 fn new_order_market_has_no_price_or_tif() {
4033 let p = params(
4034 Side::Buy,
4035 None,
4036 OrderKind::Market,
4037 gtc(),
4038 MarginEffect::Borrow,
4039 )
4040 .expect("build");
4041 assert_eq!(p.r#type.as_str(), "MARKET");
4042 assert_eq!(p.price, None);
4043 assert_eq!(p.stop_price, None);
4044 assert!(p.time_in_force.is_none());
4045 }
4046
4047 #[test]
4048 fn post_only_limit_maps_to_limit_maker() {
4049 let p = params(
4050 Side::Buy,
4051 Some(Decimal::from(10)),
4052 OrderKind::Limit,
4053 TimeInForce::GoodUntilCancelled { post_only: true },
4054 MarginEffect::Borrow,
4055 )
4056 .expect("build");
4057 assert_eq!(p.r#type.as_str(), "LIMIT_MAKER");
4058 assert!(p.time_in_force.is_none());
4060 }
4061
4062 #[test]
4063 fn conditional_kinds_set_stop_price() {
4064 let trigger = Decimal::from(48_000);
4065
4066 let stop = params(
4067 Side::Sell,
4068 None,
4069 OrderKind::Stop {
4070 trigger_price: trigger,
4071 },
4072 gtc(),
4073 MarginEffect::Borrow,
4074 )
4075 .expect("build");
4076 assert_eq!(stop.r#type.as_str(), "STOP_LOSS");
4077 assert_eq!(stop.stop_price, Some(trigger));
4078 assert_eq!(stop.price, None);
4079
4080 let stop_limit = params(
4081 Side::Sell,
4082 Some(Decimal::from(47_900)),
4083 OrderKind::StopLimit {
4084 trigger_price: trigger,
4085 },
4086 gtc(),
4087 MarginEffect::Borrow,
4088 )
4089 .expect("build");
4090 assert_eq!(stop_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
4091 assert_eq!(stop_limit.stop_price, Some(trigger));
4092 assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
4093 assert_eq!(
4094 stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
4095 Some("GTC")
4096 );
4097
4098 let take_profit = params(
4099 Side::Sell,
4100 None,
4101 OrderKind::TakeProfit {
4102 trigger_price: trigger,
4103 },
4104 gtc(),
4105 MarginEffect::Borrow,
4106 )
4107 .expect("build");
4108 assert_eq!(take_profit.r#type.as_str(), "TAKE_PROFIT");
4109 assert_eq!(take_profit.stop_price, Some(trigger));
4110 }
4111
4112 #[test]
4115 fn a_trailing_stop_maps_to_stop_loss_with_trailing_delta() {
4116 let cases = [
4117 (Decimal::from(25), TrailingOffsetType::BasisPoints, 25),
4118 (Decimal::new(15, 1), TrailingOffsetType::Percentage, 150),
4119 ];
4120 for (offset, offset_type, trailing_delta) in cases {
4121 let p = params(
4122 Side::Sell,
4123 None,
4124 OrderKind::TrailingStop {
4125 offset,
4126 offset_type,
4127 activation_price: None,
4128 },
4129 gtc(),
4130 MarginEffect::NoSideEffect,
4131 )
4132 .expect("build");
4133 assert_eq!(p.r#type.as_str(), "STOP_LOSS");
4134 assert_eq!(p.trailing_delta, Some(trailing_delta), "{offset_type:?}");
4135 assert_eq!(p.stop_price, None);
4136 assert_eq!(p.price, None);
4137 assert!(p.time_in_force.is_none());
4138 }
4139 }
4140
4141 #[test]
4145 fn trailing_orders_carry_their_activation_price_and_fixed_limit() {
4146 let activation = Some(Decimal::from(50_000));
4147 let trailing_stop = params(
4148 Side::Sell,
4149 None,
4150 OrderKind::TrailingStop {
4151 offset: Decimal::from(25),
4152 offset_type: TrailingOffsetType::BasisPoints,
4153 activation_price: activation,
4154 },
4155 gtc(),
4156 MarginEffect::NoSideEffect,
4157 )
4158 .expect("build");
4159 assert_eq!(trailing_stop.r#type.as_str(), "STOP_LOSS");
4160 assert_eq!(trailing_stop.trailing_delta, Some(25));
4161 assert_eq!(trailing_stop.stop_price, activation);
4162 assert_eq!(trailing_stop.price, None);
4163
4164 for activation_price in [None, activation] {
4165 let fixed_limit = params(
4166 Side::Sell,
4167 Some(Decimal::from(49_000)),
4168 OrderKind::TrailingStopFixedLimit {
4169 offset: Decimal::new(15, 1),
4170 offset_type: TrailingOffsetType::Percentage,
4171 activation_price,
4172 },
4173 TimeInForce::ImmediateOrCancel,
4174 MarginEffect::NoSideEffect,
4175 )
4176 .expect("build");
4177 assert_eq!(fixed_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
4178 assert_eq!(fixed_limit.trailing_delta, Some(150));
4179 assert_eq!(fixed_limit.price, Some(Decimal::from(49_000)));
4180 assert_eq!(fixed_limit.stop_price, activation_price);
4181 assert_eq!(
4182 fixed_limit.time_in_force.as_ref().map(|t| t.as_str()),
4183 Some("IOC")
4184 );
4185 }
4186
4187 let absolute = params(
4188 Side::Sell,
4189 Some(Decimal::from(49_000)),
4190 OrderKind::TrailingStopFixedLimit {
4191 offset: Decimal::from(100),
4192 offset_type: TrailingOffsetType::Absolute,
4193 activation_price: None,
4194 },
4195 gtc(),
4196 MarginEffect::NoSideEffect,
4197 );
4198 assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
4199
4200 let no_limit = params(
4202 Side::Sell,
4203 None,
4204 OrderKind::TrailingStopFixedLimit {
4205 offset: Decimal::from(25),
4206 offset_type: TrailingOffsetType::BasisPoints,
4207 activation_price: None,
4208 },
4209 gtc(),
4210 MarginEffect::NoSideEffect,
4211 );
4212 assert!(matches!(
4213 no_limit,
4214 Err(BuildOrderError::Trailing(TrailingOrderError::LimitPrice(
4215 None
4216 )))
4217 ));
4218 let zero_activation = params(
4219 Side::Sell,
4220 None,
4221 OrderKind::TrailingStop {
4222 offset: Decimal::from(25),
4223 offset_type: TrailingOffsetType::BasisPoints,
4224 activation_price: Some(Decimal::ZERO),
4225 },
4226 gtc(),
4227 MarginEffect::NoSideEffect,
4228 );
4229 assert!(matches!(
4230 zero_activation,
4231 Err(BuildOrderError::Trailing(
4232 TrailingOrderError::NonPositiveActivation(_)
4233 ))
4234 ));
4235 }
4236
4237 #[test]
4238 fn absolute_trailing_offsets_and_trailing_stop_limits_are_unsupported() {
4239 let absolute = params(
4240 Side::Sell,
4241 None,
4242 OrderKind::TrailingStop {
4243 offset: Decimal::from(100),
4244 offset_type: TrailingOffsetType::Absolute,
4245 activation_price: None,
4246 },
4247 gtc(),
4248 MarginEffect::Borrow,
4249 );
4250 assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
4251
4252 let trailing_stop_limit = params(
4253 Side::Sell,
4254 Some(Decimal::from(100)),
4255 OrderKind::TrailingStopLimit {
4256 offset: Decimal::from(100),
4257 offset_type: TrailingOffsetType::BasisPoints,
4258 stop_price: Decimal::from(95),
4259 limit_offset: Decimal::from(10),
4260 },
4261 gtc(),
4262 MarginEffect::Borrow,
4263 );
4264 assert!(matches!(
4265 trailing_stop_limit,
4266 Err(BuildOrderError::Unsupported)
4267 ));
4268
4269 let unresolved = params(
4271 Side::Sell,
4272 Some(Decimal::from(100)),
4273 OrderKind::ConditionalUnresolved {
4274 family: crate::order::ConditionalFamily::StopLimit,
4275 trigger_price: Some(Decimal::from(95)),
4276 },
4277 gtc(),
4278 MarginEffect::Borrow,
4279 );
4280 assert!(matches!(unresolved, Err(BuildOrderError::Unsupported)));
4281 }
4282
4283 #[test]
4285 fn a_fractional_trailing_offset_is_refused() {
4286 let fractional = params(
4287 Side::Sell,
4288 None,
4289 OrderKind::TrailingStop {
4290 offset: Decimal::new(155, 3),
4291 offset_type: TrailingOffsetType::Percentage,
4292 activation_price: None,
4293 },
4294 gtc(),
4295 MarginEffect::Borrow,
4296 );
4297 assert!(matches!(
4298 fractional,
4299 Err(BuildOrderError::Trailing(TrailingOrderError::Delta(
4300 crate::client::binance::shared::TrailingDeltaError::Invalid(_)
4301 )))
4302 ));
4303 }
4304
4305 #[test]
4308 fn recalled_order_kinds_are_known_and_those_past_the_limit_warned_of() {
4309 use crate::client::order_recovery::tests::logged_with_lock_free;
4310
4311 let client = BinanceMargin::new(
4312 BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string())
4313 .with_server_time_sync(false),
4314 );
4315 let trailing = OrderKind::TrailingStopFixedLimit {
4316 offset: Decimal::from(100),
4317 offset_type: TrailingOffsetType::BasisPoints,
4318 activation_price: None,
4319 };
4320 let cid = |n: usize| ClientOrderId::new(format!("cid-{n}"));
4321
4322 let seen = logged_with_lock_free(&client.known_live, || {
4323 client.recall_order_kinds((0..MAX_KNOWN_LIVE_ORDERS).map(|n| (cid(n), trailing)));
4324 });
4325 assert!(seen.is_empty(), "nothing past the limit: {seen:?}");
4326 assert_eq!(client.known_live.lock().kind_of(&cid(0)), Some(trailing));
4327
4328 let seen = logged_with_lock_free(&client.known_live, || {
4329 client.recall_order_kinds([(cid(MAX_KNOWN_LIVE_ORDERS), trailing)]);
4330 });
4331 assert_eq!(seen, [(tracing::Level::WARN, true)]);
4332 assert_eq!(
4333 client
4334 .known_live
4335 .lock()
4336 .kind_of(&cid(MAX_KNOWN_LIVE_ORDERS)),
4337 None
4338 );
4339 }
4340
4341 #[test]
4342 fn tif_variants_map_to_margin_enum() {
4343 for (tif, expected) in [
4344 (TimeInForce::ImmediateOrCancel, "IOC"),
4345 (TimeInForce::FillOrKill, "FOK"),
4346 (gtc(), "GTC"),
4347 ] {
4348 let p = params(
4349 Side::Buy,
4350 Some(Decimal::from(1)),
4351 OrderKind::Limit,
4352 tif,
4353 MarginEffect::Borrow,
4354 )
4355 .expect("build");
4356 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
4357 }
4358 }
4359
4360 fn user_asset(
4365 asset: &str,
4366 free: &str,
4367 locked: &str,
4368 borrowed: &str,
4369 interest: &str,
4370 ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
4371 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4372 a.asset = Some(asset.to_string());
4373 a.free = Some(free.to_string());
4374 a.locked = Some(locked.to_string());
4375 a.borrowed = Some(borrowed.to_string());
4376 a.interest = Some(interest.to_string());
4377 a
4378 }
4379
4380 #[test]
4381 fn margin_balance_entry_maps_debt() {
4382 let ab =
4385 convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
4386 .expect("convert");
4387 assert_eq!(ab.asset.name().as_str(), "USDT");
4388 assert_eq!(ab.balance.total, Decimal::from(12));
4389 assert_eq!(ab.balance.free, Decimal::from(10));
4390 assert!(
4391 ab.balance.margin.is_some(),
4392 "REST snapshot must populate margin"
4393 );
4394 assert_eq!(ab.balance.net_asset(), Decimal::new(85, 1));
4395 }
4396
4397 #[test]
4398 fn margin_balance_short_is_negative_net() {
4399 let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
4401 .expect("convert");
4402 assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
4403 }
4404
4405 #[test]
4406 fn margin_balance_missing_debt_defaults_to_zero() {
4407 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4410 a.asset = Some("ETH".to_string());
4411 a.free = Some("4".to_string());
4412 a.locked = Some("0".to_string());
4413 let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
4414 assert!(ab.balance.margin.is_some());
4415 assert_eq!(ab.balance.net_asset(), Decimal::from(4));
4416 }
4417
4418 #[test]
4419 fn margin_balance_missing_free_is_dropped() {
4420 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4422 a.asset = Some("USDT".to_string());
4423 a.locked = Some("0".to_string());
4424 assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
4425 }
4426
4427 #[test]
4428 fn margin_balance_filtering() {
4429 let assets = vec![
4430 user_asset("USDT", "10", "0", "0", "0"),
4431 user_asset("BTC", "1", "0", "0", "0"),
4432 user_asset("ETH", "5", "0", "0", "0"),
4433 ];
4434 assert_eq!(
4436 filter_and_convert_margin_balances(assets.clone(), &[], Utc::now()).len(),
4437 3
4438 );
4439 let filtered = filter_and_convert_margin_balances(
4441 assets,
4442 &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
4443 Utc::now(),
4444 );
4445 assert_eq!(filtered.len(), 2);
4446 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
4447 }
4448
4449 #[test]
4450 fn margin_balance_filtering_large_slice_uses_hashset_branch() {
4451 let assets = vec![
4453 user_asset("BTC", "1", "0", "0", "0"),
4454 user_asset("ETH", "5", "0", "0", "0"),
4455 user_asset("DOGE", "9", "0", "0", "0"), ];
4457 let mut requested: Vec<AssetNameExchange> = (0..17)
4459 .map(|i| AssetNameExchange::new(format!("A{i:02}")))
4460 .collect();
4461 requested.push(AssetNameExchange::new("BTC"));
4462 requested.push(AssetNameExchange::new("ETH"));
4463 assert!(
4464 requested.len() > 16,
4465 "must exceed the linear-scan threshold"
4466 );
4467
4468 let filtered = filter_and_convert_margin_balances(assets, &requested, Utc::now());
4469 assert_eq!(filtered.len(), 2);
4470 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
4471 }
4472
4473 use binance_sdk::margin_trading::rest_api::{
4478 QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
4479 QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
4480 };
4481
4482 fn d(s: &str) -> Decimal {
4483 Decimal::from_str(s).unwrap()
4484 }
4485
4486 fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
4487 let mut b = IsoBase::new();
4488 b.asset = Some(asset.to_string());
4489 b.free = Some(free.to_string());
4490 b.locked = Some(locked.to_string());
4491 b.borrowed = Some(borrowed.to_string());
4492 b.interest = Some(interest.to_string());
4493 b
4494 }
4495
4496 fn iso_quote(
4497 asset: &str,
4498 free: &str,
4499 locked: &str,
4500 borrowed: &str,
4501 interest: &str,
4502 ) -> IsoQuote {
4503 let mut q = IsoQuote::new();
4504 q.asset = Some(asset.to_string());
4505 q.free = Some(free.to_string());
4506 q.locked = Some(locked.to_string());
4507 q.borrowed = Some(borrowed.to_string());
4508 q.interest = Some(interest.to_string());
4509 q
4510 }
4511
4512 fn iso_entry(
4513 symbol: &str,
4514 base: IsoBase,
4515 quote: IsoQuote,
4516 margin_level: Option<&str>,
4517 margin_ratio: Option<&str>,
4518 liquidate_price: Option<&str>,
4519 ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
4520 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4521 e.symbol = Some(symbol.to_string());
4522 e.base_asset = Some(Box::new(base));
4523 e.quote_asset = Some(Box::new(quote));
4524 e.margin_level = margin_level.map(str::to_string);
4525 e.margin_ratio = margin_ratio.map(str::to_string);
4526 e.liquidate_price = liquidate_price.map(str::to_string);
4527 e
4528 }
4529
4530 fn isolated_client(symbols: &[&str]) -> BinanceMargin {
4531 let config = BinanceMarginConfig::isolated(
4532 "k".to_string(),
4533 "s".to_string(),
4534 symbols
4535 .iter()
4536 .map(|s| InstrumentNameExchange::new(*s))
4537 .collect(),
4538 );
4539 BinanceMargin::new(config.with_server_time_sync(false))
4540 }
4541
4542 #[test]
4543 fn client_debug_shows_mode_and_margin_effect_without_credentials() {
4544 let debug = format!("{:?}", isolated_client(&["BTCUSDT"]));
4545 assert!(debug.contains("BinanceMargin"));
4546 assert!(debug.contains("is_isolated: true"));
4547 assert!(debug.contains("margin_effect: NoSideEffect"));
4548 assert!(!debug.contains("\"k\"") && !debug.contains("\"s\""));
4549 }
4550
4551 #[test]
4552 fn isolated_assets_map_base_quote_and_risk() {
4553 let entry = iso_entry(
4556 "BTCUSDT",
4557 iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
4558 iso_quote("USDT", "1000", "50", "300", "1.5"),
4559 Some("3.5"),
4560 Some("0.12"),
4561 Some("48000"),
4562 );
4563 let map = convert_isolated_margin_assets(vec![entry], Utc::now());
4564 let state = map
4565 .get(&InstrumentNameExchange::new("BTCUSDT"))
4566 .expect("entry present");
4567
4568 assert_eq!(state.base.asset.name().as_str(), "BTC");
4569 assert_eq!(state.base.balance.total, d("0.6"));
4570 assert_eq!(state.base.balance.free, d("0.5"));
4571 assert_eq!(state.base.balance.net_asset(), d("0.399"));
4572
4573 assert_eq!(state.quote.asset.name().as_str(), "USDT");
4574 assert_eq!(state.quote.balance.total, d("1050"));
4575 assert_eq!(state.quote.balance.net_asset(), d("748.5"));
4576
4577 assert_eq!(state.risk.margin_level, Some(d("3.5")));
4578 assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
4579 assert_eq!(state.risk.liquidation_price, Some(d("48000")));
4580 }
4581
4582 #[test]
4583 fn isolated_base_short_is_negative_net() {
4584 let entry = iso_entry(
4586 "BTCUSDT",
4587 iso_base("BTC", "0", "0", "1.5", "0.001"),
4588 iso_quote("USDT", "100", "0", "0", "0"),
4589 None,
4590 None,
4591 None,
4592 );
4593 let map = convert_isolated_margin_assets(vec![entry], Utc::now());
4594 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4595 assert_eq!(state.base.balance.net_asset(), d("-1.501"));
4596 }
4597
4598 #[test]
4599 fn isolated_missing_debt_defaults_zero_and_risk_optional() {
4600 let mut base = IsoBase::new();
4603 base.asset = Some("BTC".to_string());
4604 base.free = Some("0.5".to_string());
4605 base.locked = Some("0".to_string());
4606 let mut quote = IsoQuote::new();
4607 quote.asset = Some("USDT".to_string());
4608 quote.free = Some("100".to_string());
4609 quote.locked = Some("0".to_string());
4610
4611 let map = convert_isolated_margin_assets(
4612 vec![iso_entry("BTCUSDT", base, quote, None, None, None)],
4613 Utc::now(),
4614 );
4615 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4616 assert!(state.base.balance.margin.is_some());
4617 assert_eq!(state.base.balance.net_asset(), d("0.5"));
4618 assert_eq!(state.risk.margin_level, None);
4619 assert_eq!(state.risk.liquidation_price, None);
4620 }
4621
4622 #[test]
4623 fn isolated_unparseable_risk_is_none_but_entry_kept() {
4624 let entry = iso_entry(
4625 "BTCUSDT",
4626 iso_base("BTC", "1", "0", "0", "0"),
4627 iso_quote("USDT", "1", "0", "0", "0"),
4628 Some("not_a_number"),
4629 None,
4630 None,
4631 );
4632 let map = convert_isolated_margin_assets(vec![entry], Utc::now());
4633 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4634 assert_eq!(state.risk.margin_level, None);
4635 }
4636
4637 #[test]
4638 fn isolated_missing_base_free_drops_entry() {
4639 let mut base = IsoBase::new();
4641 base.asset = Some("BTC".to_string());
4642 base.locked = Some("0".to_string()); let entry = iso_entry(
4644 "BTCUSDT",
4645 base,
4646 iso_quote("USDT", "100", "0", "0", "0"),
4647 None,
4648 None,
4649 None,
4650 );
4651 assert!(convert_isolated_margin_assets(vec![entry], Utc::now()).is_empty());
4652 }
4653
4654 #[test]
4655 fn isolated_missing_symbol_drops_entry() {
4656 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4657 e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
4658 e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
4659 assert!(convert_isolated_margin_assets(vec![e], Utc::now()).is_empty());
4661 }
4662
4663 #[test]
4664 fn chunk_symbols_batches_by_five() {
4665 let syms: Vec<InstrumentNameExchange> = (0..12)
4666 .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
4667 .collect();
4668 let chunks = chunk_symbols(&syms);
4669 assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
4670 assert_eq!(chunks[0].split(',').count(), 5);
4671 assert_eq!(chunks[1].split(',').count(), 5);
4672 assert_eq!(chunks[2].split(',').count(), 2);
4673
4674 let three: Vec<_> = (0..3)
4676 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4677 .collect();
4678 assert_eq!(chunk_symbols(&three).len(), 1);
4679
4680 let five: Vec<_> = (0..5)
4682 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4683 .collect();
4684 let c = chunk_symbols(&five);
4685 assert_eq!(c.len(), 1);
4686 assert_eq!(c[0].split(',').count(), 5);
4687
4688 assert!(chunk_symbols(&[]).is_empty());
4690 }
4691
4692 #[test]
4693 fn effective_isolated_set_empty_returns_all_configured() {
4694 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4695 assert_eq!(
4696 client.effective_isolated_set(&[]),
4697 vec![
4698 InstrumentNameExchange::new("BTCUSDT"),
4699 InstrumentNameExchange::new("ETHUSDT"),
4700 ]
4701 );
4702 }
4703
4704 #[test]
4705 fn effective_isolated_set_intersects_requested() {
4706 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4707 assert_eq!(
4708 client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
4709 vec![InstrumentNameExchange::new("ETHUSDT")]
4710 );
4711 }
4712
4713 #[test]
4714 fn effective_isolated_set_skips_out_of_set() {
4715 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4716 assert_eq!(
4718 client.effective_isolated_set(&[
4719 InstrumentNameExchange::new("BTCUSDT"),
4720 InstrumentNameExchange::new("DOGEUSDT"),
4721 ]),
4722 vec![InstrumentNameExchange::new("BTCUSDT")]
4723 );
4724 }
4725
4726 #[tokio::test]
4727 async fn fetch_balances_isolated_returns_empty() {
4728 let client = isolated_client(&["BTCUSDT"]);
4731 assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
4732 }
4733
4734 fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
4735 let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
4736 o.order_id = order_id;
4737 o.client_order_id = Some("cid-9".to_string());
4738 o.side = Some(side.to_string());
4739 o.price = Some("50000".to_string());
4740 o.orig_qty = Some("2".to_string());
4741 o.executed_qty = Some("0.5".to_string());
4742 o.r#type = Some("LIMIT".to_string());
4743 o.time_in_force = Some("GTC".to_string());
4744 o.time = Some(1_700_000_000_000);
4745 o.status = Some("PARTIALLY_FILLED".to_string());
4746 o
4747 }
4748
4749 #[test]
4750 fn margin_open_order_converts() {
4751 let inst = InstrumentNameExchange::new("BTCUSDT");
4752 let order = convert_open_order(
4753 &open_order(Some(42), "BUY"),
4754 ExchangeId::BinanceMargin,
4755 &inst,
4756 &|_| None,
4757 )
4758 .expect("convert");
4759 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4760 assert_eq!(
4761 order.state.id.assigned().map(|id| id.0.as_str()),
4762 Some("42")
4763 );
4764 assert_eq!(order.key.cid.0.as_str(), "cid-9");
4765 assert_eq!(order.side, Side::Buy);
4766 assert_eq!(order.price, Some(Decimal::from(50_000)));
4767 assert_eq!(order.quantity, Decimal::from(2));
4768 assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
4769 assert_eq!(order.kind, OrderKind::Limit);
4770 }
4771
4772 #[test]
4775 fn margin_open_order_that_is_not_live_is_dropped() {
4776 let inst = InstrumentNameExchange::new("BTCUSDT");
4777 let mut o = open_order(Some(42), "BUY");
4778 o.status = Some("CANCELED".to_string());
4779 assert!(convert_open_order(&o, ExchangeId::BinanceMargin, &inst, &|_| None).is_none());
4780 }
4781
4782 #[test]
4783 fn margin_open_order_missing_order_id_is_dropped() {
4784 let inst = InstrumentNameExchange::new("BTCUSDT");
4785 assert!(
4786 convert_open_order(
4787 &open_order(None, "BUY"),
4788 ExchangeId::BinanceMargin,
4789 &inst,
4790 &|_| None
4791 )
4792 .is_none()
4793 );
4794 }
4795
4796 #[test]
4802 fn margin_open_order_prefers_update_time_over_creation_time() {
4803 let inst = InstrumentNameExchange::new("BTCUSDT");
4804
4805 let mut updated = open_order(Some(42), "BUY");
4806 updated.update_time = Some(1_700_000_005_000);
4807 let order = convert_open_order(&updated, ExchangeId::BinanceMargin, &inst, &|_| None)
4808 .expect("convert");
4809 assert_eq!(
4810 order.state.time_exchange,
4811 Utc.timestamp_millis_opt(1_700_000_005_000)
4812 .single()
4813 .unwrap(),
4814 "update_time must win over time"
4815 );
4816 }
4817
4818 #[test]
4821 fn margin_open_order_falls_back_to_creation_time_when_update_time_absent() {
4822 let inst = InstrumentNameExchange::new("BTCUSDT");
4823
4824 let base = open_order(Some(42), "BUY");
4825 assert!(base.update_time.is_none(), "fixture must omit update_time");
4826 let order = convert_open_order(&base, ExchangeId::BinanceMargin, &inst, &|_| None)
4827 .expect("convert");
4828 assert_eq!(
4829 order.state.time_exchange,
4830 Utc.timestamp_millis_opt(1_700_000_000_000)
4831 .single()
4832 .unwrap(),
4833 "absent update_time falls back to time"
4834 );
4835 }
4836
4837 #[test]
4838 fn margin_open_order_owned_symbol_recovers_instrument() {
4839 let mut o = open_order(Some(42), "SELL");
4841 o.symbol = Some("ETHUSDT".to_string());
4842 let order = convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin, &|_| None)
4843 .expect("convert");
4844 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4845 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4846 assert_eq!(order.side, Side::Sell);
4847 }
4848
4849 #[test]
4850 fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
4851 let o = open_order(Some(42), "BUY");
4853 assert!(o.symbol.is_none());
4854 assert!(
4855 convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin, &|_| None).is_none()
4856 );
4857 }
4858
4859 fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
4860 let mut t = QueryMarginAccountsTradeListResponseInner::new();
4861 t.id = id;
4862 t.order_id = Some(7);
4863 t.is_buyer = is_buyer;
4864 t.price = Some("48000".to_string());
4865 t.qty = Some("0.25".to_string());
4866 t.commission = Some("0.001".to_string());
4867 t.commission_asset = Some("BNB".to_string());
4868 t.time = Some(1_700_000_000_000);
4869 t
4870 }
4871
4872 #[test]
4873 fn margin_trade_converts() {
4874 let inst = InstrumentNameExchange::new("BTCUSDT");
4875 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
4876 assert_eq!(tr.id.0.as_str(), "11");
4877 assert_eq!(tr.order_id.0.as_str(), "7");
4878 assert_eq!(tr.side, Side::Sell);
4879 assert_eq!(tr.price, Decimal::from(48_000));
4880 assert_eq!(tr.quantity, Decimal::new(25, 2));
4881 assert_eq!(tr.fees.asset.name().as_str(), "BNB");
4883 assert_eq!(tr.fees.fees, Decimal::new(1, 3));
4884 }
4885
4886 #[test]
4889 fn a_margin_trade_without_a_time_is_at_this_hosts_time() {
4890 let inst = InstrumentNameExchange::new("BTCUSDT");
4891 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).unwrap();
4892 assert_eq!(
4893 (tr.time_exchange, tr.time_source),
4894 (
4895 Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
4896 TimeSource::Venue
4897 )
4898 );
4899 let before = Utc::now();
4900 let mut untimed = trade(Some(11), Some(false));
4901 untimed.time = None;
4902 let tr = convert_margin_trade(&untimed, &inst).unwrap();
4903 assert_eq!(tr.time_source, TimeSource::Host);
4904 assert!((before..=Utc::now()).contains(&tr.time_exchange));
4905 }
4906
4907 #[test]
4908 fn margin_trade_missing_fields_are_dropped() {
4909 let inst = InstrumentNameExchange::new("BTCUSDT");
4910 assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
4912 assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
4913 }
4914
4915 #[test]
4918 fn base_quote_map_extracts_base_and_quote() {
4919 let entries = vec![
4920 iso_entry(
4921 "BTCUSDT",
4922 iso_base("BTC", "0", "0", "0", "0"),
4923 iso_quote("USDT", "0", "0", "0", "0"),
4924 None,
4925 None,
4926 None,
4927 ),
4928 iso_entry(
4929 "ETHBTC",
4930 iso_base("ETH", "0", "0", "0", "0"),
4931 iso_quote("BTC", "0", "0", "0", "0"),
4932 None,
4933 None,
4934 None,
4935 ),
4936 ];
4937 let map = build_base_quote_map(&entries);
4938 assert_eq!(
4939 map.get(&InstrumentNameExchange::new("BTCUSDT"))
4940 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
4941 Some(("BTC", "USDT"))
4942 );
4943 assert_eq!(
4946 map.get(&InstrumentNameExchange::new("ETHBTC"))
4947 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
4948 Some(("ETH", "BTC"))
4949 );
4950 }
4951
4952 struct MarginMyTradesVenue {
4963 trades: Vec<(i64, i64, i64)>,
4965 end_exclusive: bool,
4966 }
4967
4968 impl wiremock::Respond for MarginMyTradesVenue {
4969 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
4970 const DAY_MS: i64 = 24 * 60 * 60 * 1000;
4971 let query: std::collections::HashMap<String, String> =
4972 request.url.query_pairs().into_owned().collect();
4973 let param = |name: &str| query.get(name).map(|v| v.parse::<i64>().unwrap());
4974 let limit = usize::try_from(param("limit").unwrap_or(500)).unwrap();
4975 let order = param("orderId");
4976
4977 let mut selected: Vec<&(i64, i64, i64)> = self
4978 .trades
4979 .iter()
4980 .filter(|(_, _, order_id)| order.is_none_or(|order| *order_id == order))
4981 .collect();
4982 if let Some(from_id) = param("fromId") {
4983 selected.retain(|(id, _, _)| *id >= from_id);
4984 } else if order.is_none() {
4985 let start = param("startTime").expect("a query by time sends startTime");
4986 let end = match param("endTime") {
4987 Some(end) if end - start >= DAY_MS => {
4988 return wiremock::ResponseTemplate::new(400).set_body_json(
4989 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
4990 );
4991 }
4992 Some(end) if self.end_exclusive => end - 1,
4993 Some(end) => end,
4994 None => start + DAY_MS - 1,
4995 };
4996 selected.retain(|(_, time, _)| (start..=end).contains(time));
4997 }
4998
4999 let page: Vec<serde_json::Value> = selected
5000 .into_iter()
5001 .take(limit)
5002 .map(|(id, time, order_id)| {
5003 serde_json::json!({
5004 "id": id, "symbol": "BTCUSDT", "time": time, "orderId": order_id,
5005 "price": "50000", "qty": "0.01", "commission": "0",
5006 "commissionAsset": "USDT", "isBuyer": true, "isMaker": false,
5007 "isBestMatch": true, "isIsolated": true,
5008 })
5009 })
5010 .collect();
5011 wiremock::ResponseTemplate::new(200).set_body_json(page)
5012 }
5013 }
5014
5015 fn since(start: i64) -> MyTradesFrom {
5017 MyTradesFrom::Span {
5018 start,
5019 end: Utc::now().timestamp_millis(),
5020 }
5021 }
5022
5023 async fn read_margin_trades(
5026 venue: MarginMyTradesVenue,
5027 from: MyTradesFrom,
5028 ) -> (Vec<i64>, Vec<std::collections::HashMap<String, String>>) {
5029 let server = wiremock::MockServer::start().await;
5030 wiremock::Mock::given(wiremock::matchers::method("GET"))
5031 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5032 .respond_with(venue)
5033 .mount(&server)
5034 .await;
5035 let rest = Arc::new(MarginTradingRestApi::from_config(
5036 ConfigurationRestApi::builder()
5037 .api_key("key")
5038 .api_secret("secret")
5039 .base_path(server.uri())
5040 .build()
5041 .unwrap(),
5042 ));
5043
5044 let read = paginate_margin_my_trades(
5045 &rest,
5046 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5047 &InstrumentNameExchange::new("BTCUSDT"),
5048 from,
5049 true,
5050 RequestKind::Query,
5051 )
5052 .await
5053 .unwrap();
5054
5055 let requests = server
5056 .received_requests()
5057 .await
5058 .unwrap()
5059 .iter()
5060 .map(|request| request.url.query_pairs().into_owned().collect())
5061 .collect();
5062 (read.into_iter().map(|t| t.id.unwrap()).collect(), requests)
5063 }
5064
5065 #[tokio::test]
5068 async fn recovered_margin_fills_carry_their_orders_client_order_ids() {
5069 recovered_margin_fills_carry_client_order_ids(true).await;
5070 recovered_margin_fills_carry_client_order_ids(false).await;
5071 }
5072
5073 async fn recovered_margin_fills_carry_client_order_ids(is_isolated: bool) {
5074 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5075 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5076 let fill_time = (disconnect + chrono::Duration::minutes(1)).timestamp_millis();
5077 let server = wiremock::MockServer::start().await;
5078 wiremock::Mock::given(wiremock::matchers::method("GET"))
5079 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5080 .respond_with(MarginMyTradesVenue {
5081 trades: vec![(1, fill_time, 1), (2, fill_time, 2)],
5082 end_exclusive: false,
5083 })
5084 .mount(&server)
5085 .await;
5086 wiremock::Mock::given(wiremock::matchers::method("GET"))
5087 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
5088 .and(wiremock::matchers::query_param("orderId", "1"))
5089 .and(wiremock::matchers::query_param(
5090 "isIsolated",
5091 if is_isolated { "TRUE" } else { "FALSE" },
5092 ))
5093 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
5094 serde_json::json!({"symbol": "BTCUSDT", "orderId": 1, "clientOrderId": "repay-1"}),
5095 ))
5096 .mount(&server)
5097 .await;
5098 wiremock::Mock::given(wiremock::matchers::method("GET"))
5099 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
5100 .and(wiremock::matchers::query_param("orderId", "2"))
5101 .respond_with(
5102 wiremock::ResponseTemplate::new(400).set_body_json(
5103 serde_json::json!({"code": -2013, "msg": "Order does not exist."}),
5104 ),
5105 )
5106 .mount(&server)
5107 .await;
5108 let rest = Arc::new(MarginTradingRestApi::from_config(
5109 ConfigurationRestApi::builder()
5110 .api_key("key")
5111 .api_secret("secret")
5112 .base_path(server.uri())
5113 .build()
5114 .unwrap(),
5115 ));
5116 let (tx, mut rx) = mpsc::unbounded_channel();
5117 let mut unrecovered = UnrecoveredFills::default();
5118 unrecovered.open(
5119 &[InstrumentNameExchange::new("BTCUSDT")],
5120 disconnect,
5121 reconnect,
5122 );
5123
5124 recover_margin_fills(
5125 &rest,
5126 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5127 &mut unrecovered,
5128 &tx,
5129 &new_dedup_cache(),
5130 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5131 is_isolated,
5132 )
5133 .await;
5134
5135 let mut cids: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
5136 .filter_map(|event| match event.kind {
5137 AccountEventKind::Trade(trade) => Some((
5138 trade.id.0.to_string(),
5139 trade.client_order_id,
5140 trade.order_filled_quantity.is_some(),
5141 )),
5142 _ => None,
5143 })
5144 .collect();
5145 cids.sort();
5146 assert_eq!(
5148 cids,
5149 [
5150 ("1".to_string(), Some(ClientOrderId::new("repay-1")), true),
5151 ("2".to_string(), None, true),
5152 ],
5153 "is_isolated: {is_isolated}"
5154 );
5155 let order_reads = server
5156 .received_requests()
5157 .await
5158 .unwrap()
5159 .iter()
5160 .filter(|request| request.url.path() == "/sapi/v1/margin/order")
5161 .count();
5162 assert_eq!(order_reads, 2, "one read per order");
5163 }
5164
5165 fn venue(trades: Vec<(i64, i64)>) -> MarginMyTradesVenue {
5167 MarginMyTradesVenue {
5168 trades: trades
5169 .into_iter()
5170 .map(|(id, time)| (id, time, id))
5171 .collect(),
5172 end_exclusive: false,
5173 }
5174 }
5175
5176 const HOUR_MS: i64 = 60 * 60 * 1000;
5177
5178 #[tokio::test]
5181 async fn margin_trades_read_a_lone_fill_beyond_the_first_day() {
5182 let now = Utc::now().timestamp_millis();
5183 let (ids, _) = read_margin_trades(
5184 venue(vec![(7, now - 72 * HOUR_MS)]),
5185 since(now - 120 * HOUR_MS),
5186 )
5187 .await;
5188
5189 assert_eq!(ids, vec![7]);
5190 }
5191
5192 #[tokio::test]
5195 async fn margin_trades_read_fills_across_several_windows_once_each() {
5196 let now = Utc::now().timestamp_millis();
5197 let trades = vec![
5198 (1, now - 119 * HOUR_MS),
5199 (2, now - 72 * HOUR_MS),
5200 (3, now - HOUR_MS),
5201 ];
5202 let (ids, _) = read_margin_trades(venue(trades), since(now - 144 * HOUR_MS)).await;
5203
5204 assert_eq!(ids, vec![1, 2, 3]);
5205 }
5206
5207 #[tokio::test]
5210 async fn margin_trades_read_a_trade_on_a_window_boundary_once() {
5211 for end_exclusive in [false, true] {
5212 let now = Utc::now().timestamp_millis();
5213 let from = now - 48 * HOUR_MS;
5214 let venue = MarginMyTradesVenue {
5215 end_exclusive,
5216 ..venue(vec![(5, from + MARGIN_MY_TRADES_WINDOW_MS)])
5217 };
5218 let (ids, _) = read_margin_trades(venue, since(from)).await;
5219
5220 assert_eq!(ids, vec![5], "end_exclusive: {end_exclusive}");
5221 }
5222 }
5223
5224 #[tokio::test]
5226 async fn margin_trades_page_on_by_id_past_a_full_window() {
5227 let now = Utc::now().timestamp_millis();
5228 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5229 let trades = (1..=count)
5230 .map(|id| (id, now - 48 * HOUR_MS + id))
5231 .collect();
5232 let (ids, _) = read_margin_trades(venue(trades), since(now - 50 * HOUR_MS)).await;
5233
5234 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5235 }
5236
5237 #[tokio::test]
5240 async fn margin_trades_page_on_past_a_full_final_window() {
5241 let now = Utc::now().timestamp_millis();
5242 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5243 let trades = (1..=count).map(|id| (id, now - HOUR_MS + id)).collect();
5244 let (ids, requests) = read_margin_trades(venue(trades), since(now - 2 * HOUR_MS)).await;
5245
5246 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5247 assert_eq!(requests.len(), 2);
5248 }
5249
5250 #[tokio::test]
5253 async fn margin_trades_quiet_lookback_sends_one_request_per_window() {
5254 let now = Utc::now().timestamp_millis();
5255 let (ids, requests) =
5256 read_margin_trades(venue(Vec::new()), since(now - 72 * HOUR_MS)).await;
5257
5258 assert!(ids.is_empty());
5259 assert_eq!(requests.len(), 4);
5261 }
5262
5263 #[tokio::test]
5266 async fn margin_trades_read_a_short_gap_in_one_request() {
5267 let now = Utc::now().timestamp_millis();
5268 let (ids, requests) =
5269 read_margin_trades(venue(vec![(9, now - HOUR_MS)]), since(now - 2 * HOUR_MS)).await;
5270
5271 assert_eq!(ids, vec![9]);
5272 assert_eq!(requests.len(), 1);
5273 }
5274
5275 #[tokio::test]
5278 async fn margin_trades_read_past_the_local_clock_to_the_spans_end() {
5279 let now = Utc::now().timestamp_millis();
5280 let span = MyTradesFrom::Span {
5281 start: now - HOUR_MS,
5282 end: now + HOUR_MS,
5283 };
5284 let (ids, requests) = read_margin_trades(venue(vec![(4, now + HOUR_MS / 2)]), span).await;
5285
5286 assert_eq!(ids, vec![4]);
5287 assert_eq!(requests.len(), 1);
5288 }
5289
5290 #[tokio::test]
5293 async fn margin_trades_to_a_far_end_stop_one_window_past_the_local_clock() {
5294 let now = Utc::now().timestamp_millis();
5295 let span = MyTradesFrom::Span {
5296 start: now - HOUR_MS,
5297 end: i64::MAX,
5298 };
5299 let (ids, requests) = read_margin_trades(venue(Vec::new()), span).await;
5300
5301 assert!(ids.is_empty());
5302 assert_eq!(requests.len(), 2);
5304 let last_end = requests[1]["endTime"].parse::<i64>().unwrap();
5305 let cut = now + MARGIN_MY_TRADES_WINDOW_MS;
5306 assert!(
5308 (cut..cut + 60_000).contains(&last_end),
5309 "the last window ends at {last_end}, not one window past {now}"
5310 );
5311 }
5312
5313 #[tokio::test]
5315 async fn margin_trades_from_the_future_send_no_request() {
5316 let now = Utc::now().timestamp_millis();
5317 let (ids, requests) = read_margin_trades(venue(Vec::new()), since(now + HOUR_MS)).await;
5318
5319 assert!(ids.is_empty());
5320 assert!(requests.is_empty());
5321 }
5322
5323 #[tokio::test]
5326 async fn margin_trades_by_order_page_by_id_without_time_bounds() {
5327 let now = Utc::now().timestamp_millis();
5328 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5329 let trades = (1..=2 * count)
5331 .map(|id| {
5332 (
5333 id,
5334 now - 48 * HOUR_MS + id,
5335 if id % 2 == 0 { 42 } else { 7 },
5336 )
5337 })
5338 .collect();
5339 let venue = MarginMyTradesVenue {
5340 trades,
5341 end_exclusive: false,
5342 };
5343 let (ids, requests) = read_margin_trades(venue, MyTradesFrom::Order(42)).await;
5344
5345 assert_eq!(ids, (1..=count).map(|n| 2 * n).collect::<Vec<_>>());
5346 assert_eq!(requests.len(), 2);
5347 assert!(
5348 requests.iter().all(
5349 |query| query.get("orderId").map(String::as_str) == Some("42")
5350 && !query.contains_key("startTime")
5351 && !query.contains_key("endTime")
5352 )
5353 );
5354 }
5355
5356 async fn margin_trades_error(response: wiremock::ResponseTemplate) -> UnindexedClientError {
5359 let server = wiremock::MockServer::start().await;
5360 wiremock::Mock::given(wiremock::matchers::method("GET"))
5361 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5362 .respond_with(response)
5363 .mount(&server)
5364 .await;
5365 let rest = Arc::new(MarginTradingRestApi::from_config(
5366 ConfigurationRestApi::builder()
5367 .api_key("key")
5368 .api_secret("secret")
5369 .base_path(server.uri())
5370 .build()
5371 .unwrap(),
5372 ));
5373
5374 paginate_margin_my_trades(
5375 &rest,
5376 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5377 &InstrumentNameExchange::new("BTCUSDT"),
5378 since(Utc::now().timestamp_millis() - HOUR_MS),
5379 true,
5380 RequestKind::Query,
5381 )
5382 .await
5383 .unwrap_err()
5384 }
5385
5386 #[tokio::test]
5389 async fn margin_trades_report_a_venue_rejection_as_a_non_transient_request_rejection() {
5390 let err = margin_trades_error(wiremock::ResponseTemplate::new(400).set_body_json(
5391 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
5392 ))
5393 .await;
5394
5395 let UnindexedClientError::Api(ApiError::RequestRejected(msg)) = &err else {
5396 panic!("expected RequestRejected, got {err:?}");
5397 };
5398 assert!(msg.contains("-1127"), "code missing from {msg:?}");
5399 assert!(!err.is_transient());
5400 }
5401
5402 #[tokio::test]
5404 async fn margin_trades_report_an_undecodable_response_as_internal() {
5405 let err = margin_trades_error(
5406 wiremock::ResponseTemplate::new(200)
5407 .set_body_json(serde_json::json!({"unexpected": 1})),
5408 )
5409 .await;
5410
5411 assert!(
5412 matches!(err, UnindexedClientError::Internal(_)),
5413 "got {err:?}"
5414 );
5415 assert!(!err.is_transient());
5416 }
5417
5418 #[tokio::test]
5421 async fn sapi_used_ip_weight_near_the_limit_pauses_queries() {
5422 for (used, pauses) in [("10799", false), ("10800", true), ("not a number", false)] {
5423 let server = wiremock::MockServer::start().await;
5424 wiremock::Mock::given(wiremock::matchers::method("GET"))
5425 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
5426 .respond_with(
5427 wiremock::ResponseTemplate::new(200)
5428 .insert_header("x-sapi-used-ip-weight-1m", used)
5429 .set_body_json(serde_json::json!([])),
5430 )
5431 .mount(&server)
5432 .await;
5433 let rest = Arc::new(MarginTradingRestApi::from_config(
5434 ConfigurationRestApi::builder()
5435 .api_key("key")
5436 .api_secret("secret")
5437 .base_path(server.uri())
5438 .build()
5439 .unwrap(),
5440 ));
5441 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5442
5443 fetch_margin_all_open_orders(
5444 rest,
5445 Arc::clone(&tracker),
5446 KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5447 false,
5448 )
5449 .await
5450 .unwrap();
5451
5452 let query_waits = tokio::time::timeout(
5453 Duration::from_millis(50),
5454 tracker.wait_if_blocked(RequestKind::Query),
5455 )
5456 .await
5457 .is_err();
5458 assert_eq!(query_waits, pauses, "used IP weight {used}");
5459 tokio::time::timeout(
5460 Duration::from_millis(50),
5461 tracker.wait_if_blocked(RequestKind::Order),
5462 )
5463 .await
5464 .expect("orders never wait on the pause")
5465 .expect("no cooldown too long to sleep through");
5466 }
5467 }
5468
5469 #[tokio::test]
5471 async fn margin_fill_recovery_does_not_wait_for_the_weight_pause() {
5472 let server = wiremock::MockServer::start().await;
5473 wiremock::Mock::given(wiremock::matchers::method("GET"))
5474 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5475 .respond_with(
5477 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
5478 "symbol": "BTCUSDT",
5479 "id": 1,
5480 "orderId": 7,
5481 "price": "100",
5482 "qty": "1",
5483 "commission": "0",
5484 "commissionAsset": "USDT",
5485 "time": Utc::now().timestamp_millis(),
5486 "isBuyer": true,
5487 "isMaker": false,
5488 }])),
5489 )
5490 .expect(2)
5491 .mount(&server)
5492 .await;
5493 let rest = Arc::new(MarginTradingRestApi::from_config(
5494 ConfigurationRestApi::builder()
5495 .api_key("key")
5496 .api_secret("secret")
5497 .base_path(server.uri())
5498 .build()
5499 .unwrap(),
5500 ));
5501 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5502 tracker.throttle(Duration::from_secs(60));
5503 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5504 let mut unrecovered = UnrecoveredFills::default();
5505 unrecovered.open(
5506 &[InstrumentNameExchange::new("BTCUSDT")],
5507 Utc::now() - chrono::Duration::minutes(1),
5508 Utc::now(),
5509 );
5510
5511 tokio::time::timeout(
5513 Duration::from_millis(800),
5514 recover_margin_fills(
5515 &rest,
5516 &tracker,
5517 &mut unrecovered,
5518 &tx,
5519 &new_dedup_cache(),
5520 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5521 false,
5522 ),
5523 )
5524 .await
5525 .expect("recovery does not wait for the pause");
5526 }
5527
5528 #[tokio::test]
5531 async fn margin_trades_read_over_a_span_stop_at_its_end() {
5532 let now = Utc::now().timestamp_millis();
5533 let trades = vec![
5534 (1, now - 50 * HOUR_MS),
5535 (2, now - 49 * HOUR_MS),
5536 (3, now - 10 * HOUR_MS),
5537 ];
5538 let (ids, _) = read_margin_trades(
5539 venue(trades),
5540 MyTradesFrom::Span {
5541 start: now - 60 * HOUR_MS,
5542 end: now - 20 * HOUR_MS,
5543 },
5544 )
5545 .await;
5546
5547 assert_eq!(ids, vec![1, 2]);
5548 }
5549
5550 #[tokio::test]
5553 async fn a_margin_gap_whose_every_read_fails_is_reported_when_given_up() {
5554 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5555 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5556 let server = wiremock::MockServer::start().await;
5557 wiremock::Mock::given(wiremock::matchers::method("GET"))
5558 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5559 .respond_with(
5560 wiremock::ResponseTemplate::new(400)
5561 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5562 )
5563 .mount(&server)
5564 .await;
5565 let rest = Arc::new(MarginTradingRestApi::from_config(
5566 ConfigurationRestApi::builder()
5567 .api_key("key")
5568 .api_secret("secret")
5569 .base_path(server.uri())
5570 .build()
5571 .expect("valid config"),
5572 ));
5573 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5574 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5575 let dedup = new_dedup_cache();
5576 let btc = InstrumentNameExchange::new("BTCUSDT");
5577 let mut unrecovered = UnrecoveredFills::default();
5578 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
5579
5580 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
5581 recover_margin_fills(
5582 &rest,
5583 &tracker,
5584 &mut unrecovered,
5585 &tx,
5586 &dedup,
5587 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5588 false,
5589 )
5590 .await;
5591 unrecovered.make_due();
5592 }
5593
5594 assert!(unrecovered.is_empty(), "the gap is given up");
5595 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5596 let [event] = forwarded.as_slice() else {
5597 panic!("only the give-up is sent: {forwarded:?}");
5598 };
5599 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
5600 panic!("expected FillRecoveryGaveUp, got {event:?}");
5601 };
5602 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
5603 assert_eq!(
5604 gave_up.scope,
5605 crate::FillRecoveryScope::Instruments(vec![btc])
5606 );
5607 assert_eq!(
5608 gave_up.start.timestamp_millis(),
5609 disconnect.timestamp_millis()
5610 );
5611 assert_eq!(
5612 gave_up.attempts,
5613 crate::client::order_recovery::MAX_GAP_RETRIES + 1
5614 );
5615 assert!(
5616 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
5617 "the last read's error: {:?}",
5618 gave_up.reason
5619 );
5620 }
5621
5622 #[tokio::test]
5625 async fn a_failed_margin_recovery_keeps_its_closed_gap_for_the_retry() {
5626 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5627 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5628 let server = wiremock::MockServer::start().await;
5629 wiremock::Mock::given(wiremock::matchers::method("GET"))
5630 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5631 .respond_with(
5632 wiremock::ResponseTemplate::new(400)
5633 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5634 )
5635 .up_to_n_times(1)
5636 .with_priority(1)
5637 .mount(&server)
5638 .await;
5639 wiremock::Mock::given(wiremock::matchers::method("GET"))
5640 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5641 .respond_with(venue(vec![
5642 (
5643 1,
5644 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
5645 ),
5646 (
5647 2,
5648 (Utc::now() - chrono::Duration::minutes(1)).timestamp_millis(),
5649 ),
5650 ]))
5651 .mount(&server)
5652 .await;
5653 let rest = Arc::new(MarginTradingRestApi::from_config(
5654 ConfigurationRestApi::builder()
5655 .api_key("key")
5656 .api_secret("secret")
5657 .base_path(server.uri())
5658 .build()
5659 .unwrap(),
5660 ));
5661 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5662 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5663 let dedup = new_dedup_cache();
5664 let mut unrecovered = UnrecoveredFills::default();
5665 unrecovered.open(
5666 &[InstrumentNameExchange::new("BTCUSDT")],
5667 disconnect,
5668 reconnect,
5669 );
5670
5671 recover_margin_fills(
5672 &rest,
5673 &tracker,
5674 &mut unrecovered,
5675 &tx,
5676 &dedup,
5677 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5678 false,
5679 )
5680 .await;
5681 assert!(!unrecovered.is_empty(), "the failed gap is kept");
5682 assert!(rx.try_recv().is_err(), "nothing forwarded");
5683
5684 unrecovered.make_due();
5685 recover_margin_fills(
5686 &rest,
5687 &tracker,
5688 &mut unrecovered,
5689 &tx,
5690 &dedup,
5691 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5692 false,
5693 )
5694 .await;
5695 assert!(unrecovered.is_empty(), "the retry recovered it");
5696 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5697 assert_eq!(
5698 forwarded.len(),
5699 1,
5700 "only the fill inside the gap: {forwarded:?}"
5701 );
5702 }
5703
5704 #[tokio::test]
5707 async fn a_timed_out_margin_recovery_charges_only_the_gaps_it_started() {
5708 let server = wiremock::MockServer::start().await;
5709 wiremock::Mock::given(wiremock::matchers::method("GET"))
5710 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5711 .respond_with(
5712 wiremock::ResponseTemplate::new(200)
5713 .set_body_json(serde_json::json!([]))
5714 .set_delay(Duration::from_secs(3_600)),
5715 )
5716 .mount(&server)
5717 .await;
5718 let rest = Arc::new(MarginTradingRestApi::from_config(
5719 ConfigurationRestApi::builder()
5720 .api_key("key")
5721 .api_secret("secret")
5722 .base_path(server.uri())
5723 .timeout(3_600_000_u64)
5724 .retries(0_u32)
5725 .build()
5726 .unwrap(),
5727 ));
5728 let instruments: Vec<_> = (0..9)
5729 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
5730 .collect();
5731 let mut unrecovered = UnrecoveredFills::default();
5732 unrecovered.open(
5733 &instruments,
5734 Utc::now() - chrono::Duration::minutes(10),
5735 Utc::now(),
5736 );
5737 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5738 tokio::time::pause();
5740
5741 recover_margin_fills(
5742 &rest,
5743 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5744 &mut unrecovered,
5745 &tx,
5746 &new_dedup_cache(),
5747 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5748 false,
5749 )
5750 .await;
5751
5752 let now = tokio::time::Instant::now();
5753 assert_eq!(
5754 unrecovered.due(now).len(),
5755 1,
5756 "the gap never started stays due"
5757 );
5758 assert_eq!(
5759 unrecovered
5760 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
5761 .len(),
5762 9,
5763 "the eight started wait for their first retry"
5764 );
5765 }
5766
5767 fn margin_order_row(cid: &str, status: &str) -> serde_json::Value {
5773 serde_json::json!({
5774 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
5775 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
5776 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "isIsolated": false,
5777 "time": 1_700_000_000_000_i64, "updateTime": 1_700_000_060_000_i64,
5778 })
5779 }
5780
5781 fn margin_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
5782 OrderKey::new(
5783 ExchangeId::BinanceMargin,
5784 InstrumentNameExchange::new(instrument),
5785 StrategyId::new("strategy"),
5786 ClientOrderId::new(cid),
5787 )
5788 }
5789
5790 #[test]
5791 fn a_margin_order_row_says_how_the_order_ended() {
5792 use crate::order::state::InactiveOrderState;
5793 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOrderResponse;
5794 let key = margin_key("BTCUSDT", "a");
5795 let ended = |row: serde_json::Value| {
5796 let row: QueryMarginAccountsOrderResponse = serde_json::from_value(row).unwrap();
5797 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, None)
5798 .map(|order| order.state)
5799 };
5800
5801 let mut filled = margin_order_row("a", "FILLED");
5802 filled["executedQty"] = "2".into();
5803 filled["cummulativeQuoteQty"] = "210".into();
5804 let Some(InactiveOrderState::FullyFilled(filled)) = ended(filled) else {
5805 panic!("FILLED is fully filled");
5806 };
5807 assert_eq!(filled.avg_price, Some(Decimal::from(105)));
5808 assert!(matches!(
5809 ended(margin_order_row("a", "CANCELED")),
5810 Some(InactiveOrderState::Cancelled(cancelled)) if cancelled.filled_quantity == Some(Decimal::ONE)
5811 ));
5812 assert_eq!(ended(margin_order_row("a", "NEW")), None);
5813
5814 let mut unknown_quote = margin_order_row("a", "FILLED");
5816 unknown_quote["executedQty"] = "2".into();
5817 unknown_quote["cummulativeQuoteQty"] = "-1".into();
5818 let Some(InactiveOrderState::FullyFilled(filled)) = ended(unknown_quote) else {
5819 panic!("still fully filled");
5820 };
5821 assert_eq!(filled.avg_price, None, "no negative average price");
5822 }
5823
5824 #[tokio::test(start_paused = true)]
5827 async fn the_host_clock_lead_is_the_clients_server_time_reads() {
5828 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
5829 assert!(config.server_time_sync);
5830 let synced = BinanceMargin::new(config.clone());
5831 assert!(synced.rate_limiter.signing_clock().is_some());
5832 assert!(synced.spot_rate_limiter.signing_clock().is_some());
5833 let mut client = BinanceMargin::new(config.with_server_time_sync(false));
5834 assert!(client.rate_limiter.signing_clock().is_none());
5835 assert!(client.spot_rate_limiter.signing_clock().is_none());
5836 assert_eq!(client.host_clock_lead(), None);
5837
5838 let host_ms = Utc::now().timestamp_millis();
5839 let read: crate::client::binance::server_time::ReadServerTime =
5840 Arc::new(move || Box::pin(async move { Ok(host_ms - 2_000) }));
5841 client.rate_limiter = Arc::new(
5842 RateLimitTracker::new(WeightPool::Sapi)
5843 .with_server_time(Some(ServerTime::new(SigningClock::default(), read))),
5844 );
5845 client
5846 .rate_limiter
5847 .wait_if_blocked(RequestKind::Query)
5848 .await
5849 .unwrap();
5850
5851 let lead = client.host_clock_lead().unwrap();
5852 assert!(
5853 lead >= TimeDelta::seconds(2) && lead < TimeDelta::seconds(3),
5854 "{lead}"
5855 );
5856 }
5857
5858 fn margin_rest_config_at(server: &wiremock::MockServer) -> ConfigurationRestApi {
5860 ConfigurationRestApi::builder()
5861 .api_key("key")
5862 .api_secret("secret")
5863 .base_path(server.uri())
5864 .build()
5865 .unwrap()
5866 }
5867
5868 fn margin_rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
5869 Arc::new(MarginTradingRestApi::from_config(margin_rest_config_at(
5870 server,
5871 )))
5872 }
5873
5874 pub(super) fn margin_client_at(
5876 server: &wiremock::MockServer,
5877 is_isolated: bool,
5878 ) -> BinanceMargin {
5879 let config = if is_isolated {
5880 BinanceMarginConfig::isolated(
5881 "key".into(),
5882 "secret".into(),
5883 vec![InstrumentNameExchange::new("BTCUSDT")],
5884 )
5885 } else {
5886 BinanceMarginConfig::cross_margin("key".into(), "secret".into())
5887 };
5888 let mode = config.pre_send_rules;
5889 let mut client =
5890 <BinanceMargin as ExecutionClient>::new(config.with_server_time_sync(false));
5891 client.rest = margin_rest_at(server);
5892 client.rest_config = Arc::new(margin_rest_config_at(server));
5893 client.spot_rest = Arc::new(binance_sdk::spot::SpotRestApi::from_config(
5894 margin_rest_config_at(server),
5895 ));
5896 client.rules = rules_over(&client, mode);
5897 client
5898 }
5899
5900 fn rules_over(client: &BinanceMargin, mode: PreSendRules) -> Arc<RulesLoader> {
5902 Arc::new(RulesLoader::new(
5903 mode,
5904 client.spot_rest.clone(),
5905 client.spot_rate_limiter.clone(),
5906 ))
5907 }
5908
5909 async fn mount_margin_order(
5911 server: &wiremock::MockServer,
5912 cid: &str,
5913 response: wiremock::ResponseTemplate,
5914 ) {
5915 wiremock::Mock::given(wiremock::matchers::method("GET"))
5916 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
5917 .and(wiremock::matchers::query_param("origClientOrderId", cid))
5918 .respond_with(response)
5919 .mount(server)
5920 .await;
5921 }
5922
5923 fn margin_venue_error(code: i64) -> wiremock::ResponseTemplate {
5924 wiremock::ResponseTemplate::new(400)
5925 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
5926 }
5927
5928 fn margin_row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
5929 wiremock::ResponseTemplate::new(200).set_body_json(margin_order_row(cid, status))
5930 }
5931
5932 async fn margin_requests(server: &wiremock::MockServer) -> Vec<(String, Option<String>)> {
5934 server
5935 .received_requests()
5936 .await
5937 .unwrap()
5938 .iter()
5939 .map(|request| {
5940 let isolated = request
5941 .url
5942 .query_pairs()
5943 .find(|(key, _)| key == "isIsolated")
5944 .map(|(_, value)| value.into_owned());
5945 (request.url.path().to_owned(), isolated)
5946 })
5947 .collect()
5948 }
5949
5950 #[tokio::test]
5955 async fn a_cross_balance_read_is_stamped_the_rest_lag_before_its_request_was_sent() {
5956 margin_balance_reads_are_stamped_the_rest_lag_before_their_requests(false, None).await;
5957 }
5958
5959 #[tokio::test]
5962 async fn an_isolated_balance_read_is_stamped_the_rest_lag_before_its_request_was_sent() {
5963 margin_balance_reads_are_stamped_the_rest_lag_before_their_requests(true, None).await;
5964 }
5965
5966 #[tokio::test]
5971 async fn a_cross_balance_read_is_stamped_on_binances_clock_when_the_host_clock_leads() {
5972 margin_balance_reads_are_stamped_the_rest_lag_before_their_requests(
5973 false,
5974 Some(TimeDelta::seconds(60)),
5975 )
5976 .await;
5977 }
5978
5979 #[tokio::test]
5981 async fn an_isolated_balance_read_is_stamped_on_binances_clock_when_the_host_clock_leads() {
5982 margin_balance_reads_are_stamped_the_rest_lag_before_their_requests(
5983 true,
5984 Some(TimeDelta::seconds(60)),
5985 )
5986 .await;
5987 }
5988
5989 async fn margin_balance_reads_are_stamped_the_rest_lag_before_their_requests(
5993 is_isolated: bool,
5994 lead: Option<TimeDelta>,
5995 ) {
5996 use crate::client::binance::shared::REST_BALANCE_LAG;
5997 const ANSWER_DELAY: Duration = Duration::from_millis(200);
5998 let asset = |name: &str| {
5999 serde_json::json!({
6000 "asset": name, "free": "1", "locked": "0", "borrowed": "0.5", "interest": "0",
6001 "netAsset": "0.5",
6002 })
6003 };
6004 let (account_path, body) = if is_isolated {
6005 (
6006 "/sapi/v1/margin/isolated/account",
6007 serde_json::json!({ "assets": [{
6008 "symbol": "BTCUSDT", "baseAsset": asset("BTC"), "quoteAsset": asset("USDT"),
6009 }] }),
6010 )
6011 } else {
6012 (
6013 "/sapi/v1/margin/account",
6014 serde_json::json!({ "userAssets": [asset("BTC")] }),
6015 )
6016 };
6017 let server = wiremock::MockServer::start().await;
6018 wiremock::Mock::given(wiremock::matchers::method("GET"))
6019 .and(wiremock::matchers::path(account_path))
6020 .respond_with(
6021 wiremock::ResponseTemplate::new(200)
6022 .set_body_json(body)
6023 .set_delay(ANSWER_DELAY),
6024 )
6025 .mount(&server)
6026 .await;
6027 wiremock::Mock::given(wiremock::matchers::method("GET"))
6028 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6029 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
6030 .mount(&server)
6031 .await;
6032 let mut client = margin_client_at(&server, is_isolated);
6033 if let Some(lead) = lead {
6034 client.rate_limiter = Arc::new(
6035 RateLimitTracker::new(WeightPool::Sapi)
6036 .with_server_time(Some(ServerTime::trailing_host(lead))),
6037 );
6038 }
6039 let behind = match lead {
6042 Some(_) => ServerTime::TRAILING_HOST_BEHIND,
6043 None => TimeDelta::milliseconds(1),
6044 };
6045 let lead = lead.unwrap_or_default();
6046
6047 let before = Utc::now();
6048 let fetched = client.fetch_balances(&[]).await.unwrap();
6049 let between = Utc::now();
6050 let snapshot = client
6051 .account_snapshot(&[], &[InstrumentNameExchange::new("BTCUSDT")])
6052 .await
6053 .unwrap();
6054 let after = Utc::now();
6055
6056 let reads: Vec<(Vec<DateTime<Utc>>, _, _)> = if is_isolated {
6058 assert!(fetched.is_empty());
6060 let pair = snapshot.instruments[0].isolated.as_ref().unwrap();
6061 vec![(
6062 vec![pair.base.time_exchange, pair.quote.time_exchange],
6063 between,
6064 after,
6065 )]
6066 } else {
6067 let times = |balances: &[AssetBalance<AssetNameExchange>]| {
6068 balances
6069 .iter()
6070 .map(|balance| balance.time_exchange)
6071 .collect()
6072 };
6073 vec![
6074 (times(&fetched), before, between),
6075 (times(&snapshot.balances), between, after),
6076 ]
6077 };
6078 for (times, from, to) in reads {
6079 assert!(!times.is_empty());
6080 for time in times {
6081 let sent = time + REST_BALANCE_LAG + lead;
6083 assert!(
6084 from - behind <= sent && sent + ANSWER_DELAY <= to,
6085 "{time} + the lag and {lead} not within {from} ..= {to} - {ANSWER_DELAY:?}"
6086 );
6087 }
6088 }
6089 }
6090
6091 #[tokio::test]
6094 async fn a_cross_restatement_read_returns_the_balances_as_snapshots() {
6095 let server = wiremock::MockServer::start().await;
6096 wiremock::Mock::given(wiremock::matchers::method("GET"))
6097 .and(wiremock::matchers::path("/sapi/v1/margin/account"))
6098 .respond_with(wiremock::ResponseTemplate::new(500))
6099 .up_to_n_times(1)
6100 .mount(&server)
6101 .await;
6102 wiremock::Mock::given(wiremock::matchers::method("GET"))
6103 .and(wiremock::matchers::path("/sapi/v1/margin/account"))
6104 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6105 serde_json::json!({ "userAssets": [{
6106 "asset": "BTC", "free": "1", "locked": "0", "borrowed": "0.5", "interest": "0",
6107 "netAsset": "0.5",
6108 }] }),
6109 ))
6110 .mount(&server)
6111 .await;
6112 let (rest, limiter) = (
6113 margin_rest_at(&server),
6114 Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6115 );
6116 let clock = SharedStreamClock::default();
6117 let read = || read_margin_restatement(&rest, &limiter, false, &[], &[], &clock, None);
6119
6120 let failed = read().await;
6121 assert!(failed.is_err(), "{failed:?}");
6122
6123 let restated = read().await.unwrap();
6124 let [event] = restated.as_slice() else {
6125 panic!("one balance: {restated:?}");
6126 };
6127 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
6128 let AccountEventKind::BalanceSnapshot(balance) = &event.kind else {
6129 panic!("a balance snapshot: {event:?}");
6130 };
6131 assert_eq!(balance.0.asset, AssetNameExchange::new("BTC"));
6132 assert_eq!(
6133 balance.0.balance.margin,
6134 Some(crate::balance::MarginDetails::new(
6135 Decimal::new(5, 1),
6136 Decimal::ZERO
6137 ))
6138 );
6139 }
6140
6141 #[tokio::test]
6142 async fn margin_fetch_ended_orders_asks_the_configured_account_and_omits_the_rest() {
6143 use crate::order::state::InactiveOrderState;
6144 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
6145 let server = wiremock::MockServer::start().await;
6146 mount_margin_order(&server, "filled", margin_row_response("filled", "FILLED")).await;
6147 mount_margin_order(&server, "live", margin_row_response("live", "NEW")).await;
6148 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
6149 mount_margin_order(&server, "no-symbol", margin_venue_error(-1121)).await;
6150 let client = margin_client_at(&server, is_isolated);
6151 let keys = ["filled", "live", "unknown"].map(|cid| margin_key("BTCUSDT", cid));
6152
6153 let ended = client
6154 .fetch_ended_orders(
6155 &[keys.to_vec(), vec![margin_key("NOSUCH", "no-symbol")]].concat(),
6156 )
6157 .await
6158 .unwrap();
6159
6160 let [order] = ended.as_slice() else {
6161 panic!("one order ended: {ended:?}");
6162 };
6163 assert_eq!(order.key, keys[0], "the strategy asked survives");
6164 assert!(matches!(order.state, InactiveOrderState::FullyFilled(_)));
6165 let requests = margin_requests(&server).await;
6166 assert_eq!(requests.len(), 4);
6167 for (path, isolated) in requests {
6168 assert_eq!(path, "/sapi/v1/margin/order");
6169 assert_eq!(
6170 isolated.as_deref(),
6171 Some(flag),
6172 "is_isolated = {is_isolated}"
6173 );
6174 }
6175 }
6176 }
6177
6178 #[tokio::test]
6179 async fn margin_fetch_ended_orders_fails_when_any_lookup_fails() {
6180 let server = wiremock::MockServer::start().await;
6181 mount_margin_order(
6182 &server,
6183 "cancelled",
6184 margin_row_response("cancelled", "CANCELED"),
6185 )
6186 .await;
6187 mount_margin_order(&server, "refused", margin_venue_error(-1100)).await;
6188 let client = margin_client_at(&server, false);
6189
6190 let result = client
6191 .fetch_ended_orders(&[
6192 margin_key("BTCUSDT", "cancelled"),
6193 margin_key("BTCUSDT", "refused"),
6194 ])
6195 .await;
6196
6197 assert!(
6198 matches!(
6199 result,
6200 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
6201 ),
6202 "{result:?}"
6203 );
6204 }
6205
6206 fn held_margin_orders() -> SharedKnownLiveOrders {
6208 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6209 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
6210 known.lock().live(
6211 &margin_key("BTCUSDT", cid),
6212 Decimal::TWO,
6213 OrderKind::Limit,
6214 &Open::new(
6215 VenueOrderId::Assigned(OrderId::new(n.to_string())),
6216 Utc::now(),
6217 Decimal::ZERO,
6218 ),
6219 );
6220 }
6221 known
6222 }
6223
6224 #[tokio::test]
6227 async fn a_live_conditional_margin_order_is_listed_with_the_kind_the_client_knows() {
6228 use crate::order::ConditionalFamily;
6229 let server = wiremock::MockServer::start().await;
6230 let mut row = margin_order_row("conditional", "NEW");
6231 row["type"] = "STOP_LOSS_LIMIT".into();
6232 row["price"] = "94".into();
6233 row["stopPrice"] = "95".into();
6234 wiremock::Mock::given(wiremock::matchers::method("GET"))
6235 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6236 .respond_with(
6237 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([row])),
6238 )
6239 .mount(&server)
6240 .await;
6241 let client = margin_client_at(&server, false);
6242 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6243 let cid = ClientOrderId::new("conditional");
6244 let kinds = |orders: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>| {
6245 orders
6246 .into_iter()
6247 .map(|order| (order.key.cid, order.kind))
6248 .collect::<Vec<_>>()
6249 };
6250
6251 let unresolved = OrderKind::ConditionalUnresolved {
6252 family: ConditionalFamily::StopLimit,
6253 trigger_price: Some(Decimal::from(95)),
6254 };
6255 assert_eq!(
6256 kinds(client.fetch_open_orders(&[]).await.unwrap()),
6257 [(cid.clone(), unresolved)]
6258 );
6259 assert_eq!(
6260 kinds(
6261 client
6262 .fetch_open_orders(std::slice::from_ref(&btcusdt))
6263 .await
6264 .unwrap()
6265 ),
6266 [(cid.clone(), unresolved)]
6267 );
6268 let listed = listed_margin_open_cids(
6269 margin_rest_at(&server),
6270 Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6271 client.known_live.clone(),
6272 vec![btcusdt.clone()],
6273 false,
6274 )
6275 .await
6276 .unwrap();
6277 assert!(listed.contains(&cid), "{listed:?}");
6278
6279 let trailing = OrderKind::TrailingStopFixedLimit {
6280 offset: Decimal::from(25),
6281 offset_type: TrailingOffsetType::BasisPoints,
6282 activation_price: Some(Decimal::from(95)),
6283 };
6284 client.recall_order_kinds([(cid.clone(), trailing)]);
6285 assert_eq!(
6286 kinds(client.fetch_open_orders(&[]).await.unwrap()),
6287 [(cid.clone(), trailing)]
6288 );
6289 assert_eq!(
6290 kinds(client.fetch_open_orders(&[btcusdt]).await.unwrap()),
6291 [(cid, trailing)]
6292 );
6293 }
6294
6295 #[tokio::test]
6300 async fn an_ended_margin_trailing_stop_is_reported_with_the_kind_it_was_held_with() {
6301 let server = wiremock::MockServer::start().await;
6302 wiremock::Mock::given(wiremock::matchers::method("GET"))
6303 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6304 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
6305 .mount(&server)
6306 .await;
6307 let mut row = margin_order_row("trailing", "CANCELED");
6308 row["type"] = "STOP_LOSS".into();
6309 row["price"] = "0.00000000".into();
6310 row["stopPrice"] = "0.00000000".into();
6311 mount_margin_order(
6312 &server,
6313 "trailing",
6314 wiremock::ResponseTemplate::new(200).set_body_json(row),
6315 )
6316 .await;
6317 let trailing_stop = OrderKind::TrailingStop {
6318 offset: Decimal::from(25),
6319 offset_type: TrailingOffsetType::BasisPoints,
6320 activation_price: None,
6321 };
6322 let hold = |known: &SharedKnownLiveOrders| {
6323 known.lock().live(
6324 &margin_key("BTCUSDT", "trailing"),
6325 Decimal::TWO,
6326 trailing_stop,
6327 &Open::new(
6328 VenueOrderId::Assigned(OrderId::new("7")),
6329 Utc::now(),
6330 Decimal::ZERO,
6331 ),
6332 );
6333 };
6334
6335 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6336 hold(&known);
6337 let mut unchecked = UncheckedOrders::default();
6338 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
6339 let (tx, mut rx) = mpsc::unbounded_channel();
6340 recover_margin_ended_orders(
6341 &margin_rest_at(&server),
6342 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6343 &known,
6344 &mut unchecked,
6345 &UnrecoveredFills::default(),
6346 &tx,
6347 false,
6348 )
6349 .await;
6350 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6351 let [event] = sent.as_slice() else {
6352 panic!("the trailing stop ended: {sent:?}");
6353 };
6354 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
6355 panic!("an order snapshot: {event:?}");
6356 };
6357 assert_eq!(snapshot.0.kind, trailing_stop);
6358 assert!(matches!(
6359 snapshot.0.state,
6360 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
6361 ));
6362 assert!(!known.lock().contains(&ClientOrderId::new("trailing")));
6363
6364 let client = margin_client_at(&server, false);
6365 hold(&client.known_live);
6366 let ended = client
6367 .fetch_ended_orders(&[margin_key("BTCUSDT", "trailing")])
6368 .await
6369 .expect("lookup");
6370 let [order] = ended.as_slice() else {
6371 panic!("the trailing stop ended: {ended:?}");
6372 };
6373 assert_eq!(order.kind, trailing_stop);
6374 }
6375
6376 #[tokio::test]
6377 async fn a_margin_reconnect_reports_how_an_unlisted_order_ended_in_the_configured_account() {
6378 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
6379 let server = wiremock::MockServer::start().await;
6380 wiremock::Mock::given(wiremock::matchers::method("GET"))
6381 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6382 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
6383 .respond_with(
6384 wiremock::ResponseTemplate::new(200)
6385 .set_body_json(serde_json::json!([margin_order_row("live", "NEW")])),
6386 )
6387 .mount(&server)
6388 .await;
6389 mount_margin_order(&server, "gone", margin_row_response("gone", "CANCELED")).await;
6390 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
6391 let known = held_margin_orders();
6392 let mut unchecked = UncheckedOrders::default();
6393 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
6394 let (tx, mut rx) = mpsc::unbounded_channel();
6395
6396 recover_margin_ended_orders(
6397 &margin_rest_at(&server),
6398 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6399 &known,
6400 &mut unchecked,
6401 &UnrecoveredFills::default(),
6402 &tx,
6403 is_isolated,
6404 )
6405 .await;
6406
6407 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6408 let [event] = sent.as_slice() else {
6409 panic!("one order ended: {sent:?}");
6410 };
6411 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
6412 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
6413 panic!("an order snapshot: {event:?}");
6414 };
6415 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
6416 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
6417 assert!(matches!(
6418 snapshot.0.state,
6419 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
6420 ));
6421 let mut requests = margin_requests(&server).await;
6422 requests.sort();
6423 let expected = |path: &str| (path.to_owned(), Some(flag.to_owned()));
6424 assert_eq!(
6425 requests,
6426 [
6427 expected("/sapi/v1/margin/openOrders"),
6428 expected("/sapi/v1/margin/order"),
6429 expected("/sapi/v1/margin/order"),
6430 ],
6431 "one listing, and a lookup of each unlisted order only"
6432 );
6433 let known = known.lock();
6434 assert!(known.contains(&ClientOrderId::new("live")));
6435 assert!(!known.contains(&ClientOrderId::new("gone")));
6436 assert!(
6437 !known.contains(&ClientOrderId::new("unknown")),
6438 "asking again cannot help"
6439 );
6440 assert!(unchecked.is_empty());
6441 }
6442 }
6443
6444 fn margin_open_request<'a>(
6445 instrument: &'a InstrumentNameExchange,
6446 cid: &str,
6447 ) -> OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange> {
6448 OrderRequestOpen {
6449 key: OrderKey::new(
6450 ExchangeId::BinanceMargin,
6451 instrument,
6452 StrategyId::new("strategy"),
6453 ClientOrderId::new(cid),
6454 ),
6455 state: crate::order::request::RequestOpen::new(
6456 Side::Buy,
6457 Some(Decimal::from(100)),
6458 Decimal::TWO,
6459 OrderKind::Limit,
6460 TimeInForce::GoodUntilCancelled { post_only: false },
6461 ),
6462 }
6463 }
6464
6465 #[tokio::test]
6468 async fn a_margin_trailing_stop_is_sent_with_trailing_delta() {
6469 let server = wiremock::MockServer::start().await;
6470 let sent_as_trailing_stop = |request: &wiremock::Request| {
6471 let mut body_as_query = request.url.clone();
6473 body_as_query.set_query(Some(&String::from_utf8_lossy(&request.body)));
6474 let params: Vec<(String, String)> = request
6475 .url
6476 .query_pairs()
6477 .chain(body_as_query.query_pairs())
6478 .map(|(k, v)| (k.into_owned(), v.into_owned()))
6479 .collect();
6480 let has = |key: &str, value: &str| params.iter().any(|(k, v)| k == key && v == value);
6481 has("type", "STOP_LOSS")
6482 && has("trailingDelta", "25")
6483 && has("sideEffectType", "NO_SIDE_EFFECT")
6484 && !params.iter().any(|(k, _)| k == "stopPrice" || k == "price")
6485 };
6486 wiremock::Mock::given(wiremock::matchers::method("POST"))
6487 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6488 .and(sent_as_trailing_stop)
6489 .respond_with(
6490 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6491 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "trailing",
6492 "transactTime": 1_700_000_000_000_i64, "price": "0", "origQty": "2",
6493 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6494 "timeInForce": "GTC", "type": "STOP_LOSS", "side": "SELL",
6495 })),
6496 )
6497 .expect(1)
6498 .mount(&server)
6499 .await;
6500 let client = margin_client_at(&server, false);
6501 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6502 let trailing_request = |cid, offset| {
6503 let mut request = margin_open_request(&btcusdt, cid);
6504 request.state.side = Side::Sell;
6505 request.state.price = None;
6506 request.state.kind = OrderKind::TrailingStop {
6507 offset,
6508 offset_type: TrailingOffsetType::BasisPoints,
6509 activation_price: None,
6510 };
6511 request
6512 };
6513
6514 let placed = client
6515 .open_order(trailing_request("trailing", Decimal::from(25)))
6516 .await;
6517 assert!(
6518 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6519 "{placed:?}"
6520 );
6521
6522 let refused = client
6523 .open_order(trailing_request("fractional", Decimal::new(25, 1)))
6524 .await;
6525 assert!(
6526 matches!(
6527 refused.state,
6528 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
6529 OrderError::InvalidPrecision(_)
6530 ))
6531 ),
6532 "{refused:?}"
6533 );
6534 }
6535
6536 #[tokio::test]
6540 async fn a_request_margin_effect_picks_the_side_effect_type_sent() {
6541 let server = wiremock::MockServer::start().await;
6542 let sends = |wire: &'static str, auto_repay_at_cancel: Option<&'static str>| {
6543 move |request: &wiremock::Request| {
6544 let mut body_as_query = request.url.clone();
6545 body_as_query.set_query(Some(&String::from_utf8_lossy(&request.body)));
6546 let params: Vec<(String, String)> = request
6547 .url
6548 .query_pairs()
6549 .chain(body_as_query.query_pairs())
6550 .map(|(k, v)| (k.into_owned(), v.into_owned()))
6551 .collect();
6552 let value = |key: &str| {
6553 params
6554 .iter()
6555 .find(|(k, _)| k == key)
6556 .map(|(_, v)| v.as_str())
6557 };
6558 value("sideEffectType") == Some(wire)
6559 && value("autoRepayAtCancel") == auto_repay_at_cancel
6560 }
6561 };
6562 for (matcher, cid) in [
6563 (sends("MARGIN_BUY", Some("true")), "borrow"),
6564 (sends("AUTO_REPAY", None), "repay"),
6565 (sends("NO_SIDE_EFFECT", None), "default"),
6566 ] {
6567 wiremock::Mock::given(wiremock::matchers::method("POST"))
6568 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6569 .and(matcher)
6570 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6571 serde_json::json!({
6572 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
6573 "transactTime": 1_700_000_000_000_i64, "price": "50000", "origQty": "2",
6574 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6575 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6576 }),
6577 ))
6578 .expect(1)
6579 .mount(&server)
6580 .await;
6581 }
6582 let client = margin_client_at(&server, false);
6583 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6584
6585 for (cid, effect) in [
6586 ("borrow", Some(MarginEffect::Borrow)),
6587 ("repay", Some(MarginEffect::Repay)),
6588 ("default", None),
6589 ] {
6590 let mut request = margin_open_request(&btcusdt, cid);
6591 request.state.margin_effect = effect;
6592 let placed = client.open_order(request).await;
6593 assert!(
6594 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6595 "{cid}: {placed:?}"
6596 );
6597 }
6598 }
6599
6600 use crate::error::{Bound, BoundsViolation, OrderField, PrecisionLimit, PrecisionViolation};
6601 use rust_decimal_macros::dec;
6602
6603 fn btcusdt_exchange_info() -> wiremock::ResponseTemplate {
6605 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6606 "timezone": "UTC", "serverTime": 1_700_000_000_000_i64,
6607 "symbols": [{
6608 "symbol": "BTCUSDT", "status": "TRADING",
6609 "filters": [
6610 {"filterType": "PRICE_FILTER", "minPrice": "0.01", "maxPrice": "0", "tickSize": "0.01"},
6611 {"filterType": "LOT_SIZE", "minQty": "0.001", "maxQty": "100", "stepSize": "0.001"},
6612 {"filterType": "NOTIONAL", "minNotional": "5", "applyMinToMarket": true,
6613 "maxNotional": "9000000", "applyMaxToMarket": false, "avgPriceMins": 5},
6614 ],
6615 }],
6616 }))
6617 }
6618
6619 async fn mount_order_accepted(server: &wiremock::MockServer, expected: u64) {
6620 wiremock::Mock::given(wiremock::matchers::method("POST"))
6621 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6622 .respond_with(
6623 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6624 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "c",
6625 "transactTime": 1_700_000_000_000_i64, "price": "50000", "origQty": "2",
6626 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6627 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6628 })),
6629 )
6630 .expect(expected)
6631 .mount(server)
6632 .await;
6633 }
6634
6635 #[tokio::test]
6638 async fn an_order_breaking_the_symbols_rules_is_refused_unsent() {
6639 let server = wiremock::MockServer::start().await;
6640 wiremock::Mock::given(wiremock::matchers::method("GET"))
6641 .and(wiremock::matchers::path("/api/v3/exchangeInfo"))
6642 .and(wiremock::matchers::query_param("symbols", r#"["BTCUSDT"]"#))
6643 .respond_with(btcusdt_exchange_info())
6644 .expect(1)
6645 .mount(&server)
6646 .await;
6647 mount_order_accepted(&server, 1).await;
6648 let client = margin_client_at(&server, false);
6649 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6650 let order = |cid: &str, price: Decimal, quantity: Decimal| {
6651 let mut request = margin_open_request(&btcusdt, cid);
6652 request.state.price = Some(price);
6653 request.state.quantity = quantity;
6654 request
6655 };
6656
6657 let off_tick = client.open_order(order("a", dec!(100.001), dec!(1))).await;
6658 assert_eq!(
6659 off_tick.state,
6660 OrderState::inactive(OrderError::InvalidPrecision(PrecisionViolation::new(
6661 OrderField::Price,
6662 dec!(100.001),
6663 PrecisionLimit::NotOnGrid { step: dec!(0.01) }
6664 )))
6665 );
6666 let dust = client.open_order(order("b", dec!(100), dec!(0.001))).await;
6667 assert_eq!(
6668 dust.state,
6669 OrderState::inactive(OrderError::OutOfBounds(BoundsViolation::new(
6670 OrderField::Notional,
6671 Bound::Min,
6672 dec!(5),
6673 dec!(0.100)
6674 )))
6675 );
6676 let placed = client.open_order(order("c", dec!(100), dec!(1))).await;
6677 assert!(
6678 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6679 "{placed:?}"
6680 );
6681 }
6682
6683 #[tokio::test]
6686 async fn a_trailing_offset_outside_its_sides_range_is_refused_unsent() {
6687 let server = wiremock::MockServer::start().await;
6688 wiremock::Mock::given(wiremock::matchers::method("GET"))
6689 .and(wiremock::matchers::path("/api/v3/exchangeInfo"))
6690 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6691 serde_json::json!({"symbols": [{
6692 "symbol": "BTCUSDT", "status": "TRADING",
6693 "filters": [
6694 {"filterType": "TRAILING_DELTA", "minTrailingAboveDelta": 10,
6695 "maxTrailingAboveDelta": 2000, "minTrailingBelowDelta": 10,
6696 "maxTrailingBelowDelta": 1000},
6697 ],
6698 }]}),
6699 ))
6700 .expect(1)
6701 .mount(&server)
6702 .await;
6703 mount_order_accepted(&server, 0).await;
6704 let client = margin_client_at(&server, false);
6705 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6706 let order = |cid: &str, side: Side, offset: Decimal| {
6707 let mut request = margin_open_request(&btcusdt, cid);
6708 request.state.side = side;
6709 request.state.price = None;
6710 request.state.kind = OrderKind::TrailingStop {
6711 offset,
6712 offset_type: crate::order::TrailingOffsetType::BasisPoints,
6713 activation_price: None,
6714 };
6715 request
6716 };
6717
6718 let buy = client.open_order(order("a", Side::Buy, dec!(2500))).await;
6719 assert_eq!(
6720 buy.state,
6721 OrderState::inactive(OrderError::OutOfBounds(BoundsViolation::new(
6722 OrderField::TrailingOffset,
6723 Bound::Max,
6724 dec!(2000),
6725 dec!(2500)
6726 )))
6727 );
6728 let sell = client.open_order(order("b", Side::Sell, dec!(1500))).await;
6729 assert_eq!(
6730 sell.state,
6731 OrderState::inactive(OrderError::OutOfBounds(BoundsViolation::new(
6732 OrderField::TrailingOffset,
6733 Bound::Max,
6734 dec!(1000),
6735 dec!(1500)
6736 )))
6737 );
6738 }
6739
6740 #[tokio::test]
6742 async fn with_the_rules_off_every_order_is_sent_and_no_rules_are_read() {
6743 let server = wiremock::MockServer::start().await;
6744 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
6745 .respond_with(btcusdt_exchange_info())
6746 .expect(0)
6747 .mount(&server)
6748 .await;
6749 mount_order_accepted(&server, 1).await;
6750 let mut client = margin_client_at(&server, false);
6751 client.rules = rules_over(&client, PreSendRules::Off);
6752 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6753 let mut request = margin_open_request(&btcusdt, "x");
6754 request.state.price = Some(dec!(100.001));
6755 let placed = client.open_order(request).await;
6756 assert!(
6757 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6758 "{placed:?}"
6759 );
6760 }
6761
6762 #[tokio::test]
6764 async fn an_order_whose_rules_cannot_be_loaded_is_sent_unchecked() {
6765 let server = wiremock::MockServer::start().await;
6766 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
6767 .respond_with(wiremock::ResponseTemplate::new(500))
6768 .mount(&server)
6769 .await;
6770 mount_order_accepted(&server, 1).await;
6771 let client = margin_client_at(&server, false);
6772 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6773 let mut request = margin_open_request(&btcusdt, "x");
6774 request.state.price = Some(dec!(100.001));
6775 let placed = client.open_order(request).await;
6776 assert!(
6777 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6778 "{placed:?}"
6779 );
6780 }
6781
6782 #[tokio::test]
6784 async fn load_rules_returns_the_rules_and_the_first_order_reuses_them() {
6785 let server = wiremock::MockServer::start().await;
6786 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
6787 .respond_with(btcusdt_exchange_info())
6788 .expect(1)
6789 .mount(&server)
6790 .await;
6791 mount_order_accepted(&server, 0).await;
6792 let client = margin_client_at(&server, false);
6793 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6794
6795 let loaded = client.load_rules(std::slice::from_ref(&btcusdt)).await;
6796 let rules = loaded.ok().and_then(|mut loaded| loaded.remove(&btcusdt));
6797 assert_eq!(
6798 rules.map(|rules| (rules.price.step, rules.quantity.step, rules.notional.min)),
6799 Some((Some(dec!(0.01)), Some(dec!(0.001)), Some(dec!(5))))
6800 );
6801
6802 let mut request = margin_open_request(&btcusdt, "x");
6803 request.state.price = Some(dec!(100.001));
6804 let refused = client.open_order(request).await;
6805 assert!(
6806 matches!(refused.state, OrderState::Inactive(_)),
6807 "{refused:?}"
6808 );
6809 }
6810
6811 #[tokio::test]
6813 async fn load_rules_of_no_symbols_sends_no_request() {
6814 let server = wiremock::MockServer::start().await;
6815 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
6816 .respond_with(btcusdt_exchange_info())
6817 .expect(0)
6818 .mount(&server)
6819 .await;
6820 let client = margin_client_at(&server, false);
6821 assert_eq!(client.load_rules(&[]).await, Ok(HashMap::new()));
6822 }
6823
6824 #[tokio::test]
6828 async fn a_cross_refusal_of_a_trailing_stop_is_an_unsupported_order_type() {
6829 let server = wiremock::MockServer::start().await;
6830 wiremock::Mock::given(wiremock::matchers::method("POST"))
6831 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6832 .respond_with(
6833 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
6834 "code": -3111,
6835 "msg": "Trailing stop order only supports limit type.",
6836 })),
6837 )
6838 .expect(1)
6839 .mount(&server)
6840 .await;
6841 let client = margin_client_at(&server, false);
6842 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6843 let mut request = margin_open_request(&btcusdt, "trailing");
6844 request.state.side = Side::Sell;
6845 request.state.price = None;
6846 request.state.kind = OrderKind::TrailingStop {
6847 offset: Decimal::from(25),
6848 offset_type: TrailingOffsetType::BasisPoints,
6849 activation_price: None,
6850 };
6851
6852 let refused = client.open_order(request).await;
6853
6854 let OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
6855 OrderError::UnsupportedOrderType(msg),
6856 )) = &refused.state
6857 else {
6858 panic!("expected UnsupportedOrderType: {refused:?}");
6859 };
6860 assert!(
6861 msg.contains("-3111") && msg.contains("only supports limit type"),
6862 "{msg}"
6863 );
6864 }
6865
6866 #[tokio::test]
6867 async fn placing_listing_and_cancelling_margin_orders_feed_the_known_live_set() {
6868 let server = wiremock::MockServer::start().await;
6869 wiremock::Mock::given(wiremock::matchers::method("POST"))
6870 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6871 .respond_with(
6872 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6873 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "placed",
6874 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
6875 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6876 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6877 })),
6878 )
6879 .mount(&server)
6880 .await;
6881 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6882 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6883 .respond_with(
6884 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6885 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
6886 "clientOrderId": "cancel", "price": "100", "origQty": "2", "executedQty": "0",
6887 "cummulativeQuoteQty": "0", "status": "CANCELED", "timeInForce": "GTC",
6888 "type": "LIMIT", "side": "BUY",
6889 })),
6890 )
6891 .mount(&server)
6892 .await;
6893 wiremock::Mock::given(wiremock::matchers::method("GET"))
6894 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6895 .respond_with(
6896 wiremock::ResponseTemplate::new(200)
6897 .set_body_json(serde_json::json!([margin_order_row("listed", "NEW")])),
6898 )
6899 .mount(&server)
6900 .await;
6901 let client = margin_client_at(&server, false);
6902 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6903
6904 let placed = client
6905 .open_order(margin_open_request(&btcusdt, "placed"))
6906 .await;
6907 assert!(
6908 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6909 "{placed:?}"
6910 );
6911 assert!(
6912 client
6913 .known_live
6914 .lock()
6915 .contains(&ClientOrderId::new("placed"))
6916 );
6917
6918 client.fetch_open_orders(&[]).await.unwrap();
6919 assert!(
6920 client
6921 .known_live
6922 .lock()
6923 .contains(&ClientOrderId::new("listed"))
6924 );
6925
6926 let cancelled = client
6927 .cancel_order(OrderRequestCancel {
6928 key: OrderKey::new(
6929 ExchangeId::BinanceMargin,
6930 &btcusdt,
6931 StrategyId::new("strategy"),
6932 ClientOrderId::new("placed"),
6933 ),
6934 state: crate::order::request::RequestCancel {
6935 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
6936 },
6937 })
6938 .await;
6939 assert!(cancelled.state.is_ok(), "{cancelled:?}");
6940 assert!(
6941 !client
6942 .known_live
6943 .lock()
6944 .contains(&ClientOrderId::new("placed"))
6945 );
6946 }
6947
6948 #[tokio::test]
6951 async fn a_margin_order_kind_is_known_while_it_is_placed() {
6952 struct KindWhenAsked {
6953 known: SharedKnownLiveOrders,
6954 seen: Arc<parking_lot::Mutex<Option<OrderKind>>>,
6955 }
6956 impl wiremock::Respond for KindWhenAsked {
6957 fn respond(&self, _: &wiremock::Request) -> wiremock::ResponseTemplate {
6958 *self.seen.lock() = self.known.lock().kind_of(&ClientOrderId::new("racing"));
6959 wiremock::ResponseTemplate::new(400).set_body_json(
6960 serde_json::json!({"code": -2010, "msg": "Account has insufficient balance."}),
6961 )
6962 }
6963 }
6964
6965 let server = wiremock::MockServer::start().await;
6966 let client = margin_client_at(&server, true);
6967 let seen = Arc::new(parking_lot::Mutex::new(None));
6968 wiremock::Mock::given(wiremock::matchers::method("POST"))
6969 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6970 .respond_with(KindWhenAsked {
6971 known: client.known_live.clone(),
6972 seen: Arc::clone(&seen),
6973 })
6974 .mount(&server)
6975 .await;
6976 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6977
6978 let refused = client
6979 .open_order(margin_open_request(&btcusdt, "racing"))
6980 .await;
6981 assert!(
6982 matches!(refused.state, OrderState::Inactive(_)) && !refused.state.is_outcome_unknown(),
6983 "{refused:?}"
6984 );
6985 assert_eq!(*seen.lock(), Some(OrderKind::Limit));
6986 assert_eq!(
6987 client
6988 .known_live
6989 .lock()
6990 .kind_of(&ClientOrderId::new("racing")),
6991 None
6992 );
6993 }
6994
6995 #[tokio::test]
6999 async fn a_dropped_margin_placement_keeps_its_kind_only_once_its_request_is_out() {
7000 let server = wiremock::MockServer::start().await;
7001 wiremock::Mock::given(wiremock::matchers::method("POST"))
7002 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7003 .respond_with(
7004 wiremock::ResponseTemplate::new(200)
7005 .set_body_json(serde_json::json!({}))
7006 .set_delay(Duration::from_secs(3_600)),
7007 )
7008 .mount(&server)
7009 .await;
7010 let client = margin_client_at(&server, true);
7011 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7012 let kind_of = |cid: &str| client.known_live.lock().kind_of(&ClientOrderId::new(cid));
7013 let orders_sent = || async {
7014 let requests = server.received_requests().await.unwrap();
7015 requests
7016 .iter()
7017 .filter(|request| request.method == wiremock::http::Method::POST)
7018 .count()
7019 };
7020
7021 client
7022 .rate_limiter
7023 .on_rate_limited(Some(Duration::from_secs(5)));
7024 let waiting = tokio::time::timeout(
7025 Duration::from_millis(100),
7026 client.open_order(margin_open_request(&btcusdt, "waiting")),
7027 )
7028 .await;
7029 assert!(waiting.is_err(), "still waiting on the rate limiter");
7030 assert_eq!(orders_sent().await, 0);
7031 assert_eq!(kind_of("waiting"), None);
7032
7033 let fresh = margin_client_at(&server, true);
7034 tokio::time::timeout(Duration::from_secs(10), async {
7037 tokio::select! {
7038 placed = fresh.open_order(margin_open_request(&btcusdt, "sent")) => {
7039 panic!("the answer is held back: {placed:?}")
7040 }
7041 () = async {
7042 while orders_sent().await == 0 {
7043 tokio::time::sleep(Duration::from_millis(10)).await;
7044 }
7045 } => {}
7046 }
7047 })
7048 .await
7049 .expect("the request reaches the server");
7050 assert_eq!(orders_sent().await, 1);
7051 assert_eq!(
7052 fresh.known_live.lock().kind_of(&ClientOrderId::new("sent")),
7053 Some(OrderKind::Limit)
7054 );
7055 }
7056
7057 #[tokio::test]
7059 async fn a_cancel_response_without_executed_qty_reports_the_fill_unknown() {
7060 let server = wiremock::MockServer::start().await;
7061 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
7062 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7063 .respond_with(
7064 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
7065 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
7066 "clientOrderId": "cancel", "price": "100", "origQty": "2",
7067 "status": "CANCELED", "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
7068 })),
7069 )
7070 .mount(&server)
7071 .await;
7072 let client = margin_client_at(&server, false);
7073
7074 let cancelled = client
7075 .cancel_order(OrderRequestCancel {
7076 key: OrderKey::new(
7077 ExchangeId::BinanceMargin,
7078 &InstrumentNameExchange::new("BTCUSDT"),
7079 StrategyId::new("strategy"),
7080 ClientOrderId::new("placed"),
7081 ),
7082 state: crate::order::request::RequestCancel {
7083 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
7084 },
7085 })
7086 .await;
7087 let Ok(cancelled) = cancelled.state else {
7088 panic!("expected Ok, got {cancelled:?}");
7089 };
7090 assert_eq!(cancelled.filled_quantity, None);
7091 }
7092
7093 fn borrowing_answer() -> serde_json::Value {
7096 serde_json::json!({
7097 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "borrow",
7098 "transactTime": 1_700_000_000_000_i64, "price": "0", "origQty": "0.0001",
7099 "executedQty": "0.0001", "cummulativeQuoteQty": "8", "status": "FILLED",
7100 "timeInForce": "GTC", "type": "MARKET", "side": "SELL", "isIsolated": false,
7101 "selfTradePreventionMode": "EXPIRE_MAKER",
7102 "marginBuyBorrowAmount": "0.00004321", "marginBuyBorrowAsset": "BTC",
7103 "fills": [{
7104 "price": "80000", "qty": "0.0001", "commission": "0.008",
7105 "commissionAsset": "USDT", "tradeId": 1,
7106 }],
7107 })
7108 }
7109
7110 fn borrowing_request(
7112 instrument: &InstrumentNameExchange,
7113 ) -> OrderRequestOpen<ExchangeId, &InstrumentNameExchange> {
7114 let mut request = margin_open_request(instrument, "borrow");
7115 request.state.side = Side::Sell;
7116 request.state.kind = OrderKind::Market;
7117 request.state.price = None;
7118 request.state.quantity = dec!(0.0001);
7119 request.state.margin_effect = Some(MarginEffect::Borrow);
7120 request
7121 }
7122
7123 fn borrowing_answer_without(fields: &[&str]) -> serde_json::Value {
7125 let mut answer = borrowing_answer();
7126 let object = answer.as_object_mut().unwrap();
7127 for field in fields {
7128 object.remove(*field);
7129 }
7130 answer
7131 }
7132
7133 #[tokio::test]
7136 async fn a_margin_placement_reports_what_the_order_borrowed() {
7137 let mut as_a_number = borrowing_answer();
7138 as_a_number["marginBuyBorrowAmount"] = serde_json::json!(5);
7139 let btc = AssetNameExchange::new("BTC");
7140 for (answer, expected) in [
7141 (
7142 borrowing_answer(),
7143 Some(MarginBorrow::new(btc.clone(), dec!(0.00004321))),
7144 ),
7145 (as_a_number, Some(MarginBorrow::new(btc, dec!(5)))),
7146 (
7147 borrowing_answer_without(&["marginBuyBorrowAmount", "marginBuyBorrowAsset"]),
7148 None,
7149 ),
7150 ] {
7151 let server = wiremock::MockServer::start().await;
7152 wiremock::Mock::given(wiremock::matchers::method("POST"))
7153 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7154 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
7155 .expect(1)
7156 .mount(&server)
7157 .await;
7158 let client = margin_client_at(&server, false);
7159 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7160
7161 let placement = client.open_margin_order(borrowing_request(&btcusdt)).await;
7162
7163 assert!(
7164 matches!(
7165 placement.order.state,
7166 OrderState::Inactive(crate::order::state::InactiveOrderState::FullyFilled(_))
7167 ),
7168 "{answer}: {placement:?}"
7169 );
7170 assert_eq!(placement.borrowed, expected, "{answer}");
7171 }
7172 }
7173
7174 #[tokio::test]
7177 async fn an_order_answered_with_a_fractional_borrowed_amount_is_read() {
7178 let server = wiremock::MockServer::start().await;
7179 wiremock::Mock::given(wiremock::matchers::method("POST"))
7180 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7181 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(borrowing_answer()))
7182 .expect(1)
7183 .mount(&server)
7184 .await;
7185 let client = margin_client_at(&server, false);
7186 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7187
7188 let placed = client.open_order(borrowing_request(&btcusdt)).await;
7189
7190 let OrderState::Inactive(crate::order::state::InactiveOrderState::FullyFilled(filled)) =
7191 placed.state
7192 else {
7193 panic!("expected a filled order, got {placed:?}");
7194 };
7195 assert_eq!(filled.id, OrderId::new("7"));
7196 assert_eq!(filled.filled_quantity, dec!(0.0001));
7197 }
7198
7199 #[tokio::test]
7202 async fn an_open_answered_unreadably_is_an_unknown_outcome() {
7203 for answer in [
7204 serde_json::json!({ "symbol": "BTCUSDT", "clientOrderId": "x" }),
7205 serde_json::json!({ "symbol": "BTCUSDT", "orderId": "not a number" }),
7206 borrowing_answer_without(&["marginBuyBorrowAsset"]),
7207 borrowing_answer_without(&["marginBuyBorrowAmount"]),
7208 ] {
7209 let server = wiremock::MockServer::start().await;
7210 wiremock::Mock::given(wiremock::matchers::method("POST"))
7211 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7212 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
7213 .expect(1)
7214 .mount(&server)
7215 .await;
7216 let client = margin_client_at(&server, false);
7217 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7218
7219 let placed = client
7220 .open_margin_order(margin_open_request(&btcusdt, "x"))
7221 .await;
7222
7223 assert!(
7224 matches!(
7225 placed.order.state,
7226 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
7227 OrderError::Connectivity(_)
7228 ))
7229 ),
7230 "{answer}: {placed:?}"
7231 );
7232 assert_eq!(placed.borrowed, None);
7233 }
7234 }
7235
7236 #[tokio::test]
7239 async fn a_cancel_answered_unreadably_is_an_unknown_outcome() {
7240 for answer in [
7241 serde_json::json!({ "symbol": "BTCUSDT", "status": "CANCELED" }),
7242 serde_json::json!({ "symbol": "BTCUSDT", "orderId": ["7"] }),
7243 serde_json::json!({ "symbol": "BTCUSDT", "orderId": "abc", "transactTime": 1 }),
7244 ] {
7245 let server = wiremock::MockServer::start().await;
7246 mount_order_accepted(&server, 1).await;
7247 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
7248 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7249 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
7250 .expect(1)
7251 .mount(&server)
7252 .await;
7253 let client = margin_client_at(&server, false);
7254 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7255 client.open_order(margin_open_request(&btcusdt, "c")).await;
7256
7257 let cancelled = client
7258 .cancel_order(OrderRequestCancel {
7259 key: OrderKey::new(
7260 ExchangeId::BinanceMargin,
7261 &btcusdt,
7262 StrategyId::new("strategy"),
7263 ClientOrderId::new("c"),
7264 ),
7265 state: crate::order::request::RequestCancel {
7266 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
7267 },
7268 })
7269 .await;
7270
7271 assert!(
7272 matches!(cancelled.state, Err(OrderError::Connectivity(_))),
7273 "{answer}: {cancelled:?}"
7274 );
7275 assert!(client.known_live.lock().contains(&ClientOrderId::new("c")));
7276 }
7277 }
7278
7279 async fn signed_requests_sent(
7282 server: &wiremock::MockServer,
7283 ) -> Vec<(Vec<(String, String)>, Vec<u8>)> {
7284 server
7285 .received_requests()
7286 .await
7287 .unwrap()
7288 .into_iter()
7289 .map(|request| {
7290 let mut query: Vec<(String, String)> = request
7291 .url
7292 .query_pairs()
7293 .filter(|(k, _)| k != "timestamp" && k != "signature")
7294 .map(|(k, v)| (k.into_owned(), v.into_owned()))
7295 .collect();
7296 query.sort();
7297 (query, request.body)
7298 })
7299 .collect()
7300 }
7301
7302 #[tokio::test]
7306 async fn a_cancel_is_stamped_with_the_answers_transact_time() {
7307 let cancel = |answer: serde_json::Value| async move {
7308 let server = wiremock::MockServer::start().await;
7309 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
7310 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7311 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
7312 .expect(1)
7313 .mount(&server)
7314 .await;
7315 let client = margin_client_at(&server, false);
7316 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7317 let cancelled = client
7318 .cancel_order(OrderRequestCancel {
7319 key: OrderKey::new(
7320 ExchangeId::BinanceMargin,
7321 &btcusdt,
7322 StrategyId::new("strategy"),
7323 ClientOrderId::new("c"),
7324 ),
7325 state: crate::order::request::RequestCancel {
7326 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
7327 },
7328 })
7329 .await;
7330 match cancelled.state {
7331 Ok(cancelled) => cancelled,
7332 Err(error) => panic!("{answer}: {error:?}"),
7333 }
7334 };
7335 let answer = |order_id: serde_json::Value, time: Option<i64>| {
7336 let mut answer = serde_json::json!({
7337 "orderId": order_id, "symbol": "BTCUSDT", "origClientOrderId": "c",
7338 "clientOrderId": "cancel", "price": "100", "origQty": "2", "executedQty": "0.5",
7339 "cummulativeQuoteQty": "50", "status": "CANCELED", "timeInForce": "GTC",
7340 "type": "LIMIT", "side": "BUY", "isIsolated": false,
7341 "selfTradePreventionMode": "EXPIRE_MAKER",
7342 });
7343 if let Some(time) = time {
7344 answer["transactTime"] = serde_json::json!(time);
7345 }
7346 answer
7347 };
7348 let sent_at = Utc.timestamp_millis_opt(1_700_000_000_123).unwrap();
7349
7350 for order_id in [serde_json::json!("7"), serde_json::json!(7)] {
7351 let cancelled = cancel(answer(order_id, Some(1_700_000_000_123))).await;
7352 assert_eq!(cancelled.id, OrderId::new("7"));
7353 assert_eq!(cancelled.time_exchange, sent_at);
7354 assert_eq!(cancelled.filled_quantity, Some(dec!(0.5)));
7355 }
7356
7357 let before = Utc::now();
7358 let cancelled = cancel(answer(serde_json::json!("7"), None)).await;
7359 assert!(
7360 cancelled.time_exchange >= before && cancelled.time_exchange <= Utc::now(),
7361 "{cancelled:?}"
7362 );
7363 }
7364
7365 #[tokio::test]
7368 async fn the_cancel_order_query_is_the_one_the_sdk_sends() {
7369 let server = wiremock::MockServer::start().await;
7370 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
7371 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7372 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
7373 .expect(2)
7374 .mount(&server)
7375 .await;
7376 let params = MarginAccountCancelOrderParams::builder("BTCUSDT".to_string())
7377 .is_isolated(MarginAccountCancelOrderIsIsolatedEnum::from_flag(true))
7378 .order_id(7)
7379 .orig_client_order_id("c".to_string())
7380 .new_client_order_id("cancel".to_string())
7381 .recv_window(5000)
7382 .build()
7383 .unwrap();
7384
7385 let _ = margin_rest_at(&server)
7386 .margin_account_cancel_order(params.clone())
7387 .await
7388 .unwrap();
7389 let _ = binance_sdk::common::utils::send_request::<serde_json::Value>(
7390 &margin_rest_config_at(&server),
7391 ORDER_PATH,
7392 reqwest::Method::DELETE,
7393 cancel_order_query(params),
7394 BTreeMap::new(),
7395 None,
7396 true,
7397 )
7398 .await
7399 .unwrap();
7400
7401 let sent = signed_requests_sent(&server).await;
7402 assert_eq!(sent.len(), 2);
7403 assert_eq!(sent[0].0.len(), 6, "every field is sent: {:?}", sent[0].0);
7404 assert_eq!(sent[0], sent[1]);
7405 }
7406
7407 #[tokio::test]
7410 async fn the_new_order_query_is_the_one_the_sdk_sends() {
7411 let server = wiremock::MockServer::start().await;
7412 wiremock::Mock::given(wiremock::matchers::method("POST"))
7413 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7414 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
7415 .expect(2)
7416 .mount(&server)
7417 .await;
7418 let params = MarginAccountNewOrderParams::builder(
7419 "BTCUSDT".to_string(),
7420 MarginAccountNewOrderSideEnum::Sell,
7421 MarginAccountNewOrderTypeEnum::StopLossLimit,
7422 )
7423 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::True)
7424 .quantity(dec!(0.5))
7425 .quote_order_qty(dec!(100.25))
7426 .price(dec!(50000.1))
7427 .stop_price(dec!(49000))
7428 .new_client_order_id("cid".to_string())
7429 .iceberg_qty(dec!(0.1))
7430 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full)
7431 .side_effect_type(MarginAccountNewOrderSideEffectTypeEnum::MarginBuy)
7432 .time_in_force(MarginAccountNewOrderTimeInForceEnum::Gtc)
7433 .self_trade_prevention_mode(
7434 binance_sdk::margin_trading::rest_api::MarginAccountNewOrderSelfTradePreventionModeEnum::ExpireMaker,
7435 )
7436 .trailing_delta(25)
7437 .auto_repay_at_cancel(false)
7438 .recv_window(5000)
7439 .build()
7440 .unwrap();
7441
7442 let _ = margin_rest_at(&server)
7443 .margin_account_new_order(params.clone())
7444 .await
7445 .unwrap();
7446 let _ = binance_sdk::common::utils::send_request::<serde_json::Value>(
7447 &margin_rest_config_at(&server),
7448 ORDER_PATH,
7449 reqwest::Method::POST,
7450 new_order_query(params),
7451 BTreeMap::new(),
7452 None,
7453 true,
7454 )
7455 .await
7456 .unwrap();
7457
7458 let sent = signed_requests_sent(&server).await;
7459 assert_eq!(sent.len(), 2);
7460 assert_eq!(sent[0].0.len(), 17, "every field is sent: {:?}", sent[0].0);
7461 assert_eq!(sent[0], sent[1]);
7462 }
7463
7464 #[test]
7465 fn a_reconnect_opens_a_gap_on_each_stream_instrument_and_checks_those_with_orders_held() {
7466 let [btc, eth, xrp] = ["BTCUSDT", "ETHUSDT", "XRPUSDT"].map(InstrumentNameExchange::new);
7467 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
7468 for (instrument, cid) in [(&btc, "btc"), (&xrp, "xrp")] {
7469 known.lock().live(
7470 &margin_key(instrument.name(), cid),
7471 Decimal::TWO,
7472 OrderKind::Limit,
7473 &Open::new(
7474 VenueOrderId::Assigned(OrderId::new(cid)),
7475 Utc::now(),
7476 Decimal::ZERO,
7477 ),
7478 );
7479 }
7480 let mut unrecovered = UnrecoveredFills::default();
7481 let mut unchecked = UncheckedOrders::default();
7482
7483 open_reconnect_recovery(
7484 &mut unrecovered,
7485 &mut unchecked,
7486 &known,
7487 &[btc.clone(), eth.clone()],
7488 Utc::now() - chrono::Duration::minutes(1),
7489 Utc::now(),
7490 );
7491
7492 assert!(unrecovered.covers(&btc) && unrecovered.covers(ð));
7493 assert!(!unrecovered.covers(&xrp), "not the stream's");
7494 assert!(unchecked.contains(&btc));
7495 assert!(!unchecked.contains(ð), "no order held");
7496 assert!(!unchecked.contains(&xrp), "its fills are not recovered");
7497 }
7498
7499 #[tokio::test]
7502 async fn a_recovered_margin_fill_that_completes_a_held_order_ends_it() {
7503 let disconnect = Utc::now() - chrono::Duration::minutes(10);
7504 let server = wiremock::MockServer::start().await;
7505 wiremock::Mock::given(wiremock::matchers::method("GET"))
7506 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7507 .respond_with(venue(vec![
7508 (
7509 1,
7510 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
7511 ),
7512 (
7513 2,
7514 (disconnect + chrono::Duration::minutes(2)).timestamp_millis(),
7515 ),
7516 ]))
7517 .mount(&server)
7518 .await;
7519 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
7522 for (cid, order_id, quantity) in [("one", "1", "0.01"), ("two", "2", "0.02")] {
7523 known.lock().live(
7524 &margin_key("BTCUSDT", cid),
7525 Decimal::from_str(quantity).unwrap(),
7526 OrderKind::Limit,
7527 &Open::new(
7528 VenueOrderId::Assigned(OrderId::new(order_id)),
7529 Utc::now(),
7530 Decimal::ZERO,
7531 ),
7532 );
7533 }
7534 let mut unrecovered = UnrecoveredFills::default();
7535 unrecovered.open(
7536 &[InstrumentNameExchange::new("BTCUSDT")],
7537 disconnect,
7538 Utc::now(),
7539 );
7540 let (tx, mut rx) = mpsc::unbounded_channel();
7541
7542 recover_margin_fills(
7543 &margin_rest_at(&server),
7544 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
7545 &mut unrecovered,
7546 &tx,
7547 &new_dedup_cache(),
7548 &known,
7549 false,
7550 )
7551 .await;
7552
7553 assert_eq!(
7554 std::iter::from_fn(|| rx.try_recv().ok()).count(),
7555 2,
7556 "both fills"
7557 );
7558 let known = known.lock();
7559 assert!(!known.contains(&ClientOrderId::new("one")), "completed");
7560 assert!(known.contains(&ClientOrderId::new("two")), "part-filled");
7561 }
7562
7563 #[tokio::test]
7566 async fn a_margin_order_that_expired_on_placement_is_not_reported_or_held_open() {
7567 use rust_decimal_macros::dec;
7568
7569 for (cid, filled, quote) in [("unfilled", "0", "0"), ("partly", "1", "100")] {
7570 let server = wiremock::MockServer::start().await;
7571 wiremock::Mock::given(wiremock::matchers::method("POST"))
7572 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7573 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
7574 serde_json::json!({
7575 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
7576 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
7577 "executedQty": filled, "cummulativeQuoteQty": quote,
7578 "status": "EXPIRED", "timeInForce": "IOC", "type": "LIMIT", "side": "BUY",
7579 }),
7580 ))
7581 .mount(&server)
7582 .await;
7583 let client = margin_client_at(&server, false);
7584 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7585
7586 let placed = client.open_order(margin_open_request(&btcusdt, cid)).await;
7587
7588 let OrderState::Inactive(crate::order::state::InactiveOrderState::Expired(expired)) =
7589 &placed.state
7590 else {
7591 panic!("{cid}: expired, not open: {placed:?}");
7592 };
7593 assert_eq!(expired.filled_quantity, Decimal::from_str(filled).ok());
7594 let late = Open::new(
7595 VenueOrderId::Assigned(OrderId::new("7")),
7596 Utc::now(),
7597 Decimal::ZERO,
7598 );
7599 let mut known = client.known_live.lock();
7600 known.live(&placed.key, dec!(2), OrderKind::Limit, &late);
7601 assert!(
7602 !known.contains(&ClientOrderId::new(cid)),
7603 "{cid}: not held, even after a late live report"
7604 );
7605 }
7606 }
7607
7608 async fn mount_borrow_terms(
7612 server: &wiremock::MockServer,
7613 pair: serde_json::Value,
7614 asset: serde_json::Value,
7615 rate: serde_json::Value,
7616 max_borrow: serde_json::Value,
7617 ) {
7618 use wiremock::{
7619 Mock, ResponseTemplate,
7620 matchers::{method, path},
7621 };
7622 for (route, body) in [
7623 ("/sapi/v1/margin/allPairs", pair.clone()),
7624 ("/sapi/v1/margin/isolated/allPairs", pair),
7625 ("/sapi/v1/margin/allAssets", asset),
7626 ("/sapi/v1/margin/next-hourly-interest-rate", rate),
7627 ("/sapi/v1/margin/maxBorrowable", max_borrow),
7628 ("/sapi/v1/margin/delist-schedule", serde_json::json!([])),
7629 ] {
7630 Mock::given(method("GET"))
7631 .and(path(route))
7632 .respond_with(ResponseTemplate::new(200).set_body_json(body))
7633 .mount(server)
7634 .await;
7635 }
7636 }
7637
7638 async fn mount_delist_schedule(server: &wiremock::MockServer, schedule: serde_json::Value) {
7641 use wiremock::{
7642 Mock, ResponseTemplate,
7643 matchers::{method, path},
7644 };
7645 Mock::given(method("GET"))
7646 .and(path("/sapi/v1/margin/delist-schedule"))
7647 .respond_with(ResponseTemplate::new(200).set_body_json(schedule))
7648 .with_priority(1)
7649 .mount(server)
7650 .await;
7651 }
7652
7653 fn btcusdt_pair(is_margin_trade: bool, is_sell_allowed: bool) -> serde_json::Value {
7654 serde_json::json!([{
7655 "base": "BTC", "quote": "USDT", "symbol": "BTCUSDT",
7656 "isMarginTrade": is_margin_trade, "isSellAllowed": is_sell_allowed, "isBuyAllowed": true,
7657 }])
7658 }
7659
7660 fn btc_asset(is_borrowable: bool) -> serde_json::Value {
7661 serde_json::json!([{ "assetName": "BTC", "isBorrowable": is_borrowable }])
7662 }
7663
7664 fn btc_rate(rate: &str) -> serde_json::Value {
7665 serde_json::json!([{ "asset": "BTC", "nextHourlyInterestRate": rate }])
7666 }
7667
7668 async fn query_value(server: &wiremock::MockServer, route: &str, key: &str) -> Option<String> {
7670 server
7671 .received_requests()
7672 .await
7673 .unwrap()
7674 .iter()
7675 .find(|request| request.url.path() == route)
7676 .and_then(|request| {
7677 request
7678 .url
7679 .query_pairs()
7680 .find(|(name, _)| name == key)
7681 .map(|(_, value)| value.into_owned())
7682 })
7683 }
7684
7685 #[tokio::test]
7686 async fn margin_shortability_reads_the_pair_the_asset_and_the_annualised_rate() {
7687 use crate::client::ShortabilityClient;
7688 use rust_decimal_macros::dec;
7689 for (is_isolated, pairs_route, flag) in [
7690 (false, "/sapi/v1/margin/allPairs", "FALSE"),
7691 (true, "/sapi/v1/margin/isolated/allPairs", "TRUE"),
7692 ] {
7693 let server = wiremock::MockServer::start().await;
7694 mount_borrow_terms(
7695 &server,
7696 btcusdt_pair(true, true),
7697 btc_asset(true),
7698 btc_rate("0.00000571"),
7699 serde_json::json!({}),
7700 )
7701 .await;
7702
7703 let shortability = margin_client_at(&server, is_isolated)
7704 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
7705 .await
7706 .unwrap();
7707
7708 assert_eq!(
7709 shortability,
7710 Shortability::new()
7711 .with_shortable(true)
7712 .with_fee_rate(dec!(0.00000571) * dec!(8760)),
7713 "isolated: {is_isolated}"
7714 );
7715 assert_eq!(
7716 query_value(&server, pairs_route, "symbol").await.as_deref(),
7717 Some("BTCUSDT"),
7718 "the pair list is asked for the one symbol"
7719 );
7720 assert_eq!(
7721 query_value(&server, "/sapi/v1/margin/allAssets", "asset")
7722 .await
7723 .as_deref(),
7724 Some("BTC"),
7725 "the base asset is the one borrowed"
7726 );
7727 let rate = "/sapi/v1/margin/next-hourly-interest-rate";
7728 assert_eq!(
7729 query_value(&server, rate, "assets").await.as_deref(),
7730 Some("BTC")
7731 );
7732 assert_eq!(
7733 query_value(&server, rate, "isIsolated").await.as_deref(),
7734 Some(flag)
7735 );
7736 }
7737 }
7738
7739 #[tokio::test]
7740 async fn margin_shortability_is_refused_by_any_flag_and_unknown_without_them() {
7741 use crate::client::ShortabilityClient;
7742 let cases = [
7743 (btcusdt_pair(false, true), btc_asset(true), Some(false)),
7744 (btcusdt_pair(true, false), btc_asset(true), Some(false)),
7745 (btcusdt_pair(true, true), btc_asset(false), Some(false)),
7746 (btcusdt_pair(true, true), serde_json::json!([]), None),
7748 ];
7749 for (pair, asset, shortable) in cases {
7750 let server = wiremock::MockServer::start().await;
7751 mount_borrow_terms(
7752 &server,
7753 pair.clone(),
7754 asset.clone(),
7755 serde_json::json!([]),
7756 serde_json::json!({}),
7757 )
7758 .await;
7759
7760 let shortability = margin_client_at(&server, false)
7761 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
7762 .await
7763 .unwrap();
7764
7765 assert_eq!(shortability.shortable, shortable, "{pair} {asset}");
7766 assert_eq!(
7767 shortability.fee_rate, None,
7768 "no rate returned is an unknown fee"
7769 );
7770 assert_eq!(shortability.easy_to_borrow, None);
7771 assert_eq!(shortability.available, None);
7772 }
7773 }
7774
7775 #[tokio::test]
7779 async fn margin_shortability_reads_the_delisting_schedule_for_this_margin() {
7780 use crate::client::ShortabilityClient;
7781 let past = Utc::now() - chrono::Duration::hours(1);
7782 let soon = Utc::now() + chrono::Duration::days(3);
7783 let later = soon + chrono::Duration::days(7);
7784 let entry = |time: DateTime<Utc>, assets: &[&str], symbols: &[&str]| {
7785 serde_json::json!({
7786 "delistTime": time.timestamp_millis(),
7787 "crossMarginAssets": assets,
7788 "isolatedMarginSymbols": symbols,
7789 })
7790 };
7791 let millis = |time: DateTime<Utc>| DateTime::from_timestamp_millis(time.timestamp_millis());
7792 let cases = [
7793 (
7795 false,
7796 vec![entry(soon, &["BTC"], &[])],
7797 millis(soon),
7798 Some(true),
7799 ),
7800 (
7801 false,
7802 vec![entry(soon, &["USDT"], &[])],
7803 millis(soon),
7804 Some(true),
7805 ),
7806 (
7807 false,
7808 vec![
7809 entry(later, &["BTC"], &[]),
7810 entry(soon, &["ETH", "USDT"], &[]),
7811 ],
7812 millis(soon),
7813 Some(true),
7814 ),
7815 (
7816 false,
7817 vec![entry(soon, &["ETH"], &["BTCUSDT"])],
7818 None,
7819 Some(true),
7820 ),
7821 (
7822 false,
7823 vec![entry(past, &["BTC"], &[])],
7824 millis(past),
7825 Some(false),
7826 ),
7827 (
7829 true,
7830 vec![entry(soon, &[], &["BTCUSDT"])],
7831 millis(soon),
7832 Some(true),
7833 ),
7834 (
7835 true,
7836 vec![entry(soon, &["BTC", "USDT"], &["ETHUSDT"])],
7837 None,
7838 Some(true),
7839 ),
7840 (
7841 true,
7842 vec![entry(past, &[], &["BTCUSDT"])],
7843 millis(past),
7844 Some(false),
7845 ),
7846 ];
7847 for (is_isolated, schedule, delisting_at, shortable) in cases {
7848 let server = wiremock::MockServer::start().await;
7849 mount_borrow_terms(
7850 &server,
7851 btcusdt_pair(true, true),
7852 btc_asset(true),
7853 serde_json::json!([]),
7854 serde_json::json!({}),
7855 )
7856 .await;
7857 let schedule = serde_json::Value::from(schedule);
7858 mount_delist_schedule(&server, schedule.clone()).await;
7859
7860 let shortability = margin_client_at(&server, is_isolated)
7861 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
7862 .await
7863 .unwrap();
7864
7865 let case = format!("isolated: {is_isolated}, {schedule}");
7866 assert_eq!(shortability.delisting_at, delisting_at, "{case}");
7867 assert_eq!(shortability.shortable, shortable, "{case}");
7868 }
7869 }
7870
7871 #[tokio::test]
7873 async fn margin_shortability_of_a_scheduled_pair_without_a_time_is_an_error() {
7874 use crate::client::ShortabilityClient;
7875 let server = wiremock::MockServer::start().await;
7876 mount_borrow_terms(
7877 &server,
7878 btcusdt_pair(true, true),
7879 btc_asset(true),
7880 serde_json::json!([]),
7881 serde_json::json!({}),
7882 )
7883 .await;
7884 mount_delist_schedule(
7885 &server,
7886 serde_json::json!([{ "crossMarginAssets": ["BTC"] }]),
7887 )
7888 .await;
7889
7890 let result = margin_client_at(&server, false)
7891 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
7892 .await;
7893
7894 assert!(
7895 matches!(result, Err(UnindexedClientError::Internal(ref message))
7896 if message.contains("delistTime")),
7897 "{result:?}"
7898 );
7899 }
7900
7901 #[tokio::test]
7902 async fn margin_shortability_of_an_unlisted_pair_is_an_invalid_instrument() {
7903 use crate::client::ShortabilityClient;
7904 let server = wiremock::MockServer::start().await;
7905 mount_borrow_terms(
7906 &server,
7907 serde_json::json!([]),
7908 btc_asset(true),
7909 btc_rate("0.00000571"),
7910 serde_json::json!({}),
7911 )
7912 .await;
7913
7914 let result = margin_client_at(&server, false)
7915 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
7916 .await;
7917
7918 assert!(
7919 matches!(
7920 result,
7921 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
7922 if instrument.name().as_str() == "BTCUSDT"
7923 ),
7924 "{result:?}"
7925 );
7926 }
7927
7928 #[tokio::test]
7929 async fn margin_borrow_capacity_asks_for_the_asset_the_side_borrows() {
7930 use crate::client::BorrowCapacityClient;
7931 use rust_decimal_macros::dec;
7932 for (is_isolated, side, asset, isolated_symbol) in [
7933 (false, Side::Sell, "BTC", None),
7934 (false, Side::Buy, "USDT", None),
7935 (true, Side::Sell, "BTC", Some("BTCUSDT")),
7936 ] {
7937 let server = wiremock::MockServer::start().await;
7938 mount_borrow_terms(
7939 &server,
7940 btcusdt_pair(true, true),
7941 btc_asset(true),
7942 btc_rate("0.00000571"),
7943 serde_json::json!({ "amount": "1.69248805", "borrowLimit": "60" }),
7944 )
7945 .await;
7946
7947 let capacity = margin_client_at(&server, is_isolated)
7948 .fetch_borrow_capacity(&InstrumentNameExchange::new("BTCUSDT"), side)
7949 .await
7950 .unwrap();
7951
7952 assert_eq!(
7953 capacity,
7954 BorrowCapacity::new(AssetNameExchange::new(asset))
7955 .with_borrowable_now(dec!(1.69248805))
7956 .with_account_limit(dec!(60)),
7957 "{side:?} isolated: {is_isolated}"
7958 );
7959 let max_borrow = "/sapi/v1/margin/maxBorrowable";
7960 assert_eq!(
7961 query_value(&server, max_borrow, "asset").await.as_deref(),
7962 Some(asset)
7963 );
7964 assert_eq!(
7965 query_value(&server, max_borrow, "isolatedSymbol")
7966 .await
7967 .as_deref(),
7968 isolated_symbol,
7969 "only isolated margin names the pair's account"
7970 );
7971 }
7972 }
7973
7974 #[tokio::test]
7975 async fn margin_borrow_capacity_keeps_an_omitted_amount_unknown_and_fails_a_malformed_one() {
7976 use crate::client::BorrowCapacityClient;
7977 use rust_decimal_macros::dec;
7978 let instrument = InstrumentNameExchange::new("BTCUSDT");
7979
7980 let server = wiremock::MockServer::start().await;
7981 mount_borrow_terms(
7982 &server,
7983 btcusdt_pair(true, true),
7984 btc_asset(true),
7985 btc_rate("0"),
7986 serde_json::json!({ "borrowLimit": "60" }),
7987 )
7988 .await;
7989 let capacity = margin_client_at(&server, false)
7990 .fetch_borrow_capacity(&instrument, Side::Sell)
7991 .await
7992 .unwrap();
7993 assert_eq!(capacity.borrowable_now, None);
7994 assert_eq!(capacity.account_limit, Some(dec!(60)));
7995
7996 let server = wiremock::MockServer::start().await;
7997 mount_borrow_terms(
7998 &server,
7999 btcusdt_pair(true, true),
8000 btc_asset(true),
8001 btc_rate("0"),
8002 serde_json::json!({ "amount": "lots", "borrowLimit": "60" }),
8003 )
8004 .await;
8005 let result = margin_client_at(&server, false)
8006 .fetch_borrow_capacity(&instrument, Side::Sell)
8007 .await;
8008 assert!(
8009 matches!(result, Err(UnindexedClientError::Internal(ref message)) if message.contains("amount")),
8010 "{result:?}"
8011 );
8012 }
8013
8014 #[tokio::test]
8015 async fn margin_borrow_capacity_without_an_isolated_account_names_the_pair() {
8016 use crate::client::BorrowCapacityClient;
8017 use wiremock::{
8018 Mock,
8019 matchers::{method, path},
8020 };
8021 let server = wiremock::MockServer::start().await;
8022 Mock::given(method("GET"))
8023 .and(path("/sapi/v1/margin/maxBorrowable"))
8024 .respond_with(
8025 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
8026 "code": -11001, "msg": "Isolated margin account does not exist."
8027 })),
8028 )
8029 .mount(&server)
8030 .await;
8031 mount_borrow_terms(
8032 &server,
8033 btcusdt_pair(true, true),
8034 btc_asset(true),
8035 btc_rate("0"),
8036 serde_json::json!({}),
8037 )
8038 .await;
8039
8040 let err = margin_client_at(&server, true)
8041 .fetch_borrow_capacity(&InstrumentNameExchange::new("BTCUSDT"), Side::Sell)
8042 .await
8043 .unwrap_err();
8044
8045 assert_eq!(
8046 err.to_string(),
8047 "API: request rejected: BTCUSDT: -11001 Isolated margin account does not exist."
8048 );
8049 assert!(!err.is_transient());
8050 }
8051
8052 const HOURLY_RATE_ROUTE: &str = "/sapi/v1/margin/next-hourly-interest-rate";
8053
8054 fn assets(names: &[&str]) -> Vec<AssetNameExchange> {
8055 names
8056 .iter()
8057 .map(|name| AssetNameExchange::new(*name))
8058 .collect()
8059 }
8060
8061 async fn hourly_rate_requests(server: &wiremock::MockServer) -> Vec<String> {
8063 server
8064 .received_requests()
8065 .await
8066 .unwrap()
8067 .iter()
8068 .filter(|request| request.url.path() == HOURLY_RATE_ROUTE)
8069 .filter_map(|request| {
8070 request
8071 .url
8072 .query_pairs()
8073 .find(|(key, _)| key == "assets")
8074 .map(|(_, value)| value.into_owned())
8075 })
8076 .collect()
8077 }
8078
8079 async fn mount_echoed_hourly_rates(
8082 server: &wiremock::MockServer,
8083 refused: Option<&'static str>,
8084 ) {
8085 wiremock::Mock::given(wiremock::matchers::path(HOURLY_RATE_ROUTE))
8086 .respond_with(move |request: &wiremock::Request| {
8087 let requested = request
8088 .url
8089 .query_pairs()
8090 .find(|(key, _)| key == "assets")
8091 .map(|(_, value)| value.into_owned())
8092 .unwrap_or_default();
8093 if refused.is_some_and(|refused| requested.split(',').any(|a| a == refused)) {
8094 return margin_venue_error(-1102);
8095 }
8096 let answer: Vec<_> = requested
8097 .split(',')
8098 .map(|asset| {
8099 serde_json::json!({"asset": asset, "nextHourlyInterestRate": "0.0001"})
8100 })
8101 .collect();
8102 wiremock::ResponseTemplate::new(200).set_body_json(answer)
8103 })
8104 .mount(server)
8105 .await;
8106 }
8107
8108 #[tokio::test]
8109 async fn margin_hourly_rates_are_read_once_per_asset_for_the_clients_margin() {
8110 use rust_decimal_macros::dec;
8111 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
8112 let server = wiremock::MockServer::start().await;
8113 wiremock::Mock::given(wiremock::matchers::method("GET"))
8114 .and(wiremock::matchers::path(HOURLY_RATE_ROUTE))
8115 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
8116 serde_json::json!([
8117 {"asset": "BTC", "nextHourlyInterestRate": "0.00000571"},
8118 {"asset": "ETH", "nextHourlyInterestRate": "0.0001"},
8119 {"asset": "USDT", "nextHourlyInterestRate": null},
8120 {"asset": "XRP", "nextHourlyInterestRate": "0.5"},
8121 {"nextHourlyInterestRate": "0.5"},
8122 ]),
8123 ))
8124 .expect(1)
8125 .mount(&server)
8126 .await;
8127 let client = margin_client_at(&server, is_isolated);
8128
8129 let rates = client
8130 .fetch_next_hourly_interest_rates(&assets(&["BTC", "ETH", "BTC", "USDT", "SOL"]))
8131 .await
8132 .unwrap();
8133 assert_eq!(
8134 rates,
8135 HashMap::from([
8136 (AssetNameExchange::new("BTC"), dec!(0.00000571)),
8137 (AssetNameExchange::new("ETH"), dec!(0.0001)),
8138 ]),
8139 "no rate, no answer, or an asset not asked for is absent"
8140 );
8141 assert_eq!(hourly_rate_requests(&server).await, ["BTC,ETH,USDT,SOL"]);
8142 assert_eq!(
8143 query_value(&server, HOURLY_RATE_ROUTE, "isIsolated")
8144 .await
8145 .as_deref(),
8146 Some(flag)
8147 );
8148 assert!(
8149 query_value(&server, HOURLY_RATE_ROUTE, "signature")
8150 .await
8151 .is_some(),
8152 "signed"
8153 );
8154 }
8155 }
8156
8157 #[tokio::test]
8158 async fn margin_hourly_rates_past_twenty_assets_are_read_twenty_at_a_time() {
8159 let server = wiremock::MockServer::start().await;
8160 mount_echoed_hourly_rates(&server, None).await;
8161 let client = margin_client_at(&server, false);
8162 let names: Vec<String> = (0..45).map(|i| format!("A{i}")).collect();
8163 let names: Vec<&str> = names.iter().map(String::as_str).collect();
8164
8165 let rates = client
8166 .fetch_next_hourly_interest_rates(&assets(&names))
8167 .await
8168 .unwrap();
8169 assert_eq!(rates.len(), 45);
8170 assert_eq!(
8171 hourly_rate_requests(&server).await,
8172 [
8173 names[..20].join(","),
8174 names[20..40].join(","),
8175 names[40..].join(",")
8176 ]
8177 );
8178 }
8179
8180 #[tokio::test]
8181 async fn margin_hourly_rates_fail_whole_when_one_request_fails() {
8182 let server = wiremock::MockServer::start().await;
8183 mount_echoed_hourly_rates(&server, Some("A20")).await;
8184 let client = margin_client_at(&server, false);
8185 let names: Vec<String> = (0..25).map(|i| format!("A{i}")).collect();
8186 let names: Vec<&str> = names.iter().map(String::as_str).collect();
8187
8188 let read = client
8189 .fetch_next_hourly_interest_rates(&assets(&names))
8190 .await;
8191 assert!(
8192 matches!(
8193 read,
8194 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
8195 ),
8196 "{read:?}"
8197 );
8198 }
8199
8200 #[tokio::test]
8201 async fn margin_hourly_rates_refuse_an_unreadable_rate_and_send_nothing_for_no_assets() {
8202 let server = wiremock::MockServer::start().await;
8203 wiremock::Mock::given(wiremock::matchers::path(HOURLY_RATE_ROUTE))
8204 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
8205 serde_json::json!([{"asset": "BTC", "nextHourlyInterestRate": "0.0.1"}]),
8206 ))
8207 .mount(&server)
8208 .await;
8209 let client = margin_client_at(&server, false);
8210
8211 let none = client.fetch_next_hourly_interest_rates(&[]).await.unwrap();
8212 assert!(none.is_empty());
8213 assert!(
8214 hourly_rate_requests(&server).await.is_empty(),
8215 "nothing sent"
8216 );
8217
8218 match client
8219 .fetch_next_hourly_interest_rates(&assets(&["BTC"]))
8220 .await
8221 {
8222 Err(UnindexedClientError::Internal(message)) => {
8223 assert!(
8224 message.contains("nextHourlyInterestRate \"0.0.1\""),
8225 "{message}"
8226 );
8227 }
8228 other => panic!("{other:?}"),
8229 }
8230 }
8231
8232 #[tokio::test]
8233 async fn margin_commission_rates_are_the_spot_accounts() {
8234 use crate::client::binance::CommissionPart;
8235 use rust_decimal_macros::dec;
8236 let part = |rate: &str| serde_json::json!({"maker": rate, "taker": rate, "buyer": "0", "seller": "0"});
8237 let server = wiremock::MockServer::start().await;
8238 wiremock::Mock::given(wiremock::matchers::method("GET"))
8239 .and(wiremock::matchers::path("/api/v3/account/commission"))
8240 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
8241 .respond_with(
8242 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8243 "symbol": "BTCUSDT",
8244 "standardCommission": part("0.001"),
8245 "specialCommission": part("0"),
8246 "taxCommission": part("0"),
8247 })),
8248 )
8249 .expect(1)
8250 .mount(&server)
8251 .await;
8252 let client = margin_client_at(&server, true);
8253 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8254
8255 let rates = client.fetch_commission_rates(&btcusdt).await.unwrap();
8256 assert_eq!(
8257 rates.standard,
8258 CommissionPart::new(dec!(0.001), dec!(0.001), dec!(0), dec!(0))
8259 );
8260 assert_eq!(rates.discount, None);
8261 assert!(
8262 query_value(&server, "/api/v3/account/commission", "signature")
8263 .await
8264 .is_some(),
8265 "signed"
8266 );
8267 }
8268}