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