1mod collateral;
63mod loans;
64mod records;
65mod stream;
66
67pub use collateral::{
68 ApplyCollateralRatiosError, CollateralRatioSet, CollateralRatioTier, CollateralRatios,
69 apply_collateral_ratios,
70};
71pub use records::MarginForcedLiquidation;
72
73use super::commission::{CommissionRates, fetch_commission_rates};
74use super::restatement::{SharedStreamClock, StreamClock, restatement_time};
75use super::rules::RulesLoader;
76use super::server_time::{ServerTime, SigningClock, default_server_time_sync, read_server_time};
77use super::shared::{
78 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
79 FILL_RECOVERY_TIMEOUT_SECS, MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing,
80 PlacementResponse, RateLimitTracker, RequestKind, SharedDedupCache, TrailingOrderError,
81 UnrecoveredFills, WeightPool, WireInt, answer_time, binance_filled_qty,
82 classify_order_kind_tif, classify_rest_order_error, classify_rest_query_error,
83 convert_ended_order, convert_open_order_listing, convert_open_order_owned_symbol,
84 dedup_key_from_event, drop_after, gap_failed, gap_time, is_duplicate, is_unknown_order,
85 new_dedup_cache, optional_decimal, placed_order_state, recovered_order_cids,
86 recovered_order_totals, response_decode_error, rest_call_with_retry, rest_read_with_retry,
87 trailing_params, unreadable_answer,
88};
89use crate::rules::{OrderRules, PreSendRules};
90use crate::{
91 AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
92 InstrumentBalanceSnapshot, IsolatedInstrumentState, IsolatedMarginRisk, UnindexedAccountEvent,
93 UnindexedAccountSnapshot,
94 balance::{AssetBalance, Balance},
95 borrow_capacity::BorrowCapacity,
96 client::{
97 BorrowCapacityClient, ExecutionClient, OrderStatusClient, ShortabilityClient,
98 order_recovery::{
99 KnownLiveOrders, MAX_KNOWN_LIVE_ORDERS, OpenListing, OrderLookup,
100 SharedKnownLiveOrders, UncheckedOrders, fetch_ended_by_key, recover_ended_orders,
101 },
102 venue_clock::HostMoment,
103 },
104 error::{ApiError, OrderError, UnindexedClientError},
105 margin::{MarginBorrow, MarginEffect, MarginPlacement, OrderLoan},
106 order::{
107 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
108 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
109 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
110 state::{Cancelled, Open, OrderState, UnindexedOrderState},
111 },
112 position::PositionReport,
113 shortability::Shortability,
114 trade::{AssetFees, TimeSource, Trade, TradeId, TradesRead},
115};
116use binance_sdk::{
117 common::{config::ConfigurationRestApi, constants::MARGIN_TRADING_REST_API_PROD_URL},
118 margin_trading::{
119 MarginTradingRestApi,
120 rest_api::{
121 GetAllCrossMarginPairsParams, GetAllIsolatedMarginSymbolParams,
122 GetAllMarginAssetsParams, GetDelistScheduleParams,
123 GetFutureHourlyInterestRateIsIsolatedEnum, GetFutureHourlyInterestRateParams,
124 MarginAccountCancelOrderIsIsolatedEnum, MarginAccountCancelOrderParams,
125 MarginAccountNewOrderIsIsolatedEnum, MarginAccountNewOrderNewOrderRespTypeEnum,
126 MarginAccountNewOrderParams, MarginAccountNewOrderSideEffectTypeEnum,
127 MarginAccountNewOrderSideEnum, MarginAccountNewOrderTimeInForceEnum,
128 MarginAccountNewOrderTypeEnum, QueryCrossMarginAccountDetailsParams,
129 QueryCrossMarginAccountDetailsResponseUserAssetsInner,
130 QueryIsolatedMarginAccountInfoParams,
131 QueryIsolatedMarginAccountInfoResponseAssetsInner,
132 QueryMarginAccountsOpenOrdersIsIsolatedEnum, QueryMarginAccountsOpenOrdersParams,
133 QueryMarginAccountsOrderIsIsolatedEnum, QueryMarginAccountsOrderParams,
134 QueryMarginAccountsTradeListIsIsolatedEnum, QueryMarginAccountsTradeListParams,
135 QueryMarginAccountsTradeListResponseInner, QueryMaxBorrowParams, RestApi,
136 },
137 },
138};
139use chrono::{DateTime, TimeDelta, TimeZone, Utc};
140use fnv::FnvHashSet;
141use futures::stream::BoxStream;
142use loans::{MAX_LOANS, SharedLoans};
143use rust_decimal::Decimal;
144use rustrade_instrument::{
145 Side,
146 asset::name::AssetNameExchange,
147 exchange::ExchangeId,
148 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
149};
150use serde::Deserialize;
151use smol_str::format_smolstr;
152use std::{
153 collections::{BTreeMap, HashMap},
154 str::FromStr,
155 sync::{
156 Arc,
157 atomic::{AtomicUsize, Ordering},
158 },
159 time::Duration,
160};
161use stream::{MarginStream, STREAM_URL, SapiListenKeys, StreamAccount};
162use tokio::sync::mpsc;
163use tracing::{debug, error, info, warn};
164
165fn binance_side_effect(effect: MarginEffect) -> MarginAccountNewOrderSideEffectTypeEnum {
176 match effect {
177 MarginEffect::NoSideEffect => MarginAccountNewOrderSideEffectTypeEnum::NoSideEffect,
178 MarginEffect::Borrow => MarginAccountNewOrderSideEffectTypeEnum::MarginBuy,
179 MarginEffect::Repay => MarginAccountNewOrderSideEffectTypeEnum::AutoRepay,
180 }
181}
182
183#[derive(Clone, Deserialize)]
193pub struct BinanceMarginConfig {
194 api_key: String,
197 secret_key: String,
198 pub testnet: bool,
204 #[serde(default)]
211 pub is_isolated: bool,
212 #[serde(default)]
224 pub isolated_symbols: Vec<InstrumentNameExchange>,
225 #[serde(default)]
230 pub margin_effect: MarginEffect,
231 #[serde(default = "crate::margin::default_auto_repay_at_cancel")]
238 pub auto_repay_at_cancel: bool,
239 #[serde(default)]
245 pub pre_send_rules: PreSendRules,
246 #[serde(default = "crate::client::binance::server_time::default_server_time_sync")]
249 pub server_time_sync: bool,
250}
251
252impl std::fmt::Debug for BinanceMarginConfig {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 f.debug_struct("BinanceMarginConfig")
256 .field("api_key", &"***")
257 .field("secret_key", &"***")
258 .field("testnet", &self.testnet)
259 .field("is_isolated", &self.is_isolated)
260 .field("isolated_symbols", &self.isolated_symbols)
261 .field("margin_effect", &self.margin_effect)
262 .field("auto_repay_at_cancel", &self.auto_repay_at_cancel)
263 .field("pre_send_rules", &self.pre_send_rules)
264 .field("server_time_sync", &self.server_time_sync)
265 .finish()
266 }
267}
268
269impl BinanceMarginConfig {
270 pub fn new(
281 api_key: String,
282 secret_key: String,
283 testnet: bool,
284 is_isolated: bool,
285 isolated_symbols: Vec<InstrumentNameExchange>,
286 margin_effect: MarginEffect,
287 auto_repay_at_cancel: bool,
288 ) -> Self {
289 Self {
290 api_key,
291 secret_key,
292 testnet,
293 is_isolated,
294 isolated_symbols,
295 margin_effect,
296 auto_repay_at_cancel,
297 pre_send_rules: PreSendRules::default(),
298 server_time_sync: default_server_time_sync(),
299 }
300 }
301
302 #[must_use]
304 pub fn with_pre_send_rules(mut self, pre_send_rules: PreSendRules) -> Self {
305 self.pre_send_rules = pre_send_rules;
306 self
307 }
308
309 #[must_use]
312 pub fn with_server_time_sync(mut self, server_time_sync: bool) -> Self {
313 self.server_time_sync = server_time_sync;
314 self
315 }
316
317 pub fn cross_margin(api_key: String, secret_key: String) -> Self {
324 Self::new(
325 api_key,
326 secret_key,
327 false,
328 false,
329 Vec::new(),
330 MarginEffect::default(),
331 true,
332 )
333 }
334
335 pub fn isolated(
343 api_key: String,
344 secret_key: String,
345 symbols: Vec<InstrumentNameExchange>,
346 ) -> Self {
347 Self::new(
348 api_key,
349 secret_key,
350 false,
351 true,
352 symbols,
353 MarginEffect::default(),
354 true,
355 )
356 }
357
358 pub fn api_key(&self) -> &str {
360 &self.api_key
361 }
362}
363
364#[derive(Clone)]
509pub struct BinanceMargin {
510 config: Arc<BinanceMarginConfig>,
511 rest: Arc<RestApi>,
513 rest_config: Arc<ConfigurationRestApi>,
518 rate_limiter: Arc<RateLimitTracker>,
520 known_live: SharedKnownLiveOrders,
523 loans: SharedLoans,
526 rules: Arc<RulesLoader>,
529 spot_rest: Arc<binance_sdk::spot::rest_api::RestApi>,
532 spot_rate_limiter: Arc<RateLimitTracker>,
533}
534
535impl std::fmt::Debug for BinanceMargin {
536 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
537 f.debug_struct("BinanceMargin")
538 .field("testnet", &self.config.testnet)
539 .field("is_isolated", &self.config.is_isolated)
540 .field("margin_effect", &self.config.margin_effect)
541 .finish_non_exhaustive()
542 }
543}
544
545impl BinanceMargin {
546 pub fn recall_order_kinds(&self, kinds: impl IntoIterator<Item = (ClientOrderId, OrderKind)>) {
573 let kinds: Vec<_> = kinds.into_iter().collect();
574 let ignored = self.known_live.lock().recall(kinds);
576 if ignored > 0 {
577 warn!(
578 ignored,
579 limit = MAX_KNOWN_LIVE_ORDERS,
580 "Binance margin: recalled order kinds past the limit are ignored"
581 );
582 }
583 }
584
585 pub fn recall_loans(&self, loans: impl IntoIterator<Item = OrderLoan>) {
603 let loans: Vec<_> = loans.into_iter().collect();
605 self.hold_loans(loans);
606 }
607
608 pub async fn load_rules(
616 &self,
617 instruments: &[InstrumentNameExchange],
618 ) -> Result<HashMap<InstrumentNameExchange, OrderRules>, UnindexedClientError> {
619 self.rules.load(instruments).await
620 }
621
622 pub async fn fetch_next_hourly_interest_rates(
644 &self,
645 assets: &[AssetNameExchange],
646 ) -> Result<HashMap<AssetNameExchange, Decimal>, UnindexedClientError> {
647 let mut seen = FnvHashSet::default();
648 let unique: Vec<&str> = assets
649 .iter()
650 .map(|asset| asset.name().as_str())
651 .filter(|name| seen.insert(*name))
652 .collect();
653 let is_isolated = self.config.is_isolated;
654 let mut rates = HashMap::with_capacity(unique.len());
655 for chunk in unique.chunks(HOURLY_RATE_ASSETS_PER_REQUEST) {
656 let requested = chunk.join(",");
657 let response =
658 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
659 let requested = requested.clone();
660 Box::pin(async move {
661 let params = GetFutureHourlyInterestRateParams::builder(
662 requested,
663 IsolatedFlag::from_flag(is_isolated),
664 )
665 .build()?;
666 rest.get_future_hourly_interest_rate(params).await
667 })
668 })
669 .await
670 .map_err(|e| classify_rest_query_error(&e, None))?;
671 for listed in response.data().await.map_err(response_decode_error)? {
672 let Some(asset) = listed.asset.filter(|asset| chunk.contains(&asset.as_str()))
673 else {
674 continue;
675 };
676 let rate = optional_decimal(
677 "nextHourlyInterestRate",
678 listed.next_hourly_interest_rate.as_deref(),
679 )?;
680 if let Some(rate) = rate {
681 rates.insert(AssetNameExchange::new(asset), rate);
682 }
683 }
684 }
685 Ok(rates)
686 }
687
688 pub async fn fetch_commission_rates(
701 &self,
702 instrument: &InstrumentNameExchange,
703 ) -> Result<CommissionRates, UnindexedClientError> {
704 fetch_commission_rates(&self.spot_rest, &self.spot_rate_limiter, instrument).await
705 }
706
707 pub fn host_clock_lead(&self) -> Option<TimeDelta> {
715 self.rate_limiter.host_clock_lead()
716 }
717
718 pub async fn open_margin_order(
767 &self,
768 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
769 ) -> MarginPlacement {
770 let instrument = request.key.instrument.clone();
771 let side = request.state.side;
772 let price = request.state.price;
773 let quantity = request.state.quantity;
774 let kind = request.state.kind;
775 let time_in_force = request.state.time_in_force;
776 let cid = request.key.cid.clone();
777
778 let order_key = OrderKey::new(
779 ExchangeId::BinanceMargin,
780 instrument.clone(),
781 request.key.strategy.clone(),
782 cid.clone(),
783 );
784
785 let inactive = |state: UnindexedOrderState| MarginPlacement {
788 order: Order {
789 key: order_key.clone(),
790 side,
791 price,
792 quantity,
793 kind,
794 time_in_force,
795 state,
796 },
797 borrowed: None,
798 };
799
800 let params = match build_new_order_params(
801 instrument.name().to_string(),
802 side,
803 price,
804 quantity,
805 kind,
806 time_in_force,
807 cid.0.to_string(),
808 request
809 .state
810 .margin_effect
811 .unwrap_or(self.config.margin_effect),
812 self.config.auto_repay_at_cancel,
813 self.config.is_isolated,
814 ) {
815 Ok(params) => params,
816 Err(BuildOrderError::Unsupported) => {
817 return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
818 format!(
819 "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
820 ),
821 )));
822 }
823 Err(BuildOrderError::Trailing(refused)) => {
824 return inactive(OrderState::inactive(refused.into_order_error()));
825 }
826 Err(BuildOrderError::Build(msg)) => {
827 error!(%msg, "BinanceMargin failed to build new order params");
828 return inactive(OrderState::inactive(OrderError::Rejected(
829 ApiError::OrderRejected(msg),
830 )));
831 }
832 };
833
834 if let Err(refused) = self
835 .rules
836 .check(instrument.name(), side, kind, price, quantity)
837 .await
838 {
839 return inactive(OrderState::inactive(refused));
840 }
841
842 let mut sending = None;
847 let placement = async {
849 let query = new_order_query(params);
852 let response = match rest_call_with_retry(
853 &self.rest_config,
854 &self.rate_limiter,
855 RequestKind::Order,
856 |cfg| {
857 sending.get_or_insert_with(|| self.known_live.sending(&cid, kind));
858 let query = query.clone();
859 Box::pin(async move {
860 binance_sdk::common::utils::send_request::<NewOrderAnswer>(
861 &cfg,
862 ORDER_PATH,
863 reqwest::Method::POST,
864 query,
865 BTreeMap::new(),
866 None,
867 true,
868 )
869 .await
870 })
871 },
872 )
873 .await
874 {
875 Ok(response) => response,
876 Err(e) => {
877 return inactive(OrderState::inactive(classify_rest_order_error(
878 &e,
879 &instrument,
880 )));
881 }
882 };
883
884 let data = match response.data().await {
885 Ok(data) => data,
886 Err(e) => {
887 return inactive(OrderState::inactive(unreadable_answer(
888 ExchangeId::BinanceMargin,
889 &e.to_string(),
890 )));
891 }
892 };
893
894 let time_exchange = answer_time(
895 ExchangeId::BinanceMargin,
896 &instrument,
897 "open_order",
898 data.transact_time,
899 );
900
901 let exchange_order_id = match data.order_id {
902 Some(id) => OrderId(format_smolstr!("{id}")),
903 None => {
904 return inactive(OrderState::inactive(unreadable_answer(
905 ExchangeId::BinanceMargin,
906 "open_order answer has no orderId",
907 )));
908 }
909 };
910
911 let borrowed = match (data.margin_buy_borrow_amount, data.margin_buy_borrow_asset) {
912 (Some(amount), Some(asset)) => Some(MarginBorrow::new(asset, amount)),
913 (None, None) => None,
914 (Some(_), None) | (None, Some(_)) => {
915 return inactive(OrderState::inactive(unreadable_answer(
916 ExchangeId::BinanceMargin,
917 "open_order answer has a borrowed amount or asset without the other",
918 )));
919 }
920 };
921
922 let state = placed_order_state(
925 ExchangeId::BinanceMargin,
926 &instrument,
927 quantity,
928 PlacementResponse {
929 status: data.status.as_deref(),
930 order_id: exchange_order_id,
931 time_exchange,
932 executed_qty: data.executed_qty.as_deref(),
933 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
934 },
935 );
936 self.known_live
937 .lock()
938 .placed(&order_key, quantity, kind, &state);
939
940 MarginPlacement {
941 order: Order {
942 key: order_key.clone(),
944 side,
945 price,
946 quantity,
947 kind,
948 time_in_force,
949 state,
950 },
951 borrowed,
952 }
953 }
954 .await;
955 if let Some(sending) = sending {
956 sending.sent(&placement.order.state);
957 }
958 if let Some(loan) = placement.loan() {
959 self.hold_loans([loan]);
960 }
961 placement
962 }
963
964 fn hold_loans(&self, loans: impl IntoIterator<Item = OrderLoan>) {
967 let dropped = {
969 let mut held = self.loans.lock();
970 loans
971 .into_iter()
972 .map(|loan| held.insert(loan))
973 .filter(|held| !held)
974 .count()
975 };
976 if dropped > 0 {
977 warn!(
978 dropped,
979 limit = MAX_LOANS,
980 "Binance margin: loans older than every held loan are not held past the limit"
981 );
982 }
983 }
984
985 fn remember_live<'a>(
987 &self,
988 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
989 ) {
990 let mut known = self.known_live.lock();
991 for order in orders {
992 known.live(&order.key, order.quantity, order.kind, &order.state);
993 }
994 }
995
996 #[allow(clippy::expect_used)] fn build_rest_config(
1007 config: &BinanceMarginConfig,
1008 signing: Option<&SigningClock>,
1009 ) -> ConfigurationRestApi {
1010 let mut builder = ConfigurationRestApi::builder()
1011 .api_key(config.api_key.clone())
1012 .api_secret(config.secret_key.clone())
1013 .base_path(MARGIN_TRADING_REST_API_PROD_URL);
1014 if let Some(signing) = signing {
1015 builder = builder.timestamp_provider(signing.provider());
1016 }
1017 builder
1018 .build()
1019 .expect("failed to build Binance margin REST configuration")
1020 }
1021
1022 fn effective_isolated_set(
1035 &self,
1036 instruments: &[InstrumentNameExchange],
1037 ) -> Vec<InstrumentNameExchange> {
1038 if instruments.is_empty() {
1039 return self.config.isolated_symbols.clone();
1040 }
1041 instruments
1042 .iter()
1043 .filter(|inst| {
1044 let in_set = self.config.isolated_symbols.contains(*inst);
1045 if !in_set {
1046 warn!(
1047 instrument = %inst.name(),
1048 "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
1049 );
1050 }
1051 in_set
1052 })
1053 .cloned()
1054 .collect()
1055 }
1056
1057 async fn isolated_account_snapshot(
1061 &self,
1062 instruments: &[InstrumentNameExchange],
1063 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1064 use futures::{StreamExt as _, TryStreamExt as _};
1065
1066 let effective = self.effective_isolated_set(instruments);
1067 if effective.is_empty() {
1068 return Ok(AccountSnapshot::new(
1071 ExchangeId::BinanceMargin,
1072 Vec::new(),
1073 Vec::new(),
1074 ));
1075 }
1076
1077 let mut balances_by_symbol = HashMap::with_capacity(effective.len());
1080 let reads = fetch_isolated_margin_account_info(
1081 self.rest.clone(),
1082 self.rate_limiter.clone(),
1083 effective.clone(),
1084 )
1085 .await?;
1086 for read in reads {
1087 balances_by_symbol.extend(convert_isolated_margin_assets(
1088 read.assets,
1089 self.rate_limiter.balance_read_time(read.sent),
1090 ));
1091 }
1092
1093 let instrument_snapshots: Vec<_> =
1096 futures::stream::iter(effective.into_iter().map(|instrument| {
1097 fetch_margin_open_orders_for_instrument(
1098 self.rest.clone(),
1099 self.rate_limiter.clone(),
1100 self.known_live.clone(),
1101 instrument,
1102 true,
1103 RequestKind::Query,
1104 )
1105 }))
1106 .buffer_unordered(8)
1107 .map(|result| {
1108 let (inst, listing) = result?;
1109 self.remember_live(&listing.orders);
1110 let wrapped = listing
1111 .orders
1112 .into_iter()
1113 .map(active_order_snapshot)
1114 .collect();
1115 let isolated = balances_by_symbol.remove(&inst);
1116 if isolated.is_none() {
1117 warn!(
1121 instrument = %inst.name(),
1122 "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
1123 );
1124 }
1125 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
1126 inst,
1127 wrapped,
1128 listing.complete,
1129 PositionReport::Unreported,
1130 isolated,
1131 ))
1132 })
1133 .try_collect()
1134 .await?;
1135
1136 Ok(AccountSnapshot::new(
1139 ExchangeId::BinanceMargin,
1140 Vec::new(),
1141 instrument_snapshots,
1142 ))
1143 }
1144}
1145
1146impl ExecutionClient for BinanceMargin {
1147 const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
1148
1149 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
1152 &[InstrumentKindDiscriminant::Spot];
1153 type Config = BinanceMarginConfig;
1154 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
1155
1156 fn new(config: Self::Config) -> Self {
1169 if config.testnet {
1170 warn!(
1171 "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
1172 using production endpoints"
1173 );
1174 }
1175 assert!(
1176 !(config.is_isolated && config.isolated_symbols.is_empty()),
1177 "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
1178 );
1179 let signing = config.server_time_sync.then(SigningClock::default);
1184 let rest_config = Self::build_rest_config(&config, signing.as_ref());
1185 let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
1186 let spot_rest = Arc::new(binance_sdk::spot::SpotRestApi::production(
1191 rest_config.clone(),
1192 ));
1193 let server_time =
1197 signing.map(|signing| ServerTime::new(signing, read_server_time(spot_rest.clone())));
1198 let spot_rate_limiter =
1199 Arc::new(RateLimitTracker::new(WeightPool::Spot).with_server_time(server_time.clone()));
1200 let rules = Arc::new(RulesLoader::new(
1201 config.pre_send_rules,
1202 spot_rest.clone(),
1203 spot_rate_limiter.clone(),
1204 ));
1205 let rest_config = Arc::new(rest_config);
1206 Self {
1207 config: Arc::new(config),
1208 rest,
1209 rest_config,
1210 rate_limiter: Arc::new(
1211 RateLimitTracker::new(WeightPool::Sapi).with_server_time(server_time),
1212 ),
1213 known_live: KnownLiveOrders::shared(ExchangeId::BinanceMargin),
1214 loans: SharedLoans::default(),
1215 rules,
1216 spot_rest,
1217 spot_rate_limiter,
1218 }
1219 }
1220
1221 async fn open_order(
1226 &self,
1227 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1228 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1229 self.open_margin_order(request).await.order
1230 }
1231
1232 async fn cancel_order(
1246 &self,
1247 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1248 ) -> UnindexedOrderResponseCancel {
1249 let instrument = request.key.instrument.clone();
1250 let key = OrderKey {
1251 exchange: request.key.exchange,
1252 instrument: instrument.clone(),
1253 strategy: request.key.strategy.clone(),
1254 cid: request.key.cid.clone(),
1255 };
1256
1257 let params = match build_cancel_order_params(
1258 instrument.name().to_string(),
1259 request.state.id.as_ref().and_then(VenueOrderId::assigned),
1260 &request.key.cid,
1261 self.config.is_isolated,
1262 ) {
1263 Ok(p) => p,
1264 Err(e) => {
1265 error!(%e, "BinanceMargin failed to build cancel order params");
1266 return UnindexedOrderResponseCancel {
1267 key,
1268 state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
1269 };
1270 }
1271 };
1272
1273 let query = cancel_order_query(params);
1276 let response = match rest_call_with_retry(
1277 &self.rest_config,
1278 &self.rate_limiter,
1279 RequestKind::Order,
1280 |cfg| {
1281 let query = query.clone();
1282 Box::pin(async move {
1283 binance_sdk::common::utils::send_request::<CancelOrderAnswer>(
1284 &cfg,
1285 ORDER_PATH,
1286 reqwest::Method::DELETE,
1287 query,
1288 BTreeMap::new(),
1289 None,
1290 true,
1291 )
1292 .await
1293 })
1294 },
1295 )
1296 .await
1297 {
1298 Ok(response) => response,
1299 Err(e) => {
1300 return UnindexedOrderResponseCancel {
1301 key,
1302 state: Err(classify_rest_order_error(&e, &instrument)),
1303 };
1304 }
1305 };
1306
1307 let data = match response.data().await {
1308 Ok(data) => data,
1309 Err(e) => {
1310 return UnindexedOrderResponseCancel {
1311 key,
1312 state: Err(unreadable_answer(ExchangeId::BinanceMargin, &e.to_string())),
1313 };
1314 }
1315 };
1316
1317 let time_exchange = answer_time(
1318 ExchangeId::BinanceMargin,
1319 &instrument,
1320 "cancel_order",
1321 data.transact_time,
1322 );
1323
1324 let exchange_order_id = match data.order_id {
1325 Some(WireInt::Int(id)) => OrderId(format_smolstr!("{id}")),
1326 Some(WireInt::Unreadable(raw)) => {
1327 return UnindexedOrderResponseCancel {
1328 key,
1329 state: Err(unreadable_answer(
1330 ExchangeId::BinanceMargin,
1331 &format!("cancel answer has an unreadable orderId: {raw}"),
1332 )),
1333 };
1334 }
1335 None => {
1336 return UnindexedOrderResponseCancel {
1337 key,
1338 state: Err(unreadable_answer(
1339 ExchangeId::BinanceMargin,
1340 "cancel answer has no orderId",
1341 )),
1342 };
1343 }
1344 };
1345
1346 let filled_qty = binance_filled_qty(
1347 ExchangeId::BinanceMargin,
1348 &exchange_order_id,
1349 data.executed_qty.as_deref(),
1350 );
1351
1352 self.known_live.lock().ended(&key.cid);
1353 UnindexedOrderResponseCancel {
1354 key,
1355 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
1356 }
1357 }
1358
1359 async fn account_snapshot(
1387 &self,
1388 assets: &[AssetNameExchange],
1389 instruments: &[InstrumentNameExchange],
1390 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1391 if self.config.is_isolated {
1392 return self.isolated_account_snapshot(instruments).await;
1393 }
1394 let (response, sent) =
1395 rest_read_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1396 Box::pin(async move {
1397 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
1398 rest.query_cross_margin_account_details(params).await
1399 })
1400 })
1401 .await
1402 .map_err(|e| classify_rest_query_error(&e, None))?;
1403
1404 let account = response.data().await.map_err(response_decode_error)?;
1405
1406 let balances = filter_and_convert_margin_balances(
1407 account.user_assets.unwrap_or_default(),
1408 assets,
1409 self.rate_limiter.balance_read_time(sent),
1410 );
1411
1412 use futures::{StreamExt as _, TryStreamExt as _};
1417 let instrument_snapshots: Vec<_> =
1418 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1419 fetch_margin_open_orders_for_instrument(
1420 self.rest.clone(),
1421 self.rate_limiter.clone(),
1422 self.known_live.clone(),
1423 instrument,
1424 false,
1426 RequestKind::Query,
1427 )
1428 }))
1429 .buffer_unordered(8)
1430 .map(|result| {
1431 let (inst, listing) = result?;
1432 self.remember_live(&listing.orders);
1433 let wrapped = listing
1434 .orders
1435 .into_iter()
1436 .map(active_order_snapshot)
1437 .collect();
1438 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
1439 inst,
1440 wrapped,
1441 listing.complete,
1442 PositionReport::Unreported,
1443 None,
1444 ))
1445 })
1446 .try_collect()
1447 .await?;
1448
1449 Ok(AccountSnapshot::new(
1450 ExchangeId::BinanceMargin,
1451 balances,
1452 instrument_snapshots,
1453 ))
1454 }
1455
1456 async fn fetch_balances(
1470 &self,
1471 assets: &[AssetNameExchange],
1472 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1473 if self.config.is_isolated {
1474 return Ok(Vec::new());
1475 }
1476 let (response, sent) =
1477 rest_read_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1478 Box::pin(async move {
1479 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
1480 rest.query_cross_margin_account_details(params).await
1481 })
1482 })
1483 .await
1484 .map_err(|e| classify_rest_query_error(&e, None))?;
1485
1486 let account = response.data().await.map_err(response_decode_error)?;
1487
1488 Ok(filter_and_convert_margin_balances(
1489 account.user_assets.unwrap_or_default(),
1490 assets,
1491 self.rate_limiter.balance_read_time(sent),
1492 ))
1493 }
1494
1495 async fn fetch_open_orders(
1510 &self,
1511 instruments: &[InstrumentNameExchange],
1512 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1513 use futures::{StreamExt as _, TryStreamExt as _};
1514
1515 if self.config.is_isolated {
1519 let effective = self.effective_isolated_set(instruments);
1520 let capacity = effective.len();
1521 return futures::stream::iter(effective.into_iter().map(|instrument| {
1522 fetch_margin_open_orders_for_instrument(
1523 self.rest.clone(),
1524 self.rate_limiter.clone(),
1525 self.known_live.clone(),
1526 instrument,
1527 true,
1528 RequestKind::Query,
1529 )
1530 }))
1531 .buffer_unordered(8)
1532 .try_fold(
1533 Vec::with_capacity(capacity),
1534 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
1535 (_, listing)| async move {
1536 acc.extend(listing.orders);
1537 Ok(acc)
1538 },
1539 )
1540 .await
1541 .inspect(|orders| self.remember_live(orders));
1542 }
1543
1544 if instruments.is_empty() {
1547 let orders = fetch_margin_all_open_orders(
1548 self.rest.clone(),
1549 self.rate_limiter.clone(),
1550 self.known_live.clone(),
1551 false,
1552 )
1553 .await?;
1554 self.remember_live(&orders);
1555 return Ok(orders);
1556 }
1557 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1558 fetch_margin_open_orders_for_instrument(
1559 self.rest.clone(),
1560 self.rate_limiter.clone(),
1561 self.known_live.clone(),
1562 instrument,
1563 false,
1564 RequestKind::Query,
1565 )
1566 }))
1567 .buffer_unordered(8)
1568 .try_fold(
1569 Vec::with_capacity(instruments.len()),
1570 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1571 acc.extend(listing.orders);
1572 Ok(acc)
1573 },
1574 )
1575 .await
1576 .inspect(|orders| self.remember_live(orders))
1577 }
1578
1579 async fn fetch_trades(
1608 &self,
1609 start: DateTime<Utc>,
1610 end: DateTime<Utc>,
1611 instruments: &[InstrumentNameExchange],
1612 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1613 use futures::StreamExt as _;
1614
1615 if start > end {
1616 return Ok(TradesRead::complete(Vec::new()));
1617 }
1618
1619 let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1622 self.effective_isolated_set(instruments)
1623 } else {
1624 instruments.to_vec()
1625 };
1626 if effective.is_empty() {
1627 debug!(
1630 is_isolated = self.config.is_isolated,
1631 "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1632 );
1633 return Ok(TradesRead::complete(Vec::new()));
1634 }
1635 let span = MyTradesFrom::Span {
1637 start: start.timestamp_millis(),
1638 end: end.timestamp_millis(),
1639 };
1640 let is_isolated = self.config.is_isolated;
1641 let mut all_trades = Vec::new();
1642
1643 let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1646 let rest = self.rest.clone();
1647 let rate_limiter = self.rate_limiter.clone();
1648 async move {
1649 let pages = paginate_margin_my_trades(
1650 &rest,
1651 &rate_limiter,
1652 &inst,
1653 span,
1654 is_isolated,
1655 RequestKind::Query,
1656 )
1657 .await?;
1658 Ok::<_, UnindexedClientError>((inst, pages))
1659 }
1660 }))
1661 .buffer_unordered(8);
1662 while let Some(result) = stream.next().await {
1663 let (instrument, trades_data) = result?;
1664 for t in trades_data {
1665 if let Some(trade) = convert_margin_trade(&t, &instrument)
1666 && (start..=end).contains(&trade.time_exchange)
1667 {
1668 all_trades.push(trade);
1669 }
1670 }
1671 }
1672
1673 Ok(TradesRead::complete(all_trades))
1674 }
1675
1676 async fn account_stream(
1836 &self,
1837 assets: &[AssetNameExchange],
1841 instruments: &[InstrumentNameExchange],
1842 ) -> Result<Self::AccountStream, UnindexedClientError> {
1843 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1844
1845 let (account, instruments) = if self.config.is_isolated {
1846 let symbols = self.config.isolated_symbols.clone();
1847 let assets: Vec<_> = fetch_isolated_margin_account_info(
1850 self.rest.clone(),
1851 self.rate_limiter.clone(),
1852 symbols.clone(),
1853 )
1854 .await?
1855 .into_iter()
1856 .flat_map(|read| read.assets)
1857 .collect();
1858 let mut base_quote = build_base_quote_map(&assets);
1859 let pairs = symbols
1860 .iter()
1861 .map(|symbol| {
1862 let assets = base_quote.remove(symbol);
1863 if assets.is_none() {
1864 warn!(
1865 symbol = %symbol.name(),
1866 "BinanceMargin isolated: account info returned no base/quote for \
1867 configured symbol — its live per-pair balance frames will be dropped \
1868 (fills/orders unaffected; balances available via account_snapshot)"
1869 );
1870 }
1871 (symbol.clone(), assets)
1872 })
1873 .collect();
1874 (StreamAccount::Isolated { pairs }, symbols)
1875 } else {
1876 (StreamAccount::Cross, instruments.to_vec())
1877 };
1878
1879 let stream = MarginStream {
1880 keys: SapiListenKeys {
1881 rest: self.rest.clone(),
1882 rate_limiter: self.rate_limiter.clone(),
1883 },
1884 url: STREAM_URL.to_owned(),
1885 rest: self.rest.clone(),
1886 rate_limiter: self.rate_limiter.clone(),
1887 known: self.known_live.clone(),
1888 account,
1889 instruments,
1890 assets: assets.to_vec(),
1891 clock: StreamClock::shared(self.rate_limiter.signing_clock()),
1892 tx,
1893 dedup: new_dedup_cache(),
1894 };
1895 let session = stream.open().await?;
1898 let handle = tokio::spawn(stream.run(session));
1899
1900 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1901 let guarded_stream = AbortOnDropStream::new(rx_stream, handle);
1902 Ok(futures::StreamExt::boxed(guarded_stream))
1903 }
1904}
1905
1906impl OrderStatusClient for BinanceMargin {
1923 async fn fetch_ended_orders(
1924 &self,
1925 orders: &[UnindexedOrderKey],
1926 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1927 fetch_ended_by_key(orders, |key| {
1928 let recorded_kind = self.known_live.lock().kind_of(&key.cid);
1929 fetch_margin_order_lookup(
1930 self.rest.clone(),
1931 self.rate_limiter.clone(),
1932 key,
1933 self.config.is_isolated,
1934 RequestKind::Query,
1935 recorded_kind,
1936 )
1937 })
1938 .await
1939 }
1940}
1941
1942const HOURS_PER_YEAR: u32 = 24 * 365;
1948
1949const HOURLY_RATE_ASSETS_PER_REQUEST: usize = 20;
1951
1952impl ShortabilityClient for BinanceMargin {
1953 async fn fetch_shortability(
1980 &self,
1981 instrument: &InstrumentNameExchange,
1982 ) -> Result<Shortability, UnindexedClientError> {
1983 let pair = self.fetch_margin_pair(instrument).await?;
1984 let (borrowable, hourly_rate, delisting_at) = tokio::try_join!(
1985 self.fetch_asset_borrowable(&pair.base),
1986 self.fetch_next_hourly_interest_rate(&pair.base),
1987 self.fetch_delisting_at(instrument, &pair),
1988 )?;
1989
1990 let mut shortability = Shortability::new();
1991 if let Some(delisting_at) = delisting_at {
1992 shortability = shortability.with_delisting_at(delisting_at);
1993 }
1994 let not_delisted = Some(delisting_at.is_none_or(|delisting_at| delisting_at > Utc::now()));
1998 let flags = [
1999 pair.is_margin_trade,
2000 pair.is_sell_allowed,
2001 borrowable,
2002 not_delisted,
2003 ];
2004 if flags.contains(&Some(false)) {
2005 shortability = shortability.with_shortable(false);
2006 } else if flags.iter().all(|flag| *flag == Some(true)) {
2007 shortability = shortability.with_shortable(true);
2008 }
2009 if let Some(hourly_rate) = hourly_rate {
2010 shortability = shortability.with_fee_rate(hourly_rate * Decimal::from(HOURS_PER_YEAR));
2011 }
2012 Ok(shortability)
2013 }
2014}
2015
2016impl BorrowCapacityClient for BinanceMargin {
2017 async fn fetch_borrow_capacity(
2034 &self,
2035 instrument: &InstrumentNameExchange,
2036 side: Side,
2037 ) -> Result<BorrowCapacity, UnindexedClientError> {
2038 let pair = self.fetch_margin_pair(instrument).await?;
2039 let asset = match side {
2040 Side::Buy => pair.quote,
2041 Side::Sell => pair.base,
2042 };
2043 let isolated_symbol = self
2044 .config
2045 .is_isolated
2046 .then(|| instrument.name().to_string());
2047 let response =
2048 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
2049 let asset = asset.name().to_string();
2050 let isolated_symbol = isolated_symbol.clone();
2051 Box::pin(async move {
2052 let params = QueryMaxBorrowParams::builder(asset)
2053 .isolated_symbol(isolated_symbol)
2054 .build()?;
2055 rest.query_max_borrow(params).await
2056 })
2057 })
2058 .await
2059 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
2060 let max_borrow = response.data().await.map_err(response_decode_error)?;
2061
2062 let mut capacity = BorrowCapacity::new(asset);
2063 if let Some(amount) = optional_decimal("amount", max_borrow.amount.as_deref())? {
2064 capacity = capacity.with_borrowable_now(amount);
2065 }
2066 if let Some(limit) = optional_decimal("borrowLimit", max_borrow.borrow_limit.as_deref())? {
2067 capacity = capacity.with_account_limit(limit);
2068 }
2069 Ok(capacity)
2070 }
2071}
2072
2073#[derive(Debug)]
2075struct MarginPair {
2076 base: AssetNameExchange,
2077 quote: AssetNameExchange,
2078 is_margin_trade: Option<bool>,
2079 is_sell_allowed: Option<bool>,
2080}
2081
2082impl BinanceMargin {
2083 async fn fetch_margin_pair(
2087 &self,
2088 instrument: &InstrumentNameExchange,
2089 ) -> Result<MarginPair, UnindexedClientError> {
2090 let symbol = instrument.name().to_string();
2091 let listed = if self.config.is_isolated {
2092 let response =
2093 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
2094 let symbol = symbol.clone();
2095 Box::pin(async move {
2096 let params = GetAllIsolatedMarginSymbolParams::builder()
2097 .symbol(symbol)
2098 .build()?;
2099 rest.get_all_isolated_margin_symbol(params).await
2100 })
2101 })
2102 .await
2103 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
2104 response
2105 .data()
2106 .await
2107 .map_err(response_decode_error)?
2108 .into_iter()
2109 .find(|pair| pair.symbol.as_deref() == Some(symbol.as_str()))
2110 .map(|pair| {
2111 (
2112 pair.base,
2113 pair.quote,
2114 pair.is_margin_trade,
2115 pair.is_sell_allowed,
2116 )
2117 })
2118 } else {
2119 let response =
2120 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
2121 let symbol = symbol.clone();
2122 Box::pin(async move {
2123 let params = GetAllCrossMarginPairsParams::builder()
2124 .symbol(symbol)
2125 .build()?;
2126 rest.get_all_cross_margin_pairs(params).await
2127 })
2128 })
2129 .await
2130 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
2131 response
2132 .data()
2133 .await
2134 .map_err(response_decode_error)?
2135 .into_iter()
2136 .find(|pair| pair.symbol.as_deref() == Some(symbol.as_str()))
2137 .map(|pair| {
2138 (
2139 pair.base,
2140 pair.quote,
2141 pair.is_margin_trade,
2142 pair.is_sell_allowed,
2143 )
2144 })
2145 };
2146 let margin = if self.config.is_isolated {
2147 "isolated"
2148 } else {
2149 "cross"
2150 };
2151 let Some((base, quote, is_margin_trade, is_sell_allowed)) = listed else {
2152 return Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(
2153 instrument.clone(),
2154 format!("Binance lists no {margin} margin pair {symbol}"),
2155 )));
2156 };
2157 let (Some(base), Some(quote)) = (base, quote) else {
2158 return Err(UnindexedClientError::Internal(format!(
2159 "Binance lists {margin} margin pair {symbol} without its base or quote asset"
2160 )));
2161 };
2162 Ok(MarginPair {
2163 base: AssetNameExchange::new(base),
2164 quote: AssetNameExchange::new(quote),
2165 is_margin_trade,
2166 is_sell_allowed,
2167 })
2168 }
2169
2170 async fn fetch_asset_borrowable(
2173 &self,
2174 asset: &AssetNameExchange,
2175 ) -> Result<Option<bool>, UnindexedClientError> {
2176 let name = asset.name().to_string();
2177 let response =
2178 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
2179 let name = name.clone();
2180 Box::pin(async move {
2181 let params = GetAllMarginAssetsParams::builder().asset(name).build()?;
2182 rest.get_all_margin_assets(params).await
2183 })
2184 })
2185 .await
2186 .map_err(|e| classify_rest_query_error(&e, None))?;
2187 Ok(response
2188 .data()
2189 .await
2190 .map_err(response_decode_error)?
2191 .into_iter()
2192 .find(|listed| listed.asset_name.as_deref() == Some(name.as_str()))
2193 .and_then(|listed| listed.is_borrowable))
2194 }
2195
2196 async fn fetch_delisting_at(
2201 &self,
2202 instrument: &InstrumentNameExchange,
2203 pair: &MarginPair,
2204 ) -> Result<Option<DateTime<Utc>>, UnindexedClientError> {
2205 let response =
2206 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
2207 Box::pin(async move {
2208 let params = GetDelistScheduleParams::builder().build()?;
2209 rest.get_delist_schedule(params).await
2210 })
2211 })
2212 .await
2213 .map_err(|e| classify_rest_query_error(&e, None))?;
2214 let schedule = response.data().await.map_err(response_decode_error)?;
2215
2216 let symbol = instrument.name().as_str();
2217 let (base, quote) = (pair.base.name().as_str(), pair.quote.name().as_str());
2218 let mut earliest: Option<DateTime<Utc>> = None;
2219 for entry in schedule {
2220 let scheduled = if self.config.is_isolated {
2221 entry
2222 .isolated_margin_symbols
2223 .iter()
2224 .flatten()
2225 .any(|listed| listed == symbol)
2226 } else {
2227 entry
2228 .cross_margin_assets
2229 .iter()
2230 .flatten()
2231 .any(|listed| listed == base || listed == quote)
2232 };
2233 if !scheduled {
2234 continue;
2235 }
2236 let delisting_at = entry
2238 .delist_time
2239 .and_then(DateTime::from_timestamp_millis)
2240 .ok_or_else(|| {
2241 UnindexedClientError::Internal(format!(
2242 "decoding Binance response: delist-schedule lists {symbol} with delistTime \
2243 {:?}",
2244 entry.delist_time
2245 ))
2246 })?;
2247 earliest = Some(earliest.map_or(delisting_at, |earliest| earliest.min(delisting_at)));
2248 }
2249 Ok(earliest)
2250 }
2251
2252 async fn fetch_next_hourly_interest_rate(
2255 &self,
2256 asset: &AssetNameExchange,
2257 ) -> Result<Option<Decimal>, UnindexedClientError> {
2258 let mut rates = self
2259 .fetch_next_hourly_interest_rates(std::slice::from_ref(asset))
2260 .await?;
2261 Ok(rates.remove(asset))
2262 }
2263}
2264
2265async fn recover_margin_fills(
2291 rest: &Arc<RestApi>,
2292 rate_limiter: &Arc<RateLimitTracker>,
2293 unrecovered: &mut UnrecoveredFills,
2294 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2295 dedup: &SharedDedupCache,
2296 known: &SharedKnownLiveOrders,
2297 is_isolated: bool,
2298) {
2299 use futures::StreamExt as _;
2300
2301 let due = unrecovered.due(tokio::time::Instant::now());
2302 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
2303 return;
2304 };
2305 info!(
2306 gaps = due.len(),
2307 oldest = %gap_time(oldest),
2308 is_isolated,
2309 "BinanceMargin recovering fills missed while disconnected"
2310 );
2311
2312 let mut settled = vec![false; due.len()];
2315 let started = AtomicUsize::new(0);
2316 let recovery = async {
2317 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
2318 let mut recovered = 0u32;
2319 let mut duplicates = 0u32;
2320
2321 let mut stream =
2322 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
2323 started.store(index + 1, Ordering::Relaxed);
2324 let rest = rest.clone();
2325 let rl = rate_limiter.clone();
2326 async move {
2327 let span = MyTradesFrom::Span {
2328 start: gap.start_ms,
2329 end: gap.end_ms,
2330 };
2331 let raw = match paginate_margin_my_trades(
2332 &rest,
2333 &rl,
2334 &inst,
2335 span,
2336 is_isolated,
2337 RequestKind::Essential,
2338 )
2339 .await
2340 {
2341 Ok(pages) => pages,
2342 Err(e) => return (index, Err(e)),
2343 };
2344 let totals = recovered_order_totals(
2348 ExchangeId::BinanceMargin,
2349 &inst,
2350 &raw,
2351 order_executions_deadline,
2352 |order_id| {
2353 paginate_margin_my_trades(
2354 &rest,
2355 &rl,
2356 &inst,
2357 MyTradesFrom::Order(order_id),
2358 is_isolated,
2359 RequestKind::Essential,
2360 )
2361 },
2362 )
2363 .await;
2364 let cids = recovered_order_cids(
2367 ExchangeId::BinanceMargin,
2368 &inst,
2369 &raw,
2370 order_executions_deadline,
2371 |order_id| fetch_margin_order_cid(&rest, &rl, &inst, order_id, is_isolated),
2372 )
2373 .await;
2374 let trades = raw
2375 .iter()
2376 .filter_map(|t| {
2377 let mut trade = convert_margin_trade(t, &inst)?;
2378 trade.order_filled_quantity =
2379 t.id.and_then(|id| totals.get(&id).copied());
2380 trade.client_order_id =
2381 t.order_id.and_then(|id| cids.get(&id).cloned());
2382 Some(trade)
2383 })
2384 .collect::<Vec<_>>();
2385 (index, Ok(trades))
2386 }
2387 }))
2388 .buffer_unordered(8);
2389 while let Some((index, result)) = stream.next().await {
2390 let (inst, gap) = &due[index];
2391 settled[index] = true;
2392 let trades = match result {
2393 Ok(trades) => trades,
2394 Err(e) => {
2395 gap_failed(
2396 unrecovered,
2397 tx,
2398 ExchangeId::BinanceMargin,
2399 inst,
2400 gap,
2401 FillRecoveryFailure::Request(e.to_string()),
2402 );
2403 continue;
2404 }
2405 };
2406 for trade in trades {
2407 let event = UnindexedAccountEvent::new(
2408 ExchangeId::BinanceMargin,
2409 AccountEventKind::Trade(trade),
2410 );
2411 if let Some(key) = dedup_key_from_event(&event)
2412 && is_duplicate(dedup, key)
2413 {
2414 duplicates += 1;
2415 continue;
2416 }
2417 let sent = {
2420 let mut known = known.lock();
2421 known.observe(&event);
2422 tx.send(event)
2423 };
2424 if sent.is_err() {
2425 debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2426 return;
2427 }
2428 recovered += 1;
2429 }
2430 unrecovered.recovered(inst, gap);
2431 }
2432 info!(
2433 recovered,
2434 duplicates, is_isolated, "BinanceMargin fill recovery complete"
2435 );
2436 };
2437 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2441 .await
2442 .is_err()
2443 {
2444 let started = started.load(Ordering::Relaxed);
2445 for ((inst, gap), _) in due[..started]
2446 .iter()
2447 .zip(&settled)
2448 .filter(|(_, settled)| !**settled)
2449 {
2450 gap_failed(
2451 unrecovered,
2452 tx,
2453 ExchangeId::BinanceMargin,
2454 inst,
2455 gap,
2456 FillRecoveryFailure::TimedOut {
2457 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2458 },
2459 );
2460 }
2461 }
2462}
2463
2464fn open_reconnect_recovery(
2469 unrecovered: &mut UnrecoveredFills,
2470 unchecked: &mut UncheckedOrders,
2471 known: &SharedKnownLiveOrders,
2472 instruments: &[InstrumentNameExchange],
2473 start: DateTime<Utc>,
2474 end: DateTime<Utc>,
2475) {
2476 unrecovered.open(instruments, start, end);
2477 let held = known.lock().instruments_among(instruments);
2478 unchecked.open(held);
2479}
2480
2481async fn read_margin_restatement(
2491 rest: &Arc<RestApi>,
2492 rate_limiter: &Arc<RateLimitTracker>,
2493 is_isolated: bool,
2494 pairs: &[InstrumentNameExchange],
2495 assets: &[AssetNameExchange],
2496 clock: &SharedStreamClock,
2497 floor: Option<DateTime<Utc>>,
2498) -> Result<Vec<UnindexedAccountEvent>, UnindexedClientError> {
2499 let event = |kind| UnindexedAccountEvent::new(ExchangeId::BinanceMargin, kind);
2500 if is_isolated {
2501 let reads =
2502 fetch_isolated_margin_account_info(rest.clone(), rate_limiter.clone(), pairs.to_vec())
2503 .await?;
2504 let clock = clock.lock();
2505 return Ok(reads
2506 .into_iter()
2507 .flat_map(|read| {
2508 let time = restatement_time(&clock, read.sent, floor);
2509 convert_isolated_margin_assets(read.assets, time)
2510 })
2511 .flat_map(|(pair, state)| {
2512 [state.base, state.quote].map(|balance| {
2513 event(AccountEventKind::InstrumentBalanceSnapshot(
2514 InstrumentBalanceSnapshot::new(pair.clone(), balance),
2515 ))
2516 })
2517 })
2518 .collect());
2519 }
2520 let (response, sent) = rest_read_with_retry(rest, rate_limiter, RequestKind::Query, |rest| {
2521 Box::pin(async move {
2522 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
2523 rest.query_cross_margin_account_details(params).await
2524 })
2525 })
2526 .await
2527 .map_err(|e| classify_rest_query_error(&e, None))?;
2528 let account = response.data().await.map_err(response_decode_error)?;
2529 let time = restatement_time(&clock.lock(), sent, floor);
2530 Ok(
2531 filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets, time)
2532 .into_iter()
2533 .map(|balance| {
2534 event(AccountEventKind::BalanceSnapshot(
2535 rustrade_integration::collection::snapshot::Snapshot(balance),
2536 ))
2537 })
2538 .collect(),
2539 )
2540}
2541
2542fn build_base_quote_map(
2550 assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
2551) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
2552 let mut map = HashMap::with_capacity(assets.len());
2553 for entry in assets {
2554 let (Some(symbol), Some(base), Some(quote)) = (
2555 entry.symbol.as_deref(),
2556 entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
2557 entry
2558 .quote_asset
2559 .as_deref()
2560 .and_then(|q| q.asset.as_deref()),
2561 ) else {
2562 warn!(
2563 "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
2564 skipping base/quote map entry"
2565 );
2566 continue;
2567 };
2568 map.insert(
2569 InstrumentNameExchange::new(symbol),
2570 (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
2571 );
2572 }
2573 map
2574}
2575
2576async fn fetch_margin_open_orders_for_instrument(
2583 rest: Arc<RestApi>,
2584 rate_limiter: Arc<RateLimitTracker>,
2585 known: SharedKnownLiveOrders,
2586 instrument: InstrumentNameExchange,
2587 is_isolated: bool,
2588 kind: RequestKind,
2589) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
2590 let symbol_str = instrument.name().to_string();
2592 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2593 let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
2594 let sym = symbol_str.clone();
2595 let isolated = isolated.clone();
2596 Box::pin(async move {
2597 let params = QueryMarginAccountsOpenOrdersParams::builder()
2598 .symbol(sym)
2599 .is_isolated(isolated)
2600 .build()?;
2601 rest.query_margin_accounts_open_orders(params).await
2602 })
2603 })
2604 .await
2605 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
2606
2607 let orders_data = response.data().await.map_err(response_decode_error)?;
2608
2609 let listing = convert_open_order_listing(
2610 &orders_data,
2611 ExchangeId::BinanceMargin,
2612 &instrument,
2613 &|cid| known.lock().kind_of(cid),
2614 );
2615
2616 Ok((instrument, listing))
2617}
2618
2619async fn fetch_margin_all_open_orders(
2626 rest: Arc<RestApi>,
2627 rate_limiter: Arc<RateLimitTracker>,
2628 known: SharedKnownLiveOrders,
2629 is_isolated: bool,
2630) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
2631 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2632 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
2633 let isolated = isolated.clone();
2634 Box::pin(async move {
2635 let params = QueryMarginAccountsOpenOrdersParams::builder()
2636 .is_isolated(isolated)
2637 .build()?;
2638 rest.query_margin_accounts_open_orders(params).await
2639 })
2640 })
2641 .await
2642 .map_err(|e| classify_rest_query_error(&e, None))?;
2643
2644 let orders_data = response.data().await.map_err(response_decode_error)?;
2645
2646 let orders = orders_data
2647 .into_iter()
2648 .filter_map(|o| {
2649 convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin, &|cid| {
2650 known.lock().kind_of(cid)
2651 })
2652 })
2653 .collect();
2654
2655 Ok(orders)
2656}
2657
2658async fn fetch_margin_order_lookup(
2665 rest: Arc<RestApi>,
2666 rate_limiter: Arc<RateLimitTracker>,
2667 key: UnindexedOrderKey,
2668 is_isolated: bool,
2669 kind: RequestKind,
2670 recorded_kind: Option<OrderKind>,
2671) -> Result<OrderLookup, UnindexedClientError> {
2672 let symbol = key.instrument.name().to_string();
2674 let cid = key.cid.0.to_string();
2675 let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
2676 let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
2677 let (symbol, cid, isolated) = (symbol.clone(), cid.clone(), isolated.clone());
2678 Box::pin(async move {
2679 let params = QueryMarginAccountsOrderParams::builder(symbol)
2680 .orig_client_order_id(cid)
2681 .is_isolated(isolated)
2682 .build()?;
2683 rest.query_margin_accounts_order(params).await
2684 })
2685 })
2686 .await
2687 {
2688 Ok(response) => response,
2689 Err(e) if is_unknown_order(&e) => {
2690 debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceMargin does not know this order");
2691 return Ok(OrderLookup::Unknown);
2692 }
2693 Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
2694 };
2695
2696 let row = response.data().await.map_err(response_decode_error)?;
2697
2698 Ok(
2699 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, recorded_kind)
2700 .map_or(OrderLookup::NotEnded, |order| {
2701 OrderLookup::Ended(Box::new(order))
2702 }),
2703 )
2704}
2705
2706async fn fetch_margin_order_cid(
2711 rest: &Arc<RestApi>,
2712 rate_limiter: &RateLimitTracker,
2713 instrument: &InstrumentNameExchange,
2714 order_id: i64,
2715 is_isolated: bool,
2716) -> Result<Option<ClientOrderId>, UnindexedClientError> {
2717 let symbol = instrument.name().to_string();
2718 let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
2719 let response = match rest_call_with_retry(rest, rate_limiter, RequestKind::Essential, |rest| {
2720 let (symbol, isolated) = (symbol.clone(), isolated.clone());
2721 Box::pin(async move {
2722 let params = QueryMarginAccountsOrderParams::builder(symbol)
2723 .order_id(order_id)
2724 .is_isolated(isolated)
2725 .build()?;
2726 rest.query_margin_accounts_order(params).await
2727 })
2728 })
2729 .await
2730 {
2731 Ok(response) => response,
2732 Err(e) if is_unknown_order(&e) => {
2733 debug!(%instrument, order_id, error = %e, "BinanceMargin does not know this order");
2734 return Ok(None);
2735 }
2736 Err(e) => return Err(classify_rest_query_error(&e, Some(instrument))),
2737 };
2738 let row = response.data().await.map_err(response_decode_error)?;
2739 Ok(row
2740 .client_order_id
2741 .filter(|cid| !cid.is_empty())
2742 .map(ClientOrderId::new))
2743}
2744
2745async fn listed_margin_open_cids(
2748 rest: Arc<RestApi>,
2749 rate_limiter: Arc<RateLimitTracker>,
2750 known: SharedKnownLiveOrders,
2751 instruments: Vec<InstrumentNameExchange>,
2752 is_isolated: bool,
2753) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
2754 let mut listed = FnvHashSet::default();
2755 for instrument in instruments {
2756 let (_, listing) = fetch_margin_open_orders_for_instrument(
2757 rest.clone(),
2758 rate_limiter.clone(),
2759 known.clone(),
2760 instrument,
2761 is_isolated,
2762 RequestKind::Essential,
2763 )
2764 .await?;
2765 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
2766 }
2767 Ok(listed)
2768}
2769
2770async fn recover_margin_ended_orders(
2773 rest: &Arc<RestApi>,
2774 rate_limiter: &Arc<RateLimitTracker>,
2775 known: &SharedKnownLiveOrders,
2776 unchecked: &mut UncheckedOrders,
2777 unrecovered: &UnrecoveredFills,
2778 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2779 is_isolated: bool,
2780) {
2781 recover_ended_orders(
2782 ExchangeId::BinanceMargin,
2783 known,
2784 unchecked,
2785 unrecovered,
2786 tx,
2787 OpenListing::PerInstrument,
2788 |instruments| {
2789 listed_margin_open_cids(
2790 rest.clone(),
2791 rate_limiter.clone(),
2792 known.clone(),
2793 instruments,
2794 is_isolated,
2795 )
2796 },
2797 |key| {
2798 let recorded_kind = known.lock().kind_of(&key.cid);
2800 fetch_margin_order_lookup(
2801 rest.clone(),
2802 rate_limiter.clone(),
2803 key,
2804 is_isolated,
2805 RequestKind::Essential,
2806 recorded_kind,
2807 )
2808 },
2809 )
2810 .await;
2811}
2812
2813const MARGIN_MY_TRADES_WINDOW_MS: i64 = 23 * 60 * 60 * 1000;
2819
2820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2823enum MarginTradesQuery {
2824 Window { start: i64, end: i64 },
2826 FromId(i64),
2828 Order(i64),
2830 OrderFromId { order_id: i64, from_id: i64 },
2832}
2833
2834impl MarginTradesQuery {
2835 fn first(from: MyTradesFrom, now_ms: i64) -> Option<Self> {
2840 match from {
2841 MyTradesFrom::Span { start, .. } => {
2842 (start <= now_ms).then(|| Self::window(start, now_ms))
2843 }
2844 MyTradesFrom::Order(order_id) => Some(Self::Order(order_id)),
2845 }
2846 }
2847
2848 fn window(start: i64, now_ms: i64) -> Self {
2850 Self::Window {
2851 start,
2852 end: start.saturating_add(MARGIN_MY_TRADES_WINDOW_MS).min(now_ms),
2853 }
2854 }
2855
2856 fn next(self, page_len: usize, last_id: Option<i64>, now_ms: i64) -> Option<Self> {
2859 let full = page_len >= BINANCE_MAX_TRADES;
2860 let after_last = || last_id?.checked_add(1);
2861 match self {
2862 Self::Window { end, .. } if page_len == 0 => {
2867 (end < now_ms).then(|| Self::window(end, now_ms))
2868 }
2869 Self::Window { end, .. } if end >= now_ms && !full => None,
2871 Self::Window { .. } => after_last().map(Self::FromId),
2874 Self::FromId(_) if full => after_last().map(Self::FromId),
2875 Self::Order(order_id) | Self::OrderFromId { order_id, .. } if full => {
2876 after_last().map(|from_id| Self::OrderFromId { order_id, from_id })
2877 }
2878 Self::FromId(_) | Self::Order(_) | Self::OrderFromId { .. } => None,
2879 }
2880 }
2881}
2882
2883async fn paginate_margin_my_trades(
2907 rest: &Arc<RestApi>,
2908 rate_limiter: &Arc<RateLimitTracker>,
2909 instrument: &InstrumentNameExchange,
2910 from: MyTradesFrom,
2911 is_isolated: bool,
2912 kind: RequestKind,
2913) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
2914 let symbol_str = instrument.name().to_string();
2916 let isolated = QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(is_isolated);
2917 #[allow(clippy::cast_possible_wrap)]
2922 let limit = BINANCE_MAX_TRADES as i64;
2923 let now_ms = match from {
2926 MyTradesFrom::Span { end, .. } => end.min(
2927 Utc::now()
2928 .timestamp_millis()
2929 .saturating_add(MARGIN_MY_TRADES_WINDOW_MS),
2930 ),
2931 MyTradesFrom::Order(_) => Utc::now().timestamp_millis(),
2932 };
2933 let mut all_pages = Vec::new();
2934 let mut requests = 0u32;
2935 let mut next = MarginTradesQuery::first(from, now_ms);
2936 while let Some(query) = next {
2937 requests += 1;
2938 let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
2939 let sym = symbol_str.clone();
2940 let isolated = isolated.clone();
2941 Box::pin(async move {
2942 let builder = QueryMarginAccountsTradeListParams::builder(sym)
2943 .is_isolated(isolated)
2944 .limit(limit);
2945 let params = match query {
2946 MarginTradesQuery::Window { start, end } => {
2947 builder.start_time(start).end_time(end)
2948 }
2949 MarginTradesQuery::FromId(id) => builder.from_id(id),
2950 MarginTradesQuery::Order(order_id) => builder.order_id(order_id),
2951 MarginTradesQuery::OrderFromId { order_id, from_id } => {
2952 builder.order_id(order_id).from_id(from_id)
2953 }
2954 }
2955 .build()?;
2956 rest.query_margin_accounts_trade_list(params).await
2957 })
2958 })
2959 .await
2960 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
2961
2962 let mut page = response.data().await.map_err(response_decode_error)?;
2963
2964 let page_len = page.len();
2965 let last_id = page.last().and_then(|t| t.id);
2966 let past_end = match from {
2968 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
2969 MyTradesFrom::Order(_) => false,
2970 };
2971 all_pages.extend(page);
2972 if past_end {
2973 break;
2974 }
2975
2976 if page_len > 0 && last_id.is_none() {
2977 warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, cannot page past it");
2978 break;
2979 }
2980 next = query.next(page_len, last_id, now_ms);
2981 }
2982 debug!(
2983 %instrument,
2984 requests,
2985 trades = all_pages.len(),
2986 "BinanceMargin paginate_my_trades: walk complete"
2987 );
2988 Ok(all_pages)
2989}
2990
2991fn convert_margin_balance_entry(
3001 b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
3002 time: DateTime<Utc>,
3003) -> Option<AssetBalance<AssetNameExchange>> {
3004 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
3005 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3006 Some(v) => v,
3007 None => {
3008 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
3009 return None;
3010 }
3011 };
3012 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3013 Some(v) => v,
3014 None => {
3015 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
3016 return None;
3017 }
3018 };
3019 let borrowed = b
3024 .borrowed
3025 .as_deref()
3026 .and_then(|s| Decimal::from_str(s).ok())
3027 .unwrap_or(Decimal::ZERO);
3028 let interest = b
3029 .interest
3030 .as_deref()
3031 .and_then(|s| Decimal::from_str(s).ok())
3032 .unwrap_or(Decimal::ZERO);
3033 Some(AssetBalance::new(
3034 asset_name,
3035 Balance::new_margin(free + locked, free, borrowed, interest),
3036 time,
3037 ))
3038}
3039
3040fn filter_and_convert_margin_balances(
3044 user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
3045 assets: &[AssetNameExchange],
3046 time: DateTime<Utc>,
3047) -> Vec<AssetBalance<AssetNameExchange>> {
3048 if assets.is_empty() {
3050 return user_assets
3051 .into_iter()
3052 .filter_map(|b| convert_margin_balance_entry(b, time))
3053 .collect();
3054 }
3055 if assets.len() <= 16 {
3057 return user_assets
3058 .into_iter()
3059 .filter_map(|b| {
3060 let asset_name_str = b.asset.as_deref()?;
3061 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
3062 return None;
3063 }
3064 convert_margin_balance_entry(b, time)
3065 })
3066 .collect();
3067 }
3068 use std::collections::HashSet;
3069 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
3070 user_assets
3071 .into_iter()
3072 .filter_map(|b| {
3073 let asset_name_str = b.asset.as_deref()?;
3074 if !asset_set.contains(asset_name_str) {
3075 return None;
3076 }
3077 convert_margin_balance_entry(b, time)
3078 })
3079 .collect()
3080}
3081
3082fn active_order_snapshot(
3089 o: Order<ExchangeId, InstrumentNameExchange, Open>,
3090) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
3091{
3092 Order {
3093 key: o.key,
3094 side: o.side,
3095 price: o.price,
3096 quantity: o.quantity,
3097 kind: o.kind,
3098 time_in_force: o.time_in_force,
3099 state: OrderState::active(o.state),
3100 }
3101}
3102
3103fn convert_isolated_asset_balance(
3113 asset: Option<&str>,
3114 free: Option<&str>,
3115 locked: Option<&str>,
3116 borrowed: Option<&str>,
3117 interest: Option<&str>,
3118 time: DateTime<Utc>,
3119) -> Option<AssetBalance<AssetNameExchange>> {
3120 let asset_name = AssetNameExchange::new(asset?);
3121 let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
3122 Some(v) => v,
3123 None => {
3124 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
3125 return None;
3126 }
3127 };
3128 let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
3129 Some(v) => v,
3130 None => {
3131 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
3132 return None;
3133 }
3134 };
3135 let borrowed = borrowed
3136 .and_then(|s| Decimal::from_str(s).ok())
3137 .unwrap_or(Decimal::ZERO);
3138 let interest = interest
3139 .and_then(|s| Decimal::from_str(s).ok())
3140 .unwrap_or(Decimal::ZERO);
3141 Some(AssetBalance::new(
3142 asset_name,
3143 Balance::new_margin(free + locked, free, borrowed, interest),
3144 time,
3145 ))
3146}
3147
3148fn convert_isolated_margin_assets(
3157 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
3158 time: DateTime<Utc>,
3159) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
3160 let mut map = HashMap::with_capacity(assets.len());
3161 for entry in assets {
3162 let Some(symbol) = entry.symbol.as_deref() else {
3163 warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
3164 continue;
3165 };
3166 let instrument = InstrumentNameExchange::new(symbol);
3167
3168 let Some(base_raw) = entry.base_asset.as_deref() else {
3169 warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
3170 continue;
3171 };
3172 let Some(quote_raw) = entry.quote_asset.as_deref() else {
3173 warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
3174 continue;
3175 };
3176
3177 let Some(base) = convert_isolated_asset_balance(
3178 base_raw.asset.as_deref(),
3179 base_raw.free.as_deref(),
3180 base_raw.locked.as_deref(),
3181 base_raw.borrowed.as_deref(),
3182 base_raw.interest.as_deref(),
3183 time,
3184 ) else {
3185 warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
3186 continue;
3187 };
3188 let Some(quote) = convert_isolated_asset_balance(
3189 quote_raw.asset.as_deref(),
3190 quote_raw.free.as_deref(),
3191 quote_raw.locked.as_deref(),
3192 quote_raw.borrowed.as_deref(),
3193 quote_raw.interest.as_deref(),
3194 time,
3195 ) else {
3196 warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
3197 continue;
3198 };
3199
3200 let risk = IsolatedMarginRisk {
3201 margin_level: entry
3202 .margin_level
3203 .as_deref()
3204 .and_then(|s| Decimal::from_str(s).ok()),
3205 margin_ratio: entry
3206 .margin_ratio
3207 .as_deref()
3208 .and_then(|s| Decimal::from_str(s).ok()),
3209 liquidation_price: entry
3210 .liquidate_price
3211 .as_deref()
3212 .and_then(|s| Decimal::from_str(s).ok()),
3213 };
3214
3215 map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
3216 }
3217 map
3218}
3219
3220fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
3223 symbols
3224 .chunks(5)
3225 .map(|chunk| {
3226 chunk
3227 .iter()
3228 .map(|s| s.name().as_str())
3229 .collect::<Vec<_>>()
3230 .join(",")
3231 })
3232 .collect()
3233}
3234
3235struct IsolatedAccountRead {
3238 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
3239 sent: HostMoment,
3240}
3241
3242async fn fetch_isolated_margin_account_info(
3248 rest: Arc<RestApi>,
3249 rate_limiter: Arc<RateLimitTracker>,
3250 symbols: Vec<InstrumentNameExchange>,
3251) -> Result<Vec<IsolatedAccountRead>, UnindexedClientError> {
3252 use futures::{StreamExt as _, TryStreamExt as _};
3253
3254 let chunks = chunk_symbols(&symbols);
3255
3256 futures::stream::iter(chunks.into_iter().map(|symbols_param| {
3257 let rest = rest.clone();
3258 let rate_limiter = rate_limiter.clone();
3259 async move {
3260 let (response, sent) =
3261 rest_read_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
3262 let symbols_param = symbols_param.clone();
3263 Box::pin(async move {
3264 let params = QueryIsolatedMarginAccountInfoParams::builder()
3265 .symbols(symbols_param)
3266 .build()?;
3267 rest.query_isolated_margin_account_info(params).await
3268 })
3269 })
3270 .await
3271 .map_err(|e| classify_rest_query_error(&e, None))?;
3272
3273 let info = response.data().await.map_err(response_decode_error)?;
3274 Ok::<_, UnindexedClientError>(IsolatedAccountRead {
3275 assets: info.assets.unwrap_or_default(),
3276 sent,
3277 })
3278 }
3279 }))
3280 .buffer_unordered(8)
3281 .try_collect()
3282 .await
3283}
3284
3285fn convert_margin_trade(
3292 t: &QueryMarginAccountsTradeListResponseInner,
3293 instrument: &InstrumentNameExchange,
3294) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3295 let trade_id_raw = match t.id {
3296 Some(id) => id,
3297 None => {
3298 warn!(%instrument, "BinanceMargin trade missing id");
3299 return None;
3300 }
3301 };
3302 let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
3303 let order_id = match t.order_id {
3304 Some(id) => OrderId(format_smolstr!("{id}")),
3305 None => {
3306 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
3307 return None;
3308 }
3309 };
3310 let side = match t.is_buyer {
3311 Some(true) => Side::Buy,
3312 Some(false) => Side::Sell,
3313 None => {
3314 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
3315 return None;
3316 }
3317 };
3318 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3319 Some(v) => v,
3320 None => {
3321 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
3322 return None;
3323 }
3324 };
3325 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3326 Some(v) => v,
3327 None => {
3328 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
3329 return None;
3330 }
3331 };
3332 let commission = t
3333 .commission
3334 .as_deref()
3335 .and_then(|s| Decimal::from_str(s).ok())
3336 .unwrap_or(Decimal::ZERO);
3337 let (time_exchange, time_source) = match t
3340 .time
3341 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
3342 {
3343 Some(ts) => (ts, TimeSource::Venue),
3344 None => {
3345 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
3346 (Utc::now(), TimeSource::Host)
3347 }
3348 };
3349
3350 let fee_asset = t
3355 .commission_asset
3356 .as_deref()
3357 .map(AssetNameExchange::from)
3358 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
3359
3360 Some(
3361 Trade::new(
3362 trade_id,
3363 order_id,
3364 instrument.clone(),
3365 StrategyId::unknown(), time_exchange,
3367 side,
3368 price,
3369 quantity,
3370 None,
3372 AssetFees::new(fee_asset, commission, None),
3373 )
3374 .with_time_source(time_source),
3375 )
3376}
3377
3378#[derive(Debug)]
3384enum BuildOrderError {
3385 Unsupported,
3387 Build(String),
3389 Trailing(TrailingOrderError),
3391}
3392
3393trait IsolatedFlag {
3404 fn from_flag(is_isolated: bool) -> Self;
3406}
3407
3408macro_rules! impl_isolated_flag {
3413 ($($t:ty),* $(,)?) => {
3414 $(
3415 impl IsolatedFlag for $t {
3416 fn from_flag(is_isolated: bool) -> Self {
3417 if is_isolated { Self::True } else { Self::False }
3418 }
3419 }
3420 )*
3421 };
3422}
3423
3424impl_isolated_flag!(
3425 MarginAccountNewOrderIsIsolatedEnum,
3426 MarginAccountCancelOrderIsIsolatedEnum,
3427 QueryMarginAccountsOpenOrdersIsIsolatedEnum,
3428 QueryMarginAccountsOrderIsIsolatedEnum,
3429 QueryMarginAccountsTradeListIsIsolatedEnum,
3430 GetFutureHourlyInterestRateIsIsolatedEnum,
3431);
3432
3433fn build_cancel_order_params(
3441 symbol: String,
3442 id: Option<&OrderId>,
3443 cid: &ClientOrderId,
3444 is_isolated: bool,
3445) -> Result<MarginAccountCancelOrderParams, String> {
3446 let mut builder = MarginAccountCancelOrderParams::builder(symbol).is_isolated(
3447 MarginAccountCancelOrderIsIsolatedEnum::from_flag(is_isolated),
3448 );
3449
3450 match id {
3451 Some(order_id) => match order_id.0.parse::<i64>() {
3452 Ok(id) => builder = builder.order_id(id),
3453 Err(_) => {
3454 error!(
3456 order_id = %order_id.0,
3457 "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
3458 );
3459 builder = builder.orig_client_order_id(cid.0.to_string());
3460 }
3461 },
3462 None => builder = builder.orig_client_order_id(cid.0.to_string()),
3463 }
3464
3465 builder.build().map_err(|e| e.to_string())
3466}
3467
3468#[allow(clippy::too_many_arguments)] fn build_new_order_params(
3477 symbol: String,
3478 side: Side,
3479 price: Option<Decimal>,
3480 quantity: Decimal,
3481 kind: OrderKind,
3482 time_in_force: TimeInForce,
3483 new_client_order_id: String,
3484 margin_effect: MarginEffect,
3485 auto_repay_at_cancel: bool,
3486 is_isolated: bool,
3487) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3488 let binance_side = match side {
3489 Side::Buy => MarginAccountNewOrderSideEnum::Buy,
3490 Side::Sell => MarginAccountNewOrderSideEnum::Sell,
3491 };
3492
3493 let (binance_type, binance_tif) =
3494 convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
3495
3496 let mut builder = MarginAccountNewOrderParams::builder(symbol, binance_side, binance_type)
3498 .quantity(quantity)
3499 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::from_flag(is_isolated))
3500 .side_effect_type(binance_side_effect(margin_effect))
3501 .new_client_order_id(new_client_order_id)
3502 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
3503
3504 if margin_effect == MarginEffect::Borrow {
3507 builder = builder.auto_repay_at_cancel(auto_repay_at_cancel);
3508 }
3509
3510 if let Some(tif) = binance_tif {
3511 builder = builder.time_in_force(tif);
3512 }
3513
3514 if let Some(trailing) = trailing_params(&kind, price).map_err(BuildOrderError::Trailing)? {
3517 builder = builder.trailing_delta(i64::from(trailing.trailing_delta));
3518 if let Some(limit) = trailing.price {
3519 builder = builder.price(limit);
3520 }
3521 if let Some(activation_price) = trailing.stop_price {
3522 builder = builder.stop_price(activation_price);
3523 }
3524 }
3525
3526 match kind {
3530 OrderKind::Limit => {
3531 builder = builder.price(price);
3532 }
3533 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
3534 builder = builder.stop_price(trigger_price);
3535 }
3536 OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
3537 builder = builder.price(price).stop_price(trigger_price);
3538 }
3539 OrderKind::TrailingStop { .. }
3542 | OrderKind::TrailingStopFixedLimit { .. }
3543 | OrderKind::Market
3544 | OrderKind::TrailingStopLimit { .. }
3545 | OrderKind::ConditionalUnresolved { .. } => {}
3546 }
3547
3548 builder
3549 .build()
3550 .map_err(|e| BuildOrderError::Build(e.to_string()))
3551}
3552
3553const ORDER_PATH: &str = "/sapi/v1/margin/order";
3556
3557fn new_order_query(params: MarginAccountNewOrderParams) -> BTreeMap<String, serde_json::Value> {
3566 use serde_json::json;
3567
3568 let MarginAccountNewOrderParams {
3569 symbol,
3570 side,
3571 r#type,
3572 is_isolated,
3573 quantity,
3574 quote_order_qty,
3575 price,
3576 stop_price,
3577 new_client_order_id,
3578 iceberg_qty,
3579 new_order_resp_type,
3580 side_effect_type,
3581 time_in_force,
3582 self_trade_prevention_mode,
3583 trailing_delta,
3584 auto_repay_at_cancel,
3585 recv_window,
3586 } = params;
3587
3588 let mut query = BTreeMap::new();
3589 let mut insert = |key: &str, value: Option<serde_json::Value>| {
3590 if let Some(value) = value {
3591 query.insert(key.to_string(), value);
3592 }
3593 };
3594 insert("symbol", Some(json!(symbol)));
3595 insert("isIsolated", is_isolated.map(|v| json!(v)));
3596 insert("side", Some(json!(side)));
3597 insert("type", Some(json!(r#type)));
3598 insert("quantity", quantity.map(|v| json!(v)));
3599 insert("quoteOrderQty", quote_order_qty.map(|v| json!(v)));
3600 insert("price", price.map(|v| json!(v)));
3601 insert("stopPrice", stop_price.map(|v| json!(v)));
3602 insert("newClientOrderId", new_client_order_id.map(|v| json!(v)));
3603 insert("icebergQty", iceberg_qty.map(|v| json!(v)));
3604 insert("newOrderRespType", new_order_resp_type.map(|v| json!(v)));
3605 insert("sideEffectType", side_effect_type.map(|v| json!(v)));
3606 insert("timeInForce", time_in_force.map(|v| json!(v)));
3607 insert(
3608 "selfTradePreventionMode",
3609 self_trade_prevention_mode.map(|v| json!(v)),
3610 );
3611 insert("trailingDelta", trailing_delta.map(|v| json!(v)));
3612 insert("autoRepayAtCancel", auto_repay_at_cancel.map(|v| json!(v)));
3613 insert("recvWindow", recv_window.map(|v| json!(v)));
3614 query
3615}
3616
3617fn cancel_order_query(
3625 params: MarginAccountCancelOrderParams,
3626) -> BTreeMap<String, serde_json::Value> {
3627 use serde_json::json;
3628
3629 let MarginAccountCancelOrderParams {
3630 symbol,
3631 is_isolated,
3632 order_id,
3633 orig_client_order_id,
3634 new_client_order_id,
3635 recv_window,
3636 } = params;
3637
3638 let mut query = BTreeMap::new();
3639 let mut insert = |key: &str, value: Option<serde_json::Value>| {
3640 if let Some(value) = value {
3641 query.insert(key.to_string(), value);
3642 }
3643 };
3644 insert("symbol", Some(json!(symbol)));
3645 insert("isIsolated", is_isolated.map(|v| json!(v)));
3646 insert("orderId", order_id.map(|v| json!(v)));
3647 insert("origClientOrderId", orig_client_order_id.map(|v| json!(v)));
3648 insert("newClientOrderId", new_client_order_id.map(|v| json!(v)));
3649 insert("recvWindow", recv_window.map(|v| json!(v)));
3650 query
3651}
3652
3653#[derive(Debug, Deserialize)]
3657#[serde(rename_all = "camelCase")]
3658struct CancelOrderAnswer {
3659 order_id: Option<WireInt>,
3662 transact_time: Option<i64>,
3663 executed_qty: Option<String>,
3664}
3665
3666#[derive(Debug, Deserialize)]
3671#[serde(rename_all = "camelCase")]
3672struct NewOrderAnswer {
3673 order_id: Option<i64>,
3674 transact_time: Option<i64>,
3675 status: Option<String>,
3677 executed_qty: Option<String>,
3679 cummulative_quote_qty: Option<String>,
3680 margin_buy_borrow_amount: Option<Decimal>,
3682 margin_buy_borrow_asset: Option<AssetNameExchange>,
3684}
3685
3686fn convert_order_kind_tif_margin(
3697 kind: OrderKind,
3698 tif: TimeInForce,
3699) -> Option<(
3700 MarginAccountNewOrderTypeEnum,
3701 Option<MarginAccountNewOrderTimeInForceEnum>,
3702)> {
3703 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
3704 let margin_type = match binance_type {
3705 BinanceOrderType::Market => MarginAccountNewOrderTypeEnum::Market,
3706 BinanceOrderType::Limit => MarginAccountNewOrderTypeEnum::Limit,
3707 BinanceOrderType::LimitMaker => MarginAccountNewOrderTypeEnum::LimitMaker,
3708 BinanceOrderType::StopLoss => MarginAccountNewOrderTypeEnum::StopLoss,
3709 BinanceOrderType::StopLossLimit => MarginAccountNewOrderTypeEnum::StopLossLimit,
3710 BinanceOrderType::TakeProfit => MarginAccountNewOrderTypeEnum::TakeProfit,
3711 BinanceOrderType::TakeProfitLimit => MarginAccountNewOrderTypeEnum::TakeProfitLimit,
3712 };
3713 let margin_tif = binance_tif.map(|t| match t {
3714 BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
3715 BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
3716 BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
3717 });
3718 Some((margin_type, margin_tif))
3719}
3720
3721#[cfg(test)]
3722#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3724 use super::*;
3725 use crate::client::binance::shared::convert_open_order;
3726 use crate::order::state::ActiveOrderState;
3727 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOpenOrdersResponseInner;
3728
3729 #[test]
3730 fn margin_effect_default_is_no_side_effect() {
3731 assert_eq!(MarginEffect::default(), MarginEffect::NoSideEffect);
3732 }
3733
3734 #[test]
3735 fn margin_effect_wire_values() {
3736 for (effect, wire) in [
3737 (MarginEffect::NoSideEffect, "NO_SIDE_EFFECT"),
3738 (MarginEffect::Borrow, "MARGIN_BUY"),
3739 (MarginEffect::Repay, "AUTO_REPAY"),
3740 ] {
3741 assert_eq!(binance_side_effect(effect).as_str(), wire);
3742 }
3743 }
3744
3745 #[test]
3746 fn config_debug_redacts_secrets() {
3747 let config = BinanceMarginConfig::new(
3748 "my_api_key".to_string(),
3749 "my_secret_key".to_string(),
3750 false,
3751 false,
3752 Vec::new(),
3753 MarginEffect::default(),
3754 true,
3755 );
3756 let debug = format!("{config:?}");
3757 assert!(!debug.contains("my_api_key"));
3758 assert!(!debug.contains("my_secret_key"));
3759 assert!(debug.contains("***"));
3760 }
3761
3762 #[test]
3763 fn cross_margin_uses_common_case_defaults() {
3764 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
3765 assert!(!config.testnet);
3766 assert!(!config.is_isolated);
3767 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
3768 assert!(config.auto_repay_at_cancel);
3769 assert_eq!(config.api_key(), "k");
3770 }
3771
3772 #[test]
3773 fn config_deserializes_with_defaults() {
3774 let config: BinanceMarginConfig =
3777 serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
3778 .expect("deserialize");
3779 assert!(!config.is_isolated);
3780 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
3781 assert!(config.auto_repay_at_cancel);
3782 }
3783
3784 #[test]
3785 fn config_deserializes_explicit_margin_effect() {
3786 let config: BinanceMarginConfig = serde_json::from_str(
3787 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"margin_effect":"borrow","auto_repay_at_cancel":false}"#,
3788 )
3789 .expect("deserialize");
3790 assert!(config.is_isolated);
3791 assert_eq!(config.margin_effect, MarginEffect::Borrow);
3792 assert!(!config.auto_repay_at_cancel);
3793 }
3794
3795 #[test]
3796 fn config_refuses_the_retired_combined_effect() {
3797 let parsed = serde_json::from_str::<BinanceMarginConfig>(
3799 r#"{"api_key":"k","secret_key":"s","testnet":false,"margin_effect":"auto_borrow_repay"}"#,
3800 );
3801 assert!(parsed.is_err());
3802 }
3803
3804 #[test]
3805 fn isolated_ctor_sets_symbols_and_flag() {
3806 let symbols = vec![
3807 InstrumentNameExchange::new("BTCUSDT"),
3808 InstrumentNameExchange::new("ETHUSDT"),
3809 ];
3810 let config =
3811 BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
3812 assert!(config.is_isolated);
3813 assert_eq!(config.isolated_symbols, symbols);
3814 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
3815 }
3816
3817 #[test]
3818 fn isolated_config_with_symbols_constructs() {
3819 let config = BinanceMarginConfig::isolated(
3821 "k".to_string(),
3822 "s".to_string(),
3823 vec![InstrumentNameExchange::new("BTCUSDT")],
3824 );
3825 let _client = BinanceMargin::new(config); }
3827
3828 #[test]
3829 #[should_panic(expected = "non-empty isolated_symbols")]
3830 fn isolated_empty_symbols_panics_at_new() {
3831 let config = BinanceMarginConfig::new(
3833 "k".to_string(),
3834 "s".to_string(),
3835 false,
3836 true,
3837 Vec::new(),
3838 MarginEffect::default(),
3839 true,
3840 );
3841 let _ = BinanceMargin::new(config);
3842 }
3843
3844 #[test]
3845 #[should_panic(expected = "non-empty isolated_symbols")]
3846 fn isolated_empty_symbols_panics_via_deserialize_path() {
3847 let config: BinanceMarginConfig = serde_json::from_str(
3850 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
3851 )
3852 .expect("deserialize");
3853 assert!(config.isolated_symbols.is_empty());
3854 let _ = BinanceMargin::new(config);
3855 }
3856
3857 #[test]
3858 fn isolated_flag_maps_mode() {
3859 assert_eq!(
3862 MarginAccountNewOrderIsIsolatedEnum::from_flag(true).as_str(),
3863 "TRUE"
3864 );
3865 assert_eq!(
3866 MarginAccountNewOrderIsIsolatedEnum::from_flag(false).as_str(),
3867 "FALSE"
3868 );
3869 assert_eq!(
3870 MarginAccountCancelOrderIsIsolatedEnum::from_flag(true).as_str(),
3871 "TRUE"
3872 );
3873 assert_eq!(
3874 MarginAccountCancelOrderIsIsolatedEnum::from_flag(false).as_str(),
3875 "FALSE"
3876 );
3877 assert_eq!(
3878 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(true).as_str(),
3879 "TRUE"
3880 );
3881 assert_eq!(
3882 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(false).as_str(),
3883 "FALSE"
3884 );
3885 assert_eq!(
3886 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(true).as_str(),
3887 "TRUE"
3888 );
3889 assert_eq!(
3890 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(false).as_str(),
3891 "FALSE"
3892 );
3893 }
3894
3895 #[test]
3900 fn cancel_params_cross_with_parseable_order_id() {
3901 let p = build_cancel_order_params(
3902 "BTCUSDT".to_string(),
3903 Some(&OrderId::new("12345")),
3904 &ClientOrderId::new("cid-1"),
3905 false,
3906 )
3907 .expect("build");
3908 assert_eq!(p.symbol, "BTCUSDT");
3909 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("FALSE"));
3910 assert_eq!(p.order_id, Some(12345));
3911 assert_eq!(p.orig_client_order_id, None);
3912 }
3913
3914 #[test]
3915 fn cancel_params_isolated_is_config_driven() {
3916 let p = build_cancel_order_params(
3917 "BTCUSDT".to_string(),
3918 Some(&OrderId::new("12345")),
3919 &ClientOrderId::new("cid-1"),
3920 true,
3921 )
3922 .expect("build");
3923 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("TRUE"));
3924 assert_eq!(p.order_id, Some(12345));
3925 }
3926
3927 #[test]
3928 fn cancel_params_non_i64_order_id_falls_back_to_cid() {
3929 let p = build_cancel_order_params(
3930 "BTCUSDT".to_string(),
3931 Some(&OrderId::new("not-an-i64")),
3932 &ClientOrderId::new("cid-1"),
3933 false,
3934 )
3935 .expect("build");
3936 assert_eq!(p.order_id, None);
3937 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3938 }
3939
3940 #[test]
3941 fn cancel_params_absent_order_id_uses_cid() {
3942 let p = build_cancel_order_params(
3943 "BTCUSDT".to_string(),
3944 None,
3945 &ClientOrderId::new("cid-1"),
3946 false,
3947 )
3948 .expect("build");
3949 assert_eq!(p.order_id, None);
3950 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3951 }
3952
3953 use crate::order::TrailingOffsetType;
3958
3959 fn params(
3961 side: Side,
3962 price: Option<Decimal>,
3963 kind: OrderKind,
3964 tif: TimeInForce,
3965 margin_effect: MarginEffect,
3966 ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3967 build_new_order_params(
3968 "BTCUSDT".to_string(),
3969 side,
3970 price,
3971 Decimal::from(2),
3972 kind,
3973 tif,
3974 "cid-1".to_string(),
3975 margin_effect,
3976 true,
3977 false, )
3979 }
3980
3981 fn gtc() -> TimeInForce {
3982 TimeInForce::GoodUntilCancelled { post_only: false }
3983 }
3984
3985 #[test]
3986 fn new_order_limit_maps_core_fields() {
3987 let p = params(
3988 Side::Buy,
3989 Some(Decimal::from(50_000)),
3990 OrderKind::Limit,
3991 gtc(),
3992 MarginEffect::Borrow,
3993 )
3994 .expect("build");
3995
3996 assert_eq!(p.symbol, "BTCUSDT");
3997 assert_eq!(p.side.as_str(), "BUY");
3998 assert_eq!(p.r#type.as_str(), "LIMIT");
3999 assert_eq!(p.quantity, Some(Decimal::from(2)));
4000 assert_eq!(p.price, Some(Decimal::from(50_000)));
4001 assert_eq!(p.stop_price, None);
4002 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
4003 assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
4004 assert_eq!(
4006 p.new_order_resp_type.as_ref().map(|r| r.as_str()),
4007 Some("FULL")
4008 );
4009 }
4010
4011 #[test]
4012 fn new_order_is_isolated_is_config_driven() {
4013 let cross = build_new_order_params(
4015 "BTCUSDT".to_string(),
4016 Side::Sell,
4017 Some(Decimal::from(10)),
4018 Decimal::from(2),
4019 OrderKind::Limit,
4020 gtc(),
4021 "cid-1".to_string(),
4022 MarginEffect::Borrow,
4023 true,
4024 false,
4025 )
4026 .expect("build");
4027 assert_eq!(
4028 cross.is_isolated.as_ref().map(|i| i.as_str()),
4029 Some("FALSE")
4030 );
4031 assert_eq!(cross.side.as_str(), "SELL");
4032
4033 let isolated = build_new_order_params(
4035 "BTCUSDT".to_string(),
4036 Side::Sell,
4037 Some(Decimal::from(10)),
4038 Decimal::from(2),
4039 OrderKind::Limit,
4040 gtc(),
4041 "cid-1".to_string(),
4042 MarginEffect::Borrow,
4043 true,
4044 true,
4045 )
4046 .expect("build");
4047 assert_eq!(
4048 isolated.is_isolated.as_ref().map(|i| i.as_str()),
4049 Some("TRUE")
4050 );
4051 }
4052
4053 #[test]
4054 fn side_effect_and_auto_repay_gating() {
4055 let build = |effect, auto_repay_at_cancel| {
4056 build_new_order_params(
4057 "BTCUSDT".to_string(),
4058 Side::Buy,
4059 Some(Decimal::from(1)),
4060 Decimal::from(2),
4061 OrderKind::Limit,
4062 gtc(),
4063 "cid-1".to_string(),
4064 effect,
4065 auto_repay_at_cancel,
4066 false,
4067 )
4068 .expect("build")
4069 };
4070 let wire = |params: &MarginAccountNewOrderParams| {
4071 params
4072 .side_effect_type
4073 .as_ref()
4074 .map(|s| s.as_str().to_owned())
4075 };
4076
4077 let borrow = build(MarginEffect::Borrow, true);
4079 assert_eq!(wire(&borrow).as_deref(), Some("MARGIN_BUY"));
4080 assert_eq!(borrow.auto_repay_at_cancel, Some(true));
4081 let borrow_off = build(MarginEffect::Borrow, false);
4082 assert_eq!(borrow_off.auto_repay_at_cancel, Some(false));
4083
4084 let repay = build(MarginEffect::Repay, true);
4086 assert_eq!(wire(&repay).as_deref(), Some("AUTO_REPAY"));
4087 assert_eq!(repay.auto_repay_at_cancel, None);
4088 let none = build(MarginEffect::NoSideEffect, true);
4089 assert_eq!(wire(&none).as_deref(), Some("NO_SIDE_EFFECT"));
4090 assert_eq!(none.auto_repay_at_cancel, None);
4091 }
4092
4093 #[test]
4094 fn new_order_market_has_no_price_or_tif() {
4095 let p = params(
4096 Side::Buy,
4097 None,
4098 OrderKind::Market,
4099 gtc(),
4100 MarginEffect::Borrow,
4101 )
4102 .expect("build");
4103 assert_eq!(p.r#type.as_str(), "MARKET");
4104 assert_eq!(p.price, None);
4105 assert_eq!(p.stop_price, None);
4106 assert!(p.time_in_force.is_none());
4107 }
4108
4109 #[test]
4110 fn post_only_limit_maps_to_limit_maker() {
4111 let p = params(
4112 Side::Buy,
4113 Some(Decimal::from(10)),
4114 OrderKind::Limit,
4115 TimeInForce::GoodUntilCancelled { post_only: true },
4116 MarginEffect::Borrow,
4117 )
4118 .expect("build");
4119 assert_eq!(p.r#type.as_str(), "LIMIT_MAKER");
4120 assert!(p.time_in_force.is_none());
4122 }
4123
4124 #[test]
4125 fn conditional_kinds_set_stop_price() {
4126 let trigger = Decimal::from(48_000);
4127
4128 let stop = params(
4129 Side::Sell,
4130 None,
4131 OrderKind::Stop {
4132 trigger_price: trigger,
4133 },
4134 gtc(),
4135 MarginEffect::Borrow,
4136 )
4137 .expect("build");
4138 assert_eq!(stop.r#type.as_str(), "STOP_LOSS");
4139 assert_eq!(stop.stop_price, Some(trigger));
4140 assert_eq!(stop.price, None);
4141
4142 let stop_limit = params(
4143 Side::Sell,
4144 Some(Decimal::from(47_900)),
4145 OrderKind::StopLimit {
4146 trigger_price: trigger,
4147 },
4148 gtc(),
4149 MarginEffect::Borrow,
4150 )
4151 .expect("build");
4152 assert_eq!(stop_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
4153 assert_eq!(stop_limit.stop_price, Some(trigger));
4154 assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
4155 assert_eq!(
4156 stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
4157 Some("GTC")
4158 );
4159
4160 let take_profit = params(
4161 Side::Sell,
4162 None,
4163 OrderKind::TakeProfit {
4164 trigger_price: trigger,
4165 },
4166 gtc(),
4167 MarginEffect::Borrow,
4168 )
4169 .expect("build");
4170 assert_eq!(take_profit.r#type.as_str(), "TAKE_PROFIT");
4171 assert_eq!(take_profit.stop_price, Some(trigger));
4172 }
4173
4174 #[test]
4177 fn a_trailing_stop_maps_to_stop_loss_with_trailing_delta() {
4178 let cases = [
4179 (Decimal::from(25), TrailingOffsetType::BasisPoints, 25),
4180 (Decimal::new(15, 1), TrailingOffsetType::Percentage, 150),
4181 ];
4182 for (offset, offset_type, trailing_delta) in cases {
4183 let p = params(
4184 Side::Sell,
4185 None,
4186 OrderKind::TrailingStop {
4187 offset,
4188 offset_type,
4189 activation_price: None,
4190 },
4191 gtc(),
4192 MarginEffect::NoSideEffect,
4193 )
4194 .expect("build");
4195 assert_eq!(p.r#type.as_str(), "STOP_LOSS");
4196 assert_eq!(p.trailing_delta, Some(trailing_delta), "{offset_type:?}");
4197 assert_eq!(p.stop_price, None);
4198 assert_eq!(p.price, None);
4199 assert!(p.time_in_force.is_none());
4200 }
4201 }
4202
4203 #[test]
4207 fn trailing_orders_carry_their_activation_price_and_fixed_limit() {
4208 let activation = Some(Decimal::from(50_000));
4209 let trailing_stop = params(
4210 Side::Sell,
4211 None,
4212 OrderKind::TrailingStop {
4213 offset: Decimal::from(25),
4214 offset_type: TrailingOffsetType::BasisPoints,
4215 activation_price: activation,
4216 },
4217 gtc(),
4218 MarginEffect::NoSideEffect,
4219 )
4220 .expect("build");
4221 assert_eq!(trailing_stop.r#type.as_str(), "STOP_LOSS");
4222 assert_eq!(trailing_stop.trailing_delta, Some(25));
4223 assert_eq!(trailing_stop.stop_price, activation);
4224 assert_eq!(trailing_stop.price, None);
4225
4226 for activation_price in [None, activation] {
4227 let fixed_limit = params(
4228 Side::Sell,
4229 Some(Decimal::from(49_000)),
4230 OrderKind::TrailingStopFixedLimit {
4231 offset: Decimal::new(15, 1),
4232 offset_type: TrailingOffsetType::Percentage,
4233 activation_price,
4234 },
4235 TimeInForce::ImmediateOrCancel,
4236 MarginEffect::NoSideEffect,
4237 )
4238 .expect("build");
4239 assert_eq!(fixed_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
4240 assert_eq!(fixed_limit.trailing_delta, Some(150));
4241 assert_eq!(fixed_limit.price, Some(Decimal::from(49_000)));
4242 assert_eq!(fixed_limit.stop_price, activation_price);
4243 assert_eq!(
4244 fixed_limit.time_in_force.as_ref().map(|t| t.as_str()),
4245 Some("IOC")
4246 );
4247 }
4248
4249 let absolute = params(
4250 Side::Sell,
4251 Some(Decimal::from(49_000)),
4252 OrderKind::TrailingStopFixedLimit {
4253 offset: Decimal::from(100),
4254 offset_type: TrailingOffsetType::Absolute,
4255 activation_price: None,
4256 },
4257 gtc(),
4258 MarginEffect::NoSideEffect,
4259 );
4260 assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
4261
4262 let no_limit = params(
4264 Side::Sell,
4265 None,
4266 OrderKind::TrailingStopFixedLimit {
4267 offset: Decimal::from(25),
4268 offset_type: TrailingOffsetType::BasisPoints,
4269 activation_price: None,
4270 },
4271 gtc(),
4272 MarginEffect::NoSideEffect,
4273 );
4274 assert!(matches!(
4275 no_limit,
4276 Err(BuildOrderError::Trailing(TrailingOrderError::LimitPrice(
4277 None
4278 )))
4279 ));
4280 let zero_activation = params(
4281 Side::Sell,
4282 None,
4283 OrderKind::TrailingStop {
4284 offset: Decimal::from(25),
4285 offset_type: TrailingOffsetType::BasisPoints,
4286 activation_price: Some(Decimal::ZERO),
4287 },
4288 gtc(),
4289 MarginEffect::NoSideEffect,
4290 );
4291 assert!(matches!(
4292 zero_activation,
4293 Err(BuildOrderError::Trailing(
4294 TrailingOrderError::NonPositiveActivation(_)
4295 ))
4296 ));
4297 }
4298
4299 #[test]
4300 fn absolute_trailing_offsets_and_trailing_stop_limits_are_unsupported() {
4301 let absolute = params(
4302 Side::Sell,
4303 None,
4304 OrderKind::TrailingStop {
4305 offset: Decimal::from(100),
4306 offset_type: TrailingOffsetType::Absolute,
4307 activation_price: None,
4308 },
4309 gtc(),
4310 MarginEffect::Borrow,
4311 );
4312 assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
4313
4314 let trailing_stop_limit = params(
4315 Side::Sell,
4316 Some(Decimal::from(100)),
4317 OrderKind::TrailingStopLimit {
4318 offset: Decimal::from(100),
4319 offset_type: TrailingOffsetType::BasisPoints,
4320 stop_price: Decimal::from(95),
4321 limit_offset: Decimal::from(10),
4322 },
4323 gtc(),
4324 MarginEffect::Borrow,
4325 );
4326 assert!(matches!(
4327 trailing_stop_limit,
4328 Err(BuildOrderError::Unsupported)
4329 ));
4330
4331 let unresolved = params(
4333 Side::Sell,
4334 Some(Decimal::from(100)),
4335 OrderKind::ConditionalUnresolved {
4336 family: crate::order::ConditionalFamily::StopLimit,
4337 trigger_price: Some(Decimal::from(95)),
4338 },
4339 gtc(),
4340 MarginEffect::Borrow,
4341 );
4342 assert!(matches!(unresolved, Err(BuildOrderError::Unsupported)));
4343 }
4344
4345 #[test]
4347 fn a_fractional_trailing_offset_is_refused() {
4348 let fractional = params(
4349 Side::Sell,
4350 None,
4351 OrderKind::TrailingStop {
4352 offset: Decimal::new(155, 3),
4353 offset_type: TrailingOffsetType::Percentage,
4354 activation_price: None,
4355 },
4356 gtc(),
4357 MarginEffect::Borrow,
4358 );
4359 assert!(matches!(
4360 fractional,
4361 Err(BuildOrderError::Trailing(TrailingOrderError::Delta(
4362 crate::client::binance::shared::TrailingDeltaError::Invalid(_)
4363 )))
4364 ));
4365 }
4366
4367 #[test]
4370 fn recalled_order_kinds_are_known_and_those_past_the_limit_warned_of() {
4371 use crate::client::order_recovery::tests::logged_with_lock_free;
4372
4373 let client = BinanceMargin::new(
4374 BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string())
4375 .with_server_time_sync(false),
4376 );
4377 let trailing = OrderKind::TrailingStopFixedLimit {
4378 offset: Decimal::from(100),
4379 offset_type: TrailingOffsetType::BasisPoints,
4380 activation_price: None,
4381 };
4382 let cid = |n: usize| ClientOrderId::new(format!("cid-{n}"));
4383
4384 let seen = logged_with_lock_free(&client.known_live, || {
4385 client.recall_order_kinds((0..MAX_KNOWN_LIVE_ORDERS).map(|n| (cid(n), trailing)));
4386 });
4387 assert!(seen.is_empty(), "nothing past the limit: {seen:?}");
4388 assert_eq!(client.known_live.lock().kind_of(&cid(0)), Some(trailing));
4389
4390 let seen = logged_with_lock_free(&client.known_live, || {
4391 client.recall_order_kinds([(cid(MAX_KNOWN_LIVE_ORDERS), trailing)]);
4392 });
4393 assert_eq!(seen, [(tracing::Level::WARN, true)]);
4394 assert_eq!(
4395 client
4396 .known_live
4397 .lock()
4398 .kind_of(&cid(MAX_KNOWN_LIVE_ORDERS)),
4399 None
4400 );
4401 }
4402
4403 #[test]
4404 fn tif_variants_map_to_margin_enum() {
4405 for (tif, expected) in [
4406 (TimeInForce::ImmediateOrCancel, "IOC"),
4407 (TimeInForce::FillOrKill, "FOK"),
4408 (gtc(), "GTC"),
4409 ] {
4410 let p = params(
4411 Side::Buy,
4412 Some(Decimal::from(1)),
4413 OrderKind::Limit,
4414 tif,
4415 MarginEffect::Borrow,
4416 )
4417 .expect("build");
4418 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
4419 }
4420 }
4421
4422 fn user_asset(
4427 asset: &str,
4428 free: &str,
4429 locked: &str,
4430 borrowed: &str,
4431 interest: &str,
4432 ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
4433 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4434 a.asset = Some(asset.to_string());
4435 a.free = Some(free.to_string());
4436 a.locked = Some(locked.to_string());
4437 a.borrowed = Some(borrowed.to_string());
4438 a.interest = Some(interest.to_string());
4439 a
4440 }
4441
4442 #[test]
4443 fn margin_balance_entry_maps_debt() {
4444 let ab =
4447 convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
4448 .expect("convert");
4449 assert_eq!(ab.asset.name().as_str(), "USDT");
4450 assert_eq!(ab.balance.total, Decimal::from(12));
4451 assert_eq!(ab.balance.free, Decimal::from(10));
4452 assert!(
4453 ab.balance.margin.is_some(),
4454 "REST snapshot must populate margin"
4455 );
4456 assert_eq!(ab.balance.net_asset(), Decimal::new(85, 1));
4457 }
4458
4459 #[test]
4460 fn margin_balance_short_is_negative_net() {
4461 let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
4463 .expect("convert");
4464 assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
4465 }
4466
4467 #[test]
4468 fn margin_balance_missing_debt_defaults_to_zero() {
4469 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4472 a.asset = Some("ETH".to_string());
4473 a.free = Some("4".to_string());
4474 a.locked = Some("0".to_string());
4475 let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
4476 assert!(ab.balance.margin.is_some());
4477 assert_eq!(ab.balance.net_asset(), Decimal::from(4));
4478 }
4479
4480 #[test]
4481 fn margin_balance_missing_free_is_dropped() {
4482 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4484 a.asset = Some("USDT".to_string());
4485 a.locked = Some("0".to_string());
4486 assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
4487 }
4488
4489 #[test]
4490 fn margin_balance_filtering() {
4491 let assets = vec![
4492 user_asset("USDT", "10", "0", "0", "0"),
4493 user_asset("BTC", "1", "0", "0", "0"),
4494 user_asset("ETH", "5", "0", "0", "0"),
4495 ];
4496 assert_eq!(
4498 filter_and_convert_margin_balances(assets.clone(), &[], Utc::now()).len(),
4499 3
4500 );
4501 let filtered = filter_and_convert_margin_balances(
4503 assets,
4504 &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
4505 Utc::now(),
4506 );
4507 assert_eq!(filtered.len(), 2);
4508 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
4509 }
4510
4511 #[test]
4512 fn margin_balance_filtering_large_slice_uses_hashset_branch() {
4513 let assets = vec![
4515 user_asset("BTC", "1", "0", "0", "0"),
4516 user_asset("ETH", "5", "0", "0", "0"),
4517 user_asset("DOGE", "9", "0", "0", "0"), ];
4519 let mut requested: Vec<AssetNameExchange> = (0..17)
4521 .map(|i| AssetNameExchange::new(format!("A{i:02}")))
4522 .collect();
4523 requested.push(AssetNameExchange::new("BTC"));
4524 requested.push(AssetNameExchange::new("ETH"));
4525 assert!(
4526 requested.len() > 16,
4527 "must exceed the linear-scan threshold"
4528 );
4529
4530 let filtered = filter_and_convert_margin_balances(assets, &requested, Utc::now());
4531 assert_eq!(filtered.len(), 2);
4532 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
4533 }
4534
4535 use binance_sdk::margin_trading::rest_api::{
4540 QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
4541 QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
4542 };
4543
4544 fn d(s: &str) -> Decimal {
4545 Decimal::from_str(s).unwrap()
4546 }
4547
4548 fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
4549 let mut b = IsoBase::new();
4550 b.asset = Some(asset.to_string());
4551 b.free = Some(free.to_string());
4552 b.locked = Some(locked.to_string());
4553 b.borrowed = Some(borrowed.to_string());
4554 b.interest = Some(interest.to_string());
4555 b
4556 }
4557
4558 fn iso_quote(
4559 asset: &str,
4560 free: &str,
4561 locked: &str,
4562 borrowed: &str,
4563 interest: &str,
4564 ) -> IsoQuote {
4565 let mut q = IsoQuote::new();
4566 q.asset = Some(asset.to_string());
4567 q.free = Some(free.to_string());
4568 q.locked = Some(locked.to_string());
4569 q.borrowed = Some(borrowed.to_string());
4570 q.interest = Some(interest.to_string());
4571 q
4572 }
4573
4574 fn iso_entry(
4575 symbol: &str,
4576 base: IsoBase,
4577 quote: IsoQuote,
4578 margin_level: Option<&str>,
4579 margin_ratio: Option<&str>,
4580 liquidate_price: Option<&str>,
4581 ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
4582 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4583 e.symbol = Some(symbol.to_string());
4584 e.base_asset = Some(Box::new(base));
4585 e.quote_asset = Some(Box::new(quote));
4586 e.margin_level = margin_level.map(str::to_string);
4587 e.margin_ratio = margin_ratio.map(str::to_string);
4588 e.liquidate_price = liquidate_price.map(str::to_string);
4589 e
4590 }
4591
4592 fn isolated_client(symbols: &[&str]) -> BinanceMargin {
4593 let config = BinanceMarginConfig::isolated(
4594 "k".to_string(),
4595 "s".to_string(),
4596 symbols
4597 .iter()
4598 .map(|s| InstrumentNameExchange::new(*s))
4599 .collect(),
4600 );
4601 BinanceMargin::new(config.with_server_time_sync(false))
4602 }
4603
4604 #[test]
4605 fn client_debug_shows_mode_and_margin_effect_without_credentials() {
4606 let debug = format!("{:?}", isolated_client(&["BTCUSDT"]));
4607 assert!(debug.contains("BinanceMargin"));
4608 assert!(debug.contains("is_isolated: true"));
4609 assert!(debug.contains("margin_effect: NoSideEffect"));
4610 assert!(!debug.contains("\"k\"") && !debug.contains("\"s\""));
4611 }
4612
4613 #[test]
4614 fn isolated_assets_map_base_quote_and_risk() {
4615 let entry = iso_entry(
4618 "BTCUSDT",
4619 iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
4620 iso_quote("USDT", "1000", "50", "300", "1.5"),
4621 Some("3.5"),
4622 Some("0.12"),
4623 Some("48000"),
4624 );
4625 let map = convert_isolated_margin_assets(vec![entry], Utc::now());
4626 let state = map
4627 .get(&InstrumentNameExchange::new("BTCUSDT"))
4628 .expect("entry present");
4629
4630 assert_eq!(state.base.asset.name().as_str(), "BTC");
4631 assert_eq!(state.base.balance.total, d("0.6"));
4632 assert_eq!(state.base.balance.free, d("0.5"));
4633 assert_eq!(state.base.balance.net_asset(), d("0.399"));
4634
4635 assert_eq!(state.quote.asset.name().as_str(), "USDT");
4636 assert_eq!(state.quote.balance.total, d("1050"));
4637 assert_eq!(state.quote.balance.net_asset(), d("748.5"));
4638
4639 assert_eq!(state.risk.margin_level, Some(d("3.5")));
4640 assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
4641 assert_eq!(state.risk.liquidation_price, Some(d("48000")));
4642 }
4643
4644 #[test]
4645 fn isolated_base_short_is_negative_net() {
4646 let entry = iso_entry(
4648 "BTCUSDT",
4649 iso_base("BTC", "0", "0", "1.5", "0.001"),
4650 iso_quote("USDT", "100", "0", "0", "0"),
4651 None,
4652 None,
4653 None,
4654 );
4655 let map = convert_isolated_margin_assets(vec![entry], Utc::now());
4656 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4657 assert_eq!(state.base.balance.net_asset(), d("-1.501"));
4658 }
4659
4660 #[test]
4661 fn isolated_missing_debt_defaults_zero_and_risk_optional() {
4662 let mut base = IsoBase::new();
4665 base.asset = Some("BTC".to_string());
4666 base.free = Some("0.5".to_string());
4667 base.locked = Some("0".to_string());
4668 let mut quote = IsoQuote::new();
4669 quote.asset = Some("USDT".to_string());
4670 quote.free = Some("100".to_string());
4671 quote.locked = Some("0".to_string());
4672
4673 let map = convert_isolated_margin_assets(
4674 vec![iso_entry("BTCUSDT", base, quote, None, None, None)],
4675 Utc::now(),
4676 );
4677 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4678 assert!(state.base.balance.margin.is_some());
4679 assert_eq!(state.base.balance.net_asset(), d("0.5"));
4680 assert_eq!(state.risk.margin_level, None);
4681 assert_eq!(state.risk.liquidation_price, None);
4682 }
4683
4684 #[test]
4685 fn isolated_unparseable_risk_is_none_but_entry_kept() {
4686 let entry = iso_entry(
4687 "BTCUSDT",
4688 iso_base("BTC", "1", "0", "0", "0"),
4689 iso_quote("USDT", "1", "0", "0", "0"),
4690 Some("not_a_number"),
4691 None,
4692 None,
4693 );
4694 let map = convert_isolated_margin_assets(vec![entry], Utc::now());
4695 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4696 assert_eq!(state.risk.margin_level, None);
4697 }
4698
4699 #[test]
4700 fn isolated_missing_base_free_drops_entry() {
4701 let mut base = IsoBase::new();
4703 base.asset = Some("BTC".to_string());
4704 base.locked = Some("0".to_string()); let entry = iso_entry(
4706 "BTCUSDT",
4707 base,
4708 iso_quote("USDT", "100", "0", "0", "0"),
4709 None,
4710 None,
4711 None,
4712 );
4713 assert!(convert_isolated_margin_assets(vec![entry], Utc::now()).is_empty());
4714 }
4715
4716 #[test]
4717 fn isolated_missing_symbol_drops_entry() {
4718 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4719 e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
4720 e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
4721 assert!(convert_isolated_margin_assets(vec![e], Utc::now()).is_empty());
4723 }
4724
4725 #[test]
4726 fn chunk_symbols_batches_by_five() {
4727 let syms: Vec<InstrumentNameExchange> = (0..12)
4728 .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
4729 .collect();
4730 let chunks = chunk_symbols(&syms);
4731 assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
4732 assert_eq!(chunks[0].split(',').count(), 5);
4733 assert_eq!(chunks[1].split(',').count(), 5);
4734 assert_eq!(chunks[2].split(',').count(), 2);
4735
4736 let three: Vec<_> = (0..3)
4738 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4739 .collect();
4740 assert_eq!(chunk_symbols(&three).len(), 1);
4741
4742 let five: Vec<_> = (0..5)
4744 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4745 .collect();
4746 let c = chunk_symbols(&five);
4747 assert_eq!(c.len(), 1);
4748 assert_eq!(c[0].split(',').count(), 5);
4749
4750 assert!(chunk_symbols(&[]).is_empty());
4752 }
4753
4754 #[test]
4755 fn effective_isolated_set_empty_returns_all_configured() {
4756 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4757 assert_eq!(
4758 client.effective_isolated_set(&[]),
4759 vec![
4760 InstrumentNameExchange::new("BTCUSDT"),
4761 InstrumentNameExchange::new("ETHUSDT"),
4762 ]
4763 );
4764 }
4765
4766 #[test]
4767 fn effective_isolated_set_intersects_requested() {
4768 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4769 assert_eq!(
4770 client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
4771 vec![InstrumentNameExchange::new("ETHUSDT")]
4772 );
4773 }
4774
4775 #[test]
4776 fn effective_isolated_set_skips_out_of_set() {
4777 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4778 assert_eq!(
4780 client.effective_isolated_set(&[
4781 InstrumentNameExchange::new("BTCUSDT"),
4782 InstrumentNameExchange::new("DOGEUSDT"),
4783 ]),
4784 vec![InstrumentNameExchange::new("BTCUSDT")]
4785 );
4786 }
4787
4788 #[tokio::test]
4789 async fn fetch_balances_isolated_returns_empty() {
4790 let client = isolated_client(&["BTCUSDT"]);
4793 assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
4794 }
4795
4796 fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
4797 let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
4798 o.order_id = order_id;
4799 o.client_order_id = Some("cid-9".to_string());
4800 o.side = Some(side.to_string());
4801 o.price = Some("50000".to_string());
4802 o.orig_qty = Some("2".to_string());
4803 o.executed_qty = Some("0.5".to_string());
4804 o.r#type = Some("LIMIT".to_string());
4805 o.time_in_force = Some("GTC".to_string());
4806 o.time = Some(1_700_000_000_000);
4807 o.status = Some("PARTIALLY_FILLED".to_string());
4808 o
4809 }
4810
4811 #[test]
4812 fn margin_open_order_converts() {
4813 let inst = InstrumentNameExchange::new("BTCUSDT");
4814 let order = convert_open_order(
4815 &open_order(Some(42), "BUY"),
4816 ExchangeId::BinanceMargin,
4817 &inst,
4818 &|_| None,
4819 )
4820 .expect("convert");
4821 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4822 assert_eq!(
4823 order.state.id.assigned().map(|id| id.0.as_str()),
4824 Some("42")
4825 );
4826 assert_eq!(order.key.cid.0.as_str(), "cid-9");
4827 assert_eq!(order.side, Side::Buy);
4828 assert_eq!(order.price, Some(Decimal::from(50_000)));
4829 assert_eq!(order.quantity, Decimal::from(2));
4830 assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
4831 assert_eq!(order.kind, OrderKind::Limit);
4832 }
4833
4834 #[test]
4837 fn margin_open_order_that_is_not_live_is_dropped() {
4838 let inst = InstrumentNameExchange::new("BTCUSDT");
4839 let mut o = open_order(Some(42), "BUY");
4840 o.status = Some("CANCELED".to_string());
4841 assert!(convert_open_order(&o, ExchangeId::BinanceMargin, &inst, &|_| None).is_none());
4842 }
4843
4844 #[test]
4845 fn margin_open_order_missing_order_id_is_dropped() {
4846 let inst = InstrumentNameExchange::new("BTCUSDT");
4847 assert!(
4848 convert_open_order(
4849 &open_order(None, "BUY"),
4850 ExchangeId::BinanceMargin,
4851 &inst,
4852 &|_| None
4853 )
4854 .is_none()
4855 );
4856 }
4857
4858 #[test]
4864 fn margin_open_order_prefers_update_time_over_creation_time() {
4865 let inst = InstrumentNameExchange::new("BTCUSDT");
4866
4867 let mut updated = open_order(Some(42), "BUY");
4868 updated.update_time = Some(1_700_000_005_000);
4869 let order = convert_open_order(&updated, ExchangeId::BinanceMargin, &inst, &|_| None)
4870 .expect("convert");
4871 assert_eq!(
4872 order.state.time_exchange,
4873 Utc.timestamp_millis_opt(1_700_000_005_000)
4874 .single()
4875 .unwrap(),
4876 "update_time must win over time"
4877 );
4878 }
4879
4880 #[test]
4883 fn margin_open_order_falls_back_to_creation_time_when_update_time_absent() {
4884 let inst = InstrumentNameExchange::new("BTCUSDT");
4885
4886 let base = open_order(Some(42), "BUY");
4887 assert!(base.update_time.is_none(), "fixture must omit update_time");
4888 let order = convert_open_order(&base, ExchangeId::BinanceMargin, &inst, &|_| None)
4889 .expect("convert");
4890 assert_eq!(
4891 order.state.time_exchange,
4892 Utc.timestamp_millis_opt(1_700_000_000_000)
4893 .single()
4894 .unwrap(),
4895 "absent update_time falls back to time"
4896 );
4897 }
4898
4899 #[test]
4900 fn margin_open_order_owned_symbol_recovers_instrument() {
4901 let mut o = open_order(Some(42), "SELL");
4903 o.symbol = Some("ETHUSDT".to_string());
4904 let order = convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin, &|_| None)
4905 .expect("convert");
4906 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4907 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4908 assert_eq!(order.side, Side::Sell);
4909 }
4910
4911 #[test]
4912 fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
4913 let o = open_order(Some(42), "BUY");
4915 assert!(o.symbol.is_none());
4916 assert!(
4917 convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin, &|_| None).is_none()
4918 );
4919 }
4920
4921 fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
4922 let mut t = QueryMarginAccountsTradeListResponseInner::new();
4923 t.id = id;
4924 t.order_id = Some(7);
4925 t.is_buyer = is_buyer;
4926 t.price = Some("48000".to_string());
4927 t.qty = Some("0.25".to_string());
4928 t.commission = Some("0.001".to_string());
4929 t.commission_asset = Some("BNB".to_string());
4930 t.time = Some(1_700_000_000_000);
4931 t
4932 }
4933
4934 #[test]
4935 fn margin_trade_converts() {
4936 let inst = InstrumentNameExchange::new("BTCUSDT");
4937 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
4938 assert_eq!(tr.id.0.as_str(), "11");
4939 assert_eq!(tr.order_id.0.as_str(), "7");
4940 assert_eq!(tr.side, Side::Sell);
4941 assert_eq!(tr.price, Decimal::from(48_000));
4942 assert_eq!(tr.quantity, Decimal::new(25, 2));
4943 assert_eq!(tr.fees.asset.name().as_str(), "BNB");
4945 assert_eq!(tr.fees.fees, Decimal::new(1, 3));
4946 }
4947
4948 #[test]
4951 fn a_margin_trade_without_a_time_is_at_this_hosts_time() {
4952 let inst = InstrumentNameExchange::new("BTCUSDT");
4953 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).unwrap();
4954 assert_eq!(
4955 (tr.time_exchange, tr.time_source),
4956 (
4957 Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
4958 TimeSource::Venue
4959 )
4960 );
4961 let before = Utc::now();
4962 let mut untimed = trade(Some(11), Some(false));
4963 untimed.time = None;
4964 let tr = convert_margin_trade(&untimed, &inst).unwrap();
4965 assert_eq!(tr.time_source, TimeSource::Host);
4966 assert!((before..=Utc::now()).contains(&tr.time_exchange));
4967 }
4968
4969 #[test]
4970 fn margin_trade_missing_fields_are_dropped() {
4971 let inst = InstrumentNameExchange::new("BTCUSDT");
4972 assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
4974 assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
4975 }
4976
4977 #[test]
4980 fn base_quote_map_extracts_base_and_quote() {
4981 let entries = vec![
4982 iso_entry(
4983 "BTCUSDT",
4984 iso_base("BTC", "0", "0", "0", "0"),
4985 iso_quote("USDT", "0", "0", "0", "0"),
4986 None,
4987 None,
4988 None,
4989 ),
4990 iso_entry(
4991 "ETHBTC",
4992 iso_base("ETH", "0", "0", "0", "0"),
4993 iso_quote("BTC", "0", "0", "0", "0"),
4994 None,
4995 None,
4996 None,
4997 ),
4998 ];
4999 let map = build_base_quote_map(&entries);
5000 assert_eq!(
5001 map.get(&InstrumentNameExchange::new("BTCUSDT"))
5002 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5003 Some(("BTC", "USDT"))
5004 );
5005 assert_eq!(
5008 map.get(&InstrumentNameExchange::new("ETHBTC"))
5009 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5010 Some(("ETH", "BTC"))
5011 );
5012 }
5013
5014 struct MarginMyTradesVenue {
5025 trades: Vec<(i64, i64, i64)>,
5027 end_exclusive: bool,
5028 }
5029
5030 impl wiremock::Respond for MarginMyTradesVenue {
5031 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
5032 const DAY_MS: i64 = 24 * 60 * 60 * 1000;
5033 let query: std::collections::HashMap<String, String> =
5034 request.url.query_pairs().into_owned().collect();
5035 let param = |name: &str| query.get(name).map(|v| v.parse::<i64>().unwrap());
5036 let limit = usize::try_from(param("limit").unwrap_or(500)).unwrap();
5037 let order = param("orderId");
5038
5039 let mut selected: Vec<&(i64, i64, i64)> = self
5040 .trades
5041 .iter()
5042 .filter(|(_, _, order_id)| order.is_none_or(|order| *order_id == order))
5043 .collect();
5044 if let Some(from_id) = param("fromId") {
5045 selected.retain(|(id, _, _)| *id >= from_id);
5046 } else if order.is_none() {
5047 let start = param("startTime").expect("a query by time sends startTime");
5048 let end = match param("endTime") {
5049 Some(end) if end - start >= DAY_MS => {
5050 return wiremock::ResponseTemplate::new(400).set_body_json(
5051 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
5052 );
5053 }
5054 Some(end) if self.end_exclusive => end - 1,
5055 Some(end) => end,
5056 None => start + DAY_MS - 1,
5057 };
5058 selected.retain(|(_, time, _)| (start..=end).contains(time));
5059 }
5060
5061 let page: Vec<serde_json::Value> = selected
5062 .into_iter()
5063 .take(limit)
5064 .map(|(id, time, order_id)| {
5065 serde_json::json!({
5066 "id": id, "symbol": "BTCUSDT", "time": time, "orderId": order_id,
5067 "price": "50000", "qty": "0.01", "commission": "0",
5068 "commissionAsset": "USDT", "isBuyer": true, "isMaker": false,
5069 "isBestMatch": true, "isIsolated": true,
5070 })
5071 })
5072 .collect();
5073 wiremock::ResponseTemplate::new(200).set_body_json(page)
5074 }
5075 }
5076
5077 fn since(start: i64) -> MyTradesFrom {
5079 MyTradesFrom::Span {
5080 start,
5081 end: Utc::now().timestamp_millis(),
5082 }
5083 }
5084
5085 async fn read_margin_trades(
5088 venue: MarginMyTradesVenue,
5089 from: MyTradesFrom,
5090 ) -> (Vec<i64>, Vec<std::collections::HashMap<String, String>>) {
5091 let server = wiremock::MockServer::start().await;
5092 wiremock::Mock::given(wiremock::matchers::method("GET"))
5093 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5094 .respond_with(venue)
5095 .mount(&server)
5096 .await;
5097 let rest = Arc::new(MarginTradingRestApi::from_config(
5098 ConfigurationRestApi::builder()
5099 .api_key("key")
5100 .api_secret("secret")
5101 .base_path(server.uri())
5102 .build()
5103 .unwrap(),
5104 ));
5105
5106 let read = paginate_margin_my_trades(
5107 &rest,
5108 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5109 &InstrumentNameExchange::new("BTCUSDT"),
5110 from,
5111 true,
5112 RequestKind::Query,
5113 )
5114 .await
5115 .unwrap();
5116
5117 let requests = server
5118 .received_requests()
5119 .await
5120 .unwrap()
5121 .iter()
5122 .map(|request| request.url.query_pairs().into_owned().collect())
5123 .collect();
5124 (read.into_iter().map(|t| t.id.unwrap()).collect(), requests)
5125 }
5126
5127 #[tokio::test]
5130 async fn recovered_margin_fills_carry_their_orders_client_order_ids() {
5131 recovered_margin_fills_carry_client_order_ids(true).await;
5132 recovered_margin_fills_carry_client_order_ids(false).await;
5133 }
5134
5135 async fn recovered_margin_fills_carry_client_order_ids(is_isolated: bool) {
5136 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5137 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5138 let fill_time = (disconnect + chrono::Duration::minutes(1)).timestamp_millis();
5139 let server = wiremock::MockServer::start().await;
5140 wiremock::Mock::given(wiremock::matchers::method("GET"))
5141 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5142 .respond_with(MarginMyTradesVenue {
5143 trades: vec![(1, fill_time, 1), (2, fill_time, 2)],
5144 end_exclusive: false,
5145 })
5146 .mount(&server)
5147 .await;
5148 wiremock::Mock::given(wiremock::matchers::method("GET"))
5149 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
5150 .and(wiremock::matchers::query_param("orderId", "1"))
5151 .and(wiremock::matchers::query_param(
5152 "isIsolated",
5153 if is_isolated { "TRUE" } else { "FALSE" },
5154 ))
5155 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
5156 serde_json::json!({"symbol": "BTCUSDT", "orderId": 1, "clientOrderId": "repay-1"}),
5157 ))
5158 .mount(&server)
5159 .await;
5160 wiremock::Mock::given(wiremock::matchers::method("GET"))
5161 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
5162 .and(wiremock::matchers::query_param("orderId", "2"))
5163 .respond_with(
5164 wiremock::ResponseTemplate::new(400).set_body_json(
5165 serde_json::json!({"code": -2013, "msg": "Order does not exist."}),
5166 ),
5167 )
5168 .mount(&server)
5169 .await;
5170 let rest = Arc::new(MarginTradingRestApi::from_config(
5171 ConfigurationRestApi::builder()
5172 .api_key("key")
5173 .api_secret("secret")
5174 .base_path(server.uri())
5175 .build()
5176 .unwrap(),
5177 ));
5178 let (tx, mut rx) = mpsc::unbounded_channel();
5179 let mut unrecovered = UnrecoveredFills::default();
5180 unrecovered.open(
5181 &[InstrumentNameExchange::new("BTCUSDT")],
5182 disconnect,
5183 reconnect,
5184 );
5185
5186 recover_margin_fills(
5187 &rest,
5188 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5189 &mut unrecovered,
5190 &tx,
5191 &new_dedup_cache(),
5192 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5193 is_isolated,
5194 )
5195 .await;
5196
5197 let mut cids: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
5198 .filter_map(|event| match event.kind {
5199 AccountEventKind::Trade(trade) => Some((
5200 trade.id.0.to_string(),
5201 trade.client_order_id,
5202 trade.order_filled_quantity.is_some(),
5203 )),
5204 _ => None,
5205 })
5206 .collect();
5207 cids.sort();
5208 assert_eq!(
5210 cids,
5211 [
5212 ("1".to_string(), Some(ClientOrderId::new("repay-1")), true),
5213 ("2".to_string(), None, true),
5214 ],
5215 "is_isolated: {is_isolated}"
5216 );
5217 let order_reads = server
5218 .received_requests()
5219 .await
5220 .unwrap()
5221 .iter()
5222 .filter(|request| request.url.path() == "/sapi/v1/margin/order")
5223 .count();
5224 assert_eq!(order_reads, 2, "one read per order");
5225 }
5226
5227 fn venue(trades: Vec<(i64, i64)>) -> MarginMyTradesVenue {
5229 MarginMyTradesVenue {
5230 trades: trades
5231 .into_iter()
5232 .map(|(id, time)| (id, time, id))
5233 .collect(),
5234 end_exclusive: false,
5235 }
5236 }
5237
5238 const HOUR_MS: i64 = 60 * 60 * 1000;
5239
5240 #[tokio::test]
5243 async fn margin_trades_read_a_lone_fill_beyond_the_first_day() {
5244 let now = Utc::now().timestamp_millis();
5245 let (ids, _) = read_margin_trades(
5246 venue(vec![(7, now - 72 * HOUR_MS)]),
5247 since(now - 120 * HOUR_MS),
5248 )
5249 .await;
5250
5251 assert_eq!(ids, vec![7]);
5252 }
5253
5254 #[tokio::test]
5257 async fn margin_trades_read_fills_across_several_windows_once_each() {
5258 let now = Utc::now().timestamp_millis();
5259 let trades = vec![
5260 (1, now - 119 * HOUR_MS),
5261 (2, now - 72 * HOUR_MS),
5262 (3, now - HOUR_MS),
5263 ];
5264 let (ids, _) = read_margin_trades(venue(trades), since(now - 144 * HOUR_MS)).await;
5265
5266 assert_eq!(ids, vec![1, 2, 3]);
5267 }
5268
5269 #[tokio::test]
5272 async fn margin_trades_read_a_trade_on_a_window_boundary_once() {
5273 for end_exclusive in [false, true] {
5274 let now = Utc::now().timestamp_millis();
5275 let from = now - 48 * HOUR_MS;
5276 let venue = MarginMyTradesVenue {
5277 end_exclusive,
5278 ..venue(vec![(5, from + MARGIN_MY_TRADES_WINDOW_MS)])
5279 };
5280 let (ids, _) = read_margin_trades(venue, since(from)).await;
5281
5282 assert_eq!(ids, vec![5], "end_exclusive: {end_exclusive}");
5283 }
5284 }
5285
5286 #[tokio::test]
5288 async fn margin_trades_page_on_by_id_past_a_full_window() {
5289 let now = Utc::now().timestamp_millis();
5290 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5291 let trades = (1..=count)
5292 .map(|id| (id, now - 48 * HOUR_MS + id))
5293 .collect();
5294 let (ids, _) = read_margin_trades(venue(trades), since(now - 50 * HOUR_MS)).await;
5295
5296 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5297 }
5298
5299 #[tokio::test]
5302 async fn margin_trades_page_on_past_a_full_final_window() {
5303 let now = Utc::now().timestamp_millis();
5304 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5305 let trades = (1..=count).map(|id| (id, now - HOUR_MS + id)).collect();
5306 let (ids, requests) = read_margin_trades(venue(trades), since(now - 2 * HOUR_MS)).await;
5307
5308 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5309 assert_eq!(requests.len(), 2);
5310 }
5311
5312 #[tokio::test]
5315 async fn margin_trades_quiet_lookback_sends_one_request_per_window() {
5316 let now = Utc::now().timestamp_millis();
5317 let (ids, requests) =
5318 read_margin_trades(venue(Vec::new()), since(now - 72 * HOUR_MS)).await;
5319
5320 assert!(ids.is_empty());
5321 assert_eq!(requests.len(), 4);
5323 }
5324
5325 #[tokio::test]
5328 async fn margin_trades_read_a_short_gap_in_one_request() {
5329 let now = Utc::now().timestamp_millis();
5330 let (ids, requests) =
5331 read_margin_trades(venue(vec![(9, now - HOUR_MS)]), since(now - 2 * HOUR_MS)).await;
5332
5333 assert_eq!(ids, vec![9]);
5334 assert_eq!(requests.len(), 1);
5335 }
5336
5337 #[tokio::test]
5340 async fn margin_trades_read_past_the_local_clock_to_the_spans_end() {
5341 let now = Utc::now().timestamp_millis();
5342 let span = MyTradesFrom::Span {
5343 start: now - HOUR_MS,
5344 end: now + HOUR_MS,
5345 };
5346 let (ids, requests) = read_margin_trades(venue(vec![(4, now + HOUR_MS / 2)]), span).await;
5347
5348 assert_eq!(ids, vec![4]);
5349 assert_eq!(requests.len(), 1);
5350 }
5351
5352 #[tokio::test]
5355 async fn margin_trades_to_a_far_end_stop_one_window_past_the_local_clock() {
5356 let now = Utc::now().timestamp_millis();
5357 let span = MyTradesFrom::Span {
5358 start: now - HOUR_MS,
5359 end: i64::MAX,
5360 };
5361 let (ids, requests) = read_margin_trades(venue(Vec::new()), span).await;
5362
5363 assert!(ids.is_empty());
5364 assert_eq!(requests.len(), 2);
5366 let last_end = requests[1]["endTime"].parse::<i64>().unwrap();
5367 let cut = now + MARGIN_MY_TRADES_WINDOW_MS;
5368 assert!(
5370 (cut..cut + 60_000).contains(&last_end),
5371 "the last window ends at {last_end}, not one window past {now}"
5372 );
5373 }
5374
5375 #[tokio::test]
5377 async fn margin_trades_from_the_future_send_no_request() {
5378 let now = Utc::now().timestamp_millis();
5379 let (ids, requests) = read_margin_trades(venue(Vec::new()), since(now + HOUR_MS)).await;
5380
5381 assert!(ids.is_empty());
5382 assert!(requests.is_empty());
5383 }
5384
5385 #[tokio::test]
5388 async fn margin_trades_by_order_page_by_id_without_time_bounds() {
5389 let now = Utc::now().timestamp_millis();
5390 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5391 let trades = (1..=2 * count)
5393 .map(|id| {
5394 (
5395 id,
5396 now - 48 * HOUR_MS + id,
5397 if id % 2 == 0 { 42 } else { 7 },
5398 )
5399 })
5400 .collect();
5401 let venue = MarginMyTradesVenue {
5402 trades,
5403 end_exclusive: false,
5404 };
5405 let (ids, requests) = read_margin_trades(venue, MyTradesFrom::Order(42)).await;
5406
5407 assert_eq!(ids, (1..=count).map(|n| 2 * n).collect::<Vec<_>>());
5408 assert_eq!(requests.len(), 2);
5409 assert!(
5410 requests.iter().all(
5411 |query| query.get("orderId").map(String::as_str) == Some("42")
5412 && !query.contains_key("startTime")
5413 && !query.contains_key("endTime")
5414 )
5415 );
5416 }
5417
5418 async fn margin_trades_error(response: wiremock::ResponseTemplate) -> UnindexedClientError {
5421 let server = wiremock::MockServer::start().await;
5422 wiremock::Mock::given(wiremock::matchers::method("GET"))
5423 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5424 .respond_with(response)
5425 .mount(&server)
5426 .await;
5427 let rest = Arc::new(MarginTradingRestApi::from_config(
5428 ConfigurationRestApi::builder()
5429 .api_key("key")
5430 .api_secret("secret")
5431 .base_path(server.uri())
5432 .build()
5433 .unwrap(),
5434 ));
5435
5436 paginate_margin_my_trades(
5437 &rest,
5438 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5439 &InstrumentNameExchange::new("BTCUSDT"),
5440 since(Utc::now().timestamp_millis() - HOUR_MS),
5441 true,
5442 RequestKind::Query,
5443 )
5444 .await
5445 .unwrap_err()
5446 }
5447
5448 #[tokio::test]
5451 async fn margin_trades_report_a_venue_rejection_as_a_non_transient_request_rejection() {
5452 let err = margin_trades_error(wiremock::ResponseTemplate::new(400).set_body_json(
5453 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
5454 ))
5455 .await;
5456
5457 let UnindexedClientError::Api(ApiError::RequestRejected(msg)) = &err else {
5458 panic!("expected RequestRejected, got {err:?}");
5459 };
5460 assert!(msg.contains("-1127"), "code missing from {msg:?}");
5461 assert!(!err.is_transient());
5462 }
5463
5464 #[tokio::test]
5466 async fn margin_trades_report_an_undecodable_response_as_internal() {
5467 let err = margin_trades_error(
5468 wiremock::ResponseTemplate::new(200)
5469 .set_body_json(serde_json::json!({"unexpected": 1})),
5470 )
5471 .await;
5472
5473 assert!(
5474 matches!(err, UnindexedClientError::Internal(_)),
5475 "got {err:?}"
5476 );
5477 assert!(!err.is_transient());
5478 }
5479
5480 #[tokio::test]
5483 async fn sapi_used_ip_weight_near_the_limit_pauses_queries() {
5484 for (used, pauses) in [("10799", false), ("10800", true), ("not a number", false)] {
5485 let server = wiremock::MockServer::start().await;
5486 wiremock::Mock::given(wiremock::matchers::method("GET"))
5487 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
5488 .respond_with(
5489 wiremock::ResponseTemplate::new(200)
5490 .insert_header("x-sapi-used-ip-weight-1m", used)
5491 .set_body_json(serde_json::json!([])),
5492 )
5493 .mount(&server)
5494 .await;
5495 let rest = Arc::new(MarginTradingRestApi::from_config(
5496 ConfigurationRestApi::builder()
5497 .api_key("key")
5498 .api_secret("secret")
5499 .base_path(server.uri())
5500 .build()
5501 .unwrap(),
5502 ));
5503 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5504
5505 fetch_margin_all_open_orders(
5506 rest,
5507 Arc::clone(&tracker),
5508 KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5509 false,
5510 )
5511 .await
5512 .unwrap();
5513
5514 let query_waits = tokio::time::timeout(
5515 Duration::from_millis(50),
5516 tracker.wait_if_blocked(RequestKind::Query),
5517 )
5518 .await
5519 .is_err();
5520 assert_eq!(query_waits, pauses, "used IP weight {used}");
5521 tokio::time::timeout(
5522 Duration::from_millis(50),
5523 tracker.wait_if_blocked(RequestKind::Order),
5524 )
5525 .await
5526 .expect("orders never wait on the pause")
5527 .expect("no cooldown too long to sleep through");
5528 }
5529 }
5530
5531 #[tokio::test]
5533 async fn margin_fill_recovery_does_not_wait_for_the_weight_pause() {
5534 let server = wiremock::MockServer::start().await;
5535 wiremock::Mock::given(wiremock::matchers::method("GET"))
5536 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5537 .respond_with(
5539 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
5540 "symbol": "BTCUSDT",
5541 "id": 1,
5542 "orderId": 7,
5543 "price": "100",
5544 "qty": "1",
5545 "commission": "0",
5546 "commissionAsset": "USDT",
5547 "time": Utc::now().timestamp_millis(),
5548 "isBuyer": true,
5549 "isMaker": false,
5550 }])),
5551 )
5552 .expect(2)
5553 .mount(&server)
5554 .await;
5555 let rest = Arc::new(MarginTradingRestApi::from_config(
5556 ConfigurationRestApi::builder()
5557 .api_key("key")
5558 .api_secret("secret")
5559 .base_path(server.uri())
5560 .build()
5561 .unwrap(),
5562 ));
5563 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5564 tracker.throttle(Duration::from_secs(60));
5565 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5566 let mut unrecovered = UnrecoveredFills::default();
5567 unrecovered.open(
5568 &[InstrumentNameExchange::new("BTCUSDT")],
5569 Utc::now() - chrono::Duration::minutes(1),
5570 Utc::now(),
5571 );
5572
5573 tokio::time::timeout(
5575 Duration::from_millis(800),
5576 recover_margin_fills(
5577 &rest,
5578 &tracker,
5579 &mut unrecovered,
5580 &tx,
5581 &new_dedup_cache(),
5582 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5583 false,
5584 ),
5585 )
5586 .await
5587 .expect("recovery does not wait for the pause");
5588 }
5589
5590 #[tokio::test]
5593 async fn margin_trades_read_over_a_span_stop_at_its_end() {
5594 let now = Utc::now().timestamp_millis();
5595 let trades = vec![
5596 (1, now - 50 * HOUR_MS),
5597 (2, now - 49 * HOUR_MS),
5598 (3, now - 10 * HOUR_MS),
5599 ];
5600 let (ids, _) = read_margin_trades(
5601 venue(trades),
5602 MyTradesFrom::Span {
5603 start: now - 60 * HOUR_MS,
5604 end: now - 20 * HOUR_MS,
5605 },
5606 )
5607 .await;
5608
5609 assert_eq!(ids, vec![1, 2]);
5610 }
5611
5612 #[tokio::test]
5615 async fn a_margin_gap_whose_every_read_fails_is_reported_when_given_up() {
5616 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5617 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5618 let server = wiremock::MockServer::start().await;
5619 wiremock::Mock::given(wiremock::matchers::method("GET"))
5620 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5621 .respond_with(
5622 wiremock::ResponseTemplate::new(400)
5623 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5624 )
5625 .mount(&server)
5626 .await;
5627 let rest = Arc::new(MarginTradingRestApi::from_config(
5628 ConfigurationRestApi::builder()
5629 .api_key("key")
5630 .api_secret("secret")
5631 .base_path(server.uri())
5632 .build()
5633 .expect("valid config"),
5634 ));
5635 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5636 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5637 let dedup = new_dedup_cache();
5638 let btc = InstrumentNameExchange::new("BTCUSDT");
5639 let mut unrecovered = UnrecoveredFills::default();
5640 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
5641
5642 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
5643 recover_margin_fills(
5644 &rest,
5645 &tracker,
5646 &mut unrecovered,
5647 &tx,
5648 &dedup,
5649 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5650 false,
5651 )
5652 .await;
5653 unrecovered.make_due();
5654 }
5655
5656 assert!(unrecovered.is_empty(), "the gap is given up");
5657 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5658 let [event] = forwarded.as_slice() else {
5659 panic!("only the give-up is sent: {forwarded:?}");
5660 };
5661 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
5662 panic!("expected FillRecoveryGaveUp, got {event:?}");
5663 };
5664 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
5665 assert_eq!(
5666 gave_up.scope,
5667 crate::FillRecoveryScope::Instruments(vec![btc])
5668 );
5669 assert_eq!(
5670 gave_up.start.timestamp_millis(),
5671 disconnect.timestamp_millis()
5672 );
5673 assert_eq!(
5674 gave_up.attempts,
5675 crate::client::order_recovery::MAX_GAP_RETRIES + 1
5676 );
5677 assert!(
5678 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
5679 "the last read's error: {:?}",
5680 gave_up.reason
5681 );
5682 }
5683
5684 #[tokio::test]
5687 async fn a_failed_margin_recovery_keeps_its_closed_gap_for_the_retry() {
5688 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5689 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5690 let server = wiremock::MockServer::start().await;
5691 wiremock::Mock::given(wiremock::matchers::method("GET"))
5692 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5693 .respond_with(
5694 wiremock::ResponseTemplate::new(400)
5695 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5696 )
5697 .up_to_n_times(1)
5698 .with_priority(1)
5699 .mount(&server)
5700 .await;
5701 wiremock::Mock::given(wiremock::matchers::method("GET"))
5702 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5703 .respond_with(venue(vec![
5704 (
5705 1,
5706 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
5707 ),
5708 (
5709 2,
5710 (Utc::now() - chrono::Duration::minutes(1)).timestamp_millis(),
5711 ),
5712 ]))
5713 .mount(&server)
5714 .await;
5715 let rest = Arc::new(MarginTradingRestApi::from_config(
5716 ConfigurationRestApi::builder()
5717 .api_key("key")
5718 .api_secret("secret")
5719 .base_path(server.uri())
5720 .build()
5721 .unwrap(),
5722 ));
5723 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5724 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5725 let dedup = new_dedup_cache();
5726 let mut unrecovered = UnrecoveredFills::default();
5727 unrecovered.open(
5728 &[InstrumentNameExchange::new("BTCUSDT")],
5729 disconnect,
5730 reconnect,
5731 );
5732
5733 recover_margin_fills(
5734 &rest,
5735 &tracker,
5736 &mut unrecovered,
5737 &tx,
5738 &dedup,
5739 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5740 false,
5741 )
5742 .await;
5743 assert!(!unrecovered.is_empty(), "the failed gap is kept");
5744 assert!(rx.try_recv().is_err(), "nothing forwarded");
5745
5746 unrecovered.make_due();
5747 recover_margin_fills(
5748 &rest,
5749 &tracker,
5750 &mut unrecovered,
5751 &tx,
5752 &dedup,
5753 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5754 false,
5755 )
5756 .await;
5757 assert!(unrecovered.is_empty(), "the retry recovered it");
5758 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5759 assert_eq!(
5760 forwarded.len(),
5761 1,
5762 "only the fill inside the gap: {forwarded:?}"
5763 );
5764 }
5765
5766 #[tokio::test]
5769 async fn a_timed_out_margin_recovery_charges_only_the_gaps_it_started() {
5770 let server = wiremock::MockServer::start().await;
5771 wiremock::Mock::given(wiremock::matchers::method("GET"))
5772 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5773 .respond_with(
5774 wiremock::ResponseTemplate::new(200)
5775 .set_body_json(serde_json::json!([]))
5776 .set_delay(Duration::from_secs(3_600)),
5777 )
5778 .mount(&server)
5779 .await;
5780 let rest = Arc::new(MarginTradingRestApi::from_config(
5781 ConfigurationRestApi::builder()
5782 .api_key("key")
5783 .api_secret("secret")
5784 .base_path(server.uri())
5785 .timeout(3_600_000_u64)
5786 .retries(0_u32)
5787 .build()
5788 .unwrap(),
5789 ));
5790 let instruments: Vec<_> = (0..9)
5791 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
5792 .collect();
5793 let mut unrecovered = UnrecoveredFills::default();
5794 unrecovered.open(
5795 &instruments,
5796 Utc::now() - chrono::Duration::minutes(10),
5797 Utc::now(),
5798 );
5799 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5800 tokio::time::pause();
5802
5803 recover_margin_fills(
5804 &rest,
5805 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5806 &mut unrecovered,
5807 &tx,
5808 &new_dedup_cache(),
5809 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5810 false,
5811 )
5812 .await;
5813
5814 let now = tokio::time::Instant::now();
5815 assert_eq!(
5816 unrecovered.due(now).len(),
5817 1,
5818 "the gap never started stays due"
5819 );
5820 assert_eq!(
5821 unrecovered
5822 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
5823 .len(),
5824 9,
5825 "the eight started wait for their first retry"
5826 );
5827 }
5828
5829 fn margin_order_row(cid: &str, status: &str) -> serde_json::Value {
5835 serde_json::json!({
5836 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
5837 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
5838 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "isIsolated": false,
5839 "time": 1_700_000_000_000_i64, "updateTime": 1_700_000_060_000_i64,
5840 })
5841 }
5842
5843 fn margin_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
5844 OrderKey::new(
5845 ExchangeId::BinanceMargin,
5846 InstrumentNameExchange::new(instrument),
5847 StrategyId::new("strategy"),
5848 ClientOrderId::new(cid),
5849 )
5850 }
5851
5852 #[test]
5853 fn a_margin_order_row_says_how_the_order_ended() {
5854 use crate::order::state::InactiveOrderState;
5855 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOrderResponse;
5856 let key = margin_key("BTCUSDT", "a");
5857 let ended = |row: serde_json::Value| {
5858 let row: QueryMarginAccountsOrderResponse = serde_json::from_value(row).unwrap();
5859 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, None)
5860 .map(|order| order.state)
5861 };
5862
5863 let mut filled = margin_order_row("a", "FILLED");
5864 filled["executedQty"] = "2".into();
5865 filled["cummulativeQuoteQty"] = "210".into();
5866 let Some(InactiveOrderState::FullyFilled(filled)) = ended(filled) else {
5867 panic!("FILLED is fully filled");
5868 };
5869 assert_eq!(filled.avg_price, Some(Decimal::from(105)));
5870 assert!(matches!(
5871 ended(margin_order_row("a", "CANCELED")),
5872 Some(InactiveOrderState::Cancelled(cancelled)) if cancelled.filled_quantity == Some(Decimal::ONE)
5873 ));
5874 assert_eq!(ended(margin_order_row("a", "NEW")), None);
5875
5876 let mut unknown_quote = margin_order_row("a", "FILLED");
5878 unknown_quote["executedQty"] = "2".into();
5879 unknown_quote["cummulativeQuoteQty"] = "-1".into();
5880 let Some(InactiveOrderState::FullyFilled(filled)) = ended(unknown_quote) else {
5881 panic!("still fully filled");
5882 };
5883 assert_eq!(filled.avg_price, None, "no negative average price");
5884 }
5885
5886 #[tokio::test(start_paused = true)]
5889 async fn the_host_clock_lead_is_the_clients_server_time_reads() {
5890 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
5891 assert!(config.server_time_sync);
5892 let synced = BinanceMargin::new(config.clone());
5893 assert!(synced.rate_limiter.signing_clock().is_some());
5894 assert!(synced.spot_rate_limiter.signing_clock().is_some());
5895 let mut client = BinanceMargin::new(config.with_server_time_sync(false));
5896 assert!(client.rate_limiter.signing_clock().is_none());
5897 assert!(client.spot_rate_limiter.signing_clock().is_none());
5898 assert_eq!(client.host_clock_lead(), None);
5899
5900 let host_ms = Utc::now().timestamp_millis();
5901 let read: crate::client::binance::server_time::ReadServerTime =
5902 Arc::new(move || Box::pin(async move { Ok(host_ms - 2_000) }));
5903 client.rate_limiter = Arc::new(
5904 RateLimitTracker::new(WeightPool::Sapi)
5905 .with_server_time(Some(ServerTime::new(SigningClock::default(), read))),
5906 );
5907 client
5908 .rate_limiter
5909 .wait_if_blocked(RequestKind::Query)
5910 .await
5911 .unwrap();
5912
5913 let lead = client.host_clock_lead().unwrap();
5914 assert!(
5915 lead >= TimeDelta::seconds(2) && lead < TimeDelta::seconds(3),
5916 "{lead}"
5917 );
5918 }
5919
5920 fn margin_rest_config_at(server: &wiremock::MockServer) -> ConfigurationRestApi {
5922 ConfigurationRestApi::builder()
5923 .api_key("key")
5924 .api_secret("secret")
5925 .base_path(server.uri())
5926 .build()
5927 .unwrap()
5928 }
5929
5930 fn margin_rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
5931 Arc::new(MarginTradingRestApi::from_config(margin_rest_config_at(
5932 server,
5933 )))
5934 }
5935
5936 pub(super) fn margin_client_at(
5938 server: &wiremock::MockServer,
5939 is_isolated: bool,
5940 ) -> BinanceMargin {
5941 let config = if is_isolated {
5942 BinanceMarginConfig::isolated(
5943 "key".into(),
5944 "secret".into(),
5945 vec![InstrumentNameExchange::new("BTCUSDT")],
5946 )
5947 } else {
5948 BinanceMarginConfig::cross_margin("key".into(), "secret".into())
5949 };
5950 let mode = config.pre_send_rules;
5951 let mut client =
5952 <BinanceMargin as ExecutionClient>::new(config.with_server_time_sync(false));
5953 client.rest = margin_rest_at(server);
5954 client.rest_config = Arc::new(margin_rest_config_at(server));
5955 client.spot_rest = Arc::new(binance_sdk::spot::SpotRestApi::from_config(
5956 margin_rest_config_at(server),
5957 ));
5958 client.rules = rules_over(&client, mode);
5959 client
5960 }
5961
5962 fn rules_over(client: &BinanceMargin, mode: PreSendRules) -> Arc<RulesLoader> {
5964 Arc::new(RulesLoader::new(
5965 mode,
5966 client.spot_rest.clone(),
5967 client.spot_rate_limiter.clone(),
5968 ))
5969 }
5970
5971 async fn mount_margin_order(
5973 server: &wiremock::MockServer,
5974 cid: &str,
5975 response: wiremock::ResponseTemplate,
5976 ) {
5977 wiremock::Mock::given(wiremock::matchers::method("GET"))
5978 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
5979 .and(wiremock::matchers::query_param("origClientOrderId", cid))
5980 .respond_with(response)
5981 .mount(server)
5982 .await;
5983 }
5984
5985 fn margin_venue_error(code: i64) -> wiremock::ResponseTemplate {
5986 wiremock::ResponseTemplate::new(400)
5987 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
5988 }
5989
5990 fn margin_row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
5991 wiremock::ResponseTemplate::new(200).set_body_json(margin_order_row(cid, status))
5992 }
5993
5994 async fn margin_requests(server: &wiremock::MockServer) -> Vec<(String, Option<String>)> {
5996 server
5997 .received_requests()
5998 .await
5999 .unwrap()
6000 .iter()
6001 .map(|request| {
6002 let isolated = request
6003 .url
6004 .query_pairs()
6005 .find(|(key, _)| key == "isIsolated")
6006 .map(|(_, value)| value.into_owned());
6007 (request.url.path().to_owned(), isolated)
6008 })
6009 .collect()
6010 }
6011
6012 #[tokio::test]
6017 async fn a_cross_balance_read_is_stamped_the_rest_lag_before_its_request_was_sent() {
6018 margin_balance_reads_are_stamped_the_rest_lag_before_their_requests(false, None).await;
6019 }
6020
6021 #[tokio::test]
6024 async fn an_isolated_balance_read_is_stamped_the_rest_lag_before_its_request_was_sent() {
6025 margin_balance_reads_are_stamped_the_rest_lag_before_their_requests(true, None).await;
6026 }
6027
6028 #[tokio::test]
6033 async fn a_cross_balance_read_is_stamped_on_binances_clock_when_the_host_clock_leads() {
6034 margin_balance_reads_are_stamped_the_rest_lag_before_their_requests(
6035 false,
6036 Some(TimeDelta::seconds(60)),
6037 )
6038 .await;
6039 }
6040
6041 #[tokio::test]
6043 async fn an_isolated_balance_read_is_stamped_on_binances_clock_when_the_host_clock_leads() {
6044 margin_balance_reads_are_stamped_the_rest_lag_before_their_requests(
6045 true,
6046 Some(TimeDelta::seconds(60)),
6047 )
6048 .await;
6049 }
6050
6051 async fn margin_balance_reads_are_stamped_the_rest_lag_before_their_requests(
6055 is_isolated: bool,
6056 lead: Option<TimeDelta>,
6057 ) {
6058 use crate::client::binance::shared::REST_BALANCE_LAG;
6059 const ANSWER_DELAY: Duration = Duration::from_millis(200);
6060 let asset = |name: &str| {
6061 serde_json::json!({
6062 "asset": name, "free": "1", "locked": "0", "borrowed": "0.5", "interest": "0",
6063 "netAsset": "0.5",
6064 })
6065 };
6066 let (account_path, body) = if is_isolated {
6067 (
6068 "/sapi/v1/margin/isolated/account",
6069 serde_json::json!({ "assets": [{
6070 "symbol": "BTCUSDT", "baseAsset": asset("BTC"), "quoteAsset": asset("USDT"),
6071 }] }),
6072 )
6073 } else {
6074 (
6075 "/sapi/v1/margin/account",
6076 serde_json::json!({ "userAssets": [asset("BTC")] }),
6077 )
6078 };
6079 let server = wiremock::MockServer::start().await;
6080 wiremock::Mock::given(wiremock::matchers::method("GET"))
6081 .and(wiremock::matchers::path(account_path))
6082 .respond_with(
6083 wiremock::ResponseTemplate::new(200)
6084 .set_body_json(body)
6085 .set_delay(ANSWER_DELAY),
6086 )
6087 .mount(&server)
6088 .await;
6089 wiremock::Mock::given(wiremock::matchers::method("GET"))
6090 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6091 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
6092 .mount(&server)
6093 .await;
6094 let mut client = margin_client_at(&server, is_isolated);
6095 if let Some(lead) = lead {
6096 client.rate_limiter = Arc::new(
6097 RateLimitTracker::new(WeightPool::Sapi)
6098 .with_server_time(Some(ServerTime::trailing_host(lead))),
6099 );
6100 }
6101 let behind = match lead {
6104 Some(_) => ServerTime::TRAILING_HOST_BEHIND,
6105 None => TimeDelta::milliseconds(1),
6106 };
6107 let lead = lead.unwrap_or_default();
6108
6109 let before = Utc::now();
6110 let fetched = client.fetch_balances(&[]).await.unwrap();
6111 let between = Utc::now();
6112 let snapshot = client
6113 .account_snapshot(&[], &[InstrumentNameExchange::new("BTCUSDT")])
6114 .await
6115 .unwrap();
6116 let after = Utc::now();
6117
6118 let reads: Vec<(Vec<DateTime<Utc>>, _, _)> = if is_isolated {
6120 assert!(fetched.is_empty());
6122 let pair = snapshot.instruments[0].isolated.as_ref().unwrap();
6123 vec![(
6124 vec![pair.base.time_exchange, pair.quote.time_exchange],
6125 between,
6126 after,
6127 )]
6128 } else {
6129 let times = |balances: &[AssetBalance<AssetNameExchange>]| {
6130 balances
6131 .iter()
6132 .map(|balance| balance.time_exchange)
6133 .collect()
6134 };
6135 vec![
6136 (times(&fetched), before, between),
6137 (times(&snapshot.balances), between, after),
6138 ]
6139 };
6140 for (times, from, to) in reads {
6141 assert!(!times.is_empty());
6142 for time in times {
6143 let sent = time + REST_BALANCE_LAG + lead;
6145 assert!(
6146 from - behind <= sent && sent + ANSWER_DELAY <= to,
6147 "{time} + the lag and {lead} not within {from} ..= {to} - {ANSWER_DELAY:?}"
6148 );
6149 }
6150 }
6151 }
6152
6153 #[tokio::test]
6156 async fn a_cross_restatement_read_returns_the_balances_as_snapshots() {
6157 let server = wiremock::MockServer::start().await;
6158 wiremock::Mock::given(wiremock::matchers::method("GET"))
6159 .and(wiremock::matchers::path("/sapi/v1/margin/account"))
6160 .respond_with(wiremock::ResponseTemplate::new(500))
6161 .up_to_n_times(1)
6162 .mount(&server)
6163 .await;
6164 wiremock::Mock::given(wiremock::matchers::method("GET"))
6165 .and(wiremock::matchers::path("/sapi/v1/margin/account"))
6166 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6167 serde_json::json!({ "userAssets": [{
6168 "asset": "BTC", "free": "1", "locked": "0", "borrowed": "0.5", "interest": "0",
6169 "netAsset": "0.5",
6170 }] }),
6171 ))
6172 .mount(&server)
6173 .await;
6174 let (rest, limiter) = (
6175 margin_rest_at(&server),
6176 Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6177 );
6178 let clock = SharedStreamClock::default();
6179 let read = || read_margin_restatement(&rest, &limiter, false, &[], &[], &clock, None);
6181
6182 let failed = read().await;
6183 assert!(failed.is_err(), "{failed:?}");
6184
6185 let restated = read().await.unwrap();
6186 let [event] = restated.as_slice() else {
6187 panic!("one balance: {restated:?}");
6188 };
6189 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
6190 let AccountEventKind::BalanceSnapshot(balance) = &event.kind else {
6191 panic!("a balance snapshot: {event:?}");
6192 };
6193 assert_eq!(balance.0.asset, AssetNameExchange::new("BTC"));
6194 assert_eq!(
6195 balance.0.balance.margin,
6196 Some(crate::balance::MarginDetails::new(
6197 Decimal::new(5, 1),
6198 Decimal::ZERO
6199 ))
6200 );
6201 }
6202
6203 #[tokio::test]
6204 async fn margin_fetch_ended_orders_asks_the_configured_account_and_omits_the_rest() {
6205 use crate::order::state::InactiveOrderState;
6206 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
6207 let server = wiremock::MockServer::start().await;
6208 mount_margin_order(&server, "filled", margin_row_response("filled", "FILLED")).await;
6209 mount_margin_order(&server, "live", margin_row_response("live", "NEW")).await;
6210 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
6211 mount_margin_order(&server, "no-symbol", margin_venue_error(-1121)).await;
6212 let client = margin_client_at(&server, is_isolated);
6213 let keys = ["filled", "live", "unknown"].map(|cid| margin_key("BTCUSDT", cid));
6214
6215 let ended = client
6216 .fetch_ended_orders(
6217 &[keys.to_vec(), vec![margin_key("NOSUCH", "no-symbol")]].concat(),
6218 )
6219 .await
6220 .unwrap();
6221
6222 let [order] = ended.as_slice() else {
6223 panic!("one order ended: {ended:?}");
6224 };
6225 assert_eq!(order.key, keys[0], "the strategy asked survives");
6226 assert!(matches!(order.state, InactiveOrderState::FullyFilled(_)));
6227 let requests = margin_requests(&server).await;
6228 assert_eq!(requests.len(), 4);
6229 for (path, isolated) in requests {
6230 assert_eq!(path, "/sapi/v1/margin/order");
6231 assert_eq!(
6232 isolated.as_deref(),
6233 Some(flag),
6234 "is_isolated = {is_isolated}"
6235 );
6236 }
6237 }
6238 }
6239
6240 #[tokio::test]
6241 async fn margin_fetch_ended_orders_fails_when_any_lookup_fails() {
6242 let server = wiremock::MockServer::start().await;
6243 mount_margin_order(
6244 &server,
6245 "cancelled",
6246 margin_row_response("cancelled", "CANCELED"),
6247 )
6248 .await;
6249 mount_margin_order(&server, "refused", margin_venue_error(-1100)).await;
6250 let client = margin_client_at(&server, false);
6251
6252 let result = client
6253 .fetch_ended_orders(&[
6254 margin_key("BTCUSDT", "cancelled"),
6255 margin_key("BTCUSDT", "refused"),
6256 ])
6257 .await;
6258
6259 assert!(
6260 matches!(
6261 result,
6262 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
6263 ),
6264 "{result:?}"
6265 );
6266 }
6267
6268 fn held_margin_orders() -> SharedKnownLiveOrders {
6270 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6271 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
6272 known.lock().live(
6273 &margin_key("BTCUSDT", cid),
6274 Decimal::TWO,
6275 OrderKind::Limit,
6276 &Open::new(
6277 VenueOrderId::Assigned(OrderId::new(n.to_string())),
6278 Utc::now(),
6279 Decimal::ZERO,
6280 ),
6281 );
6282 }
6283 known
6284 }
6285
6286 #[tokio::test]
6289 async fn a_live_conditional_margin_order_is_listed_with_the_kind_the_client_knows() {
6290 use crate::order::ConditionalFamily;
6291 let server = wiremock::MockServer::start().await;
6292 let mut row = margin_order_row("conditional", "NEW");
6293 row["type"] = "STOP_LOSS_LIMIT".into();
6294 row["price"] = "94".into();
6295 row["stopPrice"] = "95".into();
6296 wiremock::Mock::given(wiremock::matchers::method("GET"))
6297 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6298 .respond_with(
6299 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([row])),
6300 )
6301 .mount(&server)
6302 .await;
6303 let client = margin_client_at(&server, false);
6304 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6305 let cid = ClientOrderId::new("conditional");
6306 let kinds = |orders: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>| {
6307 orders
6308 .into_iter()
6309 .map(|order| (order.key.cid, order.kind))
6310 .collect::<Vec<_>>()
6311 };
6312
6313 let unresolved = OrderKind::ConditionalUnresolved {
6314 family: ConditionalFamily::StopLimit,
6315 trigger_price: Some(Decimal::from(95)),
6316 };
6317 assert_eq!(
6318 kinds(client.fetch_open_orders(&[]).await.unwrap()),
6319 [(cid.clone(), unresolved)]
6320 );
6321 assert_eq!(
6322 kinds(
6323 client
6324 .fetch_open_orders(std::slice::from_ref(&btcusdt))
6325 .await
6326 .unwrap()
6327 ),
6328 [(cid.clone(), unresolved)]
6329 );
6330 let listed = listed_margin_open_cids(
6331 margin_rest_at(&server),
6332 Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6333 client.known_live.clone(),
6334 vec![btcusdt.clone()],
6335 false,
6336 )
6337 .await
6338 .unwrap();
6339 assert!(listed.contains(&cid), "{listed:?}");
6340
6341 let trailing = OrderKind::TrailingStopFixedLimit {
6342 offset: Decimal::from(25),
6343 offset_type: TrailingOffsetType::BasisPoints,
6344 activation_price: Some(Decimal::from(95)),
6345 };
6346 client.recall_order_kinds([(cid.clone(), trailing)]);
6347 assert_eq!(
6348 kinds(client.fetch_open_orders(&[]).await.unwrap()),
6349 [(cid.clone(), trailing)]
6350 );
6351 assert_eq!(
6352 kinds(client.fetch_open_orders(&[btcusdt]).await.unwrap()),
6353 [(cid, trailing)]
6354 );
6355 }
6356
6357 #[tokio::test]
6362 async fn an_ended_margin_trailing_stop_is_reported_with_the_kind_it_was_held_with() {
6363 let server = wiremock::MockServer::start().await;
6364 wiremock::Mock::given(wiremock::matchers::method("GET"))
6365 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6366 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
6367 .mount(&server)
6368 .await;
6369 let mut row = margin_order_row("trailing", "CANCELED");
6370 row["type"] = "STOP_LOSS".into();
6371 row["price"] = "0.00000000".into();
6372 row["stopPrice"] = "0.00000000".into();
6373 mount_margin_order(
6374 &server,
6375 "trailing",
6376 wiremock::ResponseTemplate::new(200).set_body_json(row),
6377 )
6378 .await;
6379 let trailing_stop = OrderKind::TrailingStop {
6380 offset: Decimal::from(25),
6381 offset_type: TrailingOffsetType::BasisPoints,
6382 activation_price: None,
6383 };
6384 let hold = |known: &SharedKnownLiveOrders| {
6385 known.lock().live(
6386 &margin_key("BTCUSDT", "trailing"),
6387 Decimal::TWO,
6388 trailing_stop,
6389 &Open::new(
6390 VenueOrderId::Assigned(OrderId::new("7")),
6391 Utc::now(),
6392 Decimal::ZERO,
6393 ),
6394 );
6395 };
6396
6397 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6398 hold(&known);
6399 let mut unchecked = UncheckedOrders::default();
6400 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
6401 let (tx, mut rx) = mpsc::unbounded_channel();
6402 recover_margin_ended_orders(
6403 &margin_rest_at(&server),
6404 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6405 &known,
6406 &mut unchecked,
6407 &UnrecoveredFills::default(),
6408 &tx,
6409 false,
6410 )
6411 .await;
6412 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6413 let [event] = sent.as_slice() else {
6414 panic!("the trailing stop ended: {sent:?}");
6415 };
6416 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
6417 panic!("an order snapshot: {event:?}");
6418 };
6419 assert_eq!(snapshot.0.kind, trailing_stop);
6420 assert!(matches!(
6421 snapshot.0.state,
6422 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
6423 ));
6424 assert!(!known.lock().contains(&ClientOrderId::new("trailing")));
6425
6426 let client = margin_client_at(&server, false);
6427 hold(&client.known_live);
6428 let ended = client
6429 .fetch_ended_orders(&[margin_key("BTCUSDT", "trailing")])
6430 .await
6431 .expect("lookup");
6432 let [order] = ended.as_slice() else {
6433 panic!("the trailing stop ended: {ended:?}");
6434 };
6435 assert_eq!(order.kind, trailing_stop);
6436 }
6437
6438 #[tokio::test]
6439 async fn a_margin_reconnect_reports_how_an_unlisted_order_ended_in_the_configured_account() {
6440 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
6441 let server = wiremock::MockServer::start().await;
6442 wiremock::Mock::given(wiremock::matchers::method("GET"))
6443 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6444 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
6445 .respond_with(
6446 wiremock::ResponseTemplate::new(200)
6447 .set_body_json(serde_json::json!([margin_order_row("live", "NEW")])),
6448 )
6449 .mount(&server)
6450 .await;
6451 mount_margin_order(&server, "gone", margin_row_response("gone", "CANCELED")).await;
6452 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
6453 let known = held_margin_orders();
6454 let mut unchecked = UncheckedOrders::default();
6455 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
6456 let (tx, mut rx) = mpsc::unbounded_channel();
6457
6458 recover_margin_ended_orders(
6459 &margin_rest_at(&server),
6460 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6461 &known,
6462 &mut unchecked,
6463 &UnrecoveredFills::default(),
6464 &tx,
6465 is_isolated,
6466 )
6467 .await;
6468
6469 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6470 let [event] = sent.as_slice() else {
6471 panic!("one order ended: {sent:?}");
6472 };
6473 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
6474 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
6475 panic!("an order snapshot: {event:?}");
6476 };
6477 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
6478 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
6479 assert!(matches!(
6480 snapshot.0.state,
6481 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
6482 ));
6483 let mut requests = margin_requests(&server).await;
6484 requests.sort();
6485 let expected = |path: &str| (path.to_owned(), Some(flag.to_owned()));
6486 assert_eq!(
6487 requests,
6488 [
6489 expected("/sapi/v1/margin/openOrders"),
6490 expected("/sapi/v1/margin/order"),
6491 expected("/sapi/v1/margin/order"),
6492 ],
6493 "one listing, and a lookup of each unlisted order only"
6494 );
6495 let known = known.lock();
6496 assert!(known.contains(&ClientOrderId::new("live")));
6497 assert!(!known.contains(&ClientOrderId::new("gone")));
6498 assert!(
6499 !known.contains(&ClientOrderId::new("unknown")),
6500 "asking again cannot help"
6501 );
6502 assert!(unchecked.is_empty());
6503 }
6504 }
6505
6506 fn margin_open_request<'a>(
6507 instrument: &'a InstrumentNameExchange,
6508 cid: &str,
6509 ) -> OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange> {
6510 OrderRequestOpen {
6511 key: OrderKey::new(
6512 ExchangeId::BinanceMargin,
6513 instrument,
6514 StrategyId::new("strategy"),
6515 ClientOrderId::new(cid),
6516 ),
6517 state: crate::order::request::RequestOpen::new(
6518 Side::Buy,
6519 Some(Decimal::from(100)),
6520 Decimal::TWO,
6521 OrderKind::Limit,
6522 TimeInForce::GoodUntilCancelled { post_only: false },
6523 ),
6524 }
6525 }
6526
6527 #[tokio::test]
6530 async fn a_margin_trailing_stop_is_sent_with_trailing_delta() {
6531 let server = wiremock::MockServer::start().await;
6532 let sent_as_trailing_stop = |request: &wiremock::Request| {
6533 let mut body_as_query = request.url.clone();
6535 body_as_query.set_query(Some(&String::from_utf8_lossy(&request.body)));
6536 let params: Vec<(String, String)> = request
6537 .url
6538 .query_pairs()
6539 .chain(body_as_query.query_pairs())
6540 .map(|(k, v)| (k.into_owned(), v.into_owned()))
6541 .collect();
6542 let has = |key: &str, value: &str| params.iter().any(|(k, v)| k == key && v == value);
6543 has("type", "STOP_LOSS")
6544 && has("trailingDelta", "25")
6545 && has("sideEffectType", "NO_SIDE_EFFECT")
6546 && !params.iter().any(|(k, _)| k == "stopPrice" || k == "price")
6547 };
6548 wiremock::Mock::given(wiremock::matchers::method("POST"))
6549 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6550 .and(sent_as_trailing_stop)
6551 .respond_with(
6552 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6553 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "trailing",
6554 "transactTime": 1_700_000_000_000_i64, "price": "0", "origQty": "2",
6555 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6556 "timeInForce": "GTC", "type": "STOP_LOSS", "side": "SELL",
6557 })),
6558 )
6559 .expect(1)
6560 .mount(&server)
6561 .await;
6562 let client = margin_client_at(&server, false);
6563 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6564 let trailing_request = |cid, offset| {
6565 let mut request = margin_open_request(&btcusdt, cid);
6566 request.state.side = Side::Sell;
6567 request.state.price = None;
6568 request.state.kind = OrderKind::TrailingStop {
6569 offset,
6570 offset_type: TrailingOffsetType::BasisPoints,
6571 activation_price: None,
6572 };
6573 request
6574 };
6575
6576 let placed = client
6577 .open_order(trailing_request("trailing", Decimal::from(25)))
6578 .await;
6579 assert!(
6580 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6581 "{placed:?}"
6582 );
6583
6584 let refused = client
6585 .open_order(trailing_request("fractional", Decimal::new(25, 1)))
6586 .await;
6587 assert!(
6588 matches!(
6589 refused.state,
6590 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
6591 OrderError::InvalidPrecision(_)
6592 ))
6593 ),
6594 "{refused:?}"
6595 );
6596 }
6597
6598 #[tokio::test]
6602 async fn a_request_margin_effect_picks_the_side_effect_type_sent() {
6603 let server = wiremock::MockServer::start().await;
6604 let sends = |wire: &'static str, auto_repay_at_cancel: Option<&'static str>| {
6605 move |request: &wiremock::Request| {
6606 let mut body_as_query = request.url.clone();
6607 body_as_query.set_query(Some(&String::from_utf8_lossy(&request.body)));
6608 let params: Vec<(String, String)> = request
6609 .url
6610 .query_pairs()
6611 .chain(body_as_query.query_pairs())
6612 .map(|(k, v)| (k.into_owned(), v.into_owned()))
6613 .collect();
6614 let value = |key: &str| {
6615 params
6616 .iter()
6617 .find(|(k, _)| k == key)
6618 .map(|(_, v)| v.as_str())
6619 };
6620 value("sideEffectType") == Some(wire)
6621 && value("autoRepayAtCancel") == auto_repay_at_cancel
6622 }
6623 };
6624 for (matcher, cid) in [
6625 (sends("MARGIN_BUY", Some("true")), "borrow"),
6626 (sends("AUTO_REPAY", None), "repay"),
6627 (sends("NO_SIDE_EFFECT", None), "default"),
6628 ] {
6629 wiremock::Mock::given(wiremock::matchers::method("POST"))
6630 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6631 .and(matcher)
6632 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6633 serde_json::json!({
6634 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
6635 "transactTime": 1_700_000_000_000_i64, "price": "50000", "origQty": "2",
6636 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6637 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6638 }),
6639 ))
6640 .expect(1)
6641 .mount(&server)
6642 .await;
6643 }
6644 let client = margin_client_at(&server, false);
6645 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6646
6647 for (cid, effect) in [
6648 ("borrow", Some(MarginEffect::Borrow)),
6649 ("repay", Some(MarginEffect::Repay)),
6650 ("default", None),
6651 ] {
6652 let mut request = margin_open_request(&btcusdt, cid);
6653 request.state.margin_effect = effect;
6654 let placed = client.open_order(request).await;
6655 assert!(
6656 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6657 "{cid}: {placed:?}"
6658 );
6659 }
6660 }
6661
6662 use crate::error::{Bound, BoundsViolation, OrderField, PrecisionLimit, PrecisionViolation};
6663 use rust_decimal_macros::dec;
6664
6665 fn btcusdt_exchange_info() -> wiremock::ResponseTemplate {
6667 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6668 "timezone": "UTC", "serverTime": 1_700_000_000_000_i64,
6669 "symbols": [{
6670 "symbol": "BTCUSDT", "status": "TRADING",
6671 "filters": [
6672 {"filterType": "PRICE_FILTER", "minPrice": "0.01", "maxPrice": "0", "tickSize": "0.01"},
6673 {"filterType": "LOT_SIZE", "minQty": "0.001", "maxQty": "100", "stepSize": "0.001"},
6674 {"filterType": "NOTIONAL", "minNotional": "5", "applyMinToMarket": true,
6675 "maxNotional": "9000000", "applyMaxToMarket": false, "avgPriceMins": 5},
6676 ],
6677 }],
6678 }))
6679 }
6680
6681 async fn mount_order_accepted(server: &wiremock::MockServer, expected: u64) {
6682 wiremock::Mock::given(wiremock::matchers::method("POST"))
6683 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6684 .respond_with(
6685 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6686 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "c",
6687 "transactTime": 1_700_000_000_000_i64, "price": "50000", "origQty": "2",
6688 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6689 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6690 })),
6691 )
6692 .expect(expected)
6693 .mount(server)
6694 .await;
6695 }
6696
6697 #[tokio::test]
6700 async fn an_order_breaking_the_symbols_rules_is_refused_unsent() {
6701 let server = wiremock::MockServer::start().await;
6702 wiremock::Mock::given(wiremock::matchers::method("GET"))
6703 .and(wiremock::matchers::path("/api/v3/exchangeInfo"))
6704 .and(wiremock::matchers::query_param("symbols", r#"["BTCUSDT"]"#))
6705 .respond_with(btcusdt_exchange_info())
6706 .expect(1)
6707 .mount(&server)
6708 .await;
6709 mount_order_accepted(&server, 1).await;
6710 let client = margin_client_at(&server, false);
6711 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6712 let order = |cid: &str, price: Decimal, quantity: Decimal| {
6713 let mut request = margin_open_request(&btcusdt, cid);
6714 request.state.price = Some(price);
6715 request.state.quantity = quantity;
6716 request
6717 };
6718
6719 let off_tick = client.open_order(order("a", dec!(100.001), dec!(1))).await;
6720 assert_eq!(
6721 off_tick.state,
6722 OrderState::inactive(OrderError::InvalidPrecision(PrecisionViolation::new(
6723 OrderField::Price,
6724 dec!(100.001),
6725 PrecisionLimit::NotOnGrid { step: dec!(0.01) }
6726 )))
6727 );
6728 let dust = client.open_order(order("b", dec!(100), dec!(0.001))).await;
6729 assert_eq!(
6730 dust.state,
6731 OrderState::inactive(OrderError::OutOfBounds(BoundsViolation::new(
6732 OrderField::Notional,
6733 Bound::Min,
6734 dec!(5),
6735 dec!(0.100)
6736 )))
6737 );
6738 let placed = client.open_order(order("c", dec!(100), dec!(1))).await;
6739 assert!(
6740 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6741 "{placed:?}"
6742 );
6743 }
6744
6745 #[tokio::test]
6748 async fn a_trailing_offset_outside_its_sides_range_is_refused_unsent() {
6749 let server = wiremock::MockServer::start().await;
6750 wiremock::Mock::given(wiremock::matchers::method("GET"))
6751 .and(wiremock::matchers::path("/api/v3/exchangeInfo"))
6752 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6753 serde_json::json!({"symbols": [{
6754 "symbol": "BTCUSDT", "status": "TRADING",
6755 "filters": [
6756 {"filterType": "TRAILING_DELTA", "minTrailingAboveDelta": 10,
6757 "maxTrailingAboveDelta": 2000, "minTrailingBelowDelta": 10,
6758 "maxTrailingBelowDelta": 1000},
6759 ],
6760 }]}),
6761 ))
6762 .expect(1)
6763 .mount(&server)
6764 .await;
6765 mount_order_accepted(&server, 0).await;
6766 let client = margin_client_at(&server, false);
6767 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6768 let order = |cid: &str, side: Side, offset: Decimal| {
6769 let mut request = margin_open_request(&btcusdt, cid);
6770 request.state.side = side;
6771 request.state.price = None;
6772 request.state.kind = OrderKind::TrailingStop {
6773 offset,
6774 offset_type: crate::order::TrailingOffsetType::BasisPoints,
6775 activation_price: None,
6776 };
6777 request
6778 };
6779
6780 let buy = client.open_order(order("a", Side::Buy, dec!(2500))).await;
6781 assert_eq!(
6782 buy.state,
6783 OrderState::inactive(OrderError::OutOfBounds(BoundsViolation::new(
6784 OrderField::TrailingOffset,
6785 Bound::Max,
6786 dec!(2000),
6787 dec!(2500)
6788 )))
6789 );
6790 let sell = client.open_order(order("b", Side::Sell, dec!(1500))).await;
6791 assert_eq!(
6792 sell.state,
6793 OrderState::inactive(OrderError::OutOfBounds(BoundsViolation::new(
6794 OrderField::TrailingOffset,
6795 Bound::Max,
6796 dec!(1000),
6797 dec!(1500)
6798 )))
6799 );
6800 }
6801
6802 #[tokio::test]
6804 async fn with_the_rules_off_every_order_is_sent_and_no_rules_are_read() {
6805 let server = wiremock::MockServer::start().await;
6806 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
6807 .respond_with(btcusdt_exchange_info())
6808 .expect(0)
6809 .mount(&server)
6810 .await;
6811 mount_order_accepted(&server, 1).await;
6812 let mut client = margin_client_at(&server, false);
6813 client.rules = rules_over(&client, PreSendRules::Off);
6814 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6815 let mut request = margin_open_request(&btcusdt, "x");
6816 request.state.price = Some(dec!(100.001));
6817 let placed = client.open_order(request).await;
6818 assert!(
6819 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6820 "{placed:?}"
6821 );
6822 }
6823
6824 #[tokio::test]
6826 async fn an_order_whose_rules_cannot_be_loaded_is_sent_unchecked() {
6827 let server = wiremock::MockServer::start().await;
6828 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
6829 .respond_with(wiremock::ResponseTemplate::new(500))
6830 .mount(&server)
6831 .await;
6832 mount_order_accepted(&server, 1).await;
6833 let client = margin_client_at(&server, false);
6834 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6835 let mut request = margin_open_request(&btcusdt, "x");
6836 request.state.price = Some(dec!(100.001));
6837 let placed = client.open_order(request).await;
6838 assert!(
6839 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6840 "{placed:?}"
6841 );
6842 }
6843
6844 #[tokio::test]
6846 async fn load_rules_returns_the_rules_and_the_first_order_reuses_them() {
6847 let server = wiremock::MockServer::start().await;
6848 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
6849 .respond_with(btcusdt_exchange_info())
6850 .expect(1)
6851 .mount(&server)
6852 .await;
6853 mount_order_accepted(&server, 0).await;
6854 let client = margin_client_at(&server, false);
6855 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6856
6857 let loaded = client.load_rules(std::slice::from_ref(&btcusdt)).await;
6858 let rules = loaded.ok().and_then(|mut loaded| loaded.remove(&btcusdt));
6859 assert_eq!(
6860 rules.map(|rules| (rules.price.step, rules.quantity.step, rules.notional.min)),
6861 Some((Some(dec!(0.01)), Some(dec!(0.001)), Some(dec!(5))))
6862 );
6863
6864 let mut request = margin_open_request(&btcusdt, "x");
6865 request.state.price = Some(dec!(100.001));
6866 let refused = client.open_order(request).await;
6867 assert!(
6868 matches!(refused.state, OrderState::Inactive(_)),
6869 "{refused:?}"
6870 );
6871 }
6872
6873 #[tokio::test]
6875 async fn load_rules_of_no_symbols_sends_no_request() {
6876 let server = wiremock::MockServer::start().await;
6877 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
6878 .respond_with(btcusdt_exchange_info())
6879 .expect(0)
6880 .mount(&server)
6881 .await;
6882 let client = margin_client_at(&server, false);
6883 assert_eq!(client.load_rules(&[]).await, Ok(HashMap::new()));
6884 }
6885
6886 #[tokio::test]
6890 async fn a_cross_refusal_of_a_trailing_stop_is_an_unsupported_order_type() {
6891 let server = wiremock::MockServer::start().await;
6892 wiremock::Mock::given(wiremock::matchers::method("POST"))
6893 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6894 .respond_with(
6895 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
6896 "code": -3111,
6897 "msg": "Trailing stop order only supports limit type.",
6898 })),
6899 )
6900 .expect(1)
6901 .mount(&server)
6902 .await;
6903 let client = margin_client_at(&server, false);
6904 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6905 let mut request = margin_open_request(&btcusdt, "trailing");
6906 request.state.side = Side::Sell;
6907 request.state.price = None;
6908 request.state.kind = OrderKind::TrailingStop {
6909 offset: Decimal::from(25),
6910 offset_type: TrailingOffsetType::BasisPoints,
6911 activation_price: None,
6912 };
6913
6914 let refused = client.open_order(request).await;
6915
6916 let OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
6917 OrderError::UnsupportedOrderType(msg),
6918 )) = &refused.state
6919 else {
6920 panic!("expected UnsupportedOrderType: {refused:?}");
6921 };
6922 assert!(
6923 msg.contains("-3111") && msg.contains("only supports limit type"),
6924 "{msg}"
6925 );
6926 }
6927
6928 #[tokio::test]
6929 async fn placing_listing_and_cancelling_margin_orders_feed_the_known_live_set() {
6930 let server = wiremock::MockServer::start().await;
6931 wiremock::Mock::given(wiremock::matchers::method("POST"))
6932 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6933 .respond_with(
6934 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6935 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "placed",
6936 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
6937 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6938 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6939 })),
6940 )
6941 .mount(&server)
6942 .await;
6943 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6944 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6945 .respond_with(
6946 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6947 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
6948 "clientOrderId": "cancel", "price": "100", "origQty": "2", "executedQty": "0",
6949 "cummulativeQuoteQty": "0", "status": "CANCELED", "timeInForce": "GTC",
6950 "type": "LIMIT", "side": "BUY",
6951 })),
6952 )
6953 .mount(&server)
6954 .await;
6955 wiremock::Mock::given(wiremock::matchers::method("GET"))
6956 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6957 .respond_with(
6958 wiremock::ResponseTemplate::new(200)
6959 .set_body_json(serde_json::json!([margin_order_row("listed", "NEW")])),
6960 )
6961 .mount(&server)
6962 .await;
6963 let client = margin_client_at(&server, false);
6964 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6965
6966 let placed = client
6967 .open_order(margin_open_request(&btcusdt, "placed"))
6968 .await;
6969 assert!(
6970 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6971 "{placed:?}"
6972 );
6973 assert!(
6974 client
6975 .known_live
6976 .lock()
6977 .contains(&ClientOrderId::new("placed"))
6978 );
6979
6980 client.fetch_open_orders(&[]).await.unwrap();
6981 assert!(
6982 client
6983 .known_live
6984 .lock()
6985 .contains(&ClientOrderId::new("listed"))
6986 );
6987
6988 let cancelled = client
6989 .cancel_order(OrderRequestCancel {
6990 key: OrderKey::new(
6991 ExchangeId::BinanceMargin,
6992 &btcusdt,
6993 StrategyId::new("strategy"),
6994 ClientOrderId::new("placed"),
6995 ),
6996 state: crate::order::request::RequestCancel {
6997 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
6998 },
6999 })
7000 .await;
7001 assert!(cancelled.state.is_ok(), "{cancelled:?}");
7002 assert!(
7003 !client
7004 .known_live
7005 .lock()
7006 .contains(&ClientOrderId::new("placed"))
7007 );
7008 }
7009
7010 #[tokio::test]
7013 async fn a_margin_order_kind_is_known_while_it_is_placed() {
7014 struct KindWhenAsked {
7015 known: SharedKnownLiveOrders,
7016 seen: Arc<parking_lot::Mutex<Option<OrderKind>>>,
7017 }
7018 impl wiremock::Respond for KindWhenAsked {
7019 fn respond(&self, _: &wiremock::Request) -> wiremock::ResponseTemplate {
7020 *self.seen.lock() = self.known.lock().kind_of(&ClientOrderId::new("racing"));
7021 wiremock::ResponseTemplate::new(400).set_body_json(
7022 serde_json::json!({"code": -2010, "msg": "Account has insufficient balance."}),
7023 )
7024 }
7025 }
7026
7027 let server = wiremock::MockServer::start().await;
7028 let client = margin_client_at(&server, true);
7029 let seen = Arc::new(parking_lot::Mutex::new(None));
7030 wiremock::Mock::given(wiremock::matchers::method("POST"))
7031 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7032 .respond_with(KindWhenAsked {
7033 known: client.known_live.clone(),
7034 seen: Arc::clone(&seen),
7035 })
7036 .mount(&server)
7037 .await;
7038 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7039
7040 let refused = client
7041 .open_order(margin_open_request(&btcusdt, "racing"))
7042 .await;
7043 assert!(
7044 matches!(refused.state, OrderState::Inactive(_)) && !refused.state.is_outcome_unknown(),
7045 "{refused:?}"
7046 );
7047 assert_eq!(*seen.lock(), Some(OrderKind::Limit));
7048 assert_eq!(
7049 client
7050 .known_live
7051 .lock()
7052 .kind_of(&ClientOrderId::new("racing")),
7053 None
7054 );
7055 }
7056
7057 #[tokio::test]
7061 async fn a_dropped_margin_placement_keeps_its_kind_only_once_its_request_is_out() {
7062 let server = wiremock::MockServer::start().await;
7063 wiremock::Mock::given(wiremock::matchers::method("POST"))
7064 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7065 .respond_with(
7066 wiremock::ResponseTemplate::new(200)
7067 .set_body_json(serde_json::json!({}))
7068 .set_delay(Duration::from_secs(3_600)),
7069 )
7070 .mount(&server)
7071 .await;
7072 let client = margin_client_at(&server, true);
7073 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7074 let kind_of = |cid: &str| client.known_live.lock().kind_of(&ClientOrderId::new(cid));
7075 let orders_sent = || async {
7076 let requests = server.received_requests().await.unwrap();
7077 requests
7078 .iter()
7079 .filter(|request| request.method == wiremock::http::Method::POST)
7080 .count()
7081 };
7082
7083 client
7084 .rate_limiter
7085 .on_rate_limited(Some(Duration::from_secs(5)));
7086 let waiting = tokio::time::timeout(
7087 Duration::from_millis(100),
7088 client.open_order(margin_open_request(&btcusdt, "waiting")),
7089 )
7090 .await;
7091 assert!(waiting.is_err(), "still waiting on the rate limiter");
7092 assert_eq!(orders_sent().await, 0);
7093 assert_eq!(kind_of("waiting"), None);
7094
7095 let fresh = margin_client_at(&server, true);
7096 tokio::time::timeout(Duration::from_secs(10), async {
7099 tokio::select! {
7100 placed = fresh.open_order(margin_open_request(&btcusdt, "sent")) => {
7101 panic!("the answer is held back: {placed:?}")
7102 }
7103 () = async {
7104 while orders_sent().await == 0 {
7105 tokio::time::sleep(Duration::from_millis(10)).await;
7106 }
7107 } => {}
7108 }
7109 })
7110 .await
7111 .expect("the request reaches the server");
7112 assert_eq!(orders_sent().await, 1);
7113 assert_eq!(
7114 fresh.known_live.lock().kind_of(&ClientOrderId::new("sent")),
7115 Some(OrderKind::Limit)
7116 );
7117 }
7118
7119 #[tokio::test]
7121 async fn a_cancel_response_without_executed_qty_reports_the_fill_unknown() {
7122 let server = wiremock::MockServer::start().await;
7123 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
7124 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7125 .respond_with(
7126 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
7127 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
7128 "clientOrderId": "cancel", "price": "100", "origQty": "2",
7129 "status": "CANCELED", "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
7130 })),
7131 )
7132 .mount(&server)
7133 .await;
7134 let client = margin_client_at(&server, false);
7135
7136 let cancelled = client
7137 .cancel_order(OrderRequestCancel {
7138 key: OrderKey::new(
7139 ExchangeId::BinanceMargin,
7140 &InstrumentNameExchange::new("BTCUSDT"),
7141 StrategyId::new("strategy"),
7142 ClientOrderId::new("placed"),
7143 ),
7144 state: crate::order::request::RequestCancel {
7145 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
7146 },
7147 })
7148 .await;
7149 let Ok(cancelled) = cancelled.state else {
7150 panic!("expected Ok, got {cancelled:?}");
7151 };
7152 assert_eq!(cancelled.filled_quantity, None);
7153 }
7154
7155 fn borrowing_answer() -> serde_json::Value {
7158 serde_json::json!({
7159 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "borrow",
7160 "transactTime": 1_700_000_000_000_i64, "price": "0", "origQty": "0.0001",
7161 "executedQty": "0.0001", "cummulativeQuoteQty": "8", "status": "FILLED",
7162 "timeInForce": "GTC", "type": "MARKET", "side": "SELL", "isIsolated": false,
7163 "selfTradePreventionMode": "EXPIRE_MAKER",
7164 "marginBuyBorrowAmount": "0.00004321", "marginBuyBorrowAsset": "BTC",
7165 "fills": [{
7166 "price": "80000", "qty": "0.0001", "commission": "0.008",
7167 "commissionAsset": "USDT", "tradeId": 1,
7168 }],
7169 })
7170 }
7171
7172 fn borrowing_request(
7174 instrument: &InstrumentNameExchange,
7175 ) -> OrderRequestOpen<ExchangeId, &InstrumentNameExchange> {
7176 let mut request = margin_open_request(instrument, "borrow");
7177 request.state.side = Side::Sell;
7178 request.state.kind = OrderKind::Market;
7179 request.state.price = None;
7180 request.state.quantity = dec!(0.0001);
7181 request.state.margin_effect = Some(MarginEffect::Borrow);
7182 request
7183 }
7184
7185 fn borrowing_answer_without(fields: &[&str]) -> serde_json::Value {
7187 let mut answer = borrowing_answer();
7188 let object = answer.as_object_mut().unwrap();
7189 for field in fields {
7190 object.remove(*field);
7191 }
7192 answer
7193 }
7194
7195 #[tokio::test]
7198 async fn a_margin_placement_reports_what_the_order_borrowed() {
7199 let mut as_a_number = borrowing_answer();
7200 as_a_number["marginBuyBorrowAmount"] = serde_json::json!(5);
7201 let btc = AssetNameExchange::new("BTC");
7202 for (answer, expected) in [
7203 (
7204 borrowing_answer(),
7205 Some(MarginBorrow::new(btc.clone(), dec!(0.00004321))),
7206 ),
7207 (as_a_number, Some(MarginBorrow::new(btc, dec!(5)))),
7208 (
7209 borrowing_answer_without(&["marginBuyBorrowAmount", "marginBuyBorrowAsset"]),
7210 None,
7211 ),
7212 ] {
7213 let server = wiremock::MockServer::start().await;
7214 wiremock::Mock::given(wiremock::matchers::method("POST"))
7215 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7216 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
7217 .expect(1)
7218 .mount(&server)
7219 .await;
7220 let client = margin_client_at(&server, false);
7221 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7222
7223 let placement = client.open_margin_order(borrowing_request(&btcusdt)).await;
7224
7225 assert!(
7226 matches!(
7227 placement.order.state,
7228 OrderState::Inactive(crate::order::state::InactiveOrderState::FullyFilled(_))
7229 ),
7230 "{answer}: {placement:?}"
7231 );
7232 assert_eq!(placement.borrowed, expected, "{answer}");
7233 }
7234 }
7235
7236 #[tokio::test]
7240 async fn a_placement_holds_the_loan_its_order_took() {
7241 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7242 let loan = OrderLoan::new(
7243 btcusdt.clone(),
7244 MarginBorrow::new(AssetNameExchange::new("BTC"), dec!(0.00004321)),
7245 crate::cash_flow::CashFlowLoan::new(
7246 Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
7247 OrderId::new("7"),
7248 ClientOrderId::new("borrow"),
7249 ),
7250 );
7251 for (answer, through_trait, expected) in [
7252 (borrowing_answer(), false, Some(&loan)),
7253 (borrowing_answer(), true, Some(&loan)),
7254 (
7255 borrowing_answer_without(&["marginBuyBorrowAmount", "marginBuyBorrowAsset"]),
7256 false,
7257 None,
7258 ),
7259 ] {
7260 let server = wiremock::MockServer::start().await;
7261 wiremock::Mock::given(wiremock::matchers::method("POST"))
7262 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7263 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
7264 .expect(1)
7265 .mount(&server)
7266 .await;
7267 let client = margin_client_at(&server, false);
7268
7269 if through_trait {
7270 client.open_order(borrowing_request(&btcusdt)).await;
7271 } else {
7272 let placement = client.open_margin_order(borrowing_request(&btcusdt)).await;
7273 assert_eq!(placement.loan().as_ref(), expected, "{answer}");
7274 }
7275
7276 let hour = crate::cash_flow::CashFlowPeriod::new(
7277 loan.loan.time - TimeDelta::minutes(1),
7278 loan.loan.time + TimeDelta::minutes(59),
7279 )
7280 .unwrap();
7281 let held = client.loans.lock();
7282 assert_eq!(held.len(), usize::from(expected.is_some()), "{answer}");
7283 let linked = held.link(&loan.borrowed.asset, loan.borrowed.amount, hour, None);
7284 match expected {
7285 Some(expected) => assert_eq!(linked, loans::LoanLink::One(expected)),
7286 None => assert_eq!(linked, loans::LoanLink::None),
7287 }
7288 }
7289 }
7290
7291 #[test]
7294 fn recalled_loans_are_held() {
7295 let client = <BinanceMargin as ExecutionClient>::new(
7296 BinanceMarginConfig::cross_margin("key".into(), "secret".into())
7297 .with_server_time_sync(false),
7298 );
7299 let loan = |order: &str, amount: Decimal| {
7300 OrderLoan::new(
7301 InstrumentNameExchange::new("BTCUSDT"),
7302 MarginBorrow::new(AssetNameExchange::new("USDT"), amount),
7303 crate::cash_flow::CashFlowLoan::new(
7304 Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
7305 OrderId::new(order),
7306 ClientOrderId::new(order),
7307 ),
7308 )
7309 };
7310
7311 client.recall_loans([loan("1", dec!(6)), loan("2", dec!(7))]);
7312 client.recall_loans([loan("1", dec!(8))]);
7313
7314 let held = client.loans.lock();
7315 assert_eq!(held.len(), 2);
7316 let hour = crate::cash_flow::CashFlowPeriod::new(
7317 Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
7318 Utc.timestamp_millis_opt(1_700_003_600_000).unwrap(),
7319 )
7320 .unwrap();
7321 let usdt = AssetNameExchange::new("USDT");
7322 assert_eq!(held.link(&usdt, dec!(6), hour, None), loans::LoanLink::None);
7323 assert_eq!(
7324 held.link(&usdt, dec!(8), hour, None),
7325 loans::LoanLink::One(&loan("1", dec!(8)))
7326 );
7327 }
7328
7329 #[test]
7332 fn a_recalled_loan_not_held_past_the_limit_is_warned_of() {
7333 let client = <BinanceMargin as ExecutionClient>::new(
7334 BinanceMarginConfig::cross_margin("key".into(), "secret".into())
7335 .with_server_time_sync(false),
7336 );
7337 let start = Utc.timestamp_millis_opt(1_700_000_000_000).unwrap();
7338 let loan = |order: usize, seconds: i64| {
7339 OrderLoan::new(
7340 InstrumentNameExchange::new("BTCUSDT"),
7341 MarginBorrow::new(AssetNameExchange::new("USDT"), dec!(6)),
7342 crate::cash_flow::CashFlowLoan::new(
7343 start + TimeDelta::seconds(seconds),
7344 OrderId::new(format_smolstr!("{order}")),
7345 ClientOrderId::new(format_smolstr!("{order}")),
7346 ),
7347 )
7348 };
7349 let warnings_of = |loans: Vec<OrderLoan>| {
7350 let ((), events) =
7351 rustrade_instrument::test_utils::logs::capture(|| client.recall_loans(loans));
7352 events
7353 .iter()
7354 .filter(|event| event.level == tracing::Level::WARN)
7355 .count()
7356 };
7357
7358 let up_to_the_limit = (0..MAX_LOANS)
7359 .map(|order| loan(order, i64::try_from(order).unwrap() + 1))
7360 .collect();
7361 assert_eq!(warnings_of(up_to_the_limit), 0);
7362 assert_eq!(
7363 warnings_of(vec![loan(MAX_LOANS, 0), loan(MAX_LOANS + 1, 0)]),
7364 1,
7365 "two older than every held loan, one warning"
7366 );
7367 assert_eq!(client.loans.lock().len(), MAX_LOANS);
7368 }
7369
7370 #[tokio::test]
7373 async fn an_order_answered_with_a_fractional_borrowed_amount_is_read() {
7374 let server = wiremock::MockServer::start().await;
7375 wiremock::Mock::given(wiremock::matchers::method("POST"))
7376 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7377 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(borrowing_answer()))
7378 .expect(1)
7379 .mount(&server)
7380 .await;
7381 let client = margin_client_at(&server, false);
7382 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7383
7384 let placed = client.open_order(borrowing_request(&btcusdt)).await;
7385
7386 let OrderState::Inactive(crate::order::state::InactiveOrderState::FullyFilled(filled)) =
7387 placed.state
7388 else {
7389 panic!("expected a filled order, got {placed:?}");
7390 };
7391 assert_eq!(filled.id, OrderId::new("7"));
7392 assert_eq!(filled.filled_quantity, dec!(0.0001));
7393 }
7394
7395 #[tokio::test]
7398 async fn an_open_answered_unreadably_is_an_unknown_outcome() {
7399 for answer in [
7400 serde_json::json!({ "symbol": "BTCUSDT", "clientOrderId": "x" }),
7401 serde_json::json!({ "symbol": "BTCUSDT", "orderId": "not a number" }),
7402 borrowing_answer_without(&["marginBuyBorrowAsset"]),
7403 borrowing_answer_without(&["marginBuyBorrowAmount"]),
7404 ] {
7405 let server = wiremock::MockServer::start().await;
7406 wiremock::Mock::given(wiremock::matchers::method("POST"))
7407 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7408 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
7409 .expect(1)
7410 .mount(&server)
7411 .await;
7412 let client = margin_client_at(&server, false);
7413 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7414
7415 let placed = client
7416 .open_margin_order(margin_open_request(&btcusdt, "x"))
7417 .await;
7418
7419 assert!(
7420 matches!(
7421 placed.order.state,
7422 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
7423 OrderError::Connectivity(_)
7424 ))
7425 ),
7426 "{answer}: {placed:?}"
7427 );
7428 assert_eq!(placed.borrowed, None);
7429 }
7430 }
7431
7432 #[tokio::test]
7435 async fn a_cancel_answered_unreadably_is_an_unknown_outcome() {
7436 for answer in [
7437 serde_json::json!({ "symbol": "BTCUSDT", "status": "CANCELED" }),
7438 serde_json::json!({ "symbol": "BTCUSDT", "orderId": ["7"] }),
7439 serde_json::json!({ "symbol": "BTCUSDT", "orderId": "abc", "transactTime": 1 }),
7440 ] {
7441 let server = wiremock::MockServer::start().await;
7442 mount_order_accepted(&server, 1).await;
7443 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
7444 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7445 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
7446 .expect(1)
7447 .mount(&server)
7448 .await;
7449 let client = margin_client_at(&server, false);
7450 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7451 client.open_order(margin_open_request(&btcusdt, "c")).await;
7452
7453 let cancelled = client
7454 .cancel_order(OrderRequestCancel {
7455 key: OrderKey::new(
7456 ExchangeId::BinanceMargin,
7457 &btcusdt,
7458 StrategyId::new("strategy"),
7459 ClientOrderId::new("c"),
7460 ),
7461 state: crate::order::request::RequestCancel {
7462 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
7463 },
7464 })
7465 .await;
7466
7467 assert!(
7468 matches!(cancelled.state, Err(OrderError::Connectivity(_))),
7469 "{answer}: {cancelled:?}"
7470 );
7471 assert!(client.known_live.lock().contains(&ClientOrderId::new("c")));
7472 }
7473 }
7474
7475 async fn signed_requests_sent(
7478 server: &wiremock::MockServer,
7479 ) -> Vec<(Vec<(String, String)>, Vec<u8>)> {
7480 server
7481 .received_requests()
7482 .await
7483 .unwrap()
7484 .into_iter()
7485 .map(|request| {
7486 let mut query: Vec<(String, String)> = request
7487 .url
7488 .query_pairs()
7489 .filter(|(k, _)| k != "timestamp" && k != "signature")
7490 .map(|(k, v)| (k.into_owned(), v.into_owned()))
7491 .collect();
7492 query.sort();
7493 (query, request.body)
7494 })
7495 .collect()
7496 }
7497
7498 #[tokio::test]
7502 async fn a_cancel_is_stamped_with_the_answers_transact_time() {
7503 let cancel = |answer: serde_json::Value| async move {
7504 let server = wiremock::MockServer::start().await;
7505 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
7506 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7507 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
7508 .expect(1)
7509 .mount(&server)
7510 .await;
7511 let client = margin_client_at(&server, false);
7512 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7513 let cancelled = client
7514 .cancel_order(OrderRequestCancel {
7515 key: OrderKey::new(
7516 ExchangeId::BinanceMargin,
7517 &btcusdt,
7518 StrategyId::new("strategy"),
7519 ClientOrderId::new("c"),
7520 ),
7521 state: crate::order::request::RequestCancel {
7522 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
7523 },
7524 })
7525 .await;
7526 match cancelled.state {
7527 Ok(cancelled) => cancelled,
7528 Err(error) => panic!("{answer}: {error:?}"),
7529 }
7530 };
7531 let answer = |order_id: serde_json::Value, time: Option<i64>| {
7532 let mut answer = serde_json::json!({
7533 "orderId": order_id, "symbol": "BTCUSDT", "origClientOrderId": "c",
7534 "clientOrderId": "cancel", "price": "100", "origQty": "2", "executedQty": "0.5",
7535 "cummulativeQuoteQty": "50", "status": "CANCELED", "timeInForce": "GTC",
7536 "type": "LIMIT", "side": "BUY", "isIsolated": false,
7537 "selfTradePreventionMode": "EXPIRE_MAKER",
7538 });
7539 if let Some(time) = time {
7540 answer["transactTime"] = serde_json::json!(time);
7541 }
7542 answer
7543 };
7544 let sent_at = Utc.timestamp_millis_opt(1_700_000_000_123).unwrap();
7545
7546 for order_id in [serde_json::json!("7"), serde_json::json!(7)] {
7547 let cancelled = cancel(answer(order_id, Some(1_700_000_000_123))).await;
7548 assert_eq!(cancelled.id, OrderId::new("7"));
7549 assert_eq!(cancelled.time_exchange, sent_at);
7550 assert_eq!(cancelled.filled_quantity, Some(dec!(0.5)));
7551 }
7552
7553 let before = Utc::now();
7554 let cancelled = cancel(answer(serde_json::json!("7"), None)).await;
7555 assert!(
7556 cancelled.time_exchange >= before && cancelled.time_exchange <= Utc::now(),
7557 "{cancelled:?}"
7558 );
7559 }
7560
7561 #[tokio::test]
7564 async fn the_cancel_order_query_is_the_one_the_sdk_sends() {
7565 let server = wiremock::MockServer::start().await;
7566 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
7567 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7568 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
7569 .expect(2)
7570 .mount(&server)
7571 .await;
7572 let params = MarginAccountCancelOrderParams::builder("BTCUSDT".to_string())
7573 .is_isolated(MarginAccountCancelOrderIsIsolatedEnum::from_flag(true))
7574 .order_id(7)
7575 .orig_client_order_id("c".to_string())
7576 .new_client_order_id("cancel".to_string())
7577 .recv_window(5000)
7578 .build()
7579 .unwrap();
7580
7581 let _ = margin_rest_at(&server)
7582 .margin_account_cancel_order(params.clone())
7583 .await
7584 .unwrap();
7585 let _ = binance_sdk::common::utils::send_request::<serde_json::Value>(
7586 &margin_rest_config_at(&server),
7587 ORDER_PATH,
7588 reqwest::Method::DELETE,
7589 cancel_order_query(params),
7590 BTreeMap::new(),
7591 None,
7592 true,
7593 )
7594 .await
7595 .unwrap();
7596
7597 let sent = signed_requests_sent(&server).await;
7598 assert_eq!(sent.len(), 2);
7599 assert_eq!(sent[0].0.len(), 6, "every field is sent: {:?}", sent[0].0);
7600 assert_eq!(sent[0], sent[1]);
7601 }
7602
7603 #[tokio::test]
7606 async fn the_new_order_query_is_the_one_the_sdk_sends() {
7607 let server = wiremock::MockServer::start().await;
7608 wiremock::Mock::given(wiremock::matchers::method("POST"))
7609 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7610 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
7611 .expect(2)
7612 .mount(&server)
7613 .await;
7614 let params = MarginAccountNewOrderParams::builder(
7615 "BTCUSDT".to_string(),
7616 MarginAccountNewOrderSideEnum::Sell,
7617 MarginAccountNewOrderTypeEnum::StopLossLimit,
7618 )
7619 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::True)
7620 .quantity(dec!(0.5))
7621 .quote_order_qty(dec!(100.25))
7622 .price(dec!(50000.1))
7623 .stop_price(dec!(49000))
7624 .new_client_order_id("cid".to_string())
7625 .iceberg_qty(dec!(0.1))
7626 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full)
7627 .side_effect_type(MarginAccountNewOrderSideEffectTypeEnum::MarginBuy)
7628 .time_in_force(MarginAccountNewOrderTimeInForceEnum::Gtc)
7629 .self_trade_prevention_mode(
7630 binance_sdk::margin_trading::rest_api::MarginAccountNewOrderSelfTradePreventionModeEnum::ExpireMaker,
7631 )
7632 .trailing_delta(25)
7633 .auto_repay_at_cancel(false)
7634 .recv_window(5000)
7635 .build()
7636 .unwrap();
7637
7638 let _ = margin_rest_at(&server)
7639 .margin_account_new_order(params.clone())
7640 .await
7641 .unwrap();
7642 let _ = binance_sdk::common::utils::send_request::<serde_json::Value>(
7643 &margin_rest_config_at(&server),
7644 ORDER_PATH,
7645 reqwest::Method::POST,
7646 new_order_query(params),
7647 BTreeMap::new(),
7648 None,
7649 true,
7650 )
7651 .await
7652 .unwrap();
7653
7654 let sent = signed_requests_sent(&server).await;
7655 assert_eq!(sent.len(), 2);
7656 assert_eq!(sent[0].0.len(), 17, "every field is sent: {:?}", sent[0].0);
7657 assert_eq!(sent[0], sent[1]);
7658 }
7659
7660 #[test]
7661 fn a_reconnect_opens_a_gap_on_each_stream_instrument_and_checks_those_with_orders_held() {
7662 let [btc, eth, xrp] = ["BTCUSDT", "ETHUSDT", "XRPUSDT"].map(InstrumentNameExchange::new);
7663 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
7664 for (instrument, cid) in [(&btc, "btc"), (&xrp, "xrp")] {
7665 known.lock().live(
7666 &margin_key(instrument.name(), cid),
7667 Decimal::TWO,
7668 OrderKind::Limit,
7669 &Open::new(
7670 VenueOrderId::Assigned(OrderId::new(cid)),
7671 Utc::now(),
7672 Decimal::ZERO,
7673 ),
7674 );
7675 }
7676 let mut unrecovered = UnrecoveredFills::default();
7677 let mut unchecked = UncheckedOrders::default();
7678
7679 open_reconnect_recovery(
7680 &mut unrecovered,
7681 &mut unchecked,
7682 &known,
7683 &[btc.clone(), eth.clone()],
7684 Utc::now() - chrono::Duration::minutes(1),
7685 Utc::now(),
7686 );
7687
7688 assert!(unrecovered.covers(&btc) && unrecovered.covers(ð));
7689 assert!(!unrecovered.covers(&xrp), "not the stream's");
7690 assert!(unchecked.contains(&btc));
7691 assert!(!unchecked.contains(ð), "no order held");
7692 assert!(!unchecked.contains(&xrp), "its fills are not recovered");
7693 }
7694
7695 #[tokio::test]
7698 async fn a_recovered_margin_fill_that_completes_a_held_order_ends_it() {
7699 let disconnect = Utc::now() - chrono::Duration::minutes(10);
7700 let server = wiremock::MockServer::start().await;
7701 wiremock::Mock::given(wiremock::matchers::method("GET"))
7702 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7703 .respond_with(venue(vec![
7704 (
7705 1,
7706 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
7707 ),
7708 (
7709 2,
7710 (disconnect + chrono::Duration::minutes(2)).timestamp_millis(),
7711 ),
7712 ]))
7713 .mount(&server)
7714 .await;
7715 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
7718 for (cid, order_id, quantity) in [("one", "1", "0.01"), ("two", "2", "0.02")] {
7719 known.lock().live(
7720 &margin_key("BTCUSDT", cid),
7721 Decimal::from_str(quantity).unwrap(),
7722 OrderKind::Limit,
7723 &Open::new(
7724 VenueOrderId::Assigned(OrderId::new(order_id)),
7725 Utc::now(),
7726 Decimal::ZERO,
7727 ),
7728 );
7729 }
7730 let mut unrecovered = UnrecoveredFills::default();
7731 unrecovered.open(
7732 &[InstrumentNameExchange::new("BTCUSDT")],
7733 disconnect,
7734 Utc::now(),
7735 );
7736 let (tx, mut rx) = mpsc::unbounded_channel();
7737
7738 recover_margin_fills(
7739 &margin_rest_at(&server),
7740 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
7741 &mut unrecovered,
7742 &tx,
7743 &new_dedup_cache(),
7744 &known,
7745 false,
7746 )
7747 .await;
7748
7749 assert_eq!(
7750 std::iter::from_fn(|| rx.try_recv().ok()).count(),
7751 2,
7752 "both fills"
7753 );
7754 let known = known.lock();
7755 assert!(!known.contains(&ClientOrderId::new("one")), "completed");
7756 assert!(known.contains(&ClientOrderId::new("two")), "part-filled");
7757 }
7758
7759 #[tokio::test]
7762 async fn a_margin_order_that_expired_on_placement_is_not_reported_or_held_open() {
7763 use rust_decimal_macros::dec;
7764
7765 for (cid, filled, quote) in [("unfilled", "0", "0"), ("partly", "1", "100")] {
7766 let server = wiremock::MockServer::start().await;
7767 wiremock::Mock::given(wiremock::matchers::method("POST"))
7768 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7769 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
7770 serde_json::json!({
7771 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
7772 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
7773 "executedQty": filled, "cummulativeQuoteQty": quote,
7774 "status": "EXPIRED", "timeInForce": "IOC", "type": "LIMIT", "side": "BUY",
7775 }),
7776 ))
7777 .mount(&server)
7778 .await;
7779 let client = margin_client_at(&server, false);
7780 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7781
7782 let placed = client.open_order(margin_open_request(&btcusdt, cid)).await;
7783
7784 let OrderState::Inactive(crate::order::state::InactiveOrderState::Expired(expired)) =
7785 &placed.state
7786 else {
7787 panic!("{cid}: expired, not open: {placed:?}");
7788 };
7789 assert_eq!(expired.filled_quantity, Decimal::from_str(filled).ok());
7790 let late = Open::new(
7791 VenueOrderId::Assigned(OrderId::new("7")),
7792 Utc::now(),
7793 Decimal::ZERO,
7794 );
7795 let mut known = client.known_live.lock();
7796 known.live(&placed.key, dec!(2), OrderKind::Limit, &late);
7797 assert!(
7798 !known.contains(&ClientOrderId::new(cid)),
7799 "{cid}: not held, even after a late live report"
7800 );
7801 }
7802 }
7803
7804 async fn mount_borrow_terms(
7808 server: &wiremock::MockServer,
7809 pair: serde_json::Value,
7810 asset: serde_json::Value,
7811 rate: serde_json::Value,
7812 max_borrow: serde_json::Value,
7813 ) {
7814 use wiremock::{
7815 Mock, ResponseTemplate,
7816 matchers::{method, path},
7817 };
7818 for (route, body) in [
7819 ("/sapi/v1/margin/allPairs", pair.clone()),
7820 ("/sapi/v1/margin/isolated/allPairs", pair),
7821 ("/sapi/v1/margin/allAssets", asset),
7822 ("/sapi/v1/margin/next-hourly-interest-rate", rate),
7823 ("/sapi/v1/margin/maxBorrowable", max_borrow),
7824 ("/sapi/v1/margin/delist-schedule", serde_json::json!([])),
7825 ] {
7826 Mock::given(method("GET"))
7827 .and(path(route))
7828 .respond_with(ResponseTemplate::new(200).set_body_json(body))
7829 .mount(server)
7830 .await;
7831 }
7832 }
7833
7834 async fn mount_delist_schedule(server: &wiremock::MockServer, schedule: serde_json::Value) {
7837 use wiremock::{
7838 Mock, ResponseTemplate,
7839 matchers::{method, path},
7840 };
7841 Mock::given(method("GET"))
7842 .and(path("/sapi/v1/margin/delist-schedule"))
7843 .respond_with(ResponseTemplate::new(200).set_body_json(schedule))
7844 .with_priority(1)
7845 .mount(server)
7846 .await;
7847 }
7848
7849 fn btcusdt_pair(is_margin_trade: bool, is_sell_allowed: bool) -> serde_json::Value {
7850 serde_json::json!([{
7851 "base": "BTC", "quote": "USDT", "symbol": "BTCUSDT",
7852 "isMarginTrade": is_margin_trade, "isSellAllowed": is_sell_allowed, "isBuyAllowed": true,
7853 }])
7854 }
7855
7856 fn btc_asset(is_borrowable: bool) -> serde_json::Value {
7857 serde_json::json!([{ "assetName": "BTC", "isBorrowable": is_borrowable }])
7858 }
7859
7860 fn btc_rate(rate: &str) -> serde_json::Value {
7861 serde_json::json!([{ "asset": "BTC", "nextHourlyInterestRate": rate }])
7862 }
7863
7864 async fn query_value(server: &wiremock::MockServer, route: &str, key: &str) -> Option<String> {
7866 server
7867 .received_requests()
7868 .await
7869 .unwrap()
7870 .iter()
7871 .find(|request| request.url.path() == route)
7872 .and_then(|request| {
7873 request
7874 .url
7875 .query_pairs()
7876 .find(|(name, _)| name == key)
7877 .map(|(_, value)| value.into_owned())
7878 })
7879 }
7880
7881 #[tokio::test]
7882 async fn margin_shortability_reads_the_pair_the_asset_and_the_annualised_rate() {
7883 use crate::client::ShortabilityClient;
7884 use rust_decimal_macros::dec;
7885 for (is_isolated, pairs_route, flag) in [
7886 (false, "/sapi/v1/margin/allPairs", "FALSE"),
7887 (true, "/sapi/v1/margin/isolated/allPairs", "TRUE"),
7888 ] {
7889 let server = wiremock::MockServer::start().await;
7890 mount_borrow_terms(
7891 &server,
7892 btcusdt_pair(true, true),
7893 btc_asset(true),
7894 btc_rate("0.00000571"),
7895 serde_json::json!({}),
7896 )
7897 .await;
7898
7899 let shortability = margin_client_at(&server, is_isolated)
7900 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
7901 .await
7902 .unwrap();
7903
7904 assert_eq!(
7905 shortability,
7906 Shortability::new()
7907 .with_shortable(true)
7908 .with_fee_rate(dec!(0.00000571) * dec!(8760)),
7909 "isolated: {is_isolated}"
7910 );
7911 assert_eq!(
7912 query_value(&server, pairs_route, "symbol").await.as_deref(),
7913 Some("BTCUSDT"),
7914 "the pair list is asked for the one symbol"
7915 );
7916 assert_eq!(
7917 query_value(&server, "/sapi/v1/margin/allAssets", "asset")
7918 .await
7919 .as_deref(),
7920 Some("BTC"),
7921 "the base asset is the one borrowed"
7922 );
7923 let rate = "/sapi/v1/margin/next-hourly-interest-rate";
7924 assert_eq!(
7925 query_value(&server, rate, "assets").await.as_deref(),
7926 Some("BTC")
7927 );
7928 assert_eq!(
7929 query_value(&server, rate, "isIsolated").await.as_deref(),
7930 Some(flag)
7931 );
7932 }
7933 }
7934
7935 #[tokio::test]
7936 async fn margin_shortability_is_refused_by_any_flag_and_unknown_without_them() {
7937 use crate::client::ShortabilityClient;
7938 let cases = [
7939 (btcusdt_pair(false, true), btc_asset(true), Some(false)),
7940 (btcusdt_pair(true, false), btc_asset(true), Some(false)),
7941 (btcusdt_pair(true, true), btc_asset(false), Some(false)),
7942 (btcusdt_pair(true, true), serde_json::json!([]), None),
7944 ];
7945 for (pair, asset, shortable) in cases {
7946 let server = wiremock::MockServer::start().await;
7947 mount_borrow_terms(
7948 &server,
7949 pair.clone(),
7950 asset.clone(),
7951 serde_json::json!([]),
7952 serde_json::json!({}),
7953 )
7954 .await;
7955
7956 let shortability = margin_client_at(&server, false)
7957 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
7958 .await
7959 .unwrap();
7960
7961 assert_eq!(shortability.shortable, shortable, "{pair} {asset}");
7962 assert_eq!(
7963 shortability.fee_rate, None,
7964 "no rate returned is an unknown fee"
7965 );
7966 assert_eq!(shortability.easy_to_borrow, None);
7967 assert_eq!(shortability.available, None);
7968 }
7969 }
7970
7971 #[tokio::test]
7975 async fn margin_shortability_reads_the_delisting_schedule_for_this_margin() {
7976 use crate::client::ShortabilityClient;
7977 let past = Utc::now() - chrono::Duration::hours(1);
7978 let soon = Utc::now() + chrono::Duration::days(3);
7979 let later = soon + chrono::Duration::days(7);
7980 let entry = |time: DateTime<Utc>, assets: &[&str], symbols: &[&str]| {
7981 serde_json::json!({
7982 "delistTime": time.timestamp_millis(),
7983 "crossMarginAssets": assets,
7984 "isolatedMarginSymbols": symbols,
7985 })
7986 };
7987 let millis = |time: DateTime<Utc>| DateTime::from_timestamp_millis(time.timestamp_millis());
7988 let cases = [
7989 (
7991 false,
7992 vec![entry(soon, &["BTC"], &[])],
7993 millis(soon),
7994 Some(true),
7995 ),
7996 (
7997 false,
7998 vec![entry(soon, &["USDT"], &[])],
7999 millis(soon),
8000 Some(true),
8001 ),
8002 (
8003 false,
8004 vec![
8005 entry(later, &["BTC"], &[]),
8006 entry(soon, &["ETH", "USDT"], &[]),
8007 ],
8008 millis(soon),
8009 Some(true),
8010 ),
8011 (
8012 false,
8013 vec![entry(soon, &["ETH"], &["BTCUSDT"])],
8014 None,
8015 Some(true),
8016 ),
8017 (
8018 false,
8019 vec![entry(past, &["BTC"], &[])],
8020 millis(past),
8021 Some(false),
8022 ),
8023 (
8025 true,
8026 vec![entry(soon, &[], &["BTCUSDT"])],
8027 millis(soon),
8028 Some(true),
8029 ),
8030 (
8031 true,
8032 vec![entry(soon, &["BTC", "USDT"], &["ETHUSDT"])],
8033 None,
8034 Some(true),
8035 ),
8036 (
8037 true,
8038 vec![entry(past, &[], &["BTCUSDT"])],
8039 millis(past),
8040 Some(false),
8041 ),
8042 ];
8043 for (is_isolated, schedule, delisting_at, shortable) in cases {
8044 let server = wiremock::MockServer::start().await;
8045 mount_borrow_terms(
8046 &server,
8047 btcusdt_pair(true, true),
8048 btc_asset(true),
8049 serde_json::json!([]),
8050 serde_json::json!({}),
8051 )
8052 .await;
8053 let schedule = serde_json::Value::from(schedule);
8054 mount_delist_schedule(&server, schedule.clone()).await;
8055
8056 let shortability = margin_client_at(&server, is_isolated)
8057 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
8058 .await
8059 .unwrap();
8060
8061 let case = format!("isolated: {is_isolated}, {schedule}");
8062 assert_eq!(shortability.delisting_at, delisting_at, "{case}");
8063 assert_eq!(shortability.shortable, shortable, "{case}");
8064 }
8065 }
8066
8067 #[tokio::test]
8069 async fn margin_shortability_of_a_scheduled_pair_without_a_time_is_an_error() {
8070 use crate::client::ShortabilityClient;
8071 let server = wiremock::MockServer::start().await;
8072 mount_borrow_terms(
8073 &server,
8074 btcusdt_pair(true, true),
8075 btc_asset(true),
8076 serde_json::json!([]),
8077 serde_json::json!({}),
8078 )
8079 .await;
8080 mount_delist_schedule(
8081 &server,
8082 serde_json::json!([{ "crossMarginAssets": ["BTC"] }]),
8083 )
8084 .await;
8085
8086 let result = margin_client_at(&server, false)
8087 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
8088 .await;
8089
8090 assert!(
8091 matches!(result, Err(UnindexedClientError::Internal(ref message))
8092 if message.contains("delistTime")),
8093 "{result:?}"
8094 );
8095 }
8096
8097 #[tokio::test]
8098 async fn margin_shortability_of_an_unlisted_pair_is_an_invalid_instrument() {
8099 use crate::client::ShortabilityClient;
8100 let server = wiremock::MockServer::start().await;
8101 mount_borrow_terms(
8102 &server,
8103 serde_json::json!([]),
8104 btc_asset(true),
8105 btc_rate("0.00000571"),
8106 serde_json::json!({}),
8107 )
8108 .await;
8109
8110 let result = margin_client_at(&server, false)
8111 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
8112 .await;
8113
8114 assert!(
8115 matches!(
8116 result,
8117 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
8118 if instrument.name().as_str() == "BTCUSDT"
8119 ),
8120 "{result:?}"
8121 );
8122 }
8123
8124 #[tokio::test]
8125 async fn margin_borrow_capacity_asks_for_the_asset_the_side_borrows() {
8126 use crate::client::BorrowCapacityClient;
8127 use rust_decimal_macros::dec;
8128 for (is_isolated, side, asset, isolated_symbol) in [
8129 (false, Side::Sell, "BTC", None),
8130 (false, Side::Buy, "USDT", None),
8131 (true, Side::Sell, "BTC", Some("BTCUSDT")),
8132 ] {
8133 let server = wiremock::MockServer::start().await;
8134 mount_borrow_terms(
8135 &server,
8136 btcusdt_pair(true, true),
8137 btc_asset(true),
8138 btc_rate("0.00000571"),
8139 serde_json::json!({ "amount": "1.69248805", "borrowLimit": "60" }),
8140 )
8141 .await;
8142
8143 let capacity = margin_client_at(&server, is_isolated)
8144 .fetch_borrow_capacity(&InstrumentNameExchange::new("BTCUSDT"), side)
8145 .await
8146 .unwrap();
8147
8148 assert_eq!(
8149 capacity,
8150 BorrowCapacity::new(AssetNameExchange::new(asset))
8151 .with_borrowable_now(dec!(1.69248805))
8152 .with_account_limit(dec!(60)),
8153 "{side:?} isolated: {is_isolated}"
8154 );
8155 let max_borrow = "/sapi/v1/margin/maxBorrowable";
8156 assert_eq!(
8157 query_value(&server, max_borrow, "asset").await.as_deref(),
8158 Some(asset)
8159 );
8160 assert_eq!(
8161 query_value(&server, max_borrow, "isolatedSymbol")
8162 .await
8163 .as_deref(),
8164 isolated_symbol,
8165 "only isolated margin names the pair's account"
8166 );
8167 }
8168 }
8169
8170 #[tokio::test]
8171 async fn margin_borrow_capacity_keeps_an_omitted_amount_unknown_and_fails_a_malformed_one() {
8172 use crate::client::BorrowCapacityClient;
8173 use rust_decimal_macros::dec;
8174 let instrument = InstrumentNameExchange::new("BTCUSDT");
8175
8176 let server = wiremock::MockServer::start().await;
8177 mount_borrow_terms(
8178 &server,
8179 btcusdt_pair(true, true),
8180 btc_asset(true),
8181 btc_rate("0"),
8182 serde_json::json!({ "borrowLimit": "60" }),
8183 )
8184 .await;
8185 let capacity = margin_client_at(&server, false)
8186 .fetch_borrow_capacity(&instrument, Side::Sell)
8187 .await
8188 .unwrap();
8189 assert_eq!(capacity.borrowable_now, None);
8190 assert_eq!(capacity.account_limit, Some(dec!(60)));
8191
8192 let server = wiremock::MockServer::start().await;
8193 mount_borrow_terms(
8194 &server,
8195 btcusdt_pair(true, true),
8196 btc_asset(true),
8197 btc_rate("0"),
8198 serde_json::json!({ "amount": "lots", "borrowLimit": "60" }),
8199 )
8200 .await;
8201 let result = margin_client_at(&server, false)
8202 .fetch_borrow_capacity(&instrument, Side::Sell)
8203 .await;
8204 assert!(
8205 matches!(result, Err(UnindexedClientError::Internal(ref message)) if message.contains("amount")),
8206 "{result:?}"
8207 );
8208 }
8209
8210 #[tokio::test]
8211 async fn margin_borrow_capacity_without_an_isolated_account_names_the_pair() {
8212 use crate::client::BorrowCapacityClient;
8213 use wiremock::{
8214 Mock,
8215 matchers::{method, path},
8216 };
8217 let server = wiremock::MockServer::start().await;
8218 Mock::given(method("GET"))
8219 .and(path("/sapi/v1/margin/maxBorrowable"))
8220 .respond_with(
8221 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
8222 "code": -11001, "msg": "Isolated margin account does not exist."
8223 })),
8224 )
8225 .mount(&server)
8226 .await;
8227 mount_borrow_terms(
8228 &server,
8229 btcusdt_pair(true, true),
8230 btc_asset(true),
8231 btc_rate("0"),
8232 serde_json::json!({}),
8233 )
8234 .await;
8235
8236 let err = margin_client_at(&server, true)
8237 .fetch_borrow_capacity(&InstrumentNameExchange::new("BTCUSDT"), Side::Sell)
8238 .await
8239 .unwrap_err();
8240
8241 assert_eq!(
8242 err.to_string(),
8243 "API: request rejected: BTCUSDT: -11001 Isolated margin account does not exist."
8244 );
8245 assert!(!err.is_transient());
8246 }
8247
8248 const HOURLY_RATE_ROUTE: &str = "/sapi/v1/margin/next-hourly-interest-rate";
8249
8250 fn assets(names: &[&str]) -> Vec<AssetNameExchange> {
8251 names
8252 .iter()
8253 .map(|name| AssetNameExchange::new(*name))
8254 .collect()
8255 }
8256
8257 async fn hourly_rate_requests(server: &wiremock::MockServer) -> Vec<String> {
8259 server
8260 .received_requests()
8261 .await
8262 .unwrap()
8263 .iter()
8264 .filter(|request| request.url.path() == HOURLY_RATE_ROUTE)
8265 .filter_map(|request| {
8266 request
8267 .url
8268 .query_pairs()
8269 .find(|(key, _)| key == "assets")
8270 .map(|(_, value)| value.into_owned())
8271 })
8272 .collect()
8273 }
8274
8275 async fn mount_echoed_hourly_rates(
8278 server: &wiremock::MockServer,
8279 refused: Option<&'static str>,
8280 ) {
8281 wiremock::Mock::given(wiremock::matchers::path(HOURLY_RATE_ROUTE))
8282 .respond_with(move |request: &wiremock::Request| {
8283 let requested = request
8284 .url
8285 .query_pairs()
8286 .find(|(key, _)| key == "assets")
8287 .map(|(_, value)| value.into_owned())
8288 .unwrap_or_default();
8289 if refused.is_some_and(|refused| requested.split(',').any(|a| a == refused)) {
8290 return margin_venue_error(-1102);
8291 }
8292 let answer: Vec<_> = requested
8293 .split(',')
8294 .map(|asset| {
8295 serde_json::json!({"asset": asset, "nextHourlyInterestRate": "0.0001"})
8296 })
8297 .collect();
8298 wiremock::ResponseTemplate::new(200).set_body_json(answer)
8299 })
8300 .mount(server)
8301 .await;
8302 }
8303
8304 #[tokio::test]
8305 async fn margin_hourly_rates_are_read_once_per_asset_for_the_clients_margin() {
8306 use rust_decimal_macros::dec;
8307 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
8308 let server = wiremock::MockServer::start().await;
8309 wiremock::Mock::given(wiremock::matchers::method("GET"))
8310 .and(wiremock::matchers::path(HOURLY_RATE_ROUTE))
8311 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
8312 serde_json::json!([
8313 {"asset": "BTC", "nextHourlyInterestRate": "0.00000571"},
8314 {"asset": "ETH", "nextHourlyInterestRate": "0.0001"},
8315 {"asset": "USDT", "nextHourlyInterestRate": null},
8316 {"asset": "XRP", "nextHourlyInterestRate": "0.5"},
8317 {"nextHourlyInterestRate": "0.5"},
8318 ]),
8319 ))
8320 .expect(1)
8321 .mount(&server)
8322 .await;
8323 let client = margin_client_at(&server, is_isolated);
8324
8325 let rates = client
8326 .fetch_next_hourly_interest_rates(&assets(&["BTC", "ETH", "BTC", "USDT", "SOL"]))
8327 .await
8328 .unwrap();
8329 assert_eq!(
8330 rates,
8331 HashMap::from([
8332 (AssetNameExchange::new("BTC"), dec!(0.00000571)),
8333 (AssetNameExchange::new("ETH"), dec!(0.0001)),
8334 ]),
8335 "no rate, no answer, or an asset not asked for is absent"
8336 );
8337 assert_eq!(hourly_rate_requests(&server).await, ["BTC,ETH,USDT,SOL"]);
8338 assert_eq!(
8339 query_value(&server, HOURLY_RATE_ROUTE, "isIsolated")
8340 .await
8341 .as_deref(),
8342 Some(flag)
8343 );
8344 assert!(
8345 query_value(&server, HOURLY_RATE_ROUTE, "signature")
8346 .await
8347 .is_some(),
8348 "signed"
8349 );
8350 }
8351 }
8352
8353 #[tokio::test]
8354 async fn margin_hourly_rates_past_twenty_assets_are_read_twenty_at_a_time() {
8355 let server = wiremock::MockServer::start().await;
8356 mount_echoed_hourly_rates(&server, None).await;
8357 let client = margin_client_at(&server, false);
8358 let names: Vec<String> = (0..45).map(|i| format!("A{i}")).collect();
8359 let names: Vec<&str> = names.iter().map(String::as_str).collect();
8360
8361 let rates = client
8362 .fetch_next_hourly_interest_rates(&assets(&names))
8363 .await
8364 .unwrap();
8365 assert_eq!(rates.len(), 45);
8366 assert_eq!(
8367 hourly_rate_requests(&server).await,
8368 [
8369 names[..20].join(","),
8370 names[20..40].join(","),
8371 names[40..].join(",")
8372 ]
8373 );
8374 }
8375
8376 #[tokio::test]
8377 async fn margin_hourly_rates_fail_whole_when_one_request_fails() {
8378 let server = wiremock::MockServer::start().await;
8379 mount_echoed_hourly_rates(&server, Some("A20")).await;
8380 let client = margin_client_at(&server, false);
8381 let names: Vec<String> = (0..25).map(|i| format!("A{i}")).collect();
8382 let names: Vec<&str> = names.iter().map(String::as_str).collect();
8383
8384 let read = client
8385 .fetch_next_hourly_interest_rates(&assets(&names))
8386 .await;
8387 assert!(
8388 matches!(
8389 read,
8390 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
8391 ),
8392 "{read:?}"
8393 );
8394 }
8395
8396 #[tokio::test]
8397 async fn margin_hourly_rates_refuse_an_unreadable_rate_and_send_nothing_for_no_assets() {
8398 let server = wiremock::MockServer::start().await;
8399 wiremock::Mock::given(wiremock::matchers::path(HOURLY_RATE_ROUTE))
8400 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
8401 serde_json::json!([{"asset": "BTC", "nextHourlyInterestRate": "0.0.1"}]),
8402 ))
8403 .mount(&server)
8404 .await;
8405 let client = margin_client_at(&server, false);
8406
8407 let none = client.fetch_next_hourly_interest_rates(&[]).await.unwrap();
8408 assert!(none.is_empty());
8409 assert!(
8410 hourly_rate_requests(&server).await.is_empty(),
8411 "nothing sent"
8412 );
8413
8414 match client
8415 .fetch_next_hourly_interest_rates(&assets(&["BTC"]))
8416 .await
8417 {
8418 Err(UnindexedClientError::Internal(message)) => {
8419 assert!(
8420 message.contains("nextHourlyInterestRate \"0.0.1\""),
8421 "{message}"
8422 );
8423 }
8424 other => panic!("{other:?}"),
8425 }
8426 }
8427
8428 #[tokio::test]
8429 async fn margin_commission_rates_are_the_spot_accounts() {
8430 use crate::client::binance::CommissionPart;
8431 use rust_decimal_macros::dec;
8432 let part = |rate: &str| serde_json::json!({"maker": rate, "taker": rate, "buyer": "0", "seller": "0"});
8433 let server = wiremock::MockServer::start().await;
8434 wiremock::Mock::given(wiremock::matchers::method("GET"))
8435 .and(wiremock::matchers::path("/api/v3/account/commission"))
8436 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
8437 .respond_with(
8438 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8439 "symbol": "BTCUSDT",
8440 "standardCommission": part("0.001"),
8441 "specialCommission": part("0"),
8442 "taxCommission": part("0"),
8443 })),
8444 )
8445 .expect(1)
8446 .mount(&server)
8447 .await;
8448 let client = margin_client_at(&server, true);
8449 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8450
8451 let rates = client.fetch_commission_rates(&btcusdt).await.unwrap();
8452 assert_eq!(
8453 rates.standard,
8454 CommissionPart::new(dec!(0.001), dec!(0.001), dec!(0), dec!(0))
8455 );
8456 assert_eq!(rates.discount, None);
8457 assert!(
8458 query_value(&server, "/api/v3/account/commission", "signature")
8459 .await
8460 .is_some(),
8461 "signed"
8462 );
8463 }
8464}