1mod records;
60mod stream;
61
62pub use records::MarginForcedLiquidation;
63
64use super::rules::RulesLoader;
65use super::shared::{
66 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
67 FILL_RECOVERY_TIMEOUT_SECS, MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing,
68 PlacementResponse, RateLimitTracker, RequestKind, SharedDedupCache, TrailingOrderError,
69 UnrecoveredFills, WeightPool, WireInt, answer_time, binance_filled_qty,
70 classify_order_kind_tif, classify_rest_order_error, classify_rest_query_error,
71 convert_ended_order, convert_open_order_listing, convert_open_order_owned_symbol,
72 dedup_key_from_event, drop_after, gap_failed, gap_time, is_duplicate, is_unknown_order,
73 new_dedup_cache, placed_order_state, recovered_order_cids, recovered_order_totals,
74 response_decode_error, rest_call_with_retry, trailing_params, unreadable_answer,
75};
76use crate::rules::{OrderRules, PreSendRules};
77use crate::{
78 AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
79 IsolatedInstrumentState, IsolatedMarginRisk, UnindexedAccountEvent, UnindexedAccountSnapshot,
80 balance::{AssetBalance, Balance},
81 borrow_capacity::BorrowCapacity,
82 client::{
83 BorrowCapacityClient, ExecutionClient, OrderStatusClient, ShortabilityClient,
84 order_recovery::{
85 KnownLiveOrders, MAX_KNOWN_LIVE_ORDERS, OpenListing, OrderLookup,
86 SharedKnownLiveOrders, UncheckedOrders, fetch_ended_by_key, recover_ended_orders,
87 },
88 },
89 error::{ApiError, OrderError, UnindexedClientError},
90 margin::{MarginBorrow, MarginEffect, MarginPlacement},
91 order::{
92 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
93 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
94 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
95 state::{Cancelled, Open, OrderState, UnindexedOrderState},
96 },
97 position::PositionReport,
98 shortability::Shortability,
99 trade::{AssetFees, Trade, TradeId, TradesRead},
100};
101use binance_sdk::{
102 common::{config::ConfigurationRestApi, constants::MARGIN_TRADING_REST_API_PROD_URL},
103 margin_trading::{
104 MarginTradingRestApi,
105 rest_api::{
106 GetAllCrossMarginPairsParams, GetAllIsolatedMarginSymbolParams,
107 GetAllMarginAssetsParams, GetDelistScheduleParams,
108 GetFutureHourlyInterestRateIsIsolatedEnum, GetFutureHourlyInterestRateParams,
109 MarginAccountCancelOrderIsIsolatedEnum, MarginAccountCancelOrderParams,
110 MarginAccountNewOrderIsIsolatedEnum, MarginAccountNewOrderNewOrderRespTypeEnum,
111 MarginAccountNewOrderParams, MarginAccountNewOrderSideEffectTypeEnum,
112 MarginAccountNewOrderSideEnum, MarginAccountNewOrderTimeInForceEnum,
113 MarginAccountNewOrderTypeEnum, QueryCrossMarginAccountDetailsParams,
114 QueryCrossMarginAccountDetailsResponseUserAssetsInner,
115 QueryIsolatedMarginAccountInfoParams,
116 QueryIsolatedMarginAccountInfoResponseAssetsInner,
117 QueryMarginAccountsOpenOrdersIsIsolatedEnum, QueryMarginAccountsOpenOrdersParams,
118 QueryMarginAccountsOrderIsIsolatedEnum, QueryMarginAccountsOrderParams,
119 QueryMarginAccountsTradeListIsIsolatedEnum, QueryMarginAccountsTradeListParams,
120 QueryMarginAccountsTradeListResponseInner, QueryMaxBorrowParams, RestApi,
121 },
122 },
123};
124use chrono::{DateTime, TimeZone, Utc};
125use fnv::FnvHashSet;
126use futures::stream::BoxStream;
127use rust_decimal::Decimal;
128use rustrade_instrument::{
129 Side,
130 asset::name::AssetNameExchange,
131 exchange::ExchangeId,
132 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
133};
134use serde::Deserialize;
135use smol_str::format_smolstr;
136use std::{
137 collections::{BTreeMap, HashMap},
138 str::FromStr,
139 sync::{
140 Arc,
141 atomic::{AtomicUsize, Ordering},
142 },
143 time::Duration,
144};
145use stream::{MarginStream, STREAM_URL, SapiListenKeys, StreamAccount};
146use tokio::sync::mpsc;
147use tracing::{debug, error, info, warn};
148
149fn binance_side_effect(effect: MarginEffect) -> MarginAccountNewOrderSideEffectTypeEnum {
160 match effect {
161 MarginEffect::NoSideEffect => MarginAccountNewOrderSideEffectTypeEnum::NoSideEffect,
162 MarginEffect::Borrow => MarginAccountNewOrderSideEffectTypeEnum::MarginBuy,
163 MarginEffect::Repay => MarginAccountNewOrderSideEffectTypeEnum::AutoRepay,
164 }
165}
166
167#[derive(Clone, Deserialize)]
177pub struct BinanceMarginConfig {
178 api_key: String,
181 secret_key: String,
182 pub testnet: bool,
188 #[serde(default)]
195 pub is_isolated: bool,
196 #[serde(default)]
208 pub isolated_symbols: Vec<InstrumentNameExchange>,
209 #[serde(default)]
214 pub margin_effect: MarginEffect,
215 #[serde(default = "crate::margin::default_auto_repay_at_cancel")]
222 pub auto_repay_at_cancel: bool,
223 #[serde(default)]
229 pub pre_send_rules: PreSendRules,
230}
231
232impl std::fmt::Debug for BinanceMarginConfig {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 f.debug_struct("BinanceMarginConfig")
236 .field("api_key", &"***")
237 .field("secret_key", &"***")
238 .field("testnet", &self.testnet)
239 .field("is_isolated", &self.is_isolated)
240 .field("isolated_symbols", &self.isolated_symbols)
241 .field("margin_effect", &self.margin_effect)
242 .field("auto_repay_at_cancel", &self.auto_repay_at_cancel)
243 .field("pre_send_rules", &self.pre_send_rules)
244 .finish()
245 }
246}
247
248impl BinanceMarginConfig {
249 pub fn new(
260 api_key: String,
261 secret_key: String,
262 testnet: bool,
263 is_isolated: bool,
264 isolated_symbols: Vec<InstrumentNameExchange>,
265 margin_effect: MarginEffect,
266 auto_repay_at_cancel: bool,
267 ) -> Self {
268 Self {
269 api_key,
270 secret_key,
271 testnet,
272 is_isolated,
273 isolated_symbols,
274 margin_effect,
275 auto_repay_at_cancel,
276 pre_send_rules: PreSendRules::default(),
277 }
278 }
279
280 #[must_use]
282 pub fn with_pre_send_rules(mut self, pre_send_rules: PreSendRules) -> Self {
283 self.pre_send_rules = pre_send_rules;
284 self
285 }
286
287 pub fn cross_margin(api_key: String, secret_key: String) -> Self {
294 Self::new(
295 api_key,
296 secret_key,
297 false,
298 false,
299 Vec::new(),
300 MarginEffect::default(),
301 true,
302 )
303 }
304
305 pub fn isolated(
313 api_key: String,
314 secret_key: String,
315 symbols: Vec<InstrumentNameExchange>,
316 ) -> Self {
317 Self::new(
318 api_key,
319 secret_key,
320 false,
321 true,
322 symbols,
323 MarginEffect::default(),
324 true,
325 )
326 }
327
328 pub fn api_key(&self) -> &str {
330 &self.api_key
331 }
332}
333
334#[derive(Clone)]
479pub struct BinanceMargin {
480 config: Arc<BinanceMarginConfig>,
481 rest: Arc<RestApi>,
483 rest_config: Arc<ConfigurationRestApi>,
488 rate_limiter: Arc<RateLimitTracker>,
490 known_live: SharedKnownLiveOrders,
493 rules: Arc<RulesLoader>,
496}
497
498impl std::fmt::Debug for BinanceMargin {
499 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500 f.debug_struct("BinanceMargin")
501 .field("testnet", &self.config.testnet)
502 .field("is_isolated", &self.config.is_isolated)
503 .field("margin_effect", &self.config.margin_effect)
504 .finish_non_exhaustive()
505 }
506}
507
508impl BinanceMargin {
509 pub fn recall_order_kinds(&self, kinds: impl IntoIterator<Item = (ClientOrderId, OrderKind)>) {
536 let kinds: Vec<_> = kinds.into_iter().collect();
537 let ignored = self.known_live.lock().recall(kinds);
539 if ignored > 0 {
540 warn!(
541 ignored,
542 limit = MAX_KNOWN_LIVE_ORDERS,
543 "Binance margin: recalled order kinds past the limit are ignored"
544 );
545 }
546 }
547
548 pub async fn load_rules(
556 &self,
557 instruments: &[InstrumentNameExchange],
558 ) -> Result<HashMap<InstrumentNameExchange, OrderRules>, UnindexedClientError> {
559 self.rules.load(instruments).await
560 }
561
562 pub async fn open_margin_order(
611 &self,
612 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
613 ) -> MarginPlacement {
614 let instrument = request.key.instrument.clone();
615 let side = request.state.side;
616 let price = request.state.price;
617 let quantity = request.state.quantity;
618 let kind = request.state.kind;
619 let time_in_force = request.state.time_in_force;
620 let cid = request.key.cid.clone();
621
622 let order_key = OrderKey::new(
623 ExchangeId::BinanceMargin,
624 instrument.clone(),
625 request.key.strategy.clone(),
626 cid.clone(),
627 );
628
629 let inactive = |state: UnindexedOrderState| MarginPlacement {
632 order: Order {
633 key: order_key.clone(),
634 side,
635 price,
636 quantity,
637 kind,
638 time_in_force,
639 state,
640 },
641 borrowed: None,
642 };
643
644 let params = match build_new_order_params(
645 instrument.name().to_string(),
646 side,
647 price,
648 quantity,
649 kind,
650 time_in_force,
651 cid.0.to_string(),
652 request
653 .state
654 .margin_effect
655 .unwrap_or(self.config.margin_effect),
656 self.config.auto_repay_at_cancel,
657 self.config.is_isolated,
658 ) {
659 Ok(params) => params,
660 Err(BuildOrderError::Unsupported) => {
661 return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
662 format!(
663 "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
664 ),
665 )));
666 }
667 Err(BuildOrderError::Trailing(refused)) => {
668 return inactive(OrderState::inactive(refused.into_order_error()));
669 }
670 Err(BuildOrderError::Build(msg)) => {
671 error!(%msg, "BinanceMargin failed to build new order params");
672 return inactive(OrderState::inactive(OrderError::Rejected(
673 ApiError::OrderRejected(msg),
674 )));
675 }
676 };
677
678 if let Err(refused) = self
679 .rules
680 .check(instrument.name(), kind, price, quantity)
681 .await
682 {
683 return inactive(OrderState::inactive(refused));
684 }
685
686 let mut sending = None;
691 let placement = async {
693 let query = new_order_query(params);
696 let response = match rest_call_with_retry(
697 &self.rest_config,
698 &self.rate_limiter,
699 RequestKind::Order,
700 |cfg| {
701 sending.get_or_insert_with(|| self.known_live.sending(&cid, kind));
702 let query = query.clone();
703 Box::pin(async move {
704 binance_sdk::common::utils::send_request::<NewOrderAnswer>(
705 &cfg,
706 ORDER_PATH,
707 reqwest::Method::POST,
708 query,
709 BTreeMap::new(),
710 None,
711 true,
712 )
713 .await
714 })
715 },
716 )
717 .await
718 {
719 Ok(response) => response,
720 Err(e) => {
721 return inactive(OrderState::inactive(classify_rest_order_error(
722 &e,
723 &instrument,
724 )));
725 }
726 };
727
728 let data = match response.data().await {
729 Ok(data) => data,
730 Err(e) => {
731 return inactive(OrderState::inactive(unreadable_answer(
732 ExchangeId::BinanceMargin,
733 &e.to_string(),
734 )));
735 }
736 };
737
738 let time_exchange = answer_time(
739 ExchangeId::BinanceMargin,
740 &instrument,
741 "open_order",
742 data.transact_time,
743 );
744
745 let exchange_order_id = match data.order_id {
746 Some(id) => OrderId(format_smolstr!("{id}")),
747 None => {
748 return inactive(OrderState::inactive(unreadable_answer(
749 ExchangeId::BinanceMargin,
750 "open_order answer has no orderId",
751 )));
752 }
753 };
754
755 let borrowed = match (data.margin_buy_borrow_amount, data.margin_buy_borrow_asset) {
756 (Some(amount), Some(asset)) => Some(MarginBorrow::new(asset, amount)),
757 (None, None) => None,
758 (Some(_), None) | (None, Some(_)) => {
759 return inactive(OrderState::inactive(unreadable_answer(
760 ExchangeId::BinanceMargin,
761 "open_order answer has a borrowed amount or asset without the other",
762 )));
763 }
764 };
765
766 let state = placed_order_state(
769 ExchangeId::BinanceMargin,
770 &instrument,
771 quantity,
772 PlacementResponse {
773 status: data.status.as_deref(),
774 order_id: exchange_order_id,
775 time_exchange,
776 executed_qty: data.executed_qty.as_deref(),
777 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
778 },
779 );
780 self.known_live
781 .lock()
782 .placed(&order_key, quantity, kind, &state);
783
784 MarginPlacement {
785 order: Order {
786 key: order_key.clone(),
788 side,
789 price,
790 quantity,
791 kind,
792 time_in_force,
793 state,
794 },
795 borrowed,
796 }
797 }
798 .await;
799 if let Some(sending) = sending {
800 sending.sent(&placement.order.state);
801 }
802 placement
803 }
804
805 fn remember_live<'a>(
807 &self,
808 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
809 ) {
810 let mut known = self.known_live.lock();
811 for order in orders {
812 known.live(&order.key, order.quantity, order.kind, &order.state);
813 }
814 }
815
816 #[allow(clippy::expect_used)] fn build_rest_config(config: &BinanceMarginConfig) -> ConfigurationRestApi {
826 ConfigurationRestApi::builder()
827 .api_key(config.api_key.clone())
828 .api_secret(config.secret_key.clone())
829 .base_path(MARGIN_TRADING_REST_API_PROD_URL)
830 .build()
831 .expect("failed to build Binance margin REST configuration")
832 }
833
834 fn effective_isolated_set(
847 &self,
848 instruments: &[InstrumentNameExchange],
849 ) -> Vec<InstrumentNameExchange> {
850 if instruments.is_empty() {
851 return self.config.isolated_symbols.clone();
852 }
853 instruments
854 .iter()
855 .filter(|inst| {
856 let in_set = self.config.isolated_symbols.contains(*inst);
857 if !in_set {
858 warn!(
859 instrument = %inst.name(),
860 "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
861 );
862 }
863 in_set
864 })
865 .cloned()
866 .collect()
867 }
868
869 async fn isolated_account_snapshot(
873 &self,
874 instruments: &[InstrumentNameExchange],
875 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
876 use futures::{StreamExt as _, TryStreamExt as _};
877
878 let effective = self.effective_isolated_set(instruments);
879 if effective.is_empty() {
880 return Ok(AccountSnapshot::new(
883 ExchangeId::BinanceMargin,
884 Vec::new(),
885 Vec::new(),
886 ));
887 }
888
889 let mut balances_by_symbol = convert_isolated_margin_assets(
891 fetch_isolated_margin_account_info(
892 self.rest.clone(),
893 self.rate_limiter.clone(),
894 effective.clone(),
895 )
896 .await?,
897 );
898
899 let instrument_snapshots: Vec<_> =
902 futures::stream::iter(effective.into_iter().map(|instrument| {
903 fetch_margin_open_orders_for_instrument(
904 self.rest.clone(),
905 self.rate_limiter.clone(),
906 self.known_live.clone(),
907 instrument,
908 true,
909 RequestKind::Query,
910 )
911 }))
912 .buffer_unordered(8)
913 .map(|result| {
914 let (inst, listing) = result?;
915 self.remember_live(&listing.orders);
916 let wrapped = listing
917 .orders
918 .into_iter()
919 .map(active_order_snapshot)
920 .collect();
921 let isolated = balances_by_symbol.remove(&inst);
922 if isolated.is_none() {
923 warn!(
927 instrument = %inst.name(),
928 "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
929 );
930 }
931 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
932 inst,
933 wrapped,
934 listing.complete,
935 PositionReport::Unreported,
936 isolated,
937 ))
938 })
939 .try_collect()
940 .await?;
941
942 Ok(AccountSnapshot::new(
945 ExchangeId::BinanceMargin,
946 Vec::new(),
947 instrument_snapshots,
948 ))
949 }
950}
951
952impl ExecutionClient for BinanceMargin {
953 const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
954
955 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
958 &[InstrumentKindDiscriminant::Spot];
959 type Config = BinanceMarginConfig;
960 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
961
962 fn new(config: Self::Config) -> Self {
975 if config.testnet {
976 warn!(
977 "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
978 using production endpoints"
979 );
980 }
981 assert!(
982 !(config.is_isolated && config.isolated_symbols.is_empty()),
983 "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
984 );
985 let rest_config = Self::build_rest_config(&config);
990 let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
991 let rules = Arc::new(RulesLoader::new(
995 config.pre_send_rules,
996 Arc::new(binance_sdk::spot::SpotRestApi::production(
997 rest_config.clone(),
998 )),
999 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
1000 ));
1001 let rest_config = Arc::new(rest_config);
1002 Self {
1003 config: Arc::new(config),
1004 rest,
1005 rest_config,
1006 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
1007 known_live: KnownLiveOrders::shared(ExchangeId::BinanceMargin),
1008 rules,
1009 }
1010 }
1011
1012 async fn open_order(
1017 &self,
1018 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1019 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1020 self.open_margin_order(request).await.order
1021 }
1022
1023 async fn cancel_order(
1037 &self,
1038 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1039 ) -> UnindexedOrderResponseCancel {
1040 let instrument = request.key.instrument.clone();
1041 let key = OrderKey {
1042 exchange: request.key.exchange,
1043 instrument: instrument.clone(),
1044 strategy: request.key.strategy.clone(),
1045 cid: request.key.cid.clone(),
1046 };
1047
1048 let params = match build_cancel_order_params(
1049 instrument.name().to_string(),
1050 request.state.id.as_ref().and_then(VenueOrderId::assigned),
1051 &request.key.cid,
1052 self.config.is_isolated,
1053 ) {
1054 Ok(p) => p,
1055 Err(e) => {
1056 error!(%e, "BinanceMargin failed to build cancel order params");
1057 return UnindexedOrderResponseCancel {
1058 key,
1059 state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
1060 };
1061 }
1062 };
1063
1064 let query = cancel_order_query(params);
1067 let response = match rest_call_with_retry(
1068 &self.rest_config,
1069 &self.rate_limiter,
1070 RequestKind::Order,
1071 |cfg| {
1072 let query = query.clone();
1073 Box::pin(async move {
1074 binance_sdk::common::utils::send_request::<CancelOrderAnswer>(
1075 &cfg,
1076 ORDER_PATH,
1077 reqwest::Method::DELETE,
1078 query,
1079 BTreeMap::new(),
1080 None,
1081 true,
1082 )
1083 .await
1084 })
1085 },
1086 )
1087 .await
1088 {
1089 Ok(response) => response,
1090 Err(e) => {
1091 return UnindexedOrderResponseCancel {
1092 key,
1093 state: Err(classify_rest_order_error(&e, &instrument)),
1094 };
1095 }
1096 };
1097
1098 let data = match response.data().await {
1099 Ok(data) => data,
1100 Err(e) => {
1101 return UnindexedOrderResponseCancel {
1102 key,
1103 state: Err(unreadable_answer(ExchangeId::BinanceMargin, &e.to_string())),
1104 };
1105 }
1106 };
1107
1108 let time_exchange = answer_time(
1109 ExchangeId::BinanceMargin,
1110 &instrument,
1111 "cancel_order",
1112 data.transact_time,
1113 );
1114
1115 let exchange_order_id = match data.order_id {
1116 Some(WireInt::Int(id)) => OrderId(format_smolstr!("{id}")),
1117 Some(WireInt::Unreadable(raw)) => {
1118 return UnindexedOrderResponseCancel {
1119 key,
1120 state: Err(unreadable_answer(
1121 ExchangeId::BinanceMargin,
1122 &format!("cancel answer has an unreadable orderId: {raw}"),
1123 )),
1124 };
1125 }
1126 None => {
1127 return UnindexedOrderResponseCancel {
1128 key,
1129 state: Err(unreadable_answer(
1130 ExchangeId::BinanceMargin,
1131 "cancel answer has no orderId",
1132 )),
1133 };
1134 }
1135 };
1136
1137 let filled_qty = binance_filled_qty(
1138 ExchangeId::BinanceMargin,
1139 &exchange_order_id,
1140 data.executed_qty.as_deref(),
1141 );
1142
1143 self.known_live.lock().ended(&key.cid);
1144 UnindexedOrderResponseCancel {
1145 key,
1146 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
1147 }
1148 }
1149
1150 async fn account_snapshot(
1173 &self,
1174 assets: &[AssetNameExchange],
1175 instruments: &[InstrumentNameExchange],
1176 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1177 if self.config.is_isolated {
1178 return self.isolated_account_snapshot(instruments).await;
1179 }
1180 let response =
1181 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1182 Box::pin(async move {
1183 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
1184 rest.query_cross_margin_account_details(params).await
1185 })
1186 })
1187 .await
1188 .map_err(|e| classify_rest_query_error(&e, None))?;
1189
1190 let account = response.data().await.map_err(response_decode_error)?;
1191
1192 let balances =
1193 filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets);
1194
1195 use futures::{StreamExt as _, TryStreamExt as _};
1200 let instrument_snapshots: Vec<_> =
1201 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1202 fetch_margin_open_orders_for_instrument(
1203 self.rest.clone(),
1204 self.rate_limiter.clone(),
1205 self.known_live.clone(),
1206 instrument,
1207 false,
1209 RequestKind::Query,
1210 )
1211 }))
1212 .buffer_unordered(8)
1213 .map(|result| {
1214 let (inst, listing) = result?;
1215 self.remember_live(&listing.orders);
1216 let wrapped = listing
1217 .orders
1218 .into_iter()
1219 .map(active_order_snapshot)
1220 .collect();
1221 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
1222 inst,
1223 wrapped,
1224 listing.complete,
1225 PositionReport::Unreported,
1226 None,
1227 ))
1228 })
1229 .try_collect()
1230 .await?;
1231
1232 Ok(AccountSnapshot::new(
1233 ExchangeId::BinanceMargin,
1234 balances,
1235 instrument_snapshots,
1236 ))
1237 }
1238
1239 async fn fetch_balances(
1253 &self,
1254 assets: &[AssetNameExchange],
1255 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1256 if self.config.is_isolated {
1257 return Ok(Vec::new());
1258 }
1259 let response =
1260 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1261 Box::pin(async move {
1262 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
1263 rest.query_cross_margin_account_details(params).await
1264 })
1265 })
1266 .await
1267 .map_err(|e| classify_rest_query_error(&e, None))?;
1268
1269 let account = response.data().await.map_err(response_decode_error)?;
1270
1271 Ok(filter_and_convert_margin_balances(
1272 account.user_assets.unwrap_or_default(),
1273 assets,
1274 ))
1275 }
1276
1277 async fn fetch_open_orders(
1292 &self,
1293 instruments: &[InstrumentNameExchange],
1294 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1295 use futures::{StreamExt as _, TryStreamExt as _};
1296
1297 if self.config.is_isolated {
1301 let effective = self.effective_isolated_set(instruments);
1302 let capacity = effective.len();
1303 return futures::stream::iter(effective.into_iter().map(|instrument| {
1304 fetch_margin_open_orders_for_instrument(
1305 self.rest.clone(),
1306 self.rate_limiter.clone(),
1307 self.known_live.clone(),
1308 instrument,
1309 true,
1310 RequestKind::Query,
1311 )
1312 }))
1313 .buffer_unordered(8)
1314 .try_fold(
1315 Vec::with_capacity(capacity),
1316 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
1317 (_, listing)| async move {
1318 acc.extend(listing.orders);
1319 Ok(acc)
1320 },
1321 )
1322 .await
1323 .inspect(|orders| self.remember_live(orders));
1324 }
1325
1326 if instruments.is_empty() {
1329 let orders = fetch_margin_all_open_orders(
1330 self.rest.clone(),
1331 self.rate_limiter.clone(),
1332 self.known_live.clone(),
1333 false,
1334 )
1335 .await?;
1336 self.remember_live(&orders);
1337 return Ok(orders);
1338 }
1339 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1340 fetch_margin_open_orders_for_instrument(
1341 self.rest.clone(),
1342 self.rate_limiter.clone(),
1343 self.known_live.clone(),
1344 instrument,
1345 false,
1346 RequestKind::Query,
1347 )
1348 }))
1349 .buffer_unordered(8)
1350 .try_fold(
1351 Vec::with_capacity(instruments.len()),
1352 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1353 acc.extend(listing.orders);
1354 Ok(acc)
1355 },
1356 )
1357 .await
1358 .inspect(|orders| self.remember_live(orders))
1359 }
1360
1361 async fn fetch_trades(
1390 &self,
1391 start: DateTime<Utc>,
1392 end: DateTime<Utc>,
1393 instruments: &[InstrumentNameExchange],
1394 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1395 use futures::StreamExt as _;
1396
1397 if start > end {
1398 return Ok(TradesRead::complete(Vec::new()));
1399 }
1400
1401 let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1404 self.effective_isolated_set(instruments)
1405 } else {
1406 instruments.to_vec()
1407 };
1408 if effective.is_empty() {
1409 debug!(
1412 is_isolated = self.config.is_isolated,
1413 "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1414 );
1415 return Ok(TradesRead::complete(Vec::new()));
1416 }
1417 let span = MyTradesFrom::Span {
1419 start: start.timestamp_millis(),
1420 end: end.timestamp_millis(),
1421 };
1422 let is_isolated = self.config.is_isolated;
1423 let mut all_trades = Vec::new();
1424
1425 let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1428 let rest = self.rest.clone();
1429 let rate_limiter = self.rate_limiter.clone();
1430 async move {
1431 let pages = paginate_margin_my_trades(
1432 &rest,
1433 &rate_limiter,
1434 &inst,
1435 span,
1436 is_isolated,
1437 RequestKind::Query,
1438 )
1439 .await?;
1440 Ok::<_, UnindexedClientError>((inst, pages))
1441 }
1442 }))
1443 .buffer_unordered(8);
1444 while let Some(result) = stream.next().await {
1445 let (instrument, trades_data) = result?;
1446 for t in trades_data {
1447 if let Some(trade) = convert_margin_trade(&t, &instrument)
1448 && (start..=end).contains(&trade.time_exchange)
1449 {
1450 all_trades.push(trade);
1451 }
1452 }
1453 }
1454
1455 Ok(TradesRead::complete(all_trades))
1456 }
1457
1458 async fn account_stream(
1613 &self,
1614 _assets: &[AssetNameExchange],
1617 instruments: &[InstrumentNameExchange],
1618 ) -> Result<Self::AccountStream, UnindexedClientError> {
1619 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1620
1621 let (account, instruments) = if self.config.is_isolated {
1622 let symbols = self.config.isolated_symbols.clone();
1623 let assets = fetch_isolated_margin_account_info(
1626 self.rest.clone(),
1627 self.rate_limiter.clone(),
1628 symbols.clone(),
1629 )
1630 .await?;
1631 let mut base_quote = build_base_quote_map(&assets);
1632 let pairs = symbols
1633 .iter()
1634 .map(|symbol| {
1635 let assets = base_quote.remove(symbol);
1636 if assets.is_none() {
1637 warn!(
1638 symbol = %symbol.name(),
1639 "BinanceMargin isolated: account info returned no base/quote for \
1640 configured symbol — its live per-pair balance frames will be dropped \
1641 (fills/orders unaffected; balances available via account_snapshot)"
1642 );
1643 }
1644 (symbol.clone(), assets)
1645 })
1646 .collect();
1647 (StreamAccount::Isolated { pairs }, symbols)
1648 } else {
1649 (StreamAccount::Cross, instruments.to_vec())
1650 };
1651
1652 let stream = MarginStream {
1653 keys: SapiListenKeys {
1654 rest: self.rest.clone(),
1655 rate_limiter: self.rate_limiter.clone(),
1656 },
1657 url: STREAM_URL.to_owned(),
1658 rest: self.rest.clone(),
1659 rate_limiter: self.rate_limiter.clone(),
1660 known: self.known_live.clone(),
1661 account,
1662 instruments,
1663 tx,
1664 dedup: new_dedup_cache(),
1665 };
1666 let session = stream.open().await?;
1669 let handle = tokio::spawn(stream.run(session));
1670
1671 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1672 let guarded_stream = AbortOnDropStream::new(rx_stream, handle);
1673 Ok(futures::StreamExt::boxed(guarded_stream))
1674 }
1675}
1676
1677impl OrderStatusClient for BinanceMargin {
1694 async fn fetch_ended_orders(
1695 &self,
1696 orders: &[UnindexedOrderKey],
1697 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1698 fetch_ended_by_key(orders, |key| {
1699 let recorded_kind = self.known_live.lock().kind_of(&key.cid);
1700 fetch_margin_order_lookup(
1701 self.rest.clone(),
1702 self.rate_limiter.clone(),
1703 key,
1704 self.config.is_isolated,
1705 RequestKind::Query,
1706 recorded_kind,
1707 )
1708 })
1709 .await
1710 }
1711}
1712
1713const HOURS_PER_YEAR: u32 = 24 * 365;
1719
1720impl ShortabilityClient for BinanceMargin {
1721 async fn fetch_shortability(
1748 &self,
1749 instrument: &InstrumentNameExchange,
1750 ) -> Result<Shortability, UnindexedClientError> {
1751 let pair = self.fetch_margin_pair(instrument).await?;
1752 let (borrowable, hourly_rate, delisting_at) = tokio::try_join!(
1753 self.fetch_asset_borrowable(&pair.base),
1754 self.fetch_next_hourly_interest_rate(&pair.base),
1755 self.fetch_delisting_at(instrument, &pair),
1756 )?;
1757
1758 let mut shortability = Shortability::new();
1759 if let Some(delisting_at) = delisting_at {
1760 shortability = shortability.with_delisting_at(delisting_at);
1761 }
1762 let not_delisted = Some(delisting_at.is_none_or(|delisting_at| delisting_at > Utc::now()));
1766 let flags = [
1767 pair.is_margin_trade,
1768 pair.is_sell_allowed,
1769 borrowable,
1770 not_delisted,
1771 ];
1772 if flags.contains(&Some(false)) {
1773 shortability = shortability.with_shortable(false);
1774 } else if flags.iter().all(|flag| *flag == Some(true)) {
1775 shortability = shortability.with_shortable(true);
1776 }
1777 if let Some(hourly_rate) = hourly_rate {
1778 shortability = shortability.with_fee_rate(hourly_rate * Decimal::from(HOURS_PER_YEAR));
1779 }
1780 Ok(shortability)
1781 }
1782}
1783
1784impl BorrowCapacityClient for BinanceMargin {
1785 async fn fetch_borrow_capacity(
1802 &self,
1803 instrument: &InstrumentNameExchange,
1804 side: Side,
1805 ) -> Result<BorrowCapacity, UnindexedClientError> {
1806 let pair = self.fetch_margin_pair(instrument).await?;
1807 let asset = match side {
1808 Side::Buy => pair.quote,
1809 Side::Sell => pair.base,
1810 };
1811 let isolated_symbol = self
1812 .config
1813 .is_isolated
1814 .then(|| instrument.name().to_string());
1815 let response =
1816 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1817 let asset = asset.name().to_string();
1818 let isolated_symbol = isolated_symbol.clone();
1819 Box::pin(async move {
1820 let params = QueryMaxBorrowParams::builder(asset)
1821 .isolated_symbol(isolated_symbol)
1822 .build()?;
1823 rest.query_max_borrow(params).await
1824 })
1825 })
1826 .await
1827 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
1828 let max_borrow = response.data().await.map_err(response_decode_error)?;
1829
1830 let mut capacity = BorrowCapacity::new(asset);
1831 if let Some(amount) = optional_decimal("amount", max_borrow.amount.as_deref())? {
1832 capacity = capacity.with_borrowable_now(amount);
1833 }
1834 if let Some(limit) = optional_decimal("borrowLimit", max_borrow.borrow_limit.as_deref())? {
1835 capacity = capacity.with_account_limit(limit);
1836 }
1837 Ok(capacity)
1838 }
1839}
1840
1841#[derive(Debug)]
1843struct MarginPair {
1844 base: AssetNameExchange,
1845 quote: AssetNameExchange,
1846 is_margin_trade: Option<bool>,
1847 is_sell_allowed: Option<bool>,
1848}
1849
1850impl BinanceMargin {
1851 async fn fetch_margin_pair(
1855 &self,
1856 instrument: &InstrumentNameExchange,
1857 ) -> Result<MarginPair, UnindexedClientError> {
1858 let symbol = instrument.name().to_string();
1859 let listed = if self.config.is_isolated {
1860 let response =
1861 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1862 let symbol = symbol.clone();
1863 Box::pin(async move {
1864 let params = GetAllIsolatedMarginSymbolParams::builder()
1865 .symbol(symbol)
1866 .build()?;
1867 rest.get_all_isolated_margin_symbol(params).await
1868 })
1869 })
1870 .await
1871 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
1872 response
1873 .data()
1874 .await
1875 .map_err(response_decode_error)?
1876 .into_iter()
1877 .find(|pair| pair.symbol.as_deref() == Some(symbol.as_str()))
1878 .map(|pair| {
1879 (
1880 pair.base,
1881 pair.quote,
1882 pair.is_margin_trade,
1883 pair.is_sell_allowed,
1884 )
1885 })
1886 } else {
1887 let response =
1888 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1889 let symbol = symbol.clone();
1890 Box::pin(async move {
1891 let params = GetAllCrossMarginPairsParams::builder()
1892 .symbol(symbol)
1893 .build()?;
1894 rest.get_all_cross_margin_pairs(params).await
1895 })
1896 })
1897 .await
1898 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
1899 response
1900 .data()
1901 .await
1902 .map_err(response_decode_error)?
1903 .into_iter()
1904 .find(|pair| pair.symbol.as_deref() == Some(symbol.as_str()))
1905 .map(|pair| {
1906 (
1907 pair.base,
1908 pair.quote,
1909 pair.is_margin_trade,
1910 pair.is_sell_allowed,
1911 )
1912 })
1913 };
1914 let margin = if self.config.is_isolated {
1915 "isolated"
1916 } else {
1917 "cross"
1918 };
1919 let Some((base, quote, is_margin_trade, is_sell_allowed)) = listed else {
1920 return Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(
1921 instrument.clone(),
1922 format!("Binance lists no {margin} margin pair {symbol}"),
1923 )));
1924 };
1925 let (Some(base), Some(quote)) = (base, quote) else {
1926 return Err(UnindexedClientError::Internal(format!(
1927 "Binance lists {margin} margin pair {symbol} without its base or quote asset"
1928 )));
1929 };
1930 Ok(MarginPair {
1931 base: AssetNameExchange::new(base),
1932 quote: AssetNameExchange::new(quote),
1933 is_margin_trade,
1934 is_sell_allowed,
1935 })
1936 }
1937
1938 async fn fetch_asset_borrowable(
1941 &self,
1942 asset: &AssetNameExchange,
1943 ) -> Result<Option<bool>, UnindexedClientError> {
1944 let name = asset.name().to_string();
1945 let response =
1946 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1947 let name = name.clone();
1948 Box::pin(async move {
1949 let params = GetAllMarginAssetsParams::builder().asset(name).build()?;
1950 rest.get_all_margin_assets(params).await
1951 })
1952 })
1953 .await
1954 .map_err(|e| classify_rest_query_error(&e, None))?;
1955 Ok(response
1956 .data()
1957 .await
1958 .map_err(response_decode_error)?
1959 .into_iter()
1960 .find(|listed| listed.asset_name.as_deref() == Some(name.as_str()))
1961 .and_then(|listed| listed.is_borrowable))
1962 }
1963
1964 async fn fetch_delisting_at(
1969 &self,
1970 instrument: &InstrumentNameExchange,
1971 pair: &MarginPair,
1972 ) -> Result<Option<DateTime<Utc>>, UnindexedClientError> {
1973 let response =
1974 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1975 Box::pin(async move {
1976 let params = GetDelistScheduleParams::builder().build()?;
1977 rest.get_delist_schedule(params).await
1978 })
1979 })
1980 .await
1981 .map_err(|e| classify_rest_query_error(&e, None))?;
1982 let schedule = response.data().await.map_err(response_decode_error)?;
1983
1984 let symbol = instrument.name().as_str();
1985 let (base, quote) = (pair.base.name().as_str(), pair.quote.name().as_str());
1986 let mut earliest: Option<DateTime<Utc>> = None;
1987 for entry in schedule {
1988 let scheduled = if self.config.is_isolated {
1989 entry
1990 .isolated_margin_symbols
1991 .iter()
1992 .flatten()
1993 .any(|listed| listed == symbol)
1994 } else {
1995 entry
1996 .cross_margin_assets
1997 .iter()
1998 .flatten()
1999 .any(|listed| listed == base || listed == quote)
2000 };
2001 if !scheduled {
2002 continue;
2003 }
2004 let delisting_at = entry
2006 .delist_time
2007 .and_then(DateTime::from_timestamp_millis)
2008 .ok_or_else(|| {
2009 UnindexedClientError::Internal(format!(
2010 "decoding Binance response: delist-schedule lists {symbol} with delistTime \
2011 {:?}",
2012 entry.delist_time
2013 ))
2014 })?;
2015 earliest = Some(earliest.map_or(delisting_at, |earliest| earliest.min(delisting_at)));
2016 }
2017 Ok(earliest)
2018 }
2019
2020 async fn fetch_next_hourly_interest_rate(
2024 &self,
2025 asset: &AssetNameExchange,
2026 ) -> Result<Option<Decimal>, UnindexedClientError> {
2027 let name = asset.name().to_string();
2028 let is_isolated = self.config.is_isolated;
2029 let response =
2030 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
2031 let name = name.clone();
2032 Box::pin(async move {
2033 let params = GetFutureHourlyInterestRateParams::builder(
2034 name,
2035 IsolatedFlag::from_flag(is_isolated),
2036 )
2037 .build()?;
2038 rest.get_future_hourly_interest_rate(params).await
2039 })
2040 })
2041 .await
2042 .map_err(|e| classify_rest_query_error(&e, None))?;
2043 let rate = response
2044 .data()
2045 .await
2046 .map_err(response_decode_error)?
2047 .into_iter()
2048 .find(|listed| listed.asset.as_deref() == Some(name.as_str()))
2049 .and_then(|listed| listed.next_hourly_interest_rate);
2050 optional_decimal("nextHourlyInterestRate", rate.as_deref())
2051 }
2052}
2053
2054fn optional_decimal(
2057 field: &str,
2058 value: Option<&str>,
2059) -> Result<Option<Decimal>, UnindexedClientError> {
2060 value
2061 .map(|value| {
2062 Decimal::from_str(value).map_err(|error| {
2063 UnindexedClientError::Internal(format!(
2064 "decoding Binance response: {field} {value:?}: {error}"
2065 ))
2066 })
2067 })
2068 .transpose()
2069}
2070
2071async fn recover_margin_fills(
2097 rest: &Arc<RestApi>,
2098 rate_limiter: &Arc<RateLimitTracker>,
2099 unrecovered: &mut UnrecoveredFills,
2100 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2101 dedup: &SharedDedupCache,
2102 known: &SharedKnownLiveOrders,
2103 is_isolated: bool,
2104) {
2105 use futures::StreamExt as _;
2106
2107 let due = unrecovered.due(tokio::time::Instant::now());
2108 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
2109 return;
2110 };
2111 info!(
2112 gaps = due.len(),
2113 oldest = %gap_time(oldest),
2114 is_isolated,
2115 "BinanceMargin recovering fills missed while disconnected"
2116 );
2117
2118 let mut settled = vec![false; due.len()];
2121 let started = AtomicUsize::new(0);
2122 let recovery = async {
2123 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
2124 let mut recovered = 0u32;
2125 let mut duplicates = 0u32;
2126
2127 let mut stream =
2128 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
2129 started.store(index + 1, Ordering::Relaxed);
2130 let rest = rest.clone();
2131 let rl = rate_limiter.clone();
2132 async move {
2133 let span = MyTradesFrom::Span {
2134 start: gap.start_ms,
2135 end: gap.end_ms,
2136 };
2137 let raw = match paginate_margin_my_trades(
2138 &rest,
2139 &rl,
2140 &inst,
2141 span,
2142 is_isolated,
2143 RequestKind::Essential,
2144 )
2145 .await
2146 {
2147 Ok(pages) => pages,
2148 Err(e) => return (index, Err(e)),
2149 };
2150 let totals = recovered_order_totals(
2154 ExchangeId::BinanceMargin,
2155 &inst,
2156 &raw,
2157 order_executions_deadline,
2158 |order_id| {
2159 paginate_margin_my_trades(
2160 &rest,
2161 &rl,
2162 &inst,
2163 MyTradesFrom::Order(order_id),
2164 is_isolated,
2165 RequestKind::Essential,
2166 )
2167 },
2168 )
2169 .await;
2170 let cids = recovered_order_cids(
2173 ExchangeId::BinanceMargin,
2174 &inst,
2175 &raw,
2176 order_executions_deadline,
2177 |order_id| fetch_margin_order_cid(&rest, &rl, &inst, order_id, is_isolated),
2178 )
2179 .await;
2180 let trades = raw
2181 .iter()
2182 .filter_map(|t| {
2183 let mut trade = convert_margin_trade(t, &inst)?;
2184 trade.order_filled_quantity =
2185 t.id.and_then(|id| totals.get(&id).copied());
2186 trade.client_order_id =
2187 t.order_id.and_then(|id| cids.get(&id).cloned());
2188 Some(trade)
2189 })
2190 .collect::<Vec<_>>();
2191 (index, Ok(trades))
2192 }
2193 }))
2194 .buffer_unordered(8);
2195 while let Some((index, result)) = stream.next().await {
2196 let (inst, gap) = &due[index];
2197 settled[index] = true;
2198 let trades = match result {
2199 Ok(trades) => trades,
2200 Err(e) => {
2201 gap_failed(
2202 unrecovered,
2203 tx,
2204 ExchangeId::BinanceMargin,
2205 inst,
2206 gap,
2207 FillRecoveryFailure::Request(e.to_string()),
2208 );
2209 continue;
2210 }
2211 };
2212 for trade in trades {
2213 let event = UnindexedAccountEvent::new(
2214 ExchangeId::BinanceMargin,
2215 AccountEventKind::Trade(trade),
2216 );
2217 if let Some(key) = dedup_key_from_event(&event)
2218 && is_duplicate(dedup, key)
2219 {
2220 duplicates += 1;
2221 continue;
2222 }
2223 let sent = {
2226 let mut known = known.lock();
2227 known.observe(&event);
2228 tx.send(event)
2229 };
2230 if sent.is_err() {
2231 debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2232 return;
2233 }
2234 recovered += 1;
2235 }
2236 unrecovered.recovered(inst, gap);
2237 }
2238 info!(
2239 recovered,
2240 duplicates, is_isolated, "BinanceMargin fill recovery complete"
2241 );
2242 };
2243 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2247 .await
2248 .is_err()
2249 {
2250 let started = started.load(Ordering::Relaxed);
2251 for ((inst, gap), _) in due[..started]
2252 .iter()
2253 .zip(&settled)
2254 .filter(|(_, settled)| !**settled)
2255 {
2256 gap_failed(
2257 unrecovered,
2258 tx,
2259 ExchangeId::BinanceMargin,
2260 inst,
2261 gap,
2262 FillRecoveryFailure::TimedOut {
2263 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2264 },
2265 );
2266 }
2267 }
2268}
2269
2270fn open_reconnect_recovery(
2275 unrecovered: &mut UnrecoveredFills,
2276 unchecked: &mut UncheckedOrders,
2277 known: &SharedKnownLiveOrders,
2278 instruments: &[InstrumentNameExchange],
2279 disconnected: DateTime<Utc>,
2280) {
2281 unrecovered.open(instruments, disconnected, Utc::now());
2282 let held = known.lock().instruments_among(instruments);
2283 unchecked.open(held);
2284}
2285
2286fn build_base_quote_map(
2294 assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
2295) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
2296 let mut map = HashMap::with_capacity(assets.len());
2297 for entry in assets {
2298 let (Some(symbol), Some(base), Some(quote)) = (
2299 entry.symbol.as_deref(),
2300 entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
2301 entry
2302 .quote_asset
2303 .as_deref()
2304 .and_then(|q| q.asset.as_deref()),
2305 ) else {
2306 warn!(
2307 "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
2308 skipping base/quote map entry"
2309 );
2310 continue;
2311 };
2312 map.insert(
2313 InstrumentNameExchange::new(symbol),
2314 (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
2315 );
2316 }
2317 map
2318}
2319
2320async fn fetch_margin_open_orders_for_instrument(
2327 rest: Arc<RestApi>,
2328 rate_limiter: Arc<RateLimitTracker>,
2329 known: SharedKnownLiveOrders,
2330 instrument: InstrumentNameExchange,
2331 is_isolated: bool,
2332 kind: RequestKind,
2333) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
2334 let symbol_str = instrument.name().to_string();
2336 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2337 let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
2338 let sym = symbol_str.clone();
2339 let isolated = isolated.clone();
2340 Box::pin(async move {
2341 let params = QueryMarginAccountsOpenOrdersParams::builder()
2342 .symbol(sym)
2343 .is_isolated(isolated)
2344 .build()?;
2345 rest.query_margin_accounts_open_orders(params).await
2346 })
2347 })
2348 .await
2349 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
2350
2351 let orders_data = response.data().await.map_err(response_decode_error)?;
2352
2353 let listing = convert_open_order_listing(
2354 &orders_data,
2355 ExchangeId::BinanceMargin,
2356 &instrument,
2357 &|cid| known.lock().kind_of(cid),
2358 );
2359
2360 Ok((instrument, listing))
2361}
2362
2363async fn fetch_margin_all_open_orders(
2370 rest: Arc<RestApi>,
2371 rate_limiter: Arc<RateLimitTracker>,
2372 known: SharedKnownLiveOrders,
2373 is_isolated: bool,
2374) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
2375 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2376 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
2377 let isolated = isolated.clone();
2378 Box::pin(async move {
2379 let params = QueryMarginAccountsOpenOrdersParams::builder()
2380 .is_isolated(isolated)
2381 .build()?;
2382 rest.query_margin_accounts_open_orders(params).await
2383 })
2384 })
2385 .await
2386 .map_err(|e| classify_rest_query_error(&e, None))?;
2387
2388 let orders_data = response.data().await.map_err(response_decode_error)?;
2389
2390 let orders = orders_data
2391 .into_iter()
2392 .filter_map(|o| {
2393 convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin, &|cid| {
2394 known.lock().kind_of(cid)
2395 })
2396 })
2397 .collect();
2398
2399 Ok(orders)
2400}
2401
2402async fn fetch_margin_order_lookup(
2409 rest: Arc<RestApi>,
2410 rate_limiter: Arc<RateLimitTracker>,
2411 key: UnindexedOrderKey,
2412 is_isolated: bool,
2413 kind: RequestKind,
2414 recorded_kind: Option<OrderKind>,
2415) -> Result<OrderLookup, UnindexedClientError> {
2416 let symbol = key.instrument.name().to_string();
2418 let cid = key.cid.0.to_string();
2419 let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
2420 let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
2421 let (symbol, cid, isolated) = (symbol.clone(), cid.clone(), isolated.clone());
2422 Box::pin(async move {
2423 let params = QueryMarginAccountsOrderParams::builder(symbol)
2424 .orig_client_order_id(cid)
2425 .is_isolated(isolated)
2426 .build()?;
2427 rest.query_margin_accounts_order(params).await
2428 })
2429 })
2430 .await
2431 {
2432 Ok(response) => response,
2433 Err(e) if is_unknown_order(&e) => {
2434 debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceMargin does not know this order");
2435 return Ok(OrderLookup::Unknown);
2436 }
2437 Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
2438 };
2439
2440 let row = response.data().await.map_err(response_decode_error)?;
2441
2442 Ok(
2443 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, recorded_kind)
2444 .map_or(OrderLookup::NotEnded, |order| {
2445 OrderLookup::Ended(Box::new(order))
2446 }),
2447 )
2448}
2449
2450async fn fetch_margin_order_cid(
2455 rest: &Arc<RestApi>,
2456 rate_limiter: &RateLimitTracker,
2457 instrument: &InstrumentNameExchange,
2458 order_id: i64,
2459 is_isolated: bool,
2460) -> Result<Option<ClientOrderId>, UnindexedClientError> {
2461 let symbol = instrument.name().to_string();
2462 let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
2463 let response = match rest_call_with_retry(rest, rate_limiter, RequestKind::Essential, |rest| {
2464 let (symbol, isolated) = (symbol.clone(), isolated.clone());
2465 Box::pin(async move {
2466 let params = QueryMarginAccountsOrderParams::builder(symbol)
2467 .order_id(order_id)
2468 .is_isolated(isolated)
2469 .build()?;
2470 rest.query_margin_accounts_order(params).await
2471 })
2472 })
2473 .await
2474 {
2475 Ok(response) => response,
2476 Err(e) if is_unknown_order(&e) => {
2477 debug!(%instrument, order_id, error = %e, "BinanceMargin does not know this order");
2478 return Ok(None);
2479 }
2480 Err(e) => return Err(classify_rest_query_error(&e, Some(instrument))),
2481 };
2482 let row = response.data().await.map_err(response_decode_error)?;
2483 Ok(row
2484 .client_order_id
2485 .filter(|cid| !cid.is_empty())
2486 .map(ClientOrderId::new))
2487}
2488
2489async fn listed_margin_open_cids(
2492 rest: Arc<RestApi>,
2493 rate_limiter: Arc<RateLimitTracker>,
2494 known: SharedKnownLiveOrders,
2495 instruments: Vec<InstrumentNameExchange>,
2496 is_isolated: bool,
2497) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
2498 let mut listed = FnvHashSet::default();
2499 for instrument in instruments {
2500 let (_, listing) = fetch_margin_open_orders_for_instrument(
2501 rest.clone(),
2502 rate_limiter.clone(),
2503 known.clone(),
2504 instrument,
2505 is_isolated,
2506 RequestKind::Essential,
2507 )
2508 .await?;
2509 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
2510 }
2511 Ok(listed)
2512}
2513
2514async fn recover_margin_ended_orders(
2517 rest: &Arc<RestApi>,
2518 rate_limiter: &Arc<RateLimitTracker>,
2519 known: &SharedKnownLiveOrders,
2520 unchecked: &mut UncheckedOrders,
2521 unrecovered: &UnrecoveredFills,
2522 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2523 is_isolated: bool,
2524) {
2525 recover_ended_orders(
2526 ExchangeId::BinanceMargin,
2527 known,
2528 unchecked,
2529 unrecovered,
2530 tx,
2531 OpenListing::PerInstrument,
2532 |instruments| {
2533 listed_margin_open_cids(
2534 rest.clone(),
2535 rate_limiter.clone(),
2536 known.clone(),
2537 instruments,
2538 is_isolated,
2539 )
2540 },
2541 |key| {
2542 let recorded_kind = known.lock().kind_of(&key.cid);
2544 fetch_margin_order_lookup(
2545 rest.clone(),
2546 rate_limiter.clone(),
2547 key,
2548 is_isolated,
2549 RequestKind::Essential,
2550 recorded_kind,
2551 )
2552 },
2553 )
2554 .await;
2555}
2556
2557const MARGIN_MY_TRADES_WINDOW_MS: i64 = 23 * 60 * 60 * 1000;
2563
2564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2567enum MarginTradesQuery {
2568 Window { start: i64, end: i64 },
2570 FromId(i64),
2572 Order(i64),
2574 OrderFromId { order_id: i64, from_id: i64 },
2576}
2577
2578impl MarginTradesQuery {
2579 fn first(from: MyTradesFrom, now_ms: i64) -> Option<Self> {
2583 match from {
2584 MyTradesFrom::Span { start, .. } => {
2585 (start <= now_ms).then(|| Self::window(start, now_ms))
2586 }
2587 MyTradesFrom::Order(order_id) => Some(Self::Order(order_id)),
2588 }
2589 }
2590
2591 fn window(start: i64, now_ms: i64) -> Self {
2593 Self::Window {
2594 start,
2595 end: start.saturating_add(MARGIN_MY_TRADES_WINDOW_MS).min(now_ms),
2596 }
2597 }
2598
2599 fn next(self, page_len: usize, last_id: Option<i64>, now_ms: i64) -> Option<Self> {
2602 let full = page_len >= BINANCE_MAX_TRADES;
2603 let after_last = || last_id?.checked_add(1);
2604 match self {
2605 Self::Window { end, .. } if page_len == 0 => {
2610 (end < now_ms).then(|| Self::window(end, now_ms))
2611 }
2612 Self::Window { end, .. } if end >= now_ms && !full => None,
2614 Self::Window { .. } => after_last().map(Self::FromId),
2617 Self::FromId(_) if full => after_last().map(Self::FromId),
2618 Self::Order(order_id) | Self::OrderFromId { order_id, .. } if full => {
2619 after_last().map(|from_id| Self::OrderFromId { order_id, from_id })
2620 }
2621 Self::FromId(_) | Self::Order(_) | Self::OrderFromId { .. } => None,
2622 }
2623 }
2624}
2625
2626async fn paginate_margin_my_trades(
2648 rest: &Arc<RestApi>,
2649 rate_limiter: &Arc<RateLimitTracker>,
2650 instrument: &InstrumentNameExchange,
2651 from: MyTradesFrom,
2652 is_isolated: bool,
2653 kind: RequestKind,
2654) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
2655 let symbol_str = instrument.name().to_string();
2657 let isolated = QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(is_isolated);
2658 #[allow(clippy::cast_possible_wrap)]
2663 let limit = BINANCE_MAX_TRADES as i64;
2664 let now_ms = match from {
2667 MyTradesFrom::Span { end, .. } => end.min(Utc::now().timestamp_millis()),
2668 MyTradesFrom::Order(_) => Utc::now().timestamp_millis(),
2669 };
2670 let mut all_pages = Vec::new();
2671 let mut requests = 0u32;
2672 let mut next = MarginTradesQuery::first(from, now_ms);
2673 while let Some(query) = next {
2674 requests += 1;
2675 let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
2676 let sym = symbol_str.clone();
2677 let isolated = isolated.clone();
2678 Box::pin(async move {
2679 let builder = QueryMarginAccountsTradeListParams::builder(sym)
2680 .is_isolated(isolated)
2681 .limit(limit);
2682 let params = match query {
2683 MarginTradesQuery::Window { start, end } => {
2684 builder.start_time(start).end_time(end)
2685 }
2686 MarginTradesQuery::FromId(id) => builder.from_id(id),
2687 MarginTradesQuery::Order(order_id) => builder.order_id(order_id),
2688 MarginTradesQuery::OrderFromId { order_id, from_id } => {
2689 builder.order_id(order_id).from_id(from_id)
2690 }
2691 }
2692 .build()?;
2693 rest.query_margin_accounts_trade_list(params).await
2694 })
2695 })
2696 .await
2697 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
2698
2699 let mut page = response.data().await.map_err(response_decode_error)?;
2700
2701 let page_len = page.len();
2702 let last_id = page.last().and_then(|t| t.id);
2703 let past_end = match from {
2705 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
2706 MyTradesFrom::Order(_) => false,
2707 };
2708 all_pages.extend(page);
2709 if past_end {
2710 break;
2711 }
2712
2713 if page_len > 0 && last_id.is_none() {
2714 warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, cannot page past it");
2715 break;
2716 }
2717 next = query.next(page_len, last_id, now_ms);
2718 }
2719 debug!(
2720 %instrument,
2721 requests,
2722 trades = all_pages.len(),
2723 "BinanceMargin paginate_my_trades: walk complete"
2724 );
2725 Ok(all_pages)
2726}
2727
2728fn convert_margin_balance_entry(
2738 b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
2739 now: DateTime<Utc>,
2740) -> Option<AssetBalance<AssetNameExchange>> {
2741 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
2742 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2743 Some(v) => v,
2744 None => {
2745 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
2746 return None;
2747 }
2748 };
2749 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2750 Some(v) => v,
2751 None => {
2752 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
2753 return None;
2754 }
2755 };
2756 let borrowed = b
2761 .borrowed
2762 .as_deref()
2763 .and_then(|s| Decimal::from_str(s).ok())
2764 .unwrap_or(Decimal::ZERO);
2765 let interest = b
2766 .interest
2767 .as_deref()
2768 .and_then(|s| Decimal::from_str(s).ok())
2769 .unwrap_or(Decimal::ZERO);
2770 Some(AssetBalance::new(
2771 asset_name,
2772 Balance::new_margin(free + locked, free, borrowed, interest),
2773 now,
2774 ))
2775}
2776
2777fn filter_and_convert_margin_balances(
2780 user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
2781 assets: &[AssetNameExchange],
2782) -> Vec<AssetBalance<AssetNameExchange>> {
2783 let now = Utc::now();
2784 if assets.is_empty() {
2786 return user_assets
2787 .into_iter()
2788 .filter_map(|b| convert_margin_balance_entry(b, now))
2789 .collect();
2790 }
2791 if assets.len() <= 16 {
2793 return user_assets
2794 .into_iter()
2795 .filter_map(|b| {
2796 let asset_name_str = b.asset.as_deref()?;
2797 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
2798 return None;
2799 }
2800 convert_margin_balance_entry(b, now)
2801 })
2802 .collect();
2803 }
2804 use std::collections::HashSet;
2805 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
2806 user_assets
2807 .into_iter()
2808 .filter_map(|b| {
2809 let asset_name_str = b.asset.as_deref()?;
2810 if !asset_set.contains(asset_name_str) {
2811 return None;
2812 }
2813 convert_margin_balance_entry(b, now)
2814 })
2815 .collect()
2816}
2817
2818fn active_order_snapshot(
2825 o: Order<ExchangeId, InstrumentNameExchange, Open>,
2826) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
2827{
2828 Order {
2829 key: o.key,
2830 side: o.side,
2831 price: o.price,
2832 quantity: o.quantity,
2833 kind: o.kind,
2834 time_in_force: o.time_in_force,
2835 state: OrderState::active(o.state),
2836 }
2837}
2838
2839fn convert_isolated_asset_balance(
2849 asset: Option<&str>,
2850 free: Option<&str>,
2851 locked: Option<&str>,
2852 borrowed: Option<&str>,
2853 interest: Option<&str>,
2854 now: DateTime<Utc>,
2855) -> Option<AssetBalance<AssetNameExchange>> {
2856 let asset_name = AssetNameExchange::new(asset?);
2857 let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
2858 Some(v) => v,
2859 None => {
2860 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
2861 return None;
2862 }
2863 };
2864 let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
2865 Some(v) => v,
2866 None => {
2867 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
2868 return None;
2869 }
2870 };
2871 let borrowed = borrowed
2872 .and_then(|s| Decimal::from_str(s).ok())
2873 .unwrap_or(Decimal::ZERO);
2874 let interest = interest
2875 .and_then(|s| Decimal::from_str(s).ok())
2876 .unwrap_or(Decimal::ZERO);
2877 Some(AssetBalance::new(
2878 asset_name,
2879 Balance::new_margin(free + locked, free, borrowed, interest),
2880 now,
2881 ))
2882}
2883
2884fn convert_isolated_margin_assets(
2892 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
2893) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
2894 let now = Utc::now();
2897 let mut map = HashMap::with_capacity(assets.len());
2898 for entry in assets {
2899 let Some(symbol) = entry.symbol.as_deref() else {
2900 warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
2901 continue;
2902 };
2903 let instrument = InstrumentNameExchange::new(symbol);
2904
2905 let Some(base_raw) = entry.base_asset.as_deref() else {
2906 warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
2907 continue;
2908 };
2909 let Some(quote_raw) = entry.quote_asset.as_deref() else {
2910 warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
2911 continue;
2912 };
2913
2914 let Some(base) = convert_isolated_asset_balance(
2915 base_raw.asset.as_deref(),
2916 base_raw.free.as_deref(),
2917 base_raw.locked.as_deref(),
2918 base_raw.borrowed.as_deref(),
2919 base_raw.interest.as_deref(),
2920 now,
2921 ) else {
2922 warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
2923 continue;
2924 };
2925 let Some(quote) = convert_isolated_asset_balance(
2926 quote_raw.asset.as_deref(),
2927 quote_raw.free.as_deref(),
2928 quote_raw.locked.as_deref(),
2929 quote_raw.borrowed.as_deref(),
2930 quote_raw.interest.as_deref(),
2931 now,
2932 ) else {
2933 warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
2934 continue;
2935 };
2936
2937 let risk = IsolatedMarginRisk {
2938 margin_level: entry
2939 .margin_level
2940 .as_deref()
2941 .and_then(|s| Decimal::from_str(s).ok()),
2942 margin_ratio: entry
2943 .margin_ratio
2944 .as_deref()
2945 .and_then(|s| Decimal::from_str(s).ok()),
2946 liquidation_price: entry
2947 .liquidate_price
2948 .as_deref()
2949 .and_then(|s| Decimal::from_str(s).ok()),
2950 };
2951
2952 map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
2953 }
2954 map
2955}
2956
2957fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
2960 symbols
2961 .chunks(5)
2962 .map(|chunk| {
2963 chunk
2964 .iter()
2965 .map(|s| s.name().as_str())
2966 .collect::<Vec<_>>()
2967 .join(",")
2968 })
2969 .collect()
2970}
2971
2972async fn fetch_isolated_margin_account_info(
2978 rest: Arc<RestApi>,
2979 rate_limiter: Arc<RateLimitTracker>,
2980 symbols: Vec<InstrumentNameExchange>,
2981) -> Result<Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>, UnindexedClientError> {
2982 use futures::{StreamExt as _, TryStreamExt as _};
2983
2984 let chunks = chunk_symbols(&symbols);
2985
2986 futures::stream::iter(chunks.into_iter().map(|symbols_param| {
2990 let rest = rest.clone();
2991 let rate_limiter = rate_limiter.clone();
2992 async move {
2993 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
2994 let symbols_param = symbols_param.clone();
2995 Box::pin(async move {
2996 let params = QueryIsolatedMarginAccountInfoParams::builder()
2997 .symbols(symbols_param)
2998 .build()?;
2999 rest.query_isolated_margin_account_info(params).await
3000 })
3001 })
3002 .await
3003 .map_err(|e| classify_rest_query_error(&e, None))?;
3004
3005 let info = response.data().await.map_err(response_decode_error)?;
3006 Ok::<_, UnindexedClientError>(info.assets.unwrap_or_default())
3007 }
3008 }))
3009 .buffer_unordered(8)
3010 .try_fold(
3011 Vec::with_capacity(symbols.len()),
3012 |mut acc, assets| async move {
3013 acc.extend(assets);
3014 Ok(acc)
3015 },
3016 )
3017 .await
3018}
3019
3020fn convert_margin_trade(
3027 t: &QueryMarginAccountsTradeListResponseInner,
3028 instrument: &InstrumentNameExchange,
3029) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3030 let trade_id_raw = match t.id {
3031 Some(id) => id,
3032 None => {
3033 warn!(%instrument, "BinanceMargin trade missing id");
3034 return None;
3035 }
3036 };
3037 let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
3038 let order_id = match t.order_id {
3039 Some(id) => OrderId(format_smolstr!("{id}")),
3040 None => {
3041 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
3042 return None;
3043 }
3044 };
3045 let side = match t.is_buyer {
3046 Some(true) => Side::Buy,
3047 Some(false) => Side::Sell,
3048 None => {
3049 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
3050 return None;
3051 }
3052 };
3053 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3054 Some(v) => v,
3055 None => {
3056 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
3057 return None;
3058 }
3059 };
3060 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3061 Some(v) => v,
3062 None => {
3063 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
3064 return None;
3065 }
3066 };
3067 let commission = t
3068 .commission
3069 .as_deref()
3070 .and_then(|s| Decimal::from_str(s).ok())
3071 .unwrap_or(Decimal::ZERO);
3072 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
3073 Some(ts) => ts,
3074 None => {
3075 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
3076 Utc::now()
3077 }
3078 };
3079
3080 let fee_asset = t
3085 .commission_asset
3086 .as_deref()
3087 .map(AssetNameExchange::from)
3088 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
3089
3090 Some(Trade::new(
3091 trade_id,
3092 order_id,
3093 instrument.clone(),
3094 StrategyId::unknown(), time_exchange,
3096 side,
3097 price,
3098 quantity,
3099 None,
3101 AssetFees::new(fee_asset, commission, None),
3102 ))
3103}
3104
3105#[derive(Debug)]
3111enum BuildOrderError {
3112 Unsupported,
3114 Build(String),
3116 Trailing(TrailingOrderError),
3118}
3119
3120trait IsolatedFlag {
3131 fn from_flag(is_isolated: bool) -> Self;
3133}
3134
3135macro_rules! impl_isolated_flag {
3140 ($($t:ty),* $(,)?) => {
3141 $(
3142 impl IsolatedFlag for $t {
3143 fn from_flag(is_isolated: bool) -> Self {
3144 if is_isolated { Self::True } else { Self::False }
3145 }
3146 }
3147 )*
3148 };
3149}
3150
3151impl_isolated_flag!(
3152 MarginAccountNewOrderIsIsolatedEnum,
3153 MarginAccountCancelOrderIsIsolatedEnum,
3154 QueryMarginAccountsOpenOrdersIsIsolatedEnum,
3155 QueryMarginAccountsOrderIsIsolatedEnum,
3156 QueryMarginAccountsTradeListIsIsolatedEnum,
3157 GetFutureHourlyInterestRateIsIsolatedEnum,
3158);
3159
3160fn build_cancel_order_params(
3168 symbol: String,
3169 id: Option<&OrderId>,
3170 cid: &ClientOrderId,
3171 is_isolated: bool,
3172) -> Result<MarginAccountCancelOrderParams, String> {
3173 let mut builder = MarginAccountCancelOrderParams::builder(symbol).is_isolated(
3174 MarginAccountCancelOrderIsIsolatedEnum::from_flag(is_isolated),
3175 );
3176
3177 match id {
3178 Some(order_id) => match order_id.0.parse::<i64>() {
3179 Ok(id) => builder = builder.order_id(id),
3180 Err(_) => {
3181 error!(
3183 order_id = %order_id.0,
3184 "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
3185 );
3186 builder = builder.orig_client_order_id(cid.0.to_string());
3187 }
3188 },
3189 None => builder = builder.orig_client_order_id(cid.0.to_string()),
3190 }
3191
3192 builder.build().map_err(|e| e.to_string())
3193}
3194
3195#[allow(clippy::too_many_arguments)] fn build_new_order_params(
3204 symbol: String,
3205 side: Side,
3206 price: Option<Decimal>,
3207 quantity: Decimal,
3208 kind: OrderKind,
3209 time_in_force: TimeInForce,
3210 new_client_order_id: String,
3211 margin_effect: MarginEffect,
3212 auto_repay_at_cancel: bool,
3213 is_isolated: bool,
3214) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3215 let binance_side = match side {
3216 Side::Buy => MarginAccountNewOrderSideEnum::Buy,
3217 Side::Sell => MarginAccountNewOrderSideEnum::Sell,
3218 };
3219
3220 let (binance_type, binance_tif) =
3221 convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
3222
3223 let mut builder = MarginAccountNewOrderParams::builder(symbol, binance_side, binance_type)
3225 .quantity(quantity)
3226 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::from_flag(is_isolated))
3227 .side_effect_type(binance_side_effect(margin_effect))
3228 .new_client_order_id(new_client_order_id)
3229 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
3230
3231 if margin_effect == MarginEffect::Borrow {
3234 builder = builder.auto_repay_at_cancel(auto_repay_at_cancel);
3235 }
3236
3237 if let Some(tif) = binance_tif {
3238 builder = builder.time_in_force(tif);
3239 }
3240
3241 if let Some(trailing) = trailing_params(&kind, price).map_err(BuildOrderError::Trailing)? {
3244 builder = builder.trailing_delta(i64::from(trailing.trailing_delta));
3245 if let Some(limit) = trailing.price {
3246 builder = builder.price(limit);
3247 }
3248 if let Some(activation_price) = trailing.stop_price {
3249 builder = builder.stop_price(activation_price);
3250 }
3251 }
3252
3253 match kind {
3257 OrderKind::Limit => {
3258 builder = builder.price(price);
3259 }
3260 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
3261 builder = builder.stop_price(trigger_price);
3262 }
3263 OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
3264 builder = builder.price(price).stop_price(trigger_price);
3265 }
3266 OrderKind::TrailingStop { .. }
3269 | OrderKind::TrailingStopFixedLimit { .. }
3270 | OrderKind::Market
3271 | OrderKind::TrailingStopLimit { .. }
3272 | OrderKind::ConditionalUnresolved { .. } => {}
3273 }
3274
3275 builder
3276 .build()
3277 .map_err(|e| BuildOrderError::Build(e.to_string()))
3278}
3279
3280const ORDER_PATH: &str = "/sapi/v1/margin/order";
3283
3284fn new_order_query(params: MarginAccountNewOrderParams) -> BTreeMap<String, serde_json::Value> {
3293 use serde_json::json;
3294
3295 let MarginAccountNewOrderParams {
3296 symbol,
3297 side,
3298 r#type,
3299 is_isolated,
3300 quantity,
3301 quote_order_qty,
3302 price,
3303 stop_price,
3304 new_client_order_id,
3305 iceberg_qty,
3306 new_order_resp_type,
3307 side_effect_type,
3308 time_in_force,
3309 self_trade_prevention_mode,
3310 trailing_delta,
3311 auto_repay_at_cancel,
3312 recv_window,
3313 } = params;
3314
3315 let mut query = BTreeMap::new();
3316 let mut insert = |key: &str, value: Option<serde_json::Value>| {
3317 if let Some(value) = value {
3318 query.insert(key.to_string(), value);
3319 }
3320 };
3321 insert("symbol", Some(json!(symbol)));
3322 insert("isIsolated", is_isolated.map(|v| json!(v)));
3323 insert("side", Some(json!(side)));
3324 insert("type", Some(json!(r#type)));
3325 insert("quantity", quantity.map(|v| json!(v)));
3326 insert("quoteOrderQty", quote_order_qty.map(|v| json!(v)));
3327 insert("price", price.map(|v| json!(v)));
3328 insert("stopPrice", stop_price.map(|v| json!(v)));
3329 insert("newClientOrderId", new_client_order_id.map(|v| json!(v)));
3330 insert("icebergQty", iceberg_qty.map(|v| json!(v)));
3331 insert("newOrderRespType", new_order_resp_type.map(|v| json!(v)));
3332 insert("sideEffectType", side_effect_type.map(|v| json!(v)));
3333 insert("timeInForce", time_in_force.map(|v| json!(v)));
3334 insert(
3335 "selfTradePreventionMode",
3336 self_trade_prevention_mode.map(|v| json!(v)),
3337 );
3338 insert("trailingDelta", trailing_delta.map(|v| json!(v)));
3339 insert("autoRepayAtCancel", auto_repay_at_cancel.map(|v| json!(v)));
3340 insert("recvWindow", recv_window.map(|v| json!(v)));
3341 query
3342}
3343
3344fn cancel_order_query(
3352 params: MarginAccountCancelOrderParams,
3353) -> BTreeMap<String, serde_json::Value> {
3354 use serde_json::json;
3355
3356 let MarginAccountCancelOrderParams {
3357 symbol,
3358 is_isolated,
3359 order_id,
3360 orig_client_order_id,
3361 new_client_order_id,
3362 recv_window,
3363 } = params;
3364
3365 let mut query = BTreeMap::new();
3366 let mut insert = |key: &str, value: Option<serde_json::Value>| {
3367 if let Some(value) = value {
3368 query.insert(key.to_string(), value);
3369 }
3370 };
3371 insert("symbol", Some(json!(symbol)));
3372 insert("isIsolated", is_isolated.map(|v| json!(v)));
3373 insert("orderId", order_id.map(|v| json!(v)));
3374 insert("origClientOrderId", orig_client_order_id.map(|v| json!(v)));
3375 insert("newClientOrderId", new_client_order_id.map(|v| json!(v)));
3376 insert("recvWindow", recv_window.map(|v| json!(v)));
3377 query
3378}
3379
3380#[derive(Debug, Deserialize)]
3384#[serde(rename_all = "camelCase")]
3385struct CancelOrderAnswer {
3386 order_id: Option<WireInt>,
3389 transact_time: Option<i64>,
3390 executed_qty: Option<String>,
3391}
3392
3393#[derive(Debug, Deserialize)]
3398#[serde(rename_all = "camelCase")]
3399struct NewOrderAnswer {
3400 order_id: Option<i64>,
3401 transact_time: Option<i64>,
3402 status: Option<String>,
3404 executed_qty: Option<String>,
3406 cummulative_quote_qty: Option<String>,
3407 margin_buy_borrow_amount: Option<Decimal>,
3409 margin_buy_borrow_asset: Option<AssetNameExchange>,
3411}
3412
3413fn convert_order_kind_tif_margin(
3424 kind: OrderKind,
3425 tif: TimeInForce,
3426) -> Option<(
3427 MarginAccountNewOrderTypeEnum,
3428 Option<MarginAccountNewOrderTimeInForceEnum>,
3429)> {
3430 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
3431 let margin_type = match binance_type {
3432 BinanceOrderType::Market => MarginAccountNewOrderTypeEnum::Market,
3433 BinanceOrderType::Limit => MarginAccountNewOrderTypeEnum::Limit,
3434 BinanceOrderType::LimitMaker => MarginAccountNewOrderTypeEnum::LimitMaker,
3435 BinanceOrderType::StopLoss => MarginAccountNewOrderTypeEnum::StopLoss,
3436 BinanceOrderType::StopLossLimit => MarginAccountNewOrderTypeEnum::StopLossLimit,
3437 BinanceOrderType::TakeProfit => MarginAccountNewOrderTypeEnum::TakeProfit,
3438 BinanceOrderType::TakeProfitLimit => MarginAccountNewOrderTypeEnum::TakeProfitLimit,
3439 };
3440 let margin_tif = binance_tif.map(|t| match t {
3441 BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
3442 BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
3443 BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
3444 });
3445 Some((margin_type, margin_tif))
3446}
3447
3448#[cfg(test)]
3449#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3451 use super::*;
3452 use crate::client::binance::shared::convert_open_order;
3453 use crate::order::state::ActiveOrderState;
3454 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOpenOrdersResponseInner;
3455
3456 #[test]
3457 fn margin_effect_default_is_no_side_effect() {
3458 assert_eq!(MarginEffect::default(), MarginEffect::NoSideEffect);
3459 }
3460
3461 #[test]
3462 fn margin_effect_wire_values() {
3463 for (effect, wire) in [
3464 (MarginEffect::NoSideEffect, "NO_SIDE_EFFECT"),
3465 (MarginEffect::Borrow, "MARGIN_BUY"),
3466 (MarginEffect::Repay, "AUTO_REPAY"),
3467 ] {
3468 assert_eq!(binance_side_effect(effect).as_str(), wire);
3469 }
3470 }
3471
3472 #[test]
3473 fn config_debug_redacts_secrets() {
3474 let config = BinanceMarginConfig::new(
3475 "my_api_key".to_string(),
3476 "my_secret_key".to_string(),
3477 false,
3478 false,
3479 Vec::new(),
3480 MarginEffect::default(),
3481 true,
3482 );
3483 let debug = format!("{config:?}");
3484 assert!(!debug.contains("my_api_key"));
3485 assert!(!debug.contains("my_secret_key"));
3486 assert!(debug.contains("***"));
3487 }
3488
3489 #[test]
3490 fn cross_margin_uses_common_case_defaults() {
3491 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
3492 assert!(!config.testnet);
3493 assert!(!config.is_isolated);
3494 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
3495 assert!(config.auto_repay_at_cancel);
3496 assert_eq!(config.api_key(), "k");
3497 }
3498
3499 #[test]
3500 fn config_deserializes_with_defaults() {
3501 let config: BinanceMarginConfig =
3504 serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
3505 .expect("deserialize");
3506 assert!(!config.is_isolated);
3507 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
3508 assert!(config.auto_repay_at_cancel);
3509 }
3510
3511 #[test]
3512 fn config_deserializes_explicit_margin_effect() {
3513 let config: BinanceMarginConfig = serde_json::from_str(
3514 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"margin_effect":"borrow","auto_repay_at_cancel":false}"#,
3515 )
3516 .expect("deserialize");
3517 assert!(config.is_isolated);
3518 assert_eq!(config.margin_effect, MarginEffect::Borrow);
3519 assert!(!config.auto_repay_at_cancel);
3520 }
3521
3522 #[test]
3523 fn config_refuses_the_retired_combined_effect() {
3524 let parsed = serde_json::from_str::<BinanceMarginConfig>(
3526 r#"{"api_key":"k","secret_key":"s","testnet":false,"margin_effect":"auto_borrow_repay"}"#,
3527 );
3528 assert!(parsed.is_err());
3529 }
3530
3531 #[test]
3532 fn isolated_ctor_sets_symbols_and_flag() {
3533 let symbols = vec![
3534 InstrumentNameExchange::new("BTCUSDT"),
3535 InstrumentNameExchange::new("ETHUSDT"),
3536 ];
3537 let config =
3538 BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
3539 assert!(config.is_isolated);
3540 assert_eq!(config.isolated_symbols, symbols);
3541 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
3542 }
3543
3544 #[test]
3545 fn isolated_config_with_symbols_constructs() {
3546 let config = BinanceMarginConfig::isolated(
3548 "k".to_string(),
3549 "s".to_string(),
3550 vec![InstrumentNameExchange::new("BTCUSDT")],
3551 );
3552 let _client = BinanceMargin::new(config); }
3554
3555 #[test]
3556 #[should_panic(expected = "non-empty isolated_symbols")]
3557 fn isolated_empty_symbols_panics_at_new() {
3558 let config = BinanceMarginConfig::new(
3560 "k".to_string(),
3561 "s".to_string(),
3562 false,
3563 true,
3564 Vec::new(),
3565 MarginEffect::default(),
3566 true,
3567 );
3568 let _ = BinanceMargin::new(config);
3569 }
3570
3571 #[test]
3572 #[should_panic(expected = "non-empty isolated_symbols")]
3573 fn isolated_empty_symbols_panics_via_deserialize_path() {
3574 let config: BinanceMarginConfig = serde_json::from_str(
3577 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
3578 )
3579 .expect("deserialize");
3580 assert!(config.isolated_symbols.is_empty());
3581 let _ = BinanceMargin::new(config);
3582 }
3583
3584 #[test]
3585 fn isolated_flag_maps_mode() {
3586 assert_eq!(
3589 MarginAccountNewOrderIsIsolatedEnum::from_flag(true).as_str(),
3590 "TRUE"
3591 );
3592 assert_eq!(
3593 MarginAccountNewOrderIsIsolatedEnum::from_flag(false).as_str(),
3594 "FALSE"
3595 );
3596 assert_eq!(
3597 MarginAccountCancelOrderIsIsolatedEnum::from_flag(true).as_str(),
3598 "TRUE"
3599 );
3600 assert_eq!(
3601 MarginAccountCancelOrderIsIsolatedEnum::from_flag(false).as_str(),
3602 "FALSE"
3603 );
3604 assert_eq!(
3605 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(true).as_str(),
3606 "TRUE"
3607 );
3608 assert_eq!(
3609 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(false).as_str(),
3610 "FALSE"
3611 );
3612 assert_eq!(
3613 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(true).as_str(),
3614 "TRUE"
3615 );
3616 assert_eq!(
3617 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(false).as_str(),
3618 "FALSE"
3619 );
3620 }
3621
3622 #[test]
3627 fn cancel_params_cross_with_parseable_order_id() {
3628 let p = build_cancel_order_params(
3629 "BTCUSDT".to_string(),
3630 Some(&OrderId::new("12345")),
3631 &ClientOrderId::new("cid-1"),
3632 false,
3633 )
3634 .expect("build");
3635 assert_eq!(p.symbol, "BTCUSDT");
3636 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("FALSE"));
3637 assert_eq!(p.order_id, Some(12345));
3638 assert_eq!(p.orig_client_order_id, None);
3639 }
3640
3641 #[test]
3642 fn cancel_params_isolated_is_config_driven() {
3643 let p = build_cancel_order_params(
3644 "BTCUSDT".to_string(),
3645 Some(&OrderId::new("12345")),
3646 &ClientOrderId::new("cid-1"),
3647 true,
3648 )
3649 .expect("build");
3650 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("TRUE"));
3651 assert_eq!(p.order_id, Some(12345));
3652 }
3653
3654 #[test]
3655 fn cancel_params_non_i64_order_id_falls_back_to_cid() {
3656 let p = build_cancel_order_params(
3657 "BTCUSDT".to_string(),
3658 Some(&OrderId::new("not-an-i64")),
3659 &ClientOrderId::new("cid-1"),
3660 false,
3661 )
3662 .expect("build");
3663 assert_eq!(p.order_id, None);
3664 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3665 }
3666
3667 #[test]
3668 fn cancel_params_absent_order_id_uses_cid() {
3669 let p = build_cancel_order_params(
3670 "BTCUSDT".to_string(),
3671 None,
3672 &ClientOrderId::new("cid-1"),
3673 false,
3674 )
3675 .expect("build");
3676 assert_eq!(p.order_id, None);
3677 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3678 }
3679
3680 use crate::order::TrailingOffsetType;
3685
3686 fn params(
3688 side: Side,
3689 price: Option<Decimal>,
3690 kind: OrderKind,
3691 tif: TimeInForce,
3692 margin_effect: MarginEffect,
3693 ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3694 build_new_order_params(
3695 "BTCUSDT".to_string(),
3696 side,
3697 price,
3698 Decimal::from(2),
3699 kind,
3700 tif,
3701 "cid-1".to_string(),
3702 margin_effect,
3703 true,
3704 false, )
3706 }
3707
3708 fn gtc() -> TimeInForce {
3709 TimeInForce::GoodUntilCancelled { post_only: false }
3710 }
3711
3712 #[test]
3713 fn new_order_limit_maps_core_fields() {
3714 let p = params(
3715 Side::Buy,
3716 Some(Decimal::from(50_000)),
3717 OrderKind::Limit,
3718 gtc(),
3719 MarginEffect::Borrow,
3720 )
3721 .expect("build");
3722
3723 assert_eq!(p.symbol, "BTCUSDT");
3724 assert_eq!(p.side.as_str(), "BUY");
3725 assert_eq!(p.r#type.as_str(), "LIMIT");
3726 assert_eq!(p.quantity, Some(Decimal::from(2)));
3727 assert_eq!(p.price, Some(Decimal::from(50_000)));
3728 assert_eq!(p.stop_price, None);
3729 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
3730 assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
3731 assert_eq!(
3733 p.new_order_resp_type.as_ref().map(|r| r.as_str()),
3734 Some("FULL")
3735 );
3736 }
3737
3738 #[test]
3739 fn new_order_is_isolated_is_config_driven() {
3740 let cross = build_new_order_params(
3742 "BTCUSDT".to_string(),
3743 Side::Sell,
3744 Some(Decimal::from(10)),
3745 Decimal::from(2),
3746 OrderKind::Limit,
3747 gtc(),
3748 "cid-1".to_string(),
3749 MarginEffect::Borrow,
3750 true,
3751 false,
3752 )
3753 .expect("build");
3754 assert_eq!(
3755 cross.is_isolated.as_ref().map(|i| i.as_str()),
3756 Some("FALSE")
3757 );
3758 assert_eq!(cross.side.as_str(), "SELL");
3759
3760 let isolated = build_new_order_params(
3762 "BTCUSDT".to_string(),
3763 Side::Sell,
3764 Some(Decimal::from(10)),
3765 Decimal::from(2),
3766 OrderKind::Limit,
3767 gtc(),
3768 "cid-1".to_string(),
3769 MarginEffect::Borrow,
3770 true,
3771 true,
3772 )
3773 .expect("build");
3774 assert_eq!(
3775 isolated.is_isolated.as_ref().map(|i| i.as_str()),
3776 Some("TRUE")
3777 );
3778 }
3779
3780 #[test]
3781 fn side_effect_and_auto_repay_gating() {
3782 let build = |effect, auto_repay_at_cancel| {
3783 build_new_order_params(
3784 "BTCUSDT".to_string(),
3785 Side::Buy,
3786 Some(Decimal::from(1)),
3787 Decimal::from(2),
3788 OrderKind::Limit,
3789 gtc(),
3790 "cid-1".to_string(),
3791 effect,
3792 auto_repay_at_cancel,
3793 false,
3794 )
3795 .expect("build")
3796 };
3797 let wire = |params: &MarginAccountNewOrderParams| {
3798 params
3799 .side_effect_type
3800 .as_ref()
3801 .map(|s| s.as_str().to_owned())
3802 };
3803
3804 let borrow = build(MarginEffect::Borrow, true);
3806 assert_eq!(wire(&borrow).as_deref(), Some("MARGIN_BUY"));
3807 assert_eq!(borrow.auto_repay_at_cancel, Some(true));
3808 let borrow_off = build(MarginEffect::Borrow, false);
3809 assert_eq!(borrow_off.auto_repay_at_cancel, Some(false));
3810
3811 let repay = build(MarginEffect::Repay, true);
3813 assert_eq!(wire(&repay).as_deref(), Some("AUTO_REPAY"));
3814 assert_eq!(repay.auto_repay_at_cancel, None);
3815 let none = build(MarginEffect::NoSideEffect, true);
3816 assert_eq!(wire(&none).as_deref(), Some("NO_SIDE_EFFECT"));
3817 assert_eq!(none.auto_repay_at_cancel, None);
3818 }
3819
3820 #[test]
3821 fn new_order_market_has_no_price_or_tif() {
3822 let p = params(
3823 Side::Buy,
3824 None,
3825 OrderKind::Market,
3826 gtc(),
3827 MarginEffect::Borrow,
3828 )
3829 .expect("build");
3830 assert_eq!(p.r#type.as_str(), "MARKET");
3831 assert_eq!(p.price, None);
3832 assert_eq!(p.stop_price, None);
3833 assert!(p.time_in_force.is_none());
3834 }
3835
3836 #[test]
3837 fn post_only_limit_maps_to_limit_maker() {
3838 let p = params(
3839 Side::Buy,
3840 Some(Decimal::from(10)),
3841 OrderKind::Limit,
3842 TimeInForce::GoodUntilCancelled { post_only: true },
3843 MarginEffect::Borrow,
3844 )
3845 .expect("build");
3846 assert_eq!(p.r#type.as_str(), "LIMIT_MAKER");
3847 assert!(p.time_in_force.is_none());
3849 }
3850
3851 #[test]
3852 fn conditional_kinds_set_stop_price() {
3853 let trigger = Decimal::from(48_000);
3854
3855 let stop = params(
3856 Side::Sell,
3857 None,
3858 OrderKind::Stop {
3859 trigger_price: trigger,
3860 },
3861 gtc(),
3862 MarginEffect::Borrow,
3863 )
3864 .expect("build");
3865 assert_eq!(stop.r#type.as_str(), "STOP_LOSS");
3866 assert_eq!(stop.stop_price, Some(trigger));
3867 assert_eq!(stop.price, None);
3868
3869 let stop_limit = params(
3870 Side::Sell,
3871 Some(Decimal::from(47_900)),
3872 OrderKind::StopLimit {
3873 trigger_price: trigger,
3874 },
3875 gtc(),
3876 MarginEffect::Borrow,
3877 )
3878 .expect("build");
3879 assert_eq!(stop_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
3880 assert_eq!(stop_limit.stop_price, Some(trigger));
3881 assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
3882 assert_eq!(
3883 stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
3884 Some("GTC")
3885 );
3886
3887 let take_profit = params(
3888 Side::Sell,
3889 None,
3890 OrderKind::TakeProfit {
3891 trigger_price: trigger,
3892 },
3893 gtc(),
3894 MarginEffect::Borrow,
3895 )
3896 .expect("build");
3897 assert_eq!(take_profit.r#type.as_str(), "TAKE_PROFIT");
3898 assert_eq!(take_profit.stop_price, Some(trigger));
3899 }
3900
3901 #[test]
3904 fn a_trailing_stop_maps_to_stop_loss_with_trailing_delta() {
3905 let cases = [
3906 (Decimal::from(25), TrailingOffsetType::BasisPoints, 25),
3907 (Decimal::new(15, 1), TrailingOffsetType::Percentage, 150),
3908 ];
3909 for (offset, offset_type, trailing_delta) in cases {
3910 let p = params(
3911 Side::Sell,
3912 None,
3913 OrderKind::TrailingStop {
3914 offset,
3915 offset_type,
3916 activation_price: None,
3917 },
3918 gtc(),
3919 MarginEffect::NoSideEffect,
3920 )
3921 .expect("build");
3922 assert_eq!(p.r#type.as_str(), "STOP_LOSS");
3923 assert_eq!(p.trailing_delta, Some(trailing_delta), "{offset_type:?}");
3924 assert_eq!(p.stop_price, None);
3925 assert_eq!(p.price, None);
3926 assert!(p.time_in_force.is_none());
3927 }
3928 }
3929
3930 #[test]
3934 fn trailing_orders_carry_their_activation_price_and_fixed_limit() {
3935 let activation = Some(Decimal::from(50_000));
3936 let trailing_stop = params(
3937 Side::Sell,
3938 None,
3939 OrderKind::TrailingStop {
3940 offset: Decimal::from(25),
3941 offset_type: TrailingOffsetType::BasisPoints,
3942 activation_price: activation,
3943 },
3944 gtc(),
3945 MarginEffect::NoSideEffect,
3946 )
3947 .expect("build");
3948 assert_eq!(trailing_stop.r#type.as_str(), "STOP_LOSS");
3949 assert_eq!(trailing_stop.trailing_delta, Some(25));
3950 assert_eq!(trailing_stop.stop_price, activation);
3951 assert_eq!(trailing_stop.price, None);
3952
3953 for activation_price in [None, activation] {
3954 let fixed_limit = params(
3955 Side::Sell,
3956 Some(Decimal::from(49_000)),
3957 OrderKind::TrailingStopFixedLimit {
3958 offset: Decimal::new(15, 1),
3959 offset_type: TrailingOffsetType::Percentage,
3960 activation_price,
3961 },
3962 TimeInForce::ImmediateOrCancel,
3963 MarginEffect::NoSideEffect,
3964 )
3965 .expect("build");
3966 assert_eq!(fixed_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
3967 assert_eq!(fixed_limit.trailing_delta, Some(150));
3968 assert_eq!(fixed_limit.price, Some(Decimal::from(49_000)));
3969 assert_eq!(fixed_limit.stop_price, activation_price);
3970 assert_eq!(
3971 fixed_limit.time_in_force.as_ref().map(|t| t.as_str()),
3972 Some("IOC")
3973 );
3974 }
3975
3976 let absolute = params(
3977 Side::Sell,
3978 Some(Decimal::from(49_000)),
3979 OrderKind::TrailingStopFixedLimit {
3980 offset: Decimal::from(100),
3981 offset_type: TrailingOffsetType::Absolute,
3982 activation_price: None,
3983 },
3984 gtc(),
3985 MarginEffect::NoSideEffect,
3986 );
3987 assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
3988
3989 let no_limit = params(
3991 Side::Sell,
3992 None,
3993 OrderKind::TrailingStopFixedLimit {
3994 offset: Decimal::from(25),
3995 offset_type: TrailingOffsetType::BasisPoints,
3996 activation_price: None,
3997 },
3998 gtc(),
3999 MarginEffect::NoSideEffect,
4000 );
4001 assert!(matches!(
4002 no_limit,
4003 Err(BuildOrderError::Trailing(TrailingOrderError::LimitPrice(
4004 None
4005 )))
4006 ));
4007 let zero_activation = params(
4008 Side::Sell,
4009 None,
4010 OrderKind::TrailingStop {
4011 offset: Decimal::from(25),
4012 offset_type: TrailingOffsetType::BasisPoints,
4013 activation_price: Some(Decimal::ZERO),
4014 },
4015 gtc(),
4016 MarginEffect::NoSideEffect,
4017 );
4018 assert!(matches!(
4019 zero_activation,
4020 Err(BuildOrderError::Trailing(
4021 TrailingOrderError::NonPositiveActivation(_)
4022 ))
4023 ));
4024 }
4025
4026 #[test]
4027 fn absolute_trailing_offsets_and_trailing_stop_limits_are_unsupported() {
4028 let absolute = params(
4029 Side::Sell,
4030 None,
4031 OrderKind::TrailingStop {
4032 offset: Decimal::from(100),
4033 offset_type: TrailingOffsetType::Absolute,
4034 activation_price: None,
4035 },
4036 gtc(),
4037 MarginEffect::Borrow,
4038 );
4039 assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
4040
4041 let trailing_stop_limit = params(
4042 Side::Sell,
4043 Some(Decimal::from(100)),
4044 OrderKind::TrailingStopLimit {
4045 offset: Decimal::from(100),
4046 offset_type: TrailingOffsetType::BasisPoints,
4047 stop_price: Decimal::from(95),
4048 limit_offset: Decimal::from(10),
4049 },
4050 gtc(),
4051 MarginEffect::Borrow,
4052 );
4053 assert!(matches!(
4054 trailing_stop_limit,
4055 Err(BuildOrderError::Unsupported)
4056 ));
4057
4058 let unresolved = params(
4060 Side::Sell,
4061 Some(Decimal::from(100)),
4062 OrderKind::ConditionalUnresolved {
4063 family: crate::order::ConditionalFamily::StopLimit,
4064 trigger_price: Some(Decimal::from(95)),
4065 },
4066 gtc(),
4067 MarginEffect::Borrow,
4068 );
4069 assert!(matches!(unresolved, Err(BuildOrderError::Unsupported)));
4070 }
4071
4072 #[test]
4074 fn a_fractional_trailing_offset_is_refused() {
4075 let fractional = params(
4076 Side::Sell,
4077 None,
4078 OrderKind::TrailingStop {
4079 offset: Decimal::new(155, 3),
4080 offset_type: TrailingOffsetType::Percentage,
4081 activation_price: None,
4082 },
4083 gtc(),
4084 MarginEffect::Borrow,
4085 );
4086 assert!(matches!(
4087 fractional,
4088 Err(BuildOrderError::Trailing(TrailingOrderError::Delta(
4089 crate::client::binance::shared::TrailingDeltaError::Invalid(_)
4090 )))
4091 ));
4092 }
4093
4094 #[test]
4097 fn recalled_order_kinds_are_known_and_those_past_the_limit_warned_of() {
4098 use crate::client::order_recovery::tests::logged_with_lock_free;
4099
4100 let client = BinanceMargin::new(BinanceMarginConfig::cross_margin(
4101 "k".to_string(),
4102 "s".to_string(),
4103 ));
4104 let trailing = OrderKind::TrailingStopFixedLimit {
4105 offset: Decimal::from(100),
4106 offset_type: TrailingOffsetType::BasisPoints,
4107 activation_price: None,
4108 };
4109 let cid = |n: usize| ClientOrderId::new(format!("cid-{n}"));
4110
4111 let seen = logged_with_lock_free(&client.known_live, || {
4112 client.recall_order_kinds((0..MAX_KNOWN_LIVE_ORDERS).map(|n| (cid(n), trailing)));
4113 });
4114 assert!(seen.is_empty(), "nothing past the limit: {seen:?}");
4115 assert_eq!(client.known_live.lock().kind_of(&cid(0)), Some(trailing));
4116
4117 let seen = logged_with_lock_free(&client.known_live, || {
4118 client.recall_order_kinds([(cid(MAX_KNOWN_LIVE_ORDERS), trailing)]);
4119 });
4120 assert_eq!(seen, [(tracing::Level::WARN, true)]);
4121 assert_eq!(
4122 client
4123 .known_live
4124 .lock()
4125 .kind_of(&cid(MAX_KNOWN_LIVE_ORDERS)),
4126 None
4127 );
4128 }
4129
4130 #[test]
4131 fn tif_variants_map_to_margin_enum() {
4132 for (tif, expected) in [
4133 (TimeInForce::ImmediateOrCancel, "IOC"),
4134 (TimeInForce::FillOrKill, "FOK"),
4135 (gtc(), "GTC"),
4136 ] {
4137 let p = params(
4138 Side::Buy,
4139 Some(Decimal::from(1)),
4140 OrderKind::Limit,
4141 tif,
4142 MarginEffect::Borrow,
4143 )
4144 .expect("build");
4145 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
4146 }
4147 }
4148
4149 fn user_asset(
4154 asset: &str,
4155 free: &str,
4156 locked: &str,
4157 borrowed: &str,
4158 interest: &str,
4159 ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
4160 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4161 a.asset = Some(asset.to_string());
4162 a.free = Some(free.to_string());
4163 a.locked = Some(locked.to_string());
4164 a.borrowed = Some(borrowed.to_string());
4165 a.interest = Some(interest.to_string());
4166 a
4167 }
4168
4169 #[test]
4170 fn margin_balance_entry_maps_debt() {
4171 let ab =
4173 convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
4174 .expect("convert");
4175 assert_eq!(ab.asset.name().as_str(), "USDT");
4176 assert_eq!(ab.balance.total, Decimal::from(12));
4177 assert_eq!(ab.balance.free, Decimal::from(10));
4178 assert!(
4179 ab.balance.margin.is_some(),
4180 "REST snapshot must populate margin"
4181 );
4182 assert_eq!(ab.balance.net_asset(), Decimal::from(9));
4184 }
4185
4186 #[test]
4187 fn margin_balance_short_is_negative_net() {
4188 let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
4190 .expect("convert");
4191 assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
4192 }
4193
4194 #[test]
4195 fn margin_balance_missing_debt_defaults_to_zero() {
4196 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4199 a.asset = Some("ETH".to_string());
4200 a.free = Some("4".to_string());
4201 a.locked = Some("0".to_string());
4202 let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
4203 assert!(ab.balance.margin.is_some());
4204 assert_eq!(ab.balance.net_asset(), Decimal::from(4));
4205 }
4206
4207 #[test]
4208 fn margin_balance_missing_free_is_dropped() {
4209 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4211 a.asset = Some("USDT".to_string());
4212 a.locked = Some("0".to_string());
4213 assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
4214 }
4215
4216 #[test]
4217 fn margin_balance_filtering() {
4218 let assets = vec![
4219 user_asset("USDT", "10", "0", "0", "0"),
4220 user_asset("BTC", "1", "0", "0", "0"),
4221 user_asset("ETH", "5", "0", "0", "0"),
4222 ];
4223 assert_eq!(
4225 filter_and_convert_margin_balances(assets.clone(), &[]).len(),
4226 3
4227 );
4228 let filtered = filter_and_convert_margin_balances(
4230 assets,
4231 &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
4232 );
4233 assert_eq!(filtered.len(), 2);
4234 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
4235 }
4236
4237 #[test]
4238 fn margin_balance_filtering_large_slice_uses_hashset_branch() {
4239 let assets = vec![
4241 user_asset("BTC", "1", "0", "0", "0"),
4242 user_asset("ETH", "5", "0", "0", "0"),
4243 user_asset("DOGE", "9", "0", "0", "0"), ];
4245 let mut requested: Vec<AssetNameExchange> = (0..17)
4247 .map(|i| AssetNameExchange::new(format!("A{i:02}")))
4248 .collect();
4249 requested.push(AssetNameExchange::new("BTC"));
4250 requested.push(AssetNameExchange::new("ETH"));
4251 assert!(
4252 requested.len() > 16,
4253 "must exceed the linear-scan threshold"
4254 );
4255
4256 let filtered = filter_and_convert_margin_balances(assets, &requested);
4257 assert_eq!(filtered.len(), 2);
4258 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
4259 }
4260
4261 use binance_sdk::margin_trading::rest_api::{
4266 QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
4267 QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
4268 };
4269
4270 fn d(s: &str) -> Decimal {
4271 Decimal::from_str(s).unwrap()
4272 }
4273
4274 fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
4275 let mut b = IsoBase::new();
4276 b.asset = Some(asset.to_string());
4277 b.free = Some(free.to_string());
4278 b.locked = Some(locked.to_string());
4279 b.borrowed = Some(borrowed.to_string());
4280 b.interest = Some(interest.to_string());
4281 b
4282 }
4283
4284 fn iso_quote(
4285 asset: &str,
4286 free: &str,
4287 locked: &str,
4288 borrowed: &str,
4289 interest: &str,
4290 ) -> IsoQuote {
4291 let mut q = IsoQuote::new();
4292 q.asset = Some(asset.to_string());
4293 q.free = Some(free.to_string());
4294 q.locked = Some(locked.to_string());
4295 q.borrowed = Some(borrowed.to_string());
4296 q.interest = Some(interest.to_string());
4297 q
4298 }
4299
4300 fn iso_entry(
4301 symbol: &str,
4302 base: IsoBase,
4303 quote: IsoQuote,
4304 margin_level: Option<&str>,
4305 margin_ratio: Option<&str>,
4306 liquidate_price: Option<&str>,
4307 ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
4308 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4309 e.symbol = Some(symbol.to_string());
4310 e.base_asset = Some(Box::new(base));
4311 e.quote_asset = Some(Box::new(quote));
4312 e.margin_level = margin_level.map(str::to_string);
4313 e.margin_ratio = margin_ratio.map(str::to_string);
4314 e.liquidate_price = liquidate_price.map(str::to_string);
4315 e
4316 }
4317
4318 fn isolated_client(symbols: &[&str]) -> BinanceMargin {
4319 let config = BinanceMarginConfig::isolated(
4320 "k".to_string(),
4321 "s".to_string(),
4322 symbols
4323 .iter()
4324 .map(|s| InstrumentNameExchange::new(*s))
4325 .collect(),
4326 );
4327 BinanceMargin::new(config)
4328 }
4329
4330 #[test]
4331 fn client_debug_shows_mode_and_margin_effect_without_credentials() {
4332 let debug = format!("{:?}", isolated_client(&["BTCUSDT"]));
4333 assert!(debug.contains("BinanceMargin"));
4334 assert!(debug.contains("is_isolated: true"));
4335 assert!(debug.contains("margin_effect: NoSideEffect"));
4336 assert!(!debug.contains("\"k\"") && !debug.contains("\"s\""));
4337 }
4338
4339 #[test]
4340 fn isolated_assets_map_base_quote_and_risk() {
4341 let entry = iso_entry(
4344 "BTCUSDT",
4345 iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
4346 iso_quote("USDT", "1000", "50", "300", "1.5"),
4347 Some("3.5"),
4348 Some("0.12"),
4349 Some("48000"),
4350 );
4351 let map = convert_isolated_margin_assets(vec![entry]);
4352 let state = map
4353 .get(&InstrumentNameExchange::new("BTCUSDT"))
4354 .expect("entry present");
4355
4356 assert_eq!(state.base.asset.name().as_str(), "BTC");
4357 assert_eq!(state.base.balance.total, d("0.6"));
4358 assert_eq!(state.base.balance.free, d("0.5"));
4359 assert_eq!(state.base.balance.net_asset(), d("0.4"));
4360
4361 assert_eq!(state.quote.asset.name().as_str(), "USDT");
4362 assert_eq!(state.quote.balance.total, d("1050"));
4363 assert_eq!(state.quote.balance.net_asset(), d("750"));
4364
4365 assert_eq!(state.risk.margin_level, Some(d("3.5")));
4366 assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
4367 assert_eq!(state.risk.liquidation_price, Some(d("48000")));
4368 }
4369
4370 #[test]
4371 fn isolated_base_short_is_negative_net() {
4372 let entry = iso_entry(
4374 "BTCUSDT",
4375 iso_base("BTC", "0", "0", "1.5", "0.001"),
4376 iso_quote("USDT", "100", "0", "0", "0"),
4377 None,
4378 None,
4379 None,
4380 );
4381 let map = convert_isolated_margin_assets(vec![entry]);
4382 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4383 assert_eq!(state.base.balance.net_asset(), d("-1.5"));
4384 }
4385
4386 #[test]
4387 fn isolated_missing_debt_defaults_zero_and_risk_optional() {
4388 let mut base = IsoBase::new();
4391 base.asset = Some("BTC".to_string());
4392 base.free = Some("0.5".to_string());
4393 base.locked = Some("0".to_string());
4394 let mut quote = IsoQuote::new();
4395 quote.asset = Some("USDT".to_string());
4396 quote.free = Some("100".to_string());
4397 quote.locked = Some("0".to_string());
4398
4399 let map = convert_isolated_margin_assets(vec![iso_entry(
4400 "BTCUSDT", base, quote, None, None, None,
4401 )]);
4402 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4403 assert!(state.base.balance.margin.is_some());
4404 assert_eq!(state.base.balance.net_asset(), d("0.5"));
4405 assert_eq!(state.risk.margin_level, None);
4406 assert_eq!(state.risk.liquidation_price, None);
4407 }
4408
4409 #[test]
4410 fn isolated_unparseable_risk_is_none_but_entry_kept() {
4411 let entry = iso_entry(
4412 "BTCUSDT",
4413 iso_base("BTC", "1", "0", "0", "0"),
4414 iso_quote("USDT", "1", "0", "0", "0"),
4415 Some("not_a_number"),
4416 None,
4417 None,
4418 );
4419 let map = convert_isolated_margin_assets(vec![entry]);
4420 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4421 assert_eq!(state.risk.margin_level, None);
4422 }
4423
4424 #[test]
4425 fn isolated_missing_base_free_drops_entry() {
4426 let mut base = IsoBase::new();
4428 base.asset = Some("BTC".to_string());
4429 base.locked = Some("0".to_string()); let entry = iso_entry(
4431 "BTCUSDT",
4432 base,
4433 iso_quote("USDT", "100", "0", "0", "0"),
4434 None,
4435 None,
4436 None,
4437 );
4438 assert!(convert_isolated_margin_assets(vec![entry]).is_empty());
4439 }
4440
4441 #[test]
4442 fn isolated_missing_symbol_drops_entry() {
4443 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4444 e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
4445 e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
4446 assert!(convert_isolated_margin_assets(vec![e]).is_empty());
4448 }
4449
4450 #[test]
4451 fn chunk_symbols_batches_by_five() {
4452 let syms: Vec<InstrumentNameExchange> = (0..12)
4453 .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
4454 .collect();
4455 let chunks = chunk_symbols(&syms);
4456 assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
4457 assert_eq!(chunks[0].split(',').count(), 5);
4458 assert_eq!(chunks[1].split(',').count(), 5);
4459 assert_eq!(chunks[2].split(',').count(), 2);
4460
4461 let three: Vec<_> = (0..3)
4463 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4464 .collect();
4465 assert_eq!(chunk_symbols(&three).len(), 1);
4466
4467 let five: Vec<_> = (0..5)
4469 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4470 .collect();
4471 let c = chunk_symbols(&five);
4472 assert_eq!(c.len(), 1);
4473 assert_eq!(c[0].split(',').count(), 5);
4474
4475 assert!(chunk_symbols(&[]).is_empty());
4477 }
4478
4479 #[test]
4480 fn effective_isolated_set_empty_returns_all_configured() {
4481 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4482 assert_eq!(
4483 client.effective_isolated_set(&[]),
4484 vec![
4485 InstrumentNameExchange::new("BTCUSDT"),
4486 InstrumentNameExchange::new("ETHUSDT"),
4487 ]
4488 );
4489 }
4490
4491 #[test]
4492 fn effective_isolated_set_intersects_requested() {
4493 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4494 assert_eq!(
4495 client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
4496 vec![InstrumentNameExchange::new("ETHUSDT")]
4497 );
4498 }
4499
4500 #[test]
4501 fn effective_isolated_set_skips_out_of_set() {
4502 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4503 assert_eq!(
4505 client.effective_isolated_set(&[
4506 InstrumentNameExchange::new("BTCUSDT"),
4507 InstrumentNameExchange::new("DOGEUSDT"),
4508 ]),
4509 vec![InstrumentNameExchange::new("BTCUSDT")]
4510 );
4511 }
4512
4513 #[tokio::test]
4514 async fn fetch_balances_isolated_returns_empty() {
4515 let client = isolated_client(&["BTCUSDT"]);
4518 assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
4519 }
4520
4521 fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
4522 let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
4523 o.order_id = order_id;
4524 o.client_order_id = Some("cid-9".to_string());
4525 o.side = Some(side.to_string());
4526 o.price = Some("50000".to_string());
4527 o.orig_qty = Some("2".to_string());
4528 o.executed_qty = Some("0.5".to_string());
4529 o.r#type = Some("LIMIT".to_string());
4530 o.time_in_force = Some("GTC".to_string());
4531 o.time = Some(1_700_000_000_000);
4532 o.status = Some("PARTIALLY_FILLED".to_string());
4533 o
4534 }
4535
4536 #[test]
4537 fn margin_open_order_converts() {
4538 let inst = InstrumentNameExchange::new("BTCUSDT");
4539 let order = convert_open_order(
4540 &open_order(Some(42), "BUY"),
4541 ExchangeId::BinanceMargin,
4542 &inst,
4543 &|_| None,
4544 )
4545 .expect("convert");
4546 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4547 assert_eq!(
4548 order.state.id.assigned().map(|id| id.0.as_str()),
4549 Some("42")
4550 );
4551 assert_eq!(order.key.cid.0.as_str(), "cid-9");
4552 assert_eq!(order.side, Side::Buy);
4553 assert_eq!(order.price, Some(Decimal::from(50_000)));
4554 assert_eq!(order.quantity, Decimal::from(2));
4555 assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
4556 assert_eq!(order.kind, OrderKind::Limit);
4557 }
4558
4559 #[test]
4562 fn margin_open_order_that_is_not_live_is_dropped() {
4563 let inst = InstrumentNameExchange::new("BTCUSDT");
4564 let mut o = open_order(Some(42), "BUY");
4565 o.status = Some("CANCELED".to_string());
4566 assert!(convert_open_order(&o, ExchangeId::BinanceMargin, &inst, &|_| None).is_none());
4567 }
4568
4569 #[test]
4570 fn margin_open_order_missing_order_id_is_dropped() {
4571 let inst = InstrumentNameExchange::new("BTCUSDT");
4572 assert!(
4573 convert_open_order(
4574 &open_order(None, "BUY"),
4575 ExchangeId::BinanceMargin,
4576 &inst,
4577 &|_| None
4578 )
4579 .is_none()
4580 );
4581 }
4582
4583 #[test]
4589 fn margin_open_order_prefers_update_time_over_creation_time() {
4590 let inst = InstrumentNameExchange::new("BTCUSDT");
4591
4592 let mut updated = open_order(Some(42), "BUY");
4593 updated.update_time = Some(1_700_000_005_000);
4594 let order = convert_open_order(&updated, ExchangeId::BinanceMargin, &inst, &|_| None)
4595 .expect("convert");
4596 assert_eq!(
4597 order.state.time_exchange,
4598 Utc.timestamp_millis_opt(1_700_000_005_000)
4599 .single()
4600 .unwrap(),
4601 "update_time must win over time"
4602 );
4603 }
4604
4605 #[test]
4608 fn margin_open_order_falls_back_to_creation_time_when_update_time_absent() {
4609 let inst = InstrumentNameExchange::new("BTCUSDT");
4610
4611 let base = open_order(Some(42), "BUY");
4612 assert!(base.update_time.is_none(), "fixture must omit update_time");
4613 let order = convert_open_order(&base, ExchangeId::BinanceMargin, &inst, &|_| None)
4614 .expect("convert");
4615 assert_eq!(
4616 order.state.time_exchange,
4617 Utc.timestamp_millis_opt(1_700_000_000_000)
4618 .single()
4619 .unwrap(),
4620 "absent update_time falls back to time"
4621 );
4622 }
4623
4624 #[test]
4625 fn margin_open_order_owned_symbol_recovers_instrument() {
4626 let mut o = open_order(Some(42), "SELL");
4628 o.symbol = Some("ETHUSDT".to_string());
4629 let order = convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin, &|_| None)
4630 .expect("convert");
4631 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4632 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4633 assert_eq!(order.side, Side::Sell);
4634 }
4635
4636 #[test]
4637 fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
4638 let o = open_order(Some(42), "BUY");
4640 assert!(o.symbol.is_none());
4641 assert!(
4642 convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin, &|_| None).is_none()
4643 );
4644 }
4645
4646 fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
4647 let mut t = QueryMarginAccountsTradeListResponseInner::new();
4648 t.id = id;
4649 t.order_id = Some(7);
4650 t.is_buyer = is_buyer;
4651 t.price = Some("48000".to_string());
4652 t.qty = Some("0.25".to_string());
4653 t.commission = Some("0.001".to_string());
4654 t.commission_asset = Some("BNB".to_string());
4655 t.time = Some(1_700_000_000_000);
4656 t
4657 }
4658
4659 #[test]
4660 fn margin_trade_converts() {
4661 let inst = InstrumentNameExchange::new("BTCUSDT");
4662 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
4663 assert_eq!(tr.id.0.as_str(), "11");
4664 assert_eq!(tr.order_id.0.as_str(), "7");
4665 assert_eq!(tr.side, Side::Sell);
4666 assert_eq!(tr.price, Decimal::from(48_000));
4667 assert_eq!(tr.quantity, Decimal::new(25, 2));
4668 assert_eq!(tr.fees.asset.name().as_str(), "BNB");
4670 assert_eq!(tr.fees.fees, Decimal::new(1, 3));
4671 }
4672
4673 #[test]
4674 fn margin_trade_missing_fields_are_dropped() {
4675 let inst = InstrumentNameExchange::new("BTCUSDT");
4676 assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
4678 assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
4679 }
4680
4681 #[test]
4684 fn base_quote_map_extracts_base_and_quote() {
4685 let entries = vec![
4686 iso_entry(
4687 "BTCUSDT",
4688 iso_base("BTC", "0", "0", "0", "0"),
4689 iso_quote("USDT", "0", "0", "0", "0"),
4690 None,
4691 None,
4692 None,
4693 ),
4694 iso_entry(
4695 "ETHBTC",
4696 iso_base("ETH", "0", "0", "0", "0"),
4697 iso_quote("BTC", "0", "0", "0", "0"),
4698 None,
4699 None,
4700 None,
4701 ),
4702 ];
4703 let map = build_base_quote_map(&entries);
4704 assert_eq!(
4705 map.get(&InstrumentNameExchange::new("BTCUSDT"))
4706 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
4707 Some(("BTC", "USDT"))
4708 );
4709 assert_eq!(
4712 map.get(&InstrumentNameExchange::new("ETHBTC"))
4713 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
4714 Some(("ETH", "BTC"))
4715 );
4716 }
4717
4718 struct MarginMyTradesVenue {
4729 trades: Vec<(i64, i64, i64)>,
4731 end_exclusive: bool,
4732 }
4733
4734 impl wiremock::Respond for MarginMyTradesVenue {
4735 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
4736 const DAY_MS: i64 = 24 * 60 * 60 * 1000;
4737 let query: std::collections::HashMap<String, String> =
4738 request.url.query_pairs().into_owned().collect();
4739 let param = |name: &str| query.get(name).map(|v| v.parse::<i64>().unwrap());
4740 let limit = usize::try_from(param("limit").unwrap_or(500)).unwrap();
4741 let order = param("orderId");
4742
4743 let mut selected: Vec<&(i64, i64, i64)> = self
4744 .trades
4745 .iter()
4746 .filter(|(_, _, order_id)| order.is_none_or(|order| *order_id == order))
4747 .collect();
4748 if let Some(from_id) = param("fromId") {
4749 selected.retain(|(id, _, _)| *id >= from_id);
4750 } else if order.is_none() {
4751 let start = param("startTime").expect("a query by time sends startTime");
4752 let end = match param("endTime") {
4753 Some(end) if end - start >= DAY_MS => {
4754 return wiremock::ResponseTemplate::new(400).set_body_json(
4755 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
4756 );
4757 }
4758 Some(end) if self.end_exclusive => end - 1,
4759 Some(end) => end,
4760 None => start + DAY_MS - 1,
4761 };
4762 selected.retain(|(_, time, _)| (start..=end).contains(time));
4763 }
4764
4765 let page: Vec<serde_json::Value> = selected
4766 .into_iter()
4767 .take(limit)
4768 .map(|(id, time, order_id)| {
4769 serde_json::json!({
4770 "id": id, "symbol": "BTCUSDT", "time": time, "orderId": order_id,
4771 "price": "50000", "qty": "0.01", "commission": "0",
4772 "commissionAsset": "USDT", "isBuyer": true, "isMaker": false,
4773 "isBestMatch": true, "isIsolated": true,
4774 })
4775 })
4776 .collect();
4777 wiremock::ResponseTemplate::new(200).set_body_json(page)
4778 }
4779 }
4780
4781 fn since(start: i64) -> MyTradesFrom {
4783 MyTradesFrom::Span {
4784 start,
4785 end: i64::MAX,
4786 }
4787 }
4788
4789 async fn read_margin_trades(
4792 venue: MarginMyTradesVenue,
4793 from: MyTradesFrom,
4794 ) -> (Vec<i64>, Vec<std::collections::HashMap<String, String>>) {
4795 let server = wiremock::MockServer::start().await;
4796 wiremock::Mock::given(wiremock::matchers::method("GET"))
4797 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
4798 .respond_with(venue)
4799 .mount(&server)
4800 .await;
4801 let rest = Arc::new(MarginTradingRestApi::from_config(
4802 ConfigurationRestApi::builder()
4803 .api_key("key")
4804 .api_secret("secret")
4805 .base_path(server.uri())
4806 .build()
4807 .unwrap(),
4808 ));
4809
4810 let read = paginate_margin_my_trades(
4811 &rest,
4812 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
4813 &InstrumentNameExchange::new("BTCUSDT"),
4814 from,
4815 true,
4816 RequestKind::Query,
4817 )
4818 .await
4819 .unwrap();
4820
4821 let requests = server
4822 .received_requests()
4823 .await
4824 .unwrap()
4825 .iter()
4826 .map(|request| request.url.query_pairs().into_owned().collect())
4827 .collect();
4828 (read.into_iter().map(|t| t.id.unwrap()).collect(), requests)
4829 }
4830
4831 #[tokio::test]
4834 async fn recovered_margin_fills_carry_their_orders_client_order_ids() {
4835 recovered_margin_fills_carry_client_order_ids(true).await;
4836 recovered_margin_fills_carry_client_order_ids(false).await;
4837 }
4838
4839 async fn recovered_margin_fills_carry_client_order_ids(is_isolated: bool) {
4840 let disconnect = Utc::now() - chrono::Duration::minutes(10);
4841 let reconnect = Utc::now() - chrono::Duration::minutes(5);
4842 let fill_time = (disconnect + chrono::Duration::minutes(1)).timestamp_millis();
4843 let server = wiremock::MockServer::start().await;
4844 wiremock::Mock::given(wiremock::matchers::method("GET"))
4845 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
4846 .respond_with(MarginMyTradesVenue {
4847 trades: vec![(1, fill_time, 1), (2, fill_time, 2)],
4848 end_exclusive: false,
4849 })
4850 .mount(&server)
4851 .await;
4852 wiremock::Mock::given(wiremock::matchers::method("GET"))
4853 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
4854 .and(wiremock::matchers::query_param("orderId", "1"))
4855 .and(wiremock::matchers::query_param(
4856 "isIsolated",
4857 if is_isolated { "TRUE" } else { "FALSE" },
4858 ))
4859 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
4860 serde_json::json!({"symbol": "BTCUSDT", "orderId": 1, "clientOrderId": "repay-1"}),
4861 ))
4862 .mount(&server)
4863 .await;
4864 wiremock::Mock::given(wiremock::matchers::method("GET"))
4865 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
4866 .and(wiremock::matchers::query_param("orderId", "2"))
4867 .respond_with(
4868 wiremock::ResponseTemplate::new(400).set_body_json(
4869 serde_json::json!({"code": -2013, "msg": "Order does not exist."}),
4870 ),
4871 )
4872 .mount(&server)
4873 .await;
4874 let rest = Arc::new(MarginTradingRestApi::from_config(
4875 ConfigurationRestApi::builder()
4876 .api_key("key")
4877 .api_secret("secret")
4878 .base_path(server.uri())
4879 .build()
4880 .unwrap(),
4881 ));
4882 let (tx, mut rx) = mpsc::unbounded_channel();
4883 let mut unrecovered = UnrecoveredFills::default();
4884 unrecovered.open(
4885 &[InstrumentNameExchange::new("BTCUSDT")],
4886 disconnect,
4887 reconnect,
4888 );
4889
4890 recover_margin_fills(
4891 &rest,
4892 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
4893 &mut unrecovered,
4894 &tx,
4895 &new_dedup_cache(),
4896 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
4897 is_isolated,
4898 )
4899 .await;
4900
4901 let mut cids: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
4902 .filter_map(|event| match event.kind {
4903 AccountEventKind::Trade(trade) => Some((
4904 trade.id.0.to_string(),
4905 trade.client_order_id,
4906 trade.order_filled_quantity.is_some(),
4907 )),
4908 _ => None,
4909 })
4910 .collect();
4911 cids.sort();
4912 assert_eq!(
4914 cids,
4915 [
4916 ("1".to_string(), Some(ClientOrderId::new("repay-1")), true),
4917 ("2".to_string(), None, true),
4918 ],
4919 "is_isolated: {is_isolated}"
4920 );
4921 let order_reads = server
4922 .received_requests()
4923 .await
4924 .unwrap()
4925 .iter()
4926 .filter(|request| request.url.path() == "/sapi/v1/margin/order")
4927 .count();
4928 assert_eq!(order_reads, 2, "one read per order");
4929 }
4930
4931 fn venue(trades: Vec<(i64, i64)>) -> MarginMyTradesVenue {
4933 MarginMyTradesVenue {
4934 trades: trades
4935 .into_iter()
4936 .map(|(id, time)| (id, time, id))
4937 .collect(),
4938 end_exclusive: false,
4939 }
4940 }
4941
4942 const HOUR_MS: i64 = 60 * 60 * 1000;
4943
4944 #[tokio::test]
4947 async fn margin_trades_read_a_lone_fill_beyond_the_first_day() {
4948 let now = Utc::now().timestamp_millis();
4949 let (ids, _) = read_margin_trades(
4950 venue(vec![(7, now - 72 * HOUR_MS)]),
4951 since(now - 120 * HOUR_MS),
4952 )
4953 .await;
4954
4955 assert_eq!(ids, vec![7]);
4956 }
4957
4958 #[tokio::test]
4961 async fn margin_trades_read_fills_across_several_windows_once_each() {
4962 let now = Utc::now().timestamp_millis();
4963 let trades = vec![
4964 (1, now - 119 * HOUR_MS),
4965 (2, now - 72 * HOUR_MS),
4966 (3, now - HOUR_MS),
4967 ];
4968 let (ids, _) = read_margin_trades(venue(trades), since(now - 144 * HOUR_MS)).await;
4969
4970 assert_eq!(ids, vec![1, 2, 3]);
4971 }
4972
4973 #[tokio::test]
4976 async fn margin_trades_read_a_trade_on_a_window_boundary_once() {
4977 for end_exclusive in [false, true] {
4978 let now = Utc::now().timestamp_millis();
4979 let from = now - 48 * HOUR_MS;
4980 let venue = MarginMyTradesVenue {
4981 end_exclusive,
4982 ..venue(vec![(5, from + MARGIN_MY_TRADES_WINDOW_MS)])
4983 };
4984 let (ids, _) = read_margin_trades(venue, since(from)).await;
4985
4986 assert_eq!(ids, vec![5], "end_exclusive: {end_exclusive}");
4987 }
4988 }
4989
4990 #[tokio::test]
4992 async fn margin_trades_page_on_by_id_past_a_full_window() {
4993 let now = Utc::now().timestamp_millis();
4994 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
4995 let trades = (1..=count)
4996 .map(|id| (id, now - 48 * HOUR_MS + id))
4997 .collect();
4998 let (ids, _) = read_margin_trades(venue(trades), since(now - 50 * HOUR_MS)).await;
4999
5000 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5001 }
5002
5003 #[tokio::test]
5006 async fn margin_trades_page_on_past_a_full_final_window() {
5007 let now = Utc::now().timestamp_millis();
5008 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5009 let trades = (1..=count).map(|id| (id, now - HOUR_MS + id)).collect();
5010 let (ids, requests) = read_margin_trades(venue(trades), since(now - 2 * HOUR_MS)).await;
5011
5012 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5013 assert_eq!(requests.len(), 2);
5014 }
5015
5016 #[tokio::test]
5019 async fn margin_trades_quiet_lookback_sends_one_request_per_window() {
5020 let now = Utc::now().timestamp_millis();
5021 let (ids, requests) =
5022 read_margin_trades(venue(Vec::new()), since(now - 72 * HOUR_MS)).await;
5023
5024 assert!(ids.is_empty());
5025 assert_eq!(requests.len(), 4);
5027 }
5028
5029 #[tokio::test]
5032 async fn margin_trades_read_a_short_gap_in_one_request() {
5033 let now = Utc::now().timestamp_millis();
5034 let (ids, requests) =
5035 read_margin_trades(venue(vec![(9, now - HOUR_MS)]), since(now - 2 * HOUR_MS)).await;
5036
5037 assert_eq!(ids, vec![9]);
5038 assert_eq!(requests.len(), 1);
5039 }
5040
5041 #[tokio::test]
5043 async fn margin_trades_from_the_future_send_no_request() {
5044 let now = Utc::now().timestamp_millis();
5045 let (ids, requests) = read_margin_trades(venue(Vec::new()), since(now + HOUR_MS)).await;
5046
5047 assert!(ids.is_empty());
5048 assert!(requests.is_empty());
5049 }
5050
5051 #[tokio::test]
5054 async fn margin_trades_by_order_page_by_id_without_time_bounds() {
5055 let now = Utc::now().timestamp_millis();
5056 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5057 let trades = (1..=2 * count)
5059 .map(|id| {
5060 (
5061 id,
5062 now - 48 * HOUR_MS + id,
5063 if id % 2 == 0 { 42 } else { 7 },
5064 )
5065 })
5066 .collect();
5067 let venue = MarginMyTradesVenue {
5068 trades,
5069 end_exclusive: false,
5070 };
5071 let (ids, requests) = read_margin_trades(venue, MyTradesFrom::Order(42)).await;
5072
5073 assert_eq!(ids, (1..=count).map(|n| 2 * n).collect::<Vec<_>>());
5074 assert_eq!(requests.len(), 2);
5075 assert!(
5076 requests.iter().all(
5077 |query| query.get("orderId").map(String::as_str) == Some("42")
5078 && !query.contains_key("startTime")
5079 && !query.contains_key("endTime")
5080 )
5081 );
5082 }
5083
5084 async fn margin_trades_error(response: wiremock::ResponseTemplate) -> UnindexedClientError {
5087 let server = wiremock::MockServer::start().await;
5088 wiremock::Mock::given(wiremock::matchers::method("GET"))
5089 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5090 .respond_with(response)
5091 .mount(&server)
5092 .await;
5093 let rest = Arc::new(MarginTradingRestApi::from_config(
5094 ConfigurationRestApi::builder()
5095 .api_key("key")
5096 .api_secret("secret")
5097 .base_path(server.uri())
5098 .build()
5099 .unwrap(),
5100 ));
5101
5102 paginate_margin_my_trades(
5103 &rest,
5104 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5105 &InstrumentNameExchange::new("BTCUSDT"),
5106 since(Utc::now().timestamp_millis() - HOUR_MS),
5107 true,
5108 RequestKind::Query,
5109 )
5110 .await
5111 .unwrap_err()
5112 }
5113
5114 #[tokio::test]
5117 async fn margin_trades_report_a_venue_rejection_as_a_non_transient_request_rejection() {
5118 let err = margin_trades_error(wiremock::ResponseTemplate::new(400).set_body_json(
5119 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
5120 ))
5121 .await;
5122
5123 let UnindexedClientError::Api(ApiError::RequestRejected(msg)) = &err else {
5124 panic!("expected RequestRejected, got {err:?}");
5125 };
5126 assert!(msg.contains("-1127"), "code missing from {msg:?}");
5127 assert!(!err.is_transient());
5128 }
5129
5130 #[tokio::test]
5132 async fn margin_trades_report_an_undecodable_response_as_internal() {
5133 let err = margin_trades_error(
5134 wiremock::ResponseTemplate::new(200)
5135 .set_body_json(serde_json::json!({"unexpected": 1})),
5136 )
5137 .await;
5138
5139 assert!(
5140 matches!(err, UnindexedClientError::Internal(_)),
5141 "got {err:?}"
5142 );
5143 assert!(!err.is_transient());
5144 }
5145
5146 #[tokio::test]
5149 async fn sapi_used_ip_weight_near_the_limit_pauses_queries() {
5150 for (used, pauses) in [("10799", false), ("10800", true), ("not a number", false)] {
5151 let server = wiremock::MockServer::start().await;
5152 wiremock::Mock::given(wiremock::matchers::method("GET"))
5153 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
5154 .respond_with(
5155 wiremock::ResponseTemplate::new(200)
5156 .insert_header("x-sapi-used-ip-weight-1m", used)
5157 .set_body_json(serde_json::json!([])),
5158 )
5159 .mount(&server)
5160 .await;
5161 let rest = Arc::new(MarginTradingRestApi::from_config(
5162 ConfigurationRestApi::builder()
5163 .api_key("key")
5164 .api_secret("secret")
5165 .base_path(server.uri())
5166 .build()
5167 .unwrap(),
5168 ));
5169 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5170
5171 fetch_margin_all_open_orders(
5172 rest,
5173 Arc::clone(&tracker),
5174 KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5175 false,
5176 )
5177 .await
5178 .unwrap();
5179
5180 let query_waits = tokio::time::timeout(
5181 Duration::from_millis(50),
5182 tracker.wait_if_blocked(RequestKind::Query),
5183 )
5184 .await
5185 .is_err();
5186 assert_eq!(query_waits, pauses, "used IP weight {used}");
5187 tokio::time::timeout(
5188 Duration::from_millis(50),
5189 tracker.wait_if_blocked(RequestKind::Order),
5190 )
5191 .await
5192 .expect("orders never wait on the pause")
5193 .expect("no cooldown too long to sleep through");
5194 }
5195 }
5196
5197 #[tokio::test]
5199 async fn margin_fill_recovery_does_not_wait_for_the_weight_pause() {
5200 let server = wiremock::MockServer::start().await;
5201 wiremock::Mock::given(wiremock::matchers::method("GET"))
5202 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5203 .respond_with(
5205 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
5206 "symbol": "BTCUSDT",
5207 "id": 1,
5208 "orderId": 7,
5209 "price": "100",
5210 "qty": "1",
5211 "commission": "0",
5212 "commissionAsset": "USDT",
5213 "time": Utc::now().timestamp_millis(),
5214 "isBuyer": true,
5215 "isMaker": false,
5216 }])),
5217 )
5218 .expect(2)
5219 .mount(&server)
5220 .await;
5221 let rest = Arc::new(MarginTradingRestApi::from_config(
5222 ConfigurationRestApi::builder()
5223 .api_key("key")
5224 .api_secret("secret")
5225 .base_path(server.uri())
5226 .build()
5227 .unwrap(),
5228 ));
5229 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5230 tracker.throttle(Duration::from_secs(60));
5231 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5232 let mut unrecovered = UnrecoveredFills::default();
5233 unrecovered.open(
5234 &[InstrumentNameExchange::new("BTCUSDT")],
5235 Utc::now() - chrono::Duration::minutes(1),
5236 Utc::now(),
5237 );
5238
5239 tokio::time::timeout(
5241 Duration::from_millis(800),
5242 recover_margin_fills(
5243 &rest,
5244 &tracker,
5245 &mut unrecovered,
5246 &tx,
5247 &new_dedup_cache(),
5248 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5249 false,
5250 ),
5251 )
5252 .await
5253 .expect("recovery does not wait for the pause");
5254 }
5255
5256 #[tokio::test]
5259 async fn margin_trades_read_over_a_span_stop_at_its_end() {
5260 let now = Utc::now().timestamp_millis();
5261 let trades = vec![
5262 (1, now - 50 * HOUR_MS),
5263 (2, now - 49 * HOUR_MS),
5264 (3, now - 10 * HOUR_MS),
5265 ];
5266 let (ids, _) = read_margin_trades(
5267 venue(trades),
5268 MyTradesFrom::Span {
5269 start: now - 60 * HOUR_MS,
5270 end: now - 20 * HOUR_MS,
5271 },
5272 )
5273 .await;
5274
5275 assert_eq!(ids, vec![1, 2]);
5276 }
5277
5278 #[tokio::test]
5281 async fn a_margin_gap_whose_every_read_fails_is_reported_when_given_up() {
5282 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5283 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5284 let server = wiremock::MockServer::start().await;
5285 wiremock::Mock::given(wiremock::matchers::method("GET"))
5286 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5287 .respond_with(
5288 wiremock::ResponseTemplate::new(400)
5289 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5290 )
5291 .mount(&server)
5292 .await;
5293 let rest = Arc::new(MarginTradingRestApi::from_config(
5294 ConfigurationRestApi::builder()
5295 .api_key("key")
5296 .api_secret("secret")
5297 .base_path(server.uri())
5298 .build()
5299 .expect("valid config"),
5300 ));
5301 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5302 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5303 let dedup = new_dedup_cache();
5304 let btc = InstrumentNameExchange::new("BTCUSDT");
5305 let mut unrecovered = UnrecoveredFills::default();
5306 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
5307
5308 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
5309 recover_margin_fills(
5310 &rest,
5311 &tracker,
5312 &mut unrecovered,
5313 &tx,
5314 &dedup,
5315 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5316 false,
5317 )
5318 .await;
5319 unrecovered.make_due();
5320 }
5321
5322 assert!(unrecovered.is_empty(), "the gap is given up");
5323 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5324 let [event] = forwarded.as_slice() else {
5325 panic!("only the give-up is sent: {forwarded:?}");
5326 };
5327 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
5328 panic!("expected FillRecoveryGaveUp, got {event:?}");
5329 };
5330 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
5331 assert_eq!(
5332 gave_up.scope,
5333 crate::FillRecoveryScope::Instruments(vec![btc])
5334 );
5335 assert_eq!(
5336 gave_up.start.timestamp_millis(),
5337 disconnect.timestamp_millis()
5338 );
5339 assert_eq!(
5340 gave_up.attempts,
5341 crate::client::order_recovery::MAX_GAP_RETRIES + 1
5342 );
5343 assert!(
5344 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
5345 "the last read's error: {:?}",
5346 gave_up.reason
5347 );
5348 }
5349
5350 #[tokio::test]
5353 async fn a_failed_margin_recovery_keeps_its_closed_gap_for_the_retry() {
5354 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5355 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5356 let server = wiremock::MockServer::start().await;
5357 wiremock::Mock::given(wiremock::matchers::method("GET"))
5358 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5359 .respond_with(
5360 wiremock::ResponseTemplate::new(400)
5361 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5362 )
5363 .up_to_n_times(1)
5364 .with_priority(1)
5365 .mount(&server)
5366 .await;
5367 wiremock::Mock::given(wiremock::matchers::method("GET"))
5368 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5369 .respond_with(venue(vec![
5370 (
5371 1,
5372 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
5373 ),
5374 (
5375 2,
5376 (Utc::now() - chrono::Duration::minutes(1)).timestamp_millis(),
5377 ),
5378 ]))
5379 .mount(&server)
5380 .await;
5381 let rest = Arc::new(MarginTradingRestApi::from_config(
5382 ConfigurationRestApi::builder()
5383 .api_key("key")
5384 .api_secret("secret")
5385 .base_path(server.uri())
5386 .build()
5387 .unwrap(),
5388 ));
5389 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5390 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5391 let dedup = new_dedup_cache();
5392 let mut unrecovered = UnrecoveredFills::default();
5393 unrecovered.open(
5394 &[InstrumentNameExchange::new("BTCUSDT")],
5395 disconnect,
5396 reconnect,
5397 );
5398
5399 recover_margin_fills(
5400 &rest,
5401 &tracker,
5402 &mut unrecovered,
5403 &tx,
5404 &dedup,
5405 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5406 false,
5407 )
5408 .await;
5409 assert!(!unrecovered.is_empty(), "the failed gap is kept");
5410 assert!(rx.try_recv().is_err(), "nothing forwarded");
5411
5412 unrecovered.make_due();
5413 recover_margin_fills(
5414 &rest,
5415 &tracker,
5416 &mut unrecovered,
5417 &tx,
5418 &dedup,
5419 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5420 false,
5421 )
5422 .await;
5423 assert!(unrecovered.is_empty(), "the retry recovered it");
5424 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5425 assert_eq!(
5426 forwarded.len(),
5427 1,
5428 "only the fill inside the gap: {forwarded:?}"
5429 );
5430 }
5431
5432 #[tokio::test]
5435 async fn a_timed_out_margin_recovery_charges_only_the_gaps_it_started() {
5436 let server = wiremock::MockServer::start().await;
5437 wiremock::Mock::given(wiremock::matchers::method("GET"))
5438 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5439 .respond_with(
5440 wiremock::ResponseTemplate::new(200)
5441 .set_body_json(serde_json::json!([]))
5442 .set_delay(Duration::from_secs(3_600)),
5443 )
5444 .mount(&server)
5445 .await;
5446 let rest = Arc::new(MarginTradingRestApi::from_config(
5447 ConfigurationRestApi::builder()
5448 .api_key("key")
5449 .api_secret("secret")
5450 .base_path(server.uri())
5451 .timeout(3_600_000_u64)
5452 .retries(0_u32)
5453 .build()
5454 .unwrap(),
5455 ));
5456 let instruments: Vec<_> = (0..9)
5457 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
5458 .collect();
5459 let mut unrecovered = UnrecoveredFills::default();
5460 unrecovered.open(
5461 &instruments,
5462 Utc::now() - chrono::Duration::minutes(10),
5463 Utc::now(),
5464 );
5465 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5466 tokio::time::pause();
5468
5469 recover_margin_fills(
5470 &rest,
5471 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5472 &mut unrecovered,
5473 &tx,
5474 &new_dedup_cache(),
5475 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5476 false,
5477 )
5478 .await;
5479
5480 let now = tokio::time::Instant::now();
5481 assert_eq!(
5482 unrecovered.due(now).len(),
5483 1,
5484 "the gap never started stays due"
5485 );
5486 assert_eq!(
5487 unrecovered
5488 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
5489 .len(),
5490 9,
5491 "the eight started wait for their first retry"
5492 );
5493 }
5494
5495 fn margin_order_row(cid: &str, status: &str) -> serde_json::Value {
5501 serde_json::json!({
5502 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
5503 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
5504 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "isIsolated": false,
5505 "time": 1_700_000_000_000_i64, "updateTime": 1_700_000_060_000_i64,
5506 })
5507 }
5508
5509 fn margin_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
5510 OrderKey::new(
5511 ExchangeId::BinanceMargin,
5512 InstrumentNameExchange::new(instrument),
5513 StrategyId::new("strategy"),
5514 ClientOrderId::new(cid),
5515 )
5516 }
5517
5518 #[test]
5519 fn a_margin_order_row_says_how_the_order_ended() {
5520 use crate::order::state::InactiveOrderState;
5521 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOrderResponse;
5522 let key = margin_key("BTCUSDT", "a");
5523 let ended = |row: serde_json::Value| {
5524 let row: QueryMarginAccountsOrderResponse = serde_json::from_value(row).unwrap();
5525 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, None)
5526 .map(|order| order.state)
5527 };
5528
5529 let mut filled = margin_order_row("a", "FILLED");
5530 filled["executedQty"] = "2".into();
5531 filled["cummulativeQuoteQty"] = "210".into();
5532 let Some(InactiveOrderState::FullyFilled(filled)) = ended(filled) else {
5533 panic!("FILLED is fully filled");
5534 };
5535 assert_eq!(filled.avg_price, Some(Decimal::from(105)));
5536 assert!(matches!(
5537 ended(margin_order_row("a", "CANCELED")),
5538 Some(InactiveOrderState::Cancelled(cancelled)) if cancelled.filled_quantity == Some(Decimal::ONE)
5539 ));
5540 assert_eq!(ended(margin_order_row("a", "NEW")), None);
5541
5542 let mut unknown_quote = margin_order_row("a", "FILLED");
5544 unknown_quote["executedQty"] = "2".into();
5545 unknown_quote["cummulativeQuoteQty"] = "-1".into();
5546 let Some(InactiveOrderState::FullyFilled(filled)) = ended(unknown_quote) else {
5547 panic!("still fully filled");
5548 };
5549 assert_eq!(filled.avg_price, None, "no negative average price");
5550 }
5551
5552 fn margin_rest_config_at(server: &wiremock::MockServer) -> ConfigurationRestApi {
5554 ConfigurationRestApi::builder()
5555 .api_key("key")
5556 .api_secret("secret")
5557 .base_path(server.uri())
5558 .build()
5559 .unwrap()
5560 }
5561
5562 fn margin_rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
5563 Arc::new(MarginTradingRestApi::from_config(margin_rest_config_at(
5564 server,
5565 )))
5566 }
5567
5568 pub(super) fn margin_client_at(
5570 server: &wiremock::MockServer,
5571 is_isolated: bool,
5572 ) -> BinanceMargin {
5573 let config = if is_isolated {
5574 BinanceMarginConfig::isolated(
5575 "key".into(),
5576 "secret".into(),
5577 vec![InstrumentNameExchange::new("BTCUSDT")],
5578 )
5579 } else {
5580 BinanceMarginConfig::cross_margin("key".into(), "secret".into())
5581 };
5582 let mode = config.pre_send_rules;
5583 let mut client = <BinanceMargin as ExecutionClient>::new(config);
5584 client.rest = margin_rest_at(server);
5585 client.rest_config = Arc::new(margin_rest_config_at(server));
5586 client.rules = rules_at(server, mode);
5587 client
5588 }
5589
5590 fn rules_at(server: &wiremock::MockServer, mode: PreSendRules) -> Arc<RulesLoader> {
5592 Arc::new(RulesLoader::new(
5593 mode,
5594 Arc::new(binance_sdk::spot::SpotRestApi::from_config(
5595 ConfigurationRestApi::builder()
5596 .api_key("key")
5597 .api_secret("secret")
5598 .base_path(server.uri())
5599 .build()
5600 .unwrap(),
5601 )),
5602 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5603 ))
5604 }
5605
5606 async fn mount_margin_order(
5608 server: &wiremock::MockServer,
5609 cid: &str,
5610 response: wiremock::ResponseTemplate,
5611 ) {
5612 wiremock::Mock::given(wiremock::matchers::method("GET"))
5613 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
5614 .and(wiremock::matchers::query_param("origClientOrderId", cid))
5615 .respond_with(response)
5616 .mount(server)
5617 .await;
5618 }
5619
5620 fn margin_venue_error(code: i64) -> wiremock::ResponseTemplate {
5621 wiremock::ResponseTemplate::new(400)
5622 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
5623 }
5624
5625 fn margin_row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
5626 wiremock::ResponseTemplate::new(200).set_body_json(margin_order_row(cid, status))
5627 }
5628
5629 async fn margin_requests(server: &wiremock::MockServer) -> Vec<(String, Option<String>)> {
5631 server
5632 .received_requests()
5633 .await
5634 .unwrap()
5635 .iter()
5636 .map(|request| {
5637 let isolated = request
5638 .url
5639 .query_pairs()
5640 .find(|(key, _)| key == "isIsolated")
5641 .map(|(_, value)| value.into_owned());
5642 (request.url.path().to_owned(), isolated)
5643 })
5644 .collect()
5645 }
5646
5647 #[tokio::test]
5648 async fn margin_fetch_ended_orders_asks_the_configured_account_and_omits_the_rest() {
5649 use crate::order::state::InactiveOrderState;
5650 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
5651 let server = wiremock::MockServer::start().await;
5652 mount_margin_order(&server, "filled", margin_row_response("filled", "FILLED")).await;
5653 mount_margin_order(&server, "live", margin_row_response("live", "NEW")).await;
5654 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
5655 mount_margin_order(&server, "no-symbol", margin_venue_error(-1121)).await;
5656 let client = margin_client_at(&server, is_isolated);
5657 let keys = ["filled", "live", "unknown"].map(|cid| margin_key("BTCUSDT", cid));
5658
5659 let ended = client
5660 .fetch_ended_orders(
5661 &[keys.to_vec(), vec![margin_key("NOSUCH", "no-symbol")]].concat(),
5662 )
5663 .await
5664 .unwrap();
5665
5666 let [order] = ended.as_slice() else {
5667 panic!("one order ended: {ended:?}");
5668 };
5669 assert_eq!(order.key, keys[0], "the strategy asked survives");
5670 assert!(matches!(order.state, InactiveOrderState::FullyFilled(_)));
5671 let requests = margin_requests(&server).await;
5672 assert_eq!(requests.len(), 4);
5673 for (path, isolated) in requests {
5674 assert_eq!(path, "/sapi/v1/margin/order");
5675 assert_eq!(
5676 isolated.as_deref(),
5677 Some(flag),
5678 "is_isolated = {is_isolated}"
5679 );
5680 }
5681 }
5682 }
5683
5684 #[tokio::test]
5685 async fn margin_fetch_ended_orders_fails_when_any_lookup_fails() {
5686 let server = wiremock::MockServer::start().await;
5687 mount_margin_order(
5688 &server,
5689 "cancelled",
5690 margin_row_response("cancelled", "CANCELED"),
5691 )
5692 .await;
5693 mount_margin_order(&server, "refused", margin_venue_error(-1100)).await;
5694 let client = margin_client_at(&server, false);
5695
5696 let result = client
5697 .fetch_ended_orders(&[
5698 margin_key("BTCUSDT", "cancelled"),
5699 margin_key("BTCUSDT", "refused"),
5700 ])
5701 .await;
5702
5703 assert!(
5704 matches!(
5705 result,
5706 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
5707 ),
5708 "{result:?}"
5709 );
5710 }
5711
5712 fn held_margin_orders() -> SharedKnownLiveOrders {
5714 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
5715 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
5716 known.lock().live(
5717 &margin_key("BTCUSDT", cid),
5718 Decimal::TWO,
5719 OrderKind::Limit,
5720 &Open::new(
5721 VenueOrderId::Assigned(OrderId::new(n.to_string())),
5722 Utc::now(),
5723 Decimal::ZERO,
5724 ),
5725 );
5726 }
5727 known
5728 }
5729
5730 #[tokio::test]
5733 async fn a_live_conditional_margin_order_is_listed_with_the_kind_the_client_knows() {
5734 use crate::order::ConditionalFamily;
5735 let server = wiremock::MockServer::start().await;
5736 let mut row = margin_order_row("conditional", "NEW");
5737 row["type"] = "STOP_LOSS_LIMIT".into();
5738 row["price"] = "94".into();
5739 row["stopPrice"] = "95".into();
5740 wiremock::Mock::given(wiremock::matchers::method("GET"))
5741 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
5742 .respond_with(
5743 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([row])),
5744 )
5745 .mount(&server)
5746 .await;
5747 let client = margin_client_at(&server, false);
5748 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
5749 let cid = ClientOrderId::new("conditional");
5750 let kinds = |orders: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>| {
5751 orders
5752 .into_iter()
5753 .map(|order| (order.key.cid, order.kind))
5754 .collect::<Vec<_>>()
5755 };
5756
5757 let unresolved = OrderKind::ConditionalUnresolved {
5758 family: ConditionalFamily::StopLimit,
5759 trigger_price: Some(Decimal::from(95)),
5760 };
5761 assert_eq!(
5762 kinds(client.fetch_open_orders(&[]).await.unwrap()),
5763 [(cid.clone(), unresolved)]
5764 );
5765 assert_eq!(
5766 kinds(
5767 client
5768 .fetch_open_orders(std::slice::from_ref(&btcusdt))
5769 .await
5770 .unwrap()
5771 ),
5772 [(cid.clone(), unresolved)]
5773 );
5774 let listed = listed_margin_open_cids(
5775 margin_rest_at(&server),
5776 Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5777 client.known_live.clone(),
5778 vec![btcusdt.clone()],
5779 false,
5780 )
5781 .await
5782 .unwrap();
5783 assert!(listed.contains(&cid), "{listed:?}");
5784
5785 let trailing = OrderKind::TrailingStopFixedLimit {
5786 offset: Decimal::from(25),
5787 offset_type: TrailingOffsetType::BasisPoints,
5788 activation_price: Some(Decimal::from(95)),
5789 };
5790 client.recall_order_kinds([(cid.clone(), trailing)]);
5791 assert_eq!(
5792 kinds(client.fetch_open_orders(&[]).await.unwrap()),
5793 [(cid.clone(), trailing)]
5794 );
5795 assert_eq!(
5796 kinds(client.fetch_open_orders(&[btcusdt]).await.unwrap()),
5797 [(cid, trailing)]
5798 );
5799 }
5800
5801 #[tokio::test]
5806 async fn an_ended_margin_trailing_stop_is_reported_with_the_kind_it_was_held_with() {
5807 let server = wiremock::MockServer::start().await;
5808 wiremock::Mock::given(wiremock::matchers::method("GET"))
5809 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
5810 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
5811 .mount(&server)
5812 .await;
5813 let mut row = margin_order_row("trailing", "CANCELED");
5814 row["type"] = "STOP_LOSS".into();
5815 row["price"] = "0.00000000".into();
5816 row["stopPrice"] = "0.00000000".into();
5817 mount_margin_order(
5818 &server,
5819 "trailing",
5820 wiremock::ResponseTemplate::new(200).set_body_json(row),
5821 )
5822 .await;
5823 let trailing_stop = OrderKind::TrailingStop {
5824 offset: Decimal::from(25),
5825 offset_type: TrailingOffsetType::BasisPoints,
5826 activation_price: None,
5827 };
5828 let hold = |known: &SharedKnownLiveOrders| {
5829 known.lock().live(
5830 &margin_key("BTCUSDT", "trailing"),
5831 Decimal::TWO,
5832 trailing_stop,
5833 &Open::new(
5834 VenueOrderId::Assigned(OrderId::new("7")),
5835 Utc::now(),
5836 Decimal::ZERO,
5837 ),
5838 );
5839 };
5840
5841 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
5842 hold(&known);
5843 let mut unchecked = UncheckedOrders::default();
5844 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
5845 let (tx, mut rx) = mpsc::unbounded_channel();
5846 recover_margin_ended_orders(
5847 &margin_rest_at(&server),
5848 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5849 &known,
5850 &mut unchecked,
5851 &UnrecoveredFills::default(),
5852 &tx,
5853 false,
5854 )
5855 .await;
5856 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5857 let [event] = sent.as_slice() else {
5858 panic!("the trailing stop ended: {sent:?}");
5859 };
5860 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
5861 panic!("an order snapshot: {event:?}");
5862 };
5863 assert_eq!(snapshot.0.kind, trailing_stop);
5864 assert!(matches!(
5865 snapshot.0.state,
5866 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
5867 ));
5868 assert!(!known.lock().contains(&ClientOrderId::new("trailing")));
5869
5870 let client = margin_client_at(&server, false);
5871 hold(&client.known_live);
5872 let ended = client
5873 .fetch_ended_orders(&[margin_key("BTCUSDT", "trailing")])
5874 .await
5875 .expect("lookup");
5876 let [order] = ended.as_slice() else {
5877 panic!("the trailing stop ended: {ended:?}");
5878 };
5879 assert_eq!(order.kind, trailing_stop);
5880 }
5881
5882 #[tokio::test]
5883 async fn a_margin_reconnect_reports_how_an_unlisted_order_ended_in_the_configured_account() {
5884 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
5885 let server = wiremock::MockServer::start().await;
5886 wiremock::Mock::given(wiremock::matchers::method("GET"))
5887 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
5888 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
5889 .respond_with(
5890 wiremock::ResponseTemplate::new(200)
5891 .set_body_json(serde_json::json!([margin_order_row("live", "NEW")])),
5892 )
5893 .mount(&server)
5894 .await;
5895 mount_margin_order(&server, "gone", margin_row_response("gone", "CANCELED")).await;
5896 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
5897 let known = held_margin_orders();
5898 let mut unchecked = UncheckedOrders::default();
5899 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
5900 let (tx, mut rx) = mpsc::unbounded_channel();
5901
5902 recover_margin_ended_orders(
5903 &margin_rest_at(&server),
5904 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5905 &known,
5906 &mut unchecked,
5907 &UnrecoveredFills::default(),
5908 &tx,
5909 is_isolated,
5910 )
5911 .await;
5912
5913 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5914 let [event] = sent.as_slice() else {
5915 panic!("one order ended: {sent:?}");
5916 };
5917 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
5918 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
5919 panic!("an order snapshot: {event:?}");
5920 };
5921 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
5922 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
5923 assert!(matches!(
5924 snapshot.0.state,
5925 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
5926 ));
5927 let mut requests = margin_requests(&server).await;
5928 requests.sort();
5929 let expected = |path: &str| (path.to_owned(), Some(flag.to_owned()));
5930 assert_eq!(
5931 requests,
5932 [
5933 expected("/sapi/v1/margin/openOrders"),
5934 expected("/sapi/v1/margin/order"),
5935 expected("/sapi/v1/margin/order"),
5936 ],
5937 "one listing, and a lookup of each unlisted order only"
5938 );
5939 let known = known.lock();
5940 assert!(known.contains(&ClientOrderId::new("live")));
5941 assert!(!known.contains(&ClientOrderId::new("gone")));
5942 assert!(
5943 !known.contains(&ClientOrderId::new("unknown")),
5944 "asking again cannot help"
5945 );
5946 assert!(unchecked.is_empty());
5947 }
5948 }
5949
5950 fn margin_open_request<'a>(
5951 instrument: &'a InstrumentNameExchange,
5952 cid: &str,
5953 ) -> OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange> {
5954 OrderRequestOpen {
5955 key: OrderKey::new(
5956 ExchangeId::BinanceMargin,
5957 instrument,
5958 StrategyId::new("strategy"),
5959 ClientOrderId::new(cid),
5960 ),
5961 state: crate::order::request::RequestOpen::new(
5962 Side::Buy,
5963 Some(Decimal::from(100)),
5964 Decimal::TWO,
5965 OrderKind::Limit,
5966 TimeInForce::GoodUntilCancelled { post_only: false },
5967 ),
5968 }
5969 }
5970
5971 #[tokio::test]
5974 async fn a_margin_trailing_stop_is_sent_with_trailing_delta() {
5975 let server = wiremock::MockServer::start().await;
5976 let sent_as_trailing_stop = |request: &wiremock::Request| {
5977 let mut body_as_query = request.url.clone();
5979 body_as_query.set_query(Some(&String::from_utf8_lossy(&request.body)));
5980 let params: Vec<(String, String)> = request
5981 .url
5982 .query_pairs()
5983 .chain(body_as_query.query_pairs())
5984 .map(|(k, v)| (k.into_owned(), v.into_owned()))
5985 .collect();
5986 let has = |key: &str, value: &str| params.iter().any(|(k, v)| k == key && v == value);
5987 has("type", "STOP_LOSS")
5988 && has("trailingDelta", "25")
5989 && has("sideEffectType", "NO_SIDE_EFFECT")
5990 && !params.iter().any(|(k, _)| k == "stopPrice" || k == "price")
5991 };
5992 wiremock::Mock::given(wiremock::matchers::method("POST"))
5993 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
5994 .and(sent_as_trailing_stop)
5995 .respond_with(
5996 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5997 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "trailing",
5998 "transactTime": 1_700_000_000_000_i64, "price": "0", "origQty": "2",
5999 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6000 "timeInForce": "GTC", "type": "STOP_LOSS", "side": "SELL",
6001 })),
6002 )
6003 .expect(1)
6004 .mount(&server)
6005 .await;
6006 let client = margin_client_at(&server, false);
6007 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6008 let trailing_request = |cid, offset| {
6009 let mut request = margin_open_request(&btcusdt, cid);
6010 request.state.side = Side::Sell;
6011 request.state.price = None;
6012 request.state.kind = OrderKind::TrailingStop {
6013 offset,
6014 offset_type: TrailingOffsetType::BasisPoints,
6015 activation_price: None,
6016 };
6017 request
6018 };
6019
6020 let placed = client
6021 .open_order(trailing_request("trailing", Decimal::from(25)))
6022 .await;
6023 assert!(
6024 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6025 "{placed:?}"
6026 );
6027
6028 let refused = client
6029 .open_order(trailing_request("fractional", Decimal::new(25, 1)))
6030 .await;
6031 assert!(
6032 matches!(
6033 refused.state,
6034 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
6035 OrderError::InvalidPrecision(_)
6036 ))
6037 ),
6038 "{refused:?}"
6039 );
6040 }
6041
6042 #[tokio::test]
6046 async fn a_request_margin_effect_picks_the_side_effect_type_sent() {
6047 let server = wiremock::MockServer::start().await;
6048 let sends = |wire: &'static str, auto_repay_at_cancel: Option<&'static str>| {
6049 move |request: &wiremock::Request| {
6050 let mut body_as_query = request.url.clone();
6051 body_as_query.set_query(Some(&String::from_utf8_lossy(&request.body)));
6052 let params: Vec<(String, String)> = request
6053 .url
6054 .query_pairs()
6055 .chain(body_as_query.query_pairs())
6056 .map(|(k, v)| (k.into_owned(), v.into_owned()))
6057 .collect();
6058 let value = |key: &str| {
6059 params
6060 .iter()
6061 .find(|(k, _)| k == key)
6062 .map(|(_, v)| v.as_str())
6063 };
6064 value("sideEffectType") == Some(wire)
6065 && value("autoRepayAtCancel") == auto_repay_at_cancel
6066 }
6067 };
6068 for (matcher, cid) in [
6069 (sends("MARGIN_BUY", Some("true")), "borrow"),
6070 (sends("AUTO_REPAY", None), "repay"),
6071 (sends("NO_SIDE_EFFECT", None), "default"),
6072 ] {
6073 wiremock::Mock::given(wiremock::matchers::method("POST"))
6074 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6075 .and(matcher)
6076 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6077 serde_json::json!({
6078 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
6079 "transactTime": 1_700_000_000_000_i64, "price": "50000", "origQty": "2",
6080 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6081 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6082 }),
6083 ))
6084 .expect(1)
6085 .mount(&server)
6086 .await;
6087 }
6088 let client = margin_client_at(&server, false);
6089 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6090
6091 for (cid, effect) in [
6092 ("borrow", Some(MarginEffect::Borrow)),
6093 ("repay", Some(MarginEffect::Repay)),
6094 ("default", None),
6095 ] {
6096 let mut request = margin_open_request(&btcusdt, cid);
6097 request.state.margin_effect = effect;
6098 let placed = client.open_order(request).await;
6099 assert!(
6100 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6101 "{cid}: {placed:?}"
6102 );
6103 }
6104 }
6105
6106 use crate::error::{Bound, BoundsViolation, OrderField, PrecisionLimit, PrecisionViolation};
6107 use rust_decimal_macros::dec;
6108
6109 fn btcusdt_exchange_info() -> wiremock::ResponseTemplate {
6111 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6112 "timezone": "UTC", "serverTime": 1_700_000_000_000_i64,
6113 "symbols": [{
6114 "symbol": "BTCUSDT", "status": "TRADING",
6115 "filters": [
6116 {"filterType": "PRICE_FILTER", "minPrice": "0.01", "maxPrice": "0", "tickSize": "0.01"},
6117 {"filterType": "LOT_SIZE", "minQty": "0.001", "maxQty": "100", "stepSize": "0.001"},
6118 {"filterType": "NOTIONAL", "minNotional": "5", "applyMinToMarket": true,
6119 "maxNotional": "9000000", "applyMaxToMarket": false, "avgPriceMins": 5},
6120 ],
6121 }],
6122 }))
6123 }
6124
6125 async fn mount_order_accepted(server: &wiremock::MockServer, expected: u64) {
6126 wiremock::Mock::given(wiremock::matchers::method("POST"))
6127 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6128 .respond_with(
6129 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6130 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "c",
6131 "transactTime": 1_700_000_000_000_i64, "price": "50000", "origQty": "2",
6132 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6133 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6134 })),
6135 )
6136 .expect(expected)
6137 .mount(server)
6138 .await;
6139 }
6140
6141 #[tokio::test]
6144 async fn an_order_breaking_the_symbols_rules_is_refused_unsent() {
6145 let server = wiremock::MockServer::start().await;
6146 wiremock::Mock::given(wiremock::matchers::method("GET"))
6147 .and(wiremock::matchers::path("/api/v3/exchangeInfo"))
6148 .and(wiremock::matchers::query_param("symbols", r#"["BTCUSDT"]"#))
6149 .respond_with(btcusdt_exchange_info())
6150 .expect(1)
6151 .mount(&server)
6152 .await;
6153 mount_order_accepted(&server, 1).await;
6154 let client = margin_client_at(&server, false);
6155 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6156 let order = |cid: &str, price: Decimal, quantity: Decimal| {
6157 let mut request = margin_open_request(&btcusdt, cid);
6158 request.state.price = Some(price);
6159 request.state.quantity = quantity;
6160 request
6161 };
6162
6163 let off_tick = client.open_order(order("a", dec!(100.001), dec!(1))).await;
6164 assert_eq!(
6165 off_tick.state,
6166 OrderState::inactive(OrderError::InvalidPrecision(PrecisionViolation::new(
6167 OrderField::Price,
6168 dec!(100.001),
6169 PrecisionLimit::NotOnGrid { step: dec!(0.01) }
6170 )))
6171 );
6172 let dust = client.open_order(order("b", dec!(100), dec!(0.001))).await;
6173 assert_eq!(
6174 dust.state,
6175 OrderState::inactive(OrderError::OutOfBounds(BoundsViolation::new(
6176 OrderField::Notional,
6177 Bound::Min,
6178 dec!(5),
6179 dec!(0.100)
6180 )))
6181 );
6182 let placed = client.open_order(order("c", dec!(100), dec!(1))).await;
6183 assert!(
6184 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6185 "{placed:?}"
6186 );
6187 }
6188
6189 #[tokio::test]
6191 async fn with_the_rules_off_every_order_is_sent_and_no_rules_are_read() {
6192 let server = wiremock::MockServer::start().await;
6193 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
6194 .respond_with(btcusdt_exchange_info())
6195 .expect(0)
6196 .mount(&server)
6197 .await;
6198 mount_order_accepted(&server, 1).await;
6199 let mut client = margin_client_at(&server, false);
6200 client.rules = rules_at(&server, PreSendRules::Off);
6201 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6202 let mut request = margin_open_request(&btcusdt, "x");
6203 request.state.price = Some(dec!(100.001));
6204 let placed = client.open_order(request).await;
6205 assert!(
6206 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6207 "{placed:?}"
6208 );
6209 }
6210
6211 #[tokio::test]
6213 async fn an_order_whose_rules_cannot_be_loaded_is_sent_unchecked() {
6214 let server = wiremock::MockServer::start().await;
6215 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
6216 .respond_with(wiremock::ResponseTemplate::new(500))
6217 .mount(&server)
6218 .await;
6219 mount_order_accepted(&server, 1).await;
6220 let client = margin_client_at(&server, false);
6221 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6222 let mut request = margin_open_request(&btcusdt, "x");
6223 request.state.price = Some(dec!(100.001));
6224 let placed = client.open_order(request).await;
6225 assert!(
6226 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6227 "{placed:?}"
6228 );
6229 }
6230
6231 #[tokio::test]
6233 async fn load_rules_returns_the_rules_and_the_first_order_reuses_them() {
6234 let server = wiremock::MockServer::start().await;
6235 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
6236 .respond_with(btcusdt_exchange_info())
6237 .expect(1)
6238 .mount(&server)
6239 .await;
6240 mount_order_accepted(&server, 0).await;
6241 let client = margin_client_at(&server, false);
6242 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6243
6244 let loaded = client.load_rules(std::slice::from_ref(&btcusdt)).await;
6245 let rules = loaded.ok().and_then(|mut loaded| loaded.remove(&btcusdt));
6246 assert_eq!(
6247 rules.map(|rules| (rules.price.step, rules.quantity.step, rules.notional.min)),
6248 Some((Some(dec!(0.01)), Some(dec!(0.001)), Some(dec!(5))))
6249 );
6250
6251 let mut request = margin_open_request(&btcusdt, "x");
6252 request.state.price = Some(dec!(100.001));
6253 let refused = client.open_order(request).await;
6254 assert!(
6255 matches!(refused.state, OrderState::Inactive(_)),
6256 "{refused:?}"
6257 );
6258 }
6259
6260 #[tokio::test]
6262 async fn load_rules_of_no_symbols_sends_no_request() {
6263 let server = wiremock::MockServer::start().await;
6264 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
6265 .respond_with(btcusdt_exchange_info())
6266 .expect(0)
6267 .mount(&server)
6268 .await;
6269 let client = margin_client_at(&server, false);
6270 assert_eq!(client.load_rules(&[]).await, Ok(HashMap::new()));
6271 }
6272
6273 #[tokio::test]
6277 async fn a_cross_refusal_of_a_trailing_stop_is_an_unsupported_order_type() {
6278 let server = wiremock::MockServer::start().await;
6279 wiremock::Mock::given(wiremock::matchers::method("POST"))
6280 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6281 .respond_with(
6282 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
6283 "code": -3111,
6284 "msg": "Trailing stop order only supports limit type.",
6285 })),
6286 )
6287 .expect(1)
6288 .mount(&server)
6289 .await;
6290 let client = margin_client_at(&server, false);
6291 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6292 let mut request = margin_open_request(&btcusdt, "trailing");
6293 request.state.side = Side::Sell;
6294 request.state.price = None;
6295 request.state.kind = OrderKind::TrailingStop {
6296 offset: Decimal::from(25),
6297 offset_type: TrailingOffsetType::BasisPoints,
6298 activation_price: None,
6299 };
6300
6301 let refused = client.open_order(request).await;
6302
6303 let OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
6304 OrderError::UnsupportedOrderType(msg),
6305 )) = &refused.state
6306 else {
6307 panic!("expected UnsupportedOrderType: {refused:?}");
6308 };
6309 assert!(
6310 msg.contains("-3111") && msg.contains("only supports limit type"),
6311 "{msg}"
6312 );
6313 }
6314
6315 #[tokio::test]
6316 async fn placing_listing_and_cancelling_margin_orders_feed_the_known_live_set() {
6317 let server = wiremock::MockServer::start().await;
6318 wiremock::Mock::given(wiremock::matchers::method("POST"))
6319 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6320 .respond_with(
6321 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6322 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "placed",
6323 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
6324 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6325 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6326 })),
6327 )
6328 .mount(&server)
6329 .await;
6330 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6331 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6332 .respond_with(
6333 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6334 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
6335 "clientOrderId": "cancel", "price": "100", "origQty": "2", "executedQty": "0",
6336 "cummulativeQuoteQty": "0", "status": "CANCELED", "timeInForce": "GTC",
6337 "type": "LIMIT", "side": "BUY",
6338 })),
6339 )
6340 .mount(&server)
6341 .await;
6342 wiremock::Mock::given(wiremock::matchers::method("GET"))
6343 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6344 .respond_with(
6345 wiremock::ResponseTemplate::new(200)
6346 .set_body_json(serde_json::json!([margin_order_row("listed", "NEW")])),
6347 )
6348 .mount(&server)
6349 .await;
6350 let client = margin_client_at(&server, false);
6351 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6352
6353 let placed = client
6354 .open_order(margin_open_request(&btcusdt, "placed"))
6355 .await;
6356 assert!(
6357 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6358 "{placed:?}"
6359 );
6360 assert!(
6361 client
6362 .known_live
6363 .lock()
6364 .contains(&ClientOrderId::new("placed"))
6365 );
6366
6367 client.fetch_open_orders(&[]).await.unwrap();
6368 assert!(
6369 client
6370 .known_live
6371 .lock()
6372 .contains(&ClientOrderId::new("listed"))
6373 );
6374
6375 let cancelled = client
6376 .cancel_order(OrderRequestCancel {
6377 key: OrderKey::new(
6378 ExchangeId::BinanceMargin,
6379 &btcusdt,
6380 StrategyId::new("strategy"),
6381 ClientOrderId::new("placed"),
6382 ),
6383 state: crate::order::request::RequestCancel {
6384 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
6385 },
6386 })
6387 .await;
6388 assert!(cancelled.state.is_ok(), "{cancelled:?}");
6389 assert!(
6390 !client
6391 .known_live
6392 .lock()
6393 .contains(&ClientOrderId::new("placed"))
6394 );
6395 }
6396
6397 #[tokio::test]
6400 async fn a_margin_order_kind_is_known_while_it_is_placed() {
6401 struct KindWhenAsked {
6402 known: SharedKnownLiveOrders,
6403 seen: Arc<parking_lot::Mutex<Option<OrderKind>>>,
6404 }
6405 impl wiremock::Respond for KindWhenAsked {
6406 fn respond(&self, _: &wiremock::Request) -> wiremock::ResponseTemplate {
6407 *self.seen.lock() = self.known.lock().kind_of(&ClientOrderId::new("racing"));
6408 wiremock::ResponseTemplate::new(400).set_body_json(
6409 serde_json::json!({"code": -2010, "msg": "Account has insufficient balance."}),
6410 )
6411 }
6412 }
6413
6414 let server = wiremock::MockServer::start().await;
6415 let client = margin_client_at(&server, true);
6416 let seen = Arc::new(parking_lot::Mutex::new(None));
6417 wiremock::Mock::given(wiremock::matchers::method("POST"))
6418 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6419 .respond_with(KindWhenAsked {
6420 known: client.known_live.clone(),
6421 seen: Arc::clone(&seen),
6422 })
6423 .mount(&server)
6424 .await;
6425 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6426
6427 let refused = client
6428 .open_order(margin_open_request(&btcusdt, "racing"))
6429 .await;
6430 assert!(
6431 matches!(refused.state, OrderState::Inactive(_)) && !refused.state.is_outcome_unknown(),
6432 "{refused:?}"
6433 );
6434 assert_eq!(*seen.lock(), Some(OrderKind::Limit));
6435 assert_eq!(
6436 client
6437 .known_live
6438 .lock()
6439 .kind_of(&ClientOrderId::new("racing")),
6440 None
6441 );
6442 }
6443
6444 #[tokio::test]
6448 async fn a_dropped_margin_placement_keeps_its_kind_only_once_its_request_is_out() {
6449 let server = wiremock::MockServer::start().await;
6450 wiremock::Mock::given(wiremock::matchers::method("POST"))
6451 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6452 .respond_with(
6453 wiremock::ResponseTemplate::new(200)
6454 .set_body_json(serde_json::json!({}))
6455 .set_delay(Duration::from_secs(3_600)),
6456 )
6457 .mount(&server)
6458 .await;
6459 let client = margin_client_at(&server, true);
6460 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6461 let kind_of = |cid: &str| client.known_live.lock().kind_of(&ClientOrderId::new(cid));
6462 let orders_sent = || async {
6463 let requests = server.received_requests().await.unwrap();
6464 requests
6465 .iter()
6466 .filter(|request| request.method == wiremock::http::Method::POST)
6467 .count()
6468 };
6469
6470 client
6471 .rate_limiter
6472 .on_rate_limited(Some(Duration::from_secs(5)));
6473 let waiting = tokio::time::timeout(
6474 Duration::from_millis(100),
6475 client.open_order(margin_open_request(&btcusdt, "waiting")),
6476 )
6477 .await;
6478 assert!(waiting.is_err(), "still waiting on the rate limiter");
6479 assert_eq!(orders_sent().await, 0);
6480 assert_eq!(kind_of("waiting"), None);
6481
6482 let fresh = margin_client_at(&server, true);
6483 tokio::time::timeout(Duration::from_secs(10), async {
6486 tokio::select! {
6487 placed = fresh.open_order(margin_open_request(&btcusdt, "sent")) => {
6488 panic!("the answer is held back: {placed:?}")
6489 }
6490 () = async {
6491 while orders_sent().await == 0 {
6492 tokio::time::sleep(Duration::from_millis(10)).await;
6493 }
6494 } => {}
6495 }
6496 })
6497 .await
6498 .expect("the request reaches the server");
6499 assert_eq!(orders_sent().await, 1);
6500 assert_eq!(
6501 fresh.known_live.lock().kind_of(&ClientOrderId::new("sent")),
6502 Some(OrderKind::Limit)
6503 );
6504 }
6505
6506 #[tokio::test]
6508 async fn a_cancel_response_without_executed_qty_reports_the_fill_unknown() {
6509 let server = wiremock::MockServer::start().await;
6510 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6511 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6512 .respond_with(
6513 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6514 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
6515 "clientOrderId": "cancel", "price": "100", "origQty": "2",
6516 "status": "CANCELED", "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6517 })),
6518 )
6519 .mount(&server)
6520 .await;
6521 let client = margin_client_at(&server, false);
6522
6523 let cancelled = client
6524 .cancel_order(OrderRequestCancel {
6525 key: OrderKey::new(
6526 ExchangeId::BinanceMargin,
6527 &InstrumentNameExchange::new("BTCUSDT"),
6528 StrategyId::new("strategy"),
6529 ClientOrderId::new("placed"),
6530 ),
6531 state: crate::order::request::RequestCancel {
6532 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
6533 },
6534 })
6535 .await;
6536 let Ok(cancelled) = cancelled.state else {
6537 panic!("expected Ok, got {cancelled:?}");
6538 };
6539 assert_eq!(cancelled.filled_quantity, None);
6540 }
6541
6542 fn borrowing_answer() -> serde_json::Value {
6545 serde_json::json!({
6546 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "borrow",
6547 "transactTime": 1_700_000_000_000_i64, "price": "0", "origQty": "0.0001",
6548 "executedQty": "0.0001", "cummulativeQuoteQty": "8", "status": "FILLED",
6549 "timeInForce": "GTC", "type": "MARKET", "side": "SELL", "isIsolated": false,
6550 "selfTradePreventionMode": "EXPIRE_MAKER",
6551 "marginBuyBorrowAmount": "0.00004321", "marginBuyBorrowAsset": "BTC",
6552 "fills": [{
6553 "price": "80000", "qty": "0.0001", "commission": "0.008",
6554 "commissionAsset": "USDT", "tradeId": 1,
6555 }],
6556 })
6557 }
6558
6559 fn borrowing_request(
6561 instrument: &InstrumentNameExchange,
6562 ) -> OrderRequestOpen<ExchangeId, &InstrumentNameExchange> {
6563 let mut request = margin_open_request(instrument, "borrow");
6564 request.state.side = Side::Sell;
6565 request.state.kind = OrderKind::Market;
6566 request.state.price = None;
6567 request.state.quantity = dec!(0.0001);
6568 request.state.margin_effect = Some(MarginEffect::Borrow);
6569 request
6570 }
6571
6572 fn borrowing_answer_without(fields: &[&str]) -> serde_json::Value {
6574 let mut answer = borrowing_answer();
6575 let object = answer.as_object_mut().unwrap();
6576 for field in fields {
6577 object.remove(*field);
6578 }
6579 answer
6580 }
6581
6582 #[tokio::test]
6585 async fn a_margin_placement_reports_what_the_order_borrowed() {
6586 let mut as_a_number = borrowing_answer();
6587 as_a_number["marginBuyBorrowAmount"] = serde_json::json!(5);
6588 let btc = AssetNameExchange::new("BTC");
6589 for (answer, expected) in [
6590 (
6591 borrowing_answer(),
6592 Some(MarginBorrow::new(btc.clone(), dec!(0.00004321))),
6593 ),
6594 (as_a_number, Some(MarginBorrow::new(btc, dec!(5)))),
6595 (
6596 borrowing_answer_without(&["marginBuyBorrowAmount", "marginBuyBorrowAsset"]),
6597 None,
6598 ),
6599 ] {
6600 let server = wiremock::MockServer::start().await;
6601 wiremock::Mock::given(wiremock::matchers::method("POST"))
6602 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6603 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
6604 .expect(1)
6605 .mount(&server)
6606 .await;
6607 let client = margin_client_at(&server, false);
6608 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6609
6610 let placement = client.open_margin_order(borrowing_request(&btcusdt)).await;
6611
6612 assert!(
6613 matches!(
6614 placement.order.state,
6615 OrderState::Inactive(crate::order::state::InactiveOrderState::FullyFilled(_))
6616 ),
6617 "{answer}: {placement:?}"
6618 );
6619 assert_eq!(placement.borrowed, expected, "{answer}");
6620 }
6621 }
6622
6623 #[tokio::test]
6626 async fn an_order_answered_with_a_fractional_borrowed_amount_is_read() {
6627 let server = wiremock::MockServer::start().await;
6628 wiremock::Mock::given(wiremock::matchers::method("POST"))
6629 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6630 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(borrowing_answer()))
6631 .expect(1)
6632 .mount(&server)
6633 .await;
6634 let client = margin_client_at(&server, false);
6635 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6636
6637 let placed = client.open_order(borrowing_request(&btcusdt)).await;
6638
6639 let OrderState::Inactive(crate::order::state::InactiveOrderState::FullyFilled(filled)) =
6640 placed.state
6641 else {
6642 panic!("expected a filled order, got {placed:?}");
6643 };
6644 assert_eq!(filled.id, OrderId::new("7"));
6645 assert_eq!(filled.filled_quantity, dec!(0.0001));
6646 }
6647
6648 #[tokio::test]
6651 async fn an_open_answered_unreadably_is_an_unknown_outcome() {
6652 for answer in [
6653 serde_json::json!({ "symbol": "BTCUSDT", "clientOrderId": "x" }),
6654 serde_json::json!({ "symbol": "BTCUSDT", "orderId": "not a number" }),
6655 borrowing_answer_without(&["marginBuyBorrowAsset"]),
6656 borrowing_answer_without(&["marginBuyBorrowAmount"]),
6657 ] {
6658 let server = wiremock::MockServer::start().await;
6659 wiremock::Mock::given(wiremock::matchers::method("POST"))
6660 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6661 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
6662 .expect(1)
6663 .mount(&server)
6664 .await;
6665 let client = margin_client_at(&server, false);
6666 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6667
6668 let placed = client
6669 .open_margin_order(margin_open_request(&btcusdt, "x"))
6670 .await;
6671
6672 assert!(
6673 matches!(
6674 placed.order.state,
6675 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
6676 OrderError::Connectivity(_)
6677 ))
6678 ),
6679 "{answer}: {placed:?}"
6680 );
6681 assert_eq!(placed.borrowed, None);
6682 }
6683 }
6684
6685 #[tokio::test]
6688 async fn a_cancel_answered_unreadably_is_an_unknown_outcome() {
6689 for answer in [
6690 serde_json::json!({ "symbol": "BTCUSDT", "status": "CANCELED" }),
6691 serde_json::json!({ "symbol": "BTCUSDT", "orderId": ["7"] }),
6692 serde_json::json!({ "symbol": "BTCUSDT", "orderId": "abc", "transactTime": 1 }),
6693 ] {
6694 let server = wiremock::MockServer::start().await;
6695 mount_order_accepted(&server, 1).await;
6696 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6697 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6698 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
6699 .expect(1)
6700 .mount(&server)
6701 .await;
6702 let client = margin_client_at(&server, false);
6703 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6704 client.open_order(margin_open_request(&btcusdt, "c")).await;
6705
6706 let cancelled = client
6707 .cancel_order(OrderRequestCancel {
6708 key: OrderKey::new(
6709 ExchangeId::BinanceMargin,
6710 &btcusdt,
6711 StrategyId::new("strategy"),
6712 ClientOrderId::new("c"),
6713 ),
6714 state: crate::order::request::RequestCancel {
6715 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
6716 },
6717 })
6718 .await;
6719
6720 assert!(
6721 matches!(cancelled.state, Err(OrderError::Connectivity(_))),
6722 "{answer}: {cancelled:?}"
6723 );
6724 assert!(client.known_live.lock().contains(&ClientOrderId::new("c")));
6725 }
6726 }
6727
6728 async fn signed_requests_sent(
6731 server: &wiremock::MockServer,
6732 ) -> Vec<(Vec<(String, String)>, Vec<u8>)> {
6733 server
6734 .received_requests()
6735 .await
6736 .unwrap()
6737 .into_iter()
6738 .map(|request| {
6739 let mut query: Vec<(String, String)> = request
6740 .url
6741 .query_pairs()
6742 .filter(|(k, _)| k != "timestamp" && k != "signature")
6743 .map(|(k, v)| (k.into_owned(), v.into_owned()))
6744 .collect();
6745 query.sort();
6746 (query, request.body)
6747 })
6748 .collect()
6749 }
6750
6751 #[tokio::test]
6755 async fn a_cancel_is_stamped_with_the_answers_transact_time() {
6756 let cancel = |answer: serde_json::Value| async move {
6757 let server = wiremock::MockServer::start().await;
6758 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6759 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6760 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
6761 .expect(1)
6762 .mount(&server)
6763 .await;
6764 let client = margin_client_at(&server, false);
6765 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6766 let cancelled = client
6767 .cancel_order(OrderRequestCancel {
6768 key: OrderKey::new(
6769 ExchangeId::BinanceMargin,
6770 &btcusdt,
6771 StrategyId::new("strategy"),
6772 ClientOrderId::new("c"),
6773 ),
6774 state: crate::order::request::RequestCancel {
6775 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
6776 },
6777 })
6778 .await;
6779 match cancelled.state {
6780 Ok(cancelled) => cancelled,
6781 Err(error) => panic!("{answer}: {error:?}"),
6782 }
6783 };
6784 let answer = |order_id: serde_json::Value, time: Option<i64>| {
6785 let mut answer = serde_json::json!({
6786 "orderId": order_id, "symbol": "BTCUSDT", "origClientOrderId": "c",
6787 "clientOrderId": "cancel", "price": "100", "origQty": "2", "executedQty": "0.5",
6788 "cummulativeQuoteQty": "50", "status": "CANCELED", "timeInForce": "GTC",
6789 "type": "LIMIT", "side": "BUY", "isIsolated": false,
6790 "selfTradePreventionMode": "EXPIRE_MAKER",
6791 });
6792 if let Some(time) = time {
6793 answer["transactTime"] = serde_json::json!(time);
6794 }
6795 answer
6796 };
6797 let sent_at = Utc.timestamp_millis_opt(1_700_000_000_123).unwrap();
6798
6799 for order_id in [serde_json::json!("7"), serde_json::json!(7)] {
6800 let cancelled = cancel(answer(order_id, Some(1_700_000_000_123))).await;
6801 assert_eq!(cancelled.id, OrderId::new("7"));
6802 assert_eq!(cancelled.time_exchange, sent_at);
6803 assert_eq!(cancelled.filled_quantity, Some(dec!(0.5)));
6804 }
6805
6806 let before = Utc::now();
6807 let cancelled = cancel(answer(serde_json::json!("7"), None)).await;
6808 assert!(
6809 cancelled.time_exchange >= before && cancelled.time_exchange <= Utc::now(),
6810 "{cancelled:?}"
6811 );
6812 }
6813
6814 #[tokio::test]
6817 async fn the_cancel_order_query_is_the_one_the_sdk_sends() {
6818 let server = wiremock::MockServer::start().await;
6819 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6820 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6821 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
6822 .expect(2)
6823 .mount(&server)
6824 .await;
6825 let params = MarginAccountCancelOrderParams::builder("BTCUSDT".to_string())
6826 .is_isolated(MarginAccountCancelOrderIsIsolatedEnum::from_flag(true))
6827 .order_id(7)
6828 .orig_client_order_id("c".to_string())
6829 .new_client_order_id("cancel".to_string())
6830 .recv_window(5000)
6831 .build()
6832 .unwrap();
6833
6834 let _ = margin_rest_at(&server)
6835 .margin_account_cancel_order(params.clone())
6836 .await
6837 .unwrap();
6838 let _ = binance_sdk::common::utils::send_request::<serde_json::Value>(
6839 &margin_rest_config_at(&server),
6840 ORDER_PATH,
6841 reqwest::Method::DELETE,
6842 cancel_order_query(params),
6843 BTreeMap::new(),
6844 None,
6845 true,
6846 )
6847 .await
6848 .unwrap();
6849
6850 let sent = signed_requests_sent(&server).await;
6851 assert_eq!(sent.len(), 2);
6852 assert_eq!(sent[0].0.len(), 6, "every field is sent: {:?}", sent[0].0);
6853 assert_eq!(sent[0], sent[1]);
6854 }
6855
6856 #[tokio::test]
6859 async fn the_new_order_query_is_the_one_the_sdk_sends() {
6860 let server = wiremock::MockServer::start().await;
6861 wiremock::Mock::given(wiremock::matchers::method("POST"))
6862 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6863 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
6864 .expect(2)
6865 .mount(&server)
6866 .await;
6867 let params = MarginAccountNewOrderParams::builder(
6868 "BTCUSDT".to_string(),
6869 MarginAccountNewOrderSideEnum::Sell,
6870 MarginAccountNewOrderTypeEnum::StopLossLimit,
6871 )
6872 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::True)
6873 .quantity(dec!(0.5))
6874 .quote_order_qty(dec!(100.25))
6875 .price(dec!(50000.1))
6876 .stop_price(dec!(49000))
6877 .new_client_order_id("cid".to_string())
6878 .iceberg_qty(dec!(0.1))
6879 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full)
6880 .side_effect_type(MarginAccountNewOrderSideEffectTypeEnum::MarginBuy)
6881 .time_in_force(MarginAccountNewOrderTimeInForceEnum::Gtc)
6882 .self_trade_prevention_mode(
6883 binance_sdk::margin_trading::rest_api::MarginAccountNewOrderSelfTradePreventionModeEnum::ExpireMaker,
6884 )
6885 .trailing_delta(25)
6886 .auto_repay_at_cancel(false)
6887 .recv_window(5000)
6888 .build()
6889 .unwrap();
6890
6891 let _ = margin_rest_at(&server)
6892 .margin_account_new_order(params.clone())
6893 .await
6894 .unwrap();
6895 let _ = binance_sdk::common::utils::send_request::<serde_json::Value>(
6896 &margin_rest_config_at(&server),
6897 ORDER_PATH,
6898 reqwest::Method::POST,
6899 new_order_query(params),
6900 BTreeMap::new(),
6901 None,
6902 true,
6903 )
6904 .await
6905 .unwrap();
6906
6907 let sent = signed_requests_sent(&server).await;
6908 assert_eq!(sent.len(), 2);
6909 assert_eq!(sent[0].0.len(), 17, "every field is sent: {:?}", sent[0].0);
6910 assert_eq!(sent[0], sent[1]);
6911 }
6912
6913 #[test]
6914 fn a_reconnect_opens_a_gap_on_each_stream_instrument_and_checks_those_with_orders_held() {
6915 let [btc, eth, xrp] = ["BTCUSDT", "ETHUSDT", "XRPUSDT"].map(InstrumentNameExchange::new);
6916 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6917 for (instrument, cid) in [(&btc, "btc"), (&xrp, "xrp")] {
6918 known.lock().live(
6919 &margin_key(instrument.name(), cid),
6920 Decimal::TWO,
6921 OrderKind::Limit,
6922 &Open::new(
6923 VenueOrderId::Assigned(OrderId::new(cid)),
6924 Utc::now(),
6925 Decimal::ZERO,
6926 ),
6927 );
6928 }
6929 let mut unrecovered = UnrecoveredFills::default();
6930 let mut unchecked = UncheckedOrders::default();
6931
6932 open_reconnect_recovery(
6933 &mut unrecovered,
6934 &mut unchecked,
6935 &known,
6936 &[btc.clone(), eth.clone()],
6937 Utc::now() - chrono::Duration::minutes(1),
6938 );
6939
6940 assert!(unrecovered.covers(&btc) && unrecovered.covers(ð));
6941 assert!(!unrecovered.covers(&xrp), "not the stream's");
6942 assert!(unchecked.contains(&btc));
6943 assert!(!unchecked.contains(ð), "no order held");
6944 assert!(!unchecked.contains(&xrp), "its fills are not recovered");
6945 }
6946
6947 #[tokio::test]
6950 async fn a_recovered_margin_fill_that_completes_a_held_order_ends_it() {
6951 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6952 let server = wiremock::MockServer::start().await;
6953 wiremock::Mock::given(wiremock::matchers::method("GET"))
6954 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6955 .respond_with(venue(vec![
6956 (
6957 1,
6958 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
6959 ),
6960 (
6961 2,
6962 (disconnect + chrono::Duration::minutes(2)).timestamp_millis(),
6963 ),
6964 ]))
6965 .mount(&server)
6966 .await;
6967 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6970 for (cid, order_id, quantity) in [("one", "1", "0.01"), ("two", "2", "0.02")] {
6971 known.lock().live(
6972 &margin_key("BTCUSDT", cid),
6973 Decimal::from_str(quantity).unwrap(),
6974 OrderKind::Limit,
6975 &Open::new(
6976 VenueOrderId::Assigned(OrderId::new(order_id)),
6977 Utc::now(),
6978 Decimal::ZERO,
6979 ),
6980 );
6981 }
6982 let mut unrecovered = UnrecoveredFills::default();
6983 unrecovered.open(
6984 &[InstrumentNameExchange::new("BTCUSDT")],
6985 disconnect,
6986 Utc::now(),
6987 );
6988 let (tx, mut rx) = mpsc::unbounded_channel();
6989
6990 recover_margin_fills(
6991 &margin_rest_at(&server),
6992 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6993 &mut unrecovered,
6994 &tx,
6995 &new_dedup_cache(),
6996 &known,
6997 false,
6998 )
6999 .await;
7000
7001 assert_eq!(
7002 std::iter::from_fn(|| rx.try_recv().ok()).count(),
7003 2,
7004 "both fills"
7005 );
7006 let known = known.lock();
7007 assert!(!known.contains(&ClientOrderId::new("one")), "completed");
7008 assert!(known.contains(&ClientOrderId::new("two")), "part-filled");
7009 }
7010
7011 #[tokio::test]
7014 async fn a_margin_order_that_expired_on_placement_is_not_reported_or_held_open() {
7015 use rust_decimal_macros::dec;
7016
7017 for (cid, filled, quote) in [("unfilled", "0", "0"), ("partly", "1", "100")] {
7018 let server = wiremock::MockServer::start().await;
7019 wiremock::Mock::given(wiremock::matchers::method("POST"))
7020 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7021 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
7022 serde_json::json!({
7023 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
7024 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
7025 "executedQty": filled, "cummulativeQuoteQty": quote,
7026 "status": "EXPIRED", "timeInForce": "IOC", "type": "LIMIT", "side": "BUY",
7027 }),
7028 ))
7029 .mount(&server)
7030 .await;
7031 let client = margin_client_at(&server, false);
7032 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7033
7034 let placed = client.open_order(margin_open_request(&btcusdt, cid)).await;
7035
7036 let OrderState::Inactive(crate::order::state::InactiveOrderState::Expired(expired)) =
7037 &placed.state
7038 else {
7039 panic!("{cid}: expired, not open: {placed:?}");
7040 };
7041 assert_eq!(expired.filled_quantity, Decimal::from_str(filled).ok());
7042 let late = Open::new(
7043 VenueOrderId::Assigned(OrderId::new("7")),
7044 Utc::now(),
7045 Decimal::ZERO,
7046 );
7047 let mut known = client.known_live.lock();
7048 known.live(&placed.key, dec!(2), OrderKind::Limit, &late);
7049 assert!(
7050 !known.contains(&ClientOrderId::new(cid)),
7051 "{cid}: not held, even after a late live report"
7052 );
7053 }
7054 }
7055
7056 async fn mount_borrow_terms(
7060 server: &wiremock::MockServer,
7061 pair: serde_json::Value,
7062 asset: serde_json::Value,
7063 rate: serde_json::Value,
7064 max_borrow: serde_json::Value,
7065 ) {
7066 use wiremock::{
7067 Mock, ResponseTemplate,
7068 matchers::{method, path},
7069 };
7070 for (route, body) in [
7071 ("/sapi/v1/margin/allPairs", pair.clone()),
7072 ("/sapi/v1/margin/isolated/allPairs", pair),
7073 ("/sapi/v1/margin/allAssets", asset),
7074 ("/sapi/v1/margin/next-hourly-interest-rate", rate),
7075 ("/sapi/v1/margin/maxBorrowable", max_borrow),
7076 ("/sapi/v1/margin/delist-schedule", serde_json::json!([])),
7077 ] {
7078 Mock::given(method("GET"))
7079 .and(path(route))
7080 .respond_with(ResponseTemplate::new(200).set_body_json(body))
7081 .mount(server)
7082 .await;
7083 }
7084 }
7085
7086 async fn mount_delist_schedule(server: &wiremock::MockServer, schedule: serde_json::Value) {
7089 use wiremock::{
7090 Mock, ResponseTemplate,
7091 matchers::{method, path},
7092 };
7093 Mock::given(method("GET"))
7094 .and(path("/sapi/v1/margin/delist-schedule"))
7095 .respond_with(ResponseTemplate::new(200).set_body_json(schedule))
7096 .with_priority(1)
7097 .mount(server)
7098 .await;
7099 }
7100
7101 fn btcusdt_pair(is_margin_trade: bool, is_sell_allowed: bool) -> serde_json::Value {
7102 serde_json::json!([{
7103 "base": "BTC", "quote": "USDT", "symbol": "BTCUSDT",
7104 "isMarginTrade": is_margin_trade, "isSellAllowed": is_sell_allowed, "isBuyAllowed": true,
7105 }])
7106 }
7107
7108 fn btc_asset(is_borrowable: bool) -> serde_json::Value {
7109 serde_json::json!([{ "assetName": "BTC", "isBorrowable": is_borrowable }])
7110 }
7111
7112 fn btc_rate(rate: &str) -> serde_json::Value {
7113 serde_json::json!([{ "asset": "BTC", "nextHourlyInterestRate": rate }])
7114 }
7115
7116 async fn query_value(server: &wiremock::MockServer, route: &str, key: &str) -> Option<String> {
7118 server
7119 .received_requests()
7120 .await
7121 .unwrap()
7122 .iter()
7123 .find(|request| request.url.path() == route)
7124 .and_then(|request| {
7125 request
7126 .url
7127 .query_pairs()
7128 .find(|(name, _)| name == key)
7129 .map(|(_, value)| value.into_owned())
7130 })
7131 }
7132
7133 #[tokio::test]
7134 async fn margin_shortability_reads_the_pair_the_asset_and_the_annualised_rate() {
7135 use crate::client::ShortabilityClient;
7136 use rust_decimal_macros::dec;
7137 for (is_isolated, pairs_route, flag) in [
7138 (false, "/sapi/v1/margin/allPairs", "FALSE"),
7139 (true, "/sapi/v1/margin/isolated/allPairs", "TRUE"),
7140 ] {
7141 let server = wiremock::MockServer::start().await;
7142 mount_borrow_terms(
7143 &server,
7144 btcusdt_pair(true, true),
7145 btc_asset(true),
7146 btc_rate("0.00000571"),
7147 serde_json::json!({}),
7148 )
7149 .await;
7150
7151 let shortability = margin_client_at(&server, is_isolated)
7152 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
7153 .await
7154 .unwrap();
7155
7156 assert_eq!(
7157 shortability,
7158 Shortability::new()
7159 .with_shortable(true)
7160 .with_fee_rate(dec!(0.00000571) * dec!(8760)),
7161 "isolated: {is_isolated}"
7162 );
7163 assert_eq!(
7164 query_value(&server, pairs_route, "symbol").await.as_deref(),
7165 Some("BTCUSDT"),
7166 "the pair list is asked for the one symbol"
7167 );
7168 assert_eq!(
7169 query_value(&server, "/sapi/v1/margin/allAssets", "asset")
7170 .await
7171 .as_deref(),
7172 Some("BTC"),
7173 "the base asset is the one borrowed"
7174 );
7175 let rate = "/sapi/v1/margin/next-hourly-interest-rate";
7176 assert_eq!(
7177 query_value(&server, rate, "assets").await.as_deref(),
7178 Some("BTC")
7179 );
7180 assert_eq!(
7181 query_value(&server, rate, "isIsolated").await.as_deref(),
7182 Some(flag)
7183 );
7184 }
7185 }
7186
7187 #[tokio::test]
7188 async fn margin_shortability_is_refused_by_any_flag_and_unknown_without_them() {
7189 use crate::client::ShortabilityClient;
7190 let cases = [
7191 (btcusdt_pair(false, true), btc_asset(true), Some(false)),
7192 (btcusdt_pair(true, false), btc_asset(true), Some(false)),
7193 (btcusdt_pair(true, true), btc_asset(false), Some(false)),
7194 (btcusdt_pair(true, true), serde_json::json!([]), None),
7196 ];
7197 for (pair, asset, shortable) in cases {
7198 let server = wiremock::MockServer::start().await;
7199 mount_borrow_terms(
7200 &server,
7201 pair.clone(),
7202 asset.clone(),
7203 serde_json::json!([]),
7204 serde_json::json!({}),
7205 )
7206 .await;
7207
7208 let shortability = margin_client_at(&server, false)
7209 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
7210 .await
7211 .unwrap();
7212
7213 assert_eq!(shortability.shortable, shortable, "{pair} {asset}");
7214 assert_eq!(
7215 shortability.fee_rate, None,
7216 "no rate returned is an unknown fee"
7217 );
7218 assert_eq!(shortability.easy_to_borrow, None);
7219 assert_eq!(shortability.available, None);
7220 }
7221 }
7222
7223 #[tokio::test]
7227 async fn margin_shortability_reads_the_delisting_schedule_for_this_margin() {
7228 use crate::client::ShortabilityClient;
7229 let past = Utc::now() - chrono::Duration::hours(1);
7230 let soon = Utc::now() + chrono::Duration::days(3);
7231 let later = soon + chrono::Duration::days(7);
7232 let entry = |time: DateTime<Utc>, assets: &[&str], symbols: &[&str]| {
7233 serde_json::json!({
7234 "delistTime": time.timestamp_millis(),
7235 "crossMarginAssets": assets,
7236 "isolatedMarginSymbols": symbols,
7237 })
7238 };
7239 let millis = |time: DateTime<Utc>| DateTime::from_timestamp_millis(time.timestamp_millis());
7240 let cases = [
7241 (
7243 false,
7244 vec![entry(soon, &["BTC"], &[])],
7245 millis(soon),
7246 Some(true),
7247 ),
7248 (
7249 false,
7250 vec![entry(soon, &["USDT"], &[])],
7251 millis(soon),
7252 Some(true),
7253 ),
7254 (
7255 false,
7256 vec![
7257 entry(later, &["BTC"], &[]),
7258 entry(soon, &["ETH", "USDT"], &[]),
7259 ],
7260 millis(soon),
7261 Some(true),
7262 ),
7263 (
7264 false,
7265 vec![entry(soon, &["ETH"], &["BTCUSDT"])],
7266 None,
7267 Some(true),
7268 ),
7269 (
7270 false,
7271 vec![entry(past, &["BTC"], &[])],
7272 millis(past),
7273 Some(false),
7274 ),
7275 (
7277 true,
7278 vec![entry(soon, &[], &["BTCUSDT"])],
7279 millis(soon),
7280 Some(true),
7281 ),
7282 (
7283 true,
7284 vec![entry(soon, &["BTC", "USDT"], &["ETHUSDT"])],
7285 None,
7286 Some(true),
7287 ),
7288 (
7289 true,
7290 vec![entry(past, &[], &["BTCUSDT"])],
7291 millis(past),
7292 Some(false),
7293 ),
7294 ];
7295 for (is_isolated, schedule, delisting_at, shortable) in cases {
7296 let server = wiremock::MockServer::start().await;
7297 mount_borrow_terms(
7298 &server,
7299 btcusdt_pair(true, true),
7300 btc_asset(true),
7301 serde_json::json!([]),
7302 serde_json::json!({}),
7303 )
7304 .await;
7305 let schedule = serde_json::Value::from(schedule);
7306 mount_delist_schedule(&server, schedule.clone()).await;
7307
7308 let shortability = margin_client_at(&server, is_isolated)
7309 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
7310 .await
7311 .unwrap();
7312
7313 let case = format!("isolated: {is_isolated}, {schedule}");
7314 assert_eq!(shortability.delisting_at, delisting_at, "{case}");
7315 assert_eq!(shortability.shortable, shortable, "{case}");
7316 }
7317 }
7318
7319 #[tokio::test]
7321 async fn margin_shortability_of_a_scheduled_pair_without_a_time_is_an_error() {
7322 use crate::client::ShortabilityClient;
7323 let server = wiremock::MockServer::start().await;
7324 mount_borrow_terms(
7325 &server,
7326 btcusdt_pair(true, true),
7327 btc_asset(true),
7328 serde_json::json!([]),
7329 serde_json::json!({}),
7330 )
7331 .await;
7332 mount_delist_schedule(
7333 &server,
7334 serde_json::json!([{ "crossMarginAssets": ["BTC"] }]),
7335 )
7336 .await;
7337
7338 let result = margin_client_at(&server, false)
7339 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
7340 .await;
7341
7342 assert!(
7343 matches!(result, Err(UnindexedClientError::Internal(ref message))
7344 if message.contains("delistTime")),
7345 "{result:?}"
7346 );
7347 }
7348
7349 #[tokio::test]
7350 async fn margin_shortability_of_an_unlisted_pair_is_an_invalid_instrument() {
7351 use crate::client::ShortabilityClient;
7352 let server = wiremock::MockServer::start().await;
7353 mount_borrow_terms(
7354 &server,
7355 serde_json::json!([]),
7356 btc_asset(true),
7357 btc_rate("0.00000571"),
7358 serde_json::json!({}),
7359 )
7360 .await;
7361
7362 let result = margin_client_at(&server, false)
7363 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
7364 .await;
7365
7366 assert!(
7367 matches!(
7368 result,
7369 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
7370 if instrument.name().as_str() == "BTCUSDT"
7371 ),
7372 "{result:?}"
7373 );
7374 }
7375
7376 #[tokio::test]
7377 async fn margin_borrow_capacity_asks_for_the_asset_the_side_borrows() {
7378 use crate::client::BorrowCapacityClient;
7379 use rust_decimal_macros::dec;
7380 for (is_isolated, side, asset, isolated_symbol) in [
7381 (false, Side::Sell, "BTC", None),
7382 (false, Side::Buy, "USDT", None),
7383 (true, Side::Sell, "BTC", Some("BTCUSDT")),
7384 ] {
7385 let server = wiremock::MockServer::start().await;
7386 mount_borrow_terms(
7387 &server,
7388 btcusdt_pair(true, true),
7389 btc_asset(true),
7390 btc_rate("0.00000571"),
7391 serde_json::json!({ "amount": "1.69248805", "borrowLimit": "60" }),
7392 )
7393 .await;
7394
7395 let capacity = margin_client_at(&server, is_isolated)
7396 .fetch_borrow_capacity(&InstrumentNameExchange::new("BTCUSDT"), side)
7397 .await
7398 .unwrap();
7399
7400 assert_eq!(
7401 capacity,
7402 BorrowCapacity::new(AssetNameExchange::new(asset))
7403 .with_borrowable_now(dec!(1.69248805))
7404 .with_account_limit(dec!(60)),
7405 "{side:?} isolated: {is_isolated}"
7406 );
7407 let max_borrow = "/sapi/v1/margin/maxBorrowable";
7408 assert_eq!(
7409 query_value(&server, max_borrow, "asset").await.as_deref(),
7410 Some(asset)
7411 );
7412 assert_eq!(
7413 query_value(&server, max_borrow, "isolatedSymbol")
7414 .await
7415 .as_deref(),
7416 isolated_symbol,
7417 "only isolated margin names the pair's account"
7418 );
7419 }
7420 }
7421
7422 #[tokio::test]
7423 async fn margin_borrow_capacity_keeps_an_omitted_amount_unknown_and_fails_a_malformed_one() {
7424 use crate::client::BorrowCapacityClient;
7425 use rust_decimal_macros::dec;
7426 let instrument = InstrumentNameExchange::new("BTCUSDT");
7427
7428 let server = wiremock::MockServer::start().await;
7429 mount_borrow_terms(
7430 &server,
7431 btcusdt_pair(true, true),
7432 btc_asset(true),
7433 btc_rate("0"),
7434 serde_json::json!({ "borrowLimit": "60" }),
7435 )
7436 .await;
7437 let capacity = margin_client_at(&server, false)
7438 .fetch_borrow_capacity(&instrument, Side::Sell)
7439 .await
7440 .unwrap();
7441 assert_eq!(capacity.borrowable_now, None);
7442 assert_eq!(capacity.account_limit, Some(dec!(60)));
7443
7444 let server = wiremock::MockServer::start().await;
7445 mount_borrow_terms(
7446 &server,
7447 btcusdt_pair(true, true),
7448 btc_asset(true),
7449 btc_rate("0"),
7450 serde_json::json!({ "amount": "lots", "borrowLimit": "60" }),
7451 )
7452 .await;
7453 let result = margin_client_at(&server, false)
7454 .fetch_borrow_capacity(&instrument, Side::Sell)
7455 .await;
7456 assert!(
7457 matches!(result, Err(UnindexedClientError::Internal(ref message)) if message.contains("amount")),
7458 "{result:?}"
7459 );
7460 }
7461
7462 #[tokio::test]
7463 async fn margin_borrow_capacity_without_an_isolated_account_names_the_pair() {
7464 use crate::client::BorrowCapacityClient;
7465 use wiremock::{
7466 Mock,
7467 matchers::{method, path},
7468 };
7469 let server = wiremock::MockServer::start().await;
7470 Mock::given(method("GET"))
7471 .and(path("/sapi/v1/margin/maxBorrowable"))
7472 .respond_with(
7473 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
7474 "code": -11001, "msg": "Isolated margin account does not exist."
7475 })),
7476 )
7477 .mount(&server)
7478 .await;
7479 mount_borrow_terms(
7480 &server,
7481 btcusdt_pair(true, true),
7482 btc_asset(true),
7483 btc_rate("0"),
7484 serde_json::json!({}),
7485 )
7486 .await;
7487
7488 let err = margin_client_at(&server, true)
7489 .fetch_borrow_capacity(&InstrumentNameExchange::new("BTCUSDT"), Side::Sell)
7490 .await
7491 .unwrap_err();
7492
7493 assert_eq!(
7494 err.to_string(),
7495 "API: request rejected: BTCUSDT: -11001 Isolated margin account does not exist."
7496 );
7497 assert!(!err.is_transient());
7498 }
7499}