1use super::shared::{
53 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
54 CONNECT_TIMEOUT_SECS, ExecutionReport, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS,
55 HEARTBEAT_TIMEOUT_SECS, MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing,
56 PlacementResponse, RateLimitTracker, RequestKind, SIGNAL_RECOVERY_LOOKBACK_MS,
57 SharedDedupCache, TrailingDeltaError, UnhandledEvents, UnrecoveredFills, UserDataFrame,
58 WeightPool, binance_filled_qty, classify_order_kind_tif, classify_rest_order_error,
59 classify_rest_query_error, convert_ended_order, convert_execution_report,
60 convert_open_order_listing, convert_open_order_owned_symbol, dedup_key_from_event, drop_after,
61 frame_excerpt, gap_failed, gap_time, is_duplicate, is_unknown_order, log_unhandled_event,
62 log_unrecognised_frame, new_dedup_cache, parse_user_data_frame, placed_order_state,
63 recovered_order_totals, response_decode_error, rest_call_with_retry,
64 trailing_delta_basis_points,
65};
66use crate::{
67 AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
68 InstrumentBalanceUpdate, IsolatedInstrumentState, IsolatedMarginRisk, UnindexedAccountEvent,
69 UnindexedAccountSnapshot,
70 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
71 client::{
72 ExecutionClient, OrderStatusClient,
73 order_recovery::{
74 KnownLiveOrders, OpenListing, OrderLookup, SharedKnownLiveOrders, UncheckedOrders,
75 fetch_ended_by_key, recover_ended_orders,
76 },
77 },
78 emit_stream_terminated,
79 error::{
80 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
81 },
82 order::{
83 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
84 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
85 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
86 state::{Cancelled, Open, OrderState, UnindexedOrderState},
87 },
88 position::PositionReport,
89 trade::{AssetFees, Trade, TradeId, TradesRead},
90};
91use binance_sdk::{
92 common::{
93 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
94 constants::{MARGIN_TRADING_REST_API_PROD_URL, SPOT_WS_API_PROD_URL},
95 models::WebsocketEvent,
96 websocket::{
97 SendWebsocketMessageResult, Subscription, WebsocketApi as WsApiBase,
98 WebsocketMessageSendOptions,
99 },
100 },
101 margin_trading::{
102 MarginTradingRestApi,
103 rest_api::{
104 MarginAccountCancelOrderIsIsolatedEnum, MarginAccountCancelOrderParams,
105 MarginAccountNewOrderIsIsolatedEnum, MarginAccountNewOrderNewOrderRespTypeEnum,
106 MarginAccountNewOrderParams, MarginAccountNewOrderSideEffectTypeEnum,
107 MarginAccountNewOrderSideEnum, MarginAccountNewOrderTimeInForceEnum,
108 MarginAccountNewOrderTypeEnum, QueryCrossMarginAccountDetailsParams,
109 QueryCrossMarginAccountDetailsResponseUserAssetsInner,
110 QueryIsolatedMarginAccountInfoParams,
111 QueryIsolatedMarginAccountInfoResponseAssetsInner,
112 QueryMarginAccountsOpenOrdersIsIsolatedEnum, QueryMarginAccountsOpenOrdersParams,
113 QueryMarginAccountsOrderIsIsolatedEnum, QueryMarginAccountsOrderParams,
114 QueryMarginAccountsTradeListIsIsolatedEnum, QueryMarginAccountsTradeListParams,
115 QueryMarginAccountsTradeListResponseInner, RestApi,
116 },
117 websocket_streams::{
118 MarginLevelStatusChange, OutboundAccountPosition, UserLiabilityChange,
119 },
120 },
121};
122use chrono::{DateTime, TimeZone, Utc};
123use fnv::FnvHashSet;
124use futures::stream::BoxStream;
125use rust_decimal::Decimal;
126use rustrade_instrument::{
127 Side,
128 asset::name::AssetNameExchange,
129 exchange::ExchangeId,
130 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
131};
132use serde::{Deserialize, Serialize};
133use smol_str::{SmolStr, format_smolstr};
134use std::{
135 collections::{BTreeMap, HashMap},
136 str::FromStr,
137 sync::{
138 Arc, Mutex,
139 atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
140 },
141 time::Duration,
142};
143use tokio::sync::{mpsc, oneshot};
144use tracing::{debug, error, info, trace, warn};
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum MarginSideEffect {
166 #[default]
174 AutoBorrowRepay,
175 NoBorrow,
179}
180
181impl MarginSideEffect {
182 pub fn as_binance_str(self) -> &'static str {
184 match self {
185 MarginSideEffect::AutoBorrowRepay => "AUTO_BORROW_REPAY",
186 MarginSideEffect::NoBorrow => "NO_SIDE_EFFECT",
187 }
188 }
189
190 fn as_sdk_enum(self) -> MarginAccountNewOrderSideEffectTypeEnum {
196 match self {
197 MarginSideEffect::AutoBorrowRepay => {
198 MarginAccountNewOrderSideEffectTypeEnum::AutoBorrowRepay
199 }
200 MarginSideEffect::NoBorrow => MarginAccountNewOrderSideEffectTypeEnum::NoSideEffect,
201 }
202 }
203}
204
205#[derive(Clone, Deserialize)]
215pub struct BinanceMarginConfig {
216 api_key: String,
219 secret_key: String,
220 pub testnet: bool,
226 #[serde(default)]
233 pub is_isolated: bool,
234 #[serde(default)]
245 pub isolated_symbols: Vec<InstrumentNameExchange>,
246 #[serde(default)]
250 pub side_effect: MarginSideEffect,
251}
252
253impl std::fmt::Debug for BinanceMarginConfig {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 f.debug_struct("BinanceMarginConfig")
257 .field("api_key", &"***")
258 .field("secret_key", &"***")
259 .field("testnet", &self.testnet)
260 .field("is_isolated", &self.is_isolated)
261 .field("isolated_symbols", &self.isolated_symbols)
262 .field("side_effect", &self.side_effect)
263 .finish()
264 }
265}
266
267impl BinanceMarginConfig {
268 pub fn new(
278 api_key: String,
279 secret_key: String,
280 testnet: bool,
281 is_isolated: bool,
282 isolated_symbols: Vec<InstrumentNameExchange>,
283 side_effect: MarginSideEffect,
284 ) -> Self {
285 Self {
286 api_key,
287 secret_key,
288 testnet,
289 is_isolated,
290 isolated_symbols,
291 side_effect,
292 }
293 }
294
295 pub fn cross_margin(api_key: String, secret_key: String) -> Self {
302 Self::new(
303 api_key,
304 secret_key,
305 false,
306 false,
307 Vec::new(),
308 MarginSideEffect::default(),
309 )
310 }
311
312 pub fn isolated(
320 api_key: String,
321 secret_key: String,
322 symbols: Vec<InstrumentNameExchange>,
323 ) -> Self {
324 Self::new(
325 api_key,
326 secret_key,
327 false,
328 true,
329 symbols,
330 MarginSideEffect::default(),
331 )
332 }
333
334 pub fn api_key(&self) -> &str {
336 &self.api_key
337 }
338}
339
340#[derive(Clone)]
433pub struct BinanceMargin {
434 config: Arc<BinanceMarginConfig>,
435 rest: Arc<RestApi>,
437 rest_config: Arc<ConfigurationRestApi>,
441 ws_config: ConfigurationWebsocketApi,
445 rate_limiter: Arc<RateLimitTracker>,
447 known_live: SharedKnownLiveOrders,
450}
451
452impl std::fmt::Debug for BinanceMargin {
453 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454 f.debug_struct("BinanceMargin")
455 .field("testnet", &self.config.testnet)
456 .field("is_isolated", &self.config.is_isolated)
457 .field("side_effect", &self.config.side_effect)
458 .finish_non_exhaustive()
459 }
460}
461
462impl BinanceMargin {
463 fn remember_live<'a>(
465 &self,
466 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
467 ) {
468 let mut known = self.known_live.lock();
469 for order in orders {
470 known.live(&order.key, order.quantity, order.kind, &order.state);
471 }
472 }
473
474 #[allow(clippy::expect_used)] fn build_rest_config(config: &BinanceMarginConfig) -> ConfigurationRestApi {
484 ConfigurationRestApi::builder()
485 .api_key(config.api_key.clone())
486 .api_secret(config.secret_key.clone())
487 .base_path(MARGIN_TRADING_REST_API_PROD_URL)
488 .build()
489 .expect("failed to build Binance margin REST configuration")
490 }
491
492 #[allow(clippy::expect_used)] fn build_ws_config(config: &BinanceMarginConfig) -> ConfigurationWebsocketApi {
503 ConfigurationWebsocketApi::builder()
504 .api_key(config.api_key.clone())
505 .api_secret(config.secret_key.clone())
506 .ws_url(SPOT_WS_API_PROD_URL)
510 .build()
511 .expect("failed to build Binance margin WebSocket configuration")
512 }
513
514 fn effective_isolated_set(
527 &self,
528 instruments: &[InstrumentNameExchange],
529 ) -> Vec<InstrumentNameExchange> {
530 if instruments.is_empty() {
531 return self.config.isolated_symbols.clone();
532 }
533 instruments
534 .iter()
535 .filter(|inst| {
536 let in_set = self.config.isolated_symbols.contains(*inst);
537 if !in_set {
538 warn!(
539 instrument = %inst.name(),
540 "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
541 );
542 }
543 in_set
544 })
545 .cloned()
546 .collect()
547 }
548
549 async fn isolated_account_snapshot(
553 &self,
554 instruments: &[InstrumentNameExchange],
555 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
556 use futures::{StreamExt as _, TryStreamExt as _};
557
558 let effective = self.effective_isolated_set(instruments);
559 if effective.is_empty() {
560 return Ok(AccountSnapshot::new(
563 ExchangeId::BinanceMargin,
564 Vec::new(),
565 Vec::new(),
566 ));
567 }
568
569 let mut balances_by_symbol = convert_isolated_margin_assets(
571 fetch_isolated_margin_account_info(
572 self.rest.clone(),
573 self.rate_limiter.clone(),
574 effective.clone(),
575 )
576 .await?,
577 );
578
579 let instrument_snapshots: Vec<_> =
582 futures::stream::iter(effective.into_iter().map(|instrument| {
583 fetch_margin_open_orders_for_instrument(
584 self.rest.clone(),
585 self.rate_limiter.clone(),
586 instrument,
587 true,
588 RequestKind::Query,
589 )
590 }))
591 .buffer_unordered(8)
592 .map(|result| {
593 let (inst, listing) = result?;
594 self.remember_live(&listing.orders);
595 let wrapped = listing
596 .orders
597 .into_iter()
598 .map(active_order_snapshot)
599 .collect();
600 let isolated = balances_by_symbol.remove(&inst);
601 if isolated.is_none() {
602 warn!(
606 instrument = %inst.name(),
607 "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
608 );
609 }
610 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
611 inst,
612 wrapped,
613 listing.complete,
614 PositionReport::Unreported,
615 isolated,
616 ))
617 })
618 .try_collect()
619 .await?;
620
621 Ok(AccountSnapshot::new(
624 ExchangeId::BinanceMargin,
625 Vec::new(),
626 instrument_snapshots,
627 ))
628 }
629}
630
631impl ExecutionClient for BinanceMargin {
632 const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
633
634 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
637 &[InstrumentKindDiscriminant::Spot];
638 type Config = BinanceMarginConfig;
639 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
640
641 fn new(config: Self::Config) -> Self {
654 if config.testnet {
655 warn!(
656 "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
657 using production endpoints"
658 );
659 }
660 assert!(
661 !(config.is_isolated && config.isolated_symbols.is_empty()),
662 "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
663 );
664 let rest_config = Self::build_rest_config(&config);
669 let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
670 let rest_config = Arc::new(rest_config);
671 let ws_config = Self::build_ws_config(&config);
672 Self {
673 config: Arc::new(config),
674 rest,
675 rest_config,
676 ws_config,
677 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
678 known_live: KnownLiveOrders::shared(ExchangeId::BinanceMargin),
679 }
680 }
681
682 async fn open_order(
698 &self,
699 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
700 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
701 let instrument = request.key.instrument.clone();
702 let side = request.state.side;
703 let price = request.state.price;
704 let quantity = request.state.quantity;
705 let kind = request.state.kind;
706 let time_in_force = request.state.time_in_force;
707 let cid = request.key.cid.clone();
708
709 let order_key = OrderKey::new(
710 ExchangeId::BinanceMargin,
711 instrument.clone(),
712 request.key.strategy.clone(),
713 cid.clone(),
714 );
715
716 let inactive = |state: UnindexedOrderState| Order {
719 key: order_key.clone(),
720 side,
721 price,
722 quantity,
723 kind,
724 time_in_force,
725 state,
726 };
727
728 let params = match build_new_order_params(
729 instrument.name().to_string(),
730 side,
731 price,
732 quantity,
733 kind,
734 time_in_force,
735 cid.0.to_string(),
736 self.config.side_effect,
737 self.config.is_isolated,
738 ) {
739 Ok(params) => params,
740 Err(BuildOrderError::Unsupported) => {
741 return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
742 format!(
743 "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
744 ),
745 )));
746 }
747 Err(BuildOrderError::TrailingDelta(refused)) => {
748 return inactive(OrderState::inactive(refused.into_order_error()));
749 }
750 Err(BuildOrderError::Build(msg)) => {
751 error!(%msg, "BinanceMargin failed to build new order params");
752 return inactive(OrderState::inactive(OrderError::Rejected(
753 ApiError::OrderRejected(msg),
754 )));
755 }
756 };
757
758 let response =
759 match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
760 let params = params.clone();
761 Box::pin(async move { rest.margin_account_new_order(params).await })
762 })
763 .await
764 {
765 Ok(response) => response,
766 Err(e) => {
767 return inactive(OrderState::inactive(classify_rest_order_error(
768 &e,
769 &instrument,
770 )));
771 }
772 };
773
774 let data = match response.data().await {
775 Ok(data) => data,
776 Err(e) => {
777 return inactive(OrderState::inactive(OrderError::Rejected(
780 ApiError::OrderRejected(e.to_string()),
781 )));
782 }
783 };
784
785 let time_exchange = data
786 .transact_time
787 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
788 .unwrap_or_else(Utc::now);
789
790 let exchange_order_id = match data.order_id {
791 Some(id) => OrderId(format_smolstr!("{id}")),
792 None => {
793 error!("BinanceMargin open_order response missing orderId");
794 return inactive(OrderState::inactive(OrderError::Rejected(
795 ApiError::OrderRejected("open_order response missing orderId".into()),
796 )));
797 }
798 };
799
800 let state = placed_order_state(
803 ExchangeId::BinanceMargin,
804 &instrument,
805 quantity,
806 PlacementResponse {
807 status: data.status.as_deref(),
808 order_id: exchange_order_id,
809 time_exchange,
810 executed_qty: data.executed_qty.as_deref(),
811 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
812 },
813 );
814 self.known_live
815 .lock()
816 .placed(&order_key, quantity, kind, &state);
817
818 Order {
819 key: order_key,
820 side,
821 price,
822 quantity,
823 kind,
824 time_in_force,
825 state,
826 }
827 }
828
829 async fn cancel_order(
839 &self,
840 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
841 ) -> UnindexedOrderResponseCancel {
842 let instrument = request.key.instrument.clone();
843 let key = OrderKey {
844 exchange: request.key.exchange,
845 instrument: instrument.clone(),
846 strategy: request.key.strategy.clone(),
847 cid: request.key.cid.clone(),
848 };
849
850 let params = match build_cancel_order_params(
851 instrument.name().to_string(),
852 request.state.id.as_ref().and_then(VenueOrderId::assigned),
853 &request.key.cid,
854 self.config.is_isolated,
855 ) {
856 Ok(p) => p,
857 Err(e) => {
858 error!(%e, "BinanceMargin failed to build cancel order params");
859 return UnindexedOrderResponseCancel {
860 key,
861 state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
862 };
863 }
864 };
865
866 let response =
867 match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
868 let params = params.clone();
869 Box::pin(async move { rest.margin_account_cancel_order(params).await })
870 })
871 .await
872 {
873 Ok(response) => response,
874 Err(e) => {
875 return UnindexedOrderResponseCancel {
876 key,
877 state: Err(classify_rest_order_error(&e, &instrument)),
878 };
879 }
880 };
881
882 let data = match response.data().await {
883 Ok(data) => data,
884 Err(e) => {
885 return UnindexedOrderResponseCancel {
888 key,
889 state: Err(OrderError::Rejected(ApiError::OrderRejected(e.to_string()))),
890 };
891 }
892 };
893
894 let time_exchange = Utc::now();
896
897 let exchange_order_id = match data.order_id {
898 Some(id) => OrderId(SmolStr::new(id)),
901 None => {
902 error!("BinanceMargin cancel response missing orderId");
903 return UnindexedOrderResponseCancel {
904 key,
905 state: Err(OrderError::Rejected(ApiError::OrderRejected(
906 "cancel response missing orderId".into(),
907 ))),
908 };
909 }
910 };
911
912 let filled_qty = binance_filled_qty(
913 ExchangeId::BinanceMargin,
914 &exchange_order_id,
915 data.executed_qty.as_deref(),
916 );
917
918 self.known_live.lock().ended(&key.cid);
919 UnindexedOrderResponseCancel {
920 key,
921 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
922 }
923 }
924
925 async fn account_snapshot(
940 &self,
941 assets: &[AssetNameExchange],
942 instruments: &[InstrumentNameExchange],
943 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
944 if self.config.is_isolated {
945 return self.isolated_account_snapshot(instruments).await;
946 }
947 let response =
948 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
949 Box::pin(async move {
950 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
951 rest.query_cross_margin_account_details(params).await
952 })
953 })
954 .await
955 .map_err(|e| classify_rest_query_error(&e, None))?;
956
957 let account = response.data().await.map_err(response_decode_error)?;
958
959 let balances =
960 filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets);
961
962 use futures::{StreamExt as _, TryStreamExt as _};
967 let instrument_snapshots: Vec<_> =
968 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
969 fetch_margin_open_orders_for_instrument(
970 self.rest.clone(),
971 self.rate_limiter.clone(),
972 instrument,
973 false,
975 RequestKind::Query,
976 )
977 }))
978 .buffer_unordered(8)
979 .map(|result| {
980 let (inst, listing) = result?;
981 self.remember_live(&listing.orders);
982 let wrapped = listing
983 .orders
984 .into_iter()
985 .map(active_order_snapshot)
986 .collect();
987 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
988 inst,
989 wrapped,
990 listing.complete,
991 PositionReport::Unreported,
992 None,
993 ))
994 })
995 .try_collect()
996 .await?;
997
998 Ok(AccountSnapshot::new(
999 ExchangeId::BinanceMargin,
1000 balances,
1001 instrument_snapshots,
1002 ))
1003 }
1004
1005 async fn fetch_balances(
1015 &self,
1016 assets: &[AssetNameExchange],
1017 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1018 if self.config.is_isolated {
1019 return Ok(Vec::new());
1020 }
1021 let response =
1022 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1023 Box::pin(async move {
1024 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
1025 rest.query_cross_margin_account_details(params).await
1026 })
1027 })
1028 .await
1029 .map_err(|e| classify_rest_query_error(&e, None))?;
1030
1031 let account = response.data().await.map_err(response_decode_error)?;
1032
1033 Ok(filter_and_convert_margin_balances(
1034 account.user_assets.unwrap_or_default(),
1035 assets,
1036 ))
1037 }
1038
1039 async fn fetch_open_orders(
1058 &self,
1059 instruments: &[InstrumentNameExchange],
1060 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1061 use futures::{StreamExt as _, TryStreamExt as _};
1062
1063 if self.config.is_isolated {
1067 let effective = self.effective_isolated_set(instruments);
1068 let capacity = effective.len();
1069 return futures::stream::iter(effective.into_iter().map(|instrument| {
1070 fetch_margin_open_orders_for_instrument(
1071 self.rest.clone(),
1072 self.rate_limiter.clone(),
1073 instrument,
1074 true,
1075 RequestKind::Query,
1076 )
1077 }))
1078 .buffer_unordered(8)
1079 .try_fold(
1080 Vec::with_capacity(capacity),
1081 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
1082 (_, listing)| async move {
1083 acc.extend(listing.orders);
1084 Ok(acc)
1085 },
1086 )
1087 .await
1088 .inspect(|orders| self.remember_live(orders));
1089 }
1090
1091 if instruments.is_empty() {
1094 let orders =
1095 fetch_margin_all_open_orders(self.rest.clone(), self.rate_limiter.clone(), false)
1096 .await?;
1097 self.remember_live(&orders);
1098 return Ok(orders);
1099 }
1100 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1101 fetch_margin_open_orders_for_instrument(
1102 self.rest.clone(),
1103 self.rate_limiter.clone(),
1104 instrument,
1105 false,
1106 RequestKind::Query,
1107 )
1108 }))
1109 .buffer_unordered(8)
1110 .try_fold(
1111 Vec::with_capacity(instruments.len()),
1112 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1113 acc.extend(listing.orders);
1114 Ok(acc)
1115 },
1116 )
1117 .await
1118 .inspect(|orders| self.remember_live(orders))
1119 }
1120
1121 async fn fetch_trades(
1150 &self,
1151 start: DateTime<Utc>,
1152 end: DateTime<Utc>,
1153 instruments: &[InstrumentNameExchange],
1154 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1155 use futures::StreamExt as _;
1156
1157 if start > end {
1158 return Ok(TradesRead::complete(Vec::new()));
1159 }
1160
1161 let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1164 self.effective_isolated_set(instruments)
1165 } else {
1166 instruments.to_vec()
1167 };
1168 if effective.is_empty() {
1169 debug!(
1172 is_isolated = self.config.is_isolated,
1173 "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1174 );
1175 return Ok(TradesRead::complete(Vec::new()));
1176 }
1177 let span = MyTradesFrom::Span {
1179 start: start.timestamp_millis(),
1180 end: end.timestamp_millis(),
1181 };
1182 let is_isolated = self.config.is_isolated;
1183 let mut all_trades = Vec::new();
1184
1185 let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1188 let rest = self.rest.clone();
1189 let rate_limiter = self.rate_limiter.clone();
1190 async move {
1191 let pages = paginate_margin_my_trades(
1192 &rest,
1193 &rate_limiter,
1194 &inst,
1195 span,
1196 is_isolated,
1197 RequestKind::Query,
1198 )
1199 .await?;
1200 Ok::<_, UnindexedClientError>((inst, pages))
1201 }
1202 }))
1203 .buffer_unordered(8);
1204 while let Some(result) = stream.next().await {
1205 let (instrument, trades_data) = result?;
1206 for t in trades_data {
1207 if let Some(trade) = convert_margin_trade(&t, &instrument)
1208 && (start..=end).contains(&trade.time_exchange)
1209 {
1210 all_trades.push(trade);
1211 }
1212 }
1213 }
1214
1215 Ok(TradesRead::complete(all_trades))
1216 }
1217
1218 async fn account_stream(
1325 &self,
1326 _assets: &[AssetNameExchange],
1329 instruments: &[InstrumentNameExchange],
1330 ) -> Result<Self::AccountStream, UnindexedClientError> {
1331 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1332 let dedup = new_dedup_cache();
1333 let rest = self.rest.clone();
1334 let rest_config = self.rest_config.clone();
1335 let ws_config = self.ws_config.clone();
1336 let rate_limiter = self.rate_limiter.clone();
1337 let known_live = self.known_live.clone();
1338
1339 let cm_handle = if self.config.is_isolated {
1340 let symbols = self.config.isolated_symbols.clone();
1344 debug_assert!(
1347 symbols.iter().all(|i| i.name().len() <= 23),
1348 "instrument name exceeds SmolStr inline capacity: {:?}",
1349 symbols.iter().find(|i| i.name().len() > 23)
1350 );
1351
1352 let assets = fetch_isolated_margin_account_info(
1358 rest.clone(),
1359 rate_limiter.clone(),
1360 symbols.clone(),
1361 )
1362 .await?;
1363 let base_quote = Arc::new(build_base_quote_map(&assets));
1364 for sym in &symbols {
1365 if !base_quote.contains_key(sym) {
1366 warn!(
1367 symbol = %sym.name(),
1368 "BinanceMargin isolated: account info returned no base/quote for configured \
1369 symbol — its live per-pair balance frames will be dropped (fills/orders \
1370 unaffected; balances available via account_snapshot)"
1371 );
1372 }
1373 }
1374
1375 let tokens = acquire_all_isolated_tokens(&rest_config, &rate_limiter, &symbols).await?;
1379 let live = isolated_connect_and_subscribe(
1380 &ws_config,
1381 &tx,
1382 &dedup,
1383 &known_live,
1384 &base_quote,
1385 &tokens,
1386 )
1387 .await
1388 .map_err(|e| {
1389 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1390 })?;
1391
1392 tokio::spawn(isolated_connection_manager(
1393 tx,
1394 dedup,
1395 ws_config,
1396 rest_config,
1397 rest,
1398 rate_limiter,
1399 known_live,
1400 symbols,
1401 base_quote,
1402 Some(live),
1403 ))
1404 } else {
1405 let instruments = instruments.to_vec();
1407 debug_assert!(
1411 instruments.iter().all(|i| i.name().len() <= 23),
1412 "instrument name exceeds SmolStr inline capacity: {:?}",
1413 instruments.iter().find(|i| i.name().len() > 23)
1414 );
1415
1416 let initial_token =
1422 acquire_user_listen_token(&rest_config, &rate_limiter, None).await?;
1423 let initial_ws = connect_margin_ws(&ws_config).await.map_err(|e| {
1424 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1425 })?;
1426
1427 tokio::spawn(margin_connection_manager(
1428 tx,
1429 dedup,
1430 ws_config,
1431 rest_config,
1432 rest,
1433 rate_limiter,
1434 known_live,
1435 instruments,
1436 Some((initial_ws, initial_token)),
1437 ))
1438 };
1439
1440 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1441 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1442 Ok(futures::StreamExt::boxed(guarded_stream))
1443 }
1444}
1445
1446impl OrderStatusClient for BinanceMargin {
1463 async fn fetch_ended_orders(
1464 &self,
1465 orders: &[UnindexedOrderKey],
1466 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1467 fetch_ended_by_key(orders, |key| {
1468 let recorded_kind = self.known_live.lock().kind_of(&key.cid);
1469 fetch_margin_order_lookup(
1470 self.rest.clone(),
1471 self.rate_limiter.clone(),
1472 key,
1473 self.config.is_isolated,
1474 RequestKind::Query,
1475 recorded_kind,
1476 )
1477 })
1478 .await
1479 }
1480}
1481
1482const TOKEN_RENEW_MARGIN_SECS: i64 = 300; const TOKEN_MIN_LIFETIME_SECS: u64 = 60;
1490
1491struct UserListenToken {
1493 token: String,
1494 expiration_time_ms: i64,
1495}
1496
1497fn build_listen_token_query(
1501 symbol: Option<&InstrumentNameExchange>,
1502) -> BTreeMap<String, serde_json::Value> {
1503 let mut query = BTreeMap::new();
1504 if let Some(sym) = symbol {
1505 query.insert(
1506 "isIsolated".to_string(),
1507 serde_json::Value::String("TRUE".to_string()),
1508 );
1509 query.insert(
1510 "symbol".to_string(),
1511 serde_json::Value::String(sym.name().to_string()),
1512 );
1513 }
1514 query
1515}
1516
1517#[derive(Deserialize)]
1519struct UserListenTokenResponse {
1520 #[serde(alias = "listenToken")]
1522 token: String,
1523 #[serde(rename = "expirationTime")]
1524 expiration_time: i64,
1525}
1526
1527async fn acquire_user_listen_token(
1542 rest_config: &Arc<ConfigurationRestApi>,
1543 rate_limiter: &RateLimitTracker,
1544 symbol: Option<&InstrumentNameExchange>,
1545) -> Result<UserListenToken, UnindexedClientError> {
1546 let query = build_listen_token_query(symbol);
1549 let response = rest_call_with_retry(rest_config, rate_limiter, RequestKind::Essential, |cfg| {
1550 let query = query.clone();
1551 Box::pin(async move {
1552 binance_sdk::common::utils::send_request::<UserListenTokenResponse>(
1553 &cfg,
1554 "/sapi/v1/userListenToken",
1555 reqwest::Method::POST,
1556 query,
1559 BTreeMap::new(),
1560 None,
1561 true, )
1563 .await
1564 })
1565 })
1566 .await
1567 .map_err(|e| classify_rest_query_error(&e, symbol))?;
1568
1569 let data = response.data().await.map_err(response_decode_error)?;
1570 Ok(UserListenToken {
1571 token: data.token,
1572 expiration_time_ms: data.expiration_time,
1573 })
1574}
1575
1576fn token_renew_after(expiration_time_ms: i64) -> Duration {
1579 let now_ms = Utc::now().timestamp_millis();
1580 let remaining_secs = expiration_time_ms
1584 .saturating_sub(now_ms)
1585 .saturating_div(1_000)
1586 .saturating_sub(TOKEN_RENEW_MARGIN_SECS);
1587 let secs = u64::try_from(remaining_secs).unwrap_or(0);
1588 if secs < TOKEN_MIN_LIFETIME_SECS {
1591 warn!(
1592 computed_secs = secs,
1593 floor_secs = TOKEN_MIN_LIFETIME_SECS,
1594 "BinanceMargin token renewal wait clamped to floor (near-expiry or odd expirationTime)"
1595 );
1596 }
1597 Duration::from_secs(secs.max(TOKEN_MIN_LIFETIME_SECS))
1598}
1599
1600async fn connect_margin_ws(
1607 ws_config: &ConfigurationWebsocketApi,
1608) -> anyhow::Result<Arc<WsApiBase>> {
1609 let ws = WsApiBase::new(ws_config.clone(), vec![]);
1610 tokio::time::timeout(
1613 Duration::from_secs(CONNECT_TIMEOUT_SECS),
1614 ws.clone().connect(),
1615 )
1616 .await
1617 .map_err(|_| {
1618 anyhow::anyhow!("BinanceMargin WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
1619 })??;
1620 Ok(ws)
1621}
1622
1623async fn subscribe_listen_token(ws: &Arc<WsApiBase>, token: &str) -> anyhow::Result<()> {
1630 let mut payload = BTreeMap::new();
1633 payload.insert(
1634 "listenToken".to_string(),
1635 serde_json::Value::String(token.to_string()),
1636 );
1637 ws.send_message::<serde_json::Value>(
1638 "userDataStream.subscribe.listenToken",
1639 payload,
1640 WebsocketMessageSendOptions::new(),
1641 )
1642 .await
1643 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1644 Ok(())
1645}
1646
1647async fn subscribe_listen_token_capture(
1660 ws: &Arc<WsApiBase>,
1661 token: &str,
1662) -> anyhow::Result<Option<i64>> {
1663 #[derive(Deserialize)]
1666 struct SubscribeAck {
1667 #[serde(rename = "subscriptionId", default)]
1668 subscription_id: Option<i64>,
1669 }
1670
1671 let mut payload = BTreeMap::new();
1672 payload.insert(
1673 "listenToken".to_string(),
1674 serde_json::Value::String(token.to_string()),
1675 );
1676 let result = ws
1677 .send_message::<SubscribeAck>(
1678 "userDataStream.subscribe.listenToken",
1679 payload,
1680 WebsocketMessageSendOptions::new(),
1681 )
1682 .await
1683 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1684 let ack = match result {
1685 SendWebsocketMessageResult::Single(resp) => resp.data()?,
1686 SendWebsocketMessageResult::Multiple(responses) => responses
1688 .into_iter()
1689 .next()
1690 .ok_or_else(|| anyhow::anyhow!("empty subscribe ack"))?
1691 .data()?,
1692 };
1693 Ok(ack.subscription_id)
1694}
1695
1696fn cross_account_position_handler(
1700 position: OutboundAccountPosition,
1701 _subscription_id: Option<i64>,
1702 buf: &mut Vec<UnindexedAccountEvent>,
1703) {
1704 convert_margin_account_position(position, buf);
1705}
1706
1707#[cfg(test)]
1712fn convert_margin_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
1713 convert_margin_user_data_events_with(frame, buf, &mut cross_account_position_handler)
1714}
1715
1716fn convert_margin_user_data_events_with(
1732 frame: &str,
1733 buf: &mut Vec<UnindexedAccountEvent>,
1734 handle_position: &mut impl FnMut(
1735 OutboundAccountPosition,
1736 Option<i64>,
1737 &mut Vec<UnindexedAccountEvent>,
1738 ),
1739) -> bool {
1740 let (subscription_id, event_type, event_raw) = match parse_user_data_frame(frame) {
1741 UserDataFrame::Response => return false,
1743 UserDataFrame::Event {
1744 subscription_id,
1745 event_type,
1746 event,
1747 } => (subscription_id, event_type, event),
1748 UserDataFrame::Unrecognised => {
1749 static SEEN: AtomicU64 = AtomicU64::new(0);
1750 log_unrecognised_frame("BinanceMargin", &SEEN, frame);
1751 return false;
1752 }
1753 };
1754 match event_type {
1755 "executionReport" => {
1756 match serde_json::from_str::<ExecutionReport>(event_raw) {
1759 Ok(report) => convert_execution_report(&report, ExchangeId::BinanceMargin, buf),
1760 Err(e) => {
1761 warn!(error = %e, frame = frame_excerpt(event_raw), "BinanceMargin: undeserializable executionReport, dropping")
1762 }
1763 }
1764 false
1765 }
1766 "outboundAccountPosition" => {
1767 match serde_json::from_str::<OutboundAccountPosition>(event_raw) {
1768 Ok(position) => handle_position(position, subscription_id, buf),
1770 Err(e) => {
1771 warn!(error = %e, frame = frame_excerpt(event_raw), "BinanceMargin: undeserializable outboundAccountPosition, dropping")
1772 }
1773 }
1774 false
1775 }
1776 "balanceUpdate" => {
1777 false
1781 }
1782 "USER_LIABILITY_CHANGE" | "userLiabilityChange" => {
1786 match serde_json::from_str::<UserLiabilityChange>(event_raw) {
1793 Ok(c) => {
1794 if tracing::enabled!(tracing::Level::INFO) {
1795 info!(
1796 asset = c.a.as_deref().unwrap_or("?"),
1797 kind = c.t.as_deref().unwrap_or("?"),
1798 principal = c.p.as_deref().unwrap_or("?"),
1799 interest = c.i.as_deref().unwrap_or("?"),
1800 "BinanceMargin USER_LIABILITY_CHANGE (observable; not applied to balance state)"
1801 );
1802 }
1803 }
1804 Err(e) => warn!(
1805 error = %e,
1806 frame = frame_excerpt(event_raw),
1807 "BinanceMargin: undeserializable USER_LIABILITY_CHANGE, dropping"
1808 ),
1809 }
1810 false
1811 }
1812 "MARGIN_LEVEL_STATUS_CHANGE" | "marginLevelStatusChange" => {
1813 if tracing::enabled!(tracing::Level::WARN) {
1818 match serde_json::from_str::<MarginLevelStatusChange>(event_raw) {
1819 Ok(c) => warn!(
1820 margin_level = c.l.as_deref().unwrap_or("?"),
1821 status = c.s.as_deref().unwrap_or("?"),
1822 "BinanceMargin MARGIN_LEVEL_STATUS_CHANGE (liquidation risk; observable, no policy)"
1823 ),
1824 Err(e) => warn!(
1825 error = %e,
1826 frame = frame_excerpt(event_raw),
1827 "BinanceMargin: undeserializable MARGIN_LEVEL_STATUS_CHANGE, dropping"
1828 ),
1829 }
1830 }
1831 false
1832 }
1833 "eventStreamTerminated" => {
1834 warn!("BinanceMargin user data stream terminated by exchange, signalling reconnect");
1835 true
1836 }
1837 "listStatus" => {
1838 trace!("BinanceMargin ignoring listStatus user data event");
1841 false
1842 }
1843 other => {
1844 static SEEN: UnhandledEvents = UnhandledEvents::new();
1845 log_unhandled_event("BinanceMargin", &SEEN, other, event_raw);
1846 false
1847 }
1848 }
1849}
1850
1851fn convert_margin_account_position(
1856 position: OutboundAccountPosition,
1857 buf: &mut Vec<UnindexedAccountEvent>,
1858) {
1859 let time_exchange = position
1860 .u
1861 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1862 .unwrap_or_else(Utc::now);
1863
1864 for b in position.b_uppercase.unwrap_or_default() {
1865 let asset = match b.a {
1866 Some(a) => AssetNameExchange::new(a),
1867 None => {
1868 warn!("BinanceMargin account position entry missing asset name");
1869 continue;
1870 }
1871 };
1872 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1873 Some(v) => v,
1874 None => {
1875 warn!(%asset, "BinanceMargin account position missing/unparseable 'free' field");
1876 continue;
1877 }
1878 };
1879 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1880 Some(v) => v,
1881 None => {
1882 warn!(%asset, "BinanceMargin account position missing/unparseable 'locked' field");
1883 continue;
1884 }
1885 };
1886 let update =
1887 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
1888 buf.push(UnindexedAccountEvent::new(
1889 ExchangeId::BinanceMargin,
1890 AccountEventKind::BalanceStreamUpdate(
1891 rustrade_integration::collection::snapshot::Snapshot::new(update),
1892 ),
1893 ));
1894 }
1895}
1896
1897fn route_isolated_account_position(
1908 position: OutboundAccountPosition,
1909 subscription_id: Option<i64>,
1910 sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
1911 base_quote: &HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>,
1912 buf: &mut Vec<UnindexedAccountEvent>,
1913) {
1914 let Some(sub_id) = subscription_id else {
1915 warn!(
1916 "BinanceMargin isolated: outboundAccountPosition without subscriptionId — dropping \
1917 (per-instrument balance routing requires it)"
1918 );
1919 return;
1920 };
1921 let instrument = {
1922 let map = sub_map.lock().unwrap_or_else(|p| p.into_inner());
1925 match map.get(&sub_id) {
1926 Some(inst) => inst.clone(),
1927 None => {
1928 warn!(
1929 subscription_id = sub_id,
1930 "BinanceMargin isolated: outboundAccountPosition for unmapped subscriptionId — dropping"
1931 );
1932 return;
1933 }
1934 }
1935 };
1936 let Some((base_asset, quote_asset)) = base_quote.get(&instrument) else {
1937 warn!(
1938 instrument = %instrument.name(),
1939 "BinanceMargin isolated: no base/quote known for instrument — dropping balance frame"
1940 );
1941 return;
1942 };
1943
1944 let time_exchange = position
1945 .u
1946 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1947 .unwrap_or_else(Utc::now);
1948
1949 let mut base_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
1950 let mut quote_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
1951 for b in position.b_uppercase.unwrap_or_default() {
1952 let Some(asset) = b.a.as_deref() else {
1953 warn!(instrument = %instrument.name(), "BinanceMargin isolated account position entry missing asset name");
1954 continue;
1955 };
1956 let side = if asset == base_asset.name().as_str() {
1958 &mut base_update
1959 } else if asset == quote_asset.name().as_str() {
1960 &mut quote_update
1961 } else {
1962 continue;
1963 };
1964 let (Some(free), Some(locked)) = (
1965 b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()),
1966 b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()),
1967 ) else {
1968 warn!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position missing/unparseable free/locked");
1969 continue;
1970 };
1971 *side = Some(AssetBalanceUpdate::new(
1972 AssetNameExchange::new(asset),
1973 BalanceUpdate::new(free, locked),
1974 time_exchange,
1975 ));
1976 }
1977
1978 let (Some(base), Some(quote)) = (base_update, quote_update) else {
1982 warn!(
1983 instrument = %instrument.name(),
1984 "BinanceMargin isolated: outboundAccountPosition missing base or quote side — dropping (no partial InstrumentBalanceUpdate)"
1985 );
1986 return;
1987 };
1988 buf.push(UnindexedAccountEvent::new(
1989 ExchangeId::BinanceMargin,
1990 AccountEventKind::InstrumentBalanceUpdate(InstrumentBalanceUpdate::new(
1991 instrument, base, quote,
1992 )),
1993 ));
1994}
1995
1996fn register_user_data_listener(
2008 ws: &Arc<WsApiBase>,
2009 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2010 dedup: SharedDedupCache,
2011 known: SharedKnownLiveOrders,
2012 heartbeat_flag: Arc<AtomicBool>,
2013 signal_tx: oneshot::Sender<()>,
2014 mut handle_position: impl FnMut(
2015 OutboundAccountPosition,
2016 Option<i64>,
2017 &mut Vec<UnindexedAccountEvent>,
2018 ) + Send
2019 + 'static,
2020) -> Subscription {
2021 let mut signal_tx_opt = Some(signal_tx);
2022 let mut event_tx = Some(tx);
2023 let mut event_buf = Vec::with_capacity(32);
2027
2028 ws.common.events.subscribe(move |event| {
2032 let Some(ref sender) = event_tx else { return };
2033 match event {
2034 WebsocketEvent::Message(json_str) => {
2035 heartbeat_flag.store(true, Ordering::Release);
2036 let terminated = convert_margin_user_data_events_with(
2039 &json_str,
2040 &mut event_buf,
2041 &mut handle_position,
2042 );
2043 for ev in event_buf.drain(..) {
2044 if let Some(key) = dedup_key_from_event(&ev)
2045 && is_duplicate(&dedup, key)
2046 {
2047 trace!("BinanceMargin dedup: skipping duplicate event");
2048 continue;
2049 }
2050 let held = KnownLiveOrders::observes(&ev.kind).then(|| {
2054 let mut held = known.lock();
2055 held.observe(&ev);
2056 held
2057 });
2058 if sender.send(ev).is_err() {
2059 drop(held);
2060 warn!("BinanceMargin account_stream receiver dropped, suppressing sends");
2061 event_tx.take();
2062 if let Some(s) = signal_tx_opt.take() {
2063 let _ = s.send(());
2064 }
2065 return;
2066 }
2067 }
2068 if terminated {
2069 event_tx.take();
2070 if let Some(s) = signal_tx_opt.take() {
2071 let _ = s.send(());
2072 }
2073 }
2074 }
2075 WebsocketEvent::Ping | WebsocketEvent::Pong => {
2076 heartbeat_flag.store(true, Ordering::Release);
2077 }
2078 WebsocketEvent::Error(e) => {
2079 warn!(%e, "BinanceMargin WebSocket error, will attempt reconnect");
2080 event_tx.take();
2081 if let Some(s) = signal_tx_opt.take() {
2082 let _ = s.send(());
2083 }
2084 }
2085 WebsocketEvent::Close(code, reason) => {
2086 warn!(code, %reason, "BinanceMargin WebSocket closed");
2087 event_tx.take();
2088 if let Some(s) = signal_tx_opt.take() {
2089 let _ = s.send(());
2090 }
2091 }
2092 _ => {
2093 trace!("BinanceMargin ignoring unhandled WebsocketEvent variant");
2094 }
2095 }
2096 })
2097}
2098
2099async fn recover_margin_fills(
2119 rest: &Arc<RestApi>,
2120 rate_limiter: &Arc<RateLimitTracker>,
2121 unrecovered: &mut UnrecoveredFills,
2122 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2123 dedup: &SharedDedupCache,
2124 known: &SharedKnownLiveOrders,
2125 is_isolated: bool,
2126) {
2127 use futures::StreamExt as _;
2128
2129 let due = unrecovered.due(tokio::time::Instant::now());
2130 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
2131 return;
2132 };
2133 info!(
2134 gaps = due.len(),
2135 oldest = %gap_time(oldest),
2136 is_isolated,
2137 "BinanceMargin recovering fills missed while disconnected"
2138 );
2139
2140 let mut settled = vec![false; due.len()];
2143 let started = AtomicUsize::new(0);
2144 let recovery = async {
2145 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
2146 let mut recovered = 0u32;
2147 let mut duplicates = 0u32;
2148
2149 let mut stream =
2150 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
2151 started.store(index + 1, Ordering::Relaxed);
2152 let rest = rest.clone();
2153 let rl = rate_limiter.clone();
2154 async move {
2155 let span = MyTradesFrom::Span {
2156 start: gap.start_ms,
2157 end: gap.end_ms,
2158 };
2159 let raw = match paginate_margin_my_trades(
2160 &rest,
2161 &rl,
2162 &inst,
2163 span,
2164 is_isolated,
2165 RequestKind::Essential,
2166 )
2167 .await
2168 {
2169 Ok(pages) => pages,
2170 Err(e) => return (index, Err(e)),
2171 };
2172 let totals = recovered_order_totals(
2176 ExchangeId::BinanceMargin,
2177 &inst,
2178 &raw,
2179 order_executions_deadline,
2180 |order_id| {
2181 paginate_margin_my_trades(
2182 &rest,
2183 &rl,
2184 &inst,
2185 MyTradesFrom::Order(order_id),
2186 is_isolated,
2187 RequestKind::Essential,
2188 )
2189 },
2190 )
2191 .await;
2192 let trades = raw
2193 .iter()
2194 .filter_map(|t| {
2195 let mut trade = convert_margin_trade(t, &inst)?;
2196 trade.order_filled_quantity =
2197 t.id.and_then(|id| totals.get(&id).copied());
2198 Some(trade)
2199 })
2200 .collect::<Vec<_>>();
2201 (index, Ok(trades))
2202 }
2203 }))
2204 .buffer_unordered(8);
2205 while let Some((index, result)) = stream.next().await {
2206 let (inst, gap) = &due[index];
2207 settled[index] = true;
2208 let trades = match result {
2209 Ok(trades) => trades,
2210 Err(e) => {
2211 gap_failed(
2212 unrecovered,
2213 tx,
2214 ExchangeId::BinanceMargin,
2215 inst,
2216 gap,
2217 FillRecoveryFailure::Request(e.to_string()),
2218 );
2219 continue;
2220 }
2221 };
2222 for trade in trades {
2223 let event = UnindexedAccountEvent::new(
2224 ExchangeId::BinanceMargin,
2225 AccountEventKind::Trade(trade),
2226 );
2227 if let Some(key) = dedup_key_from_event(&event)
2228 && is_duplicate(dedup, key)
2229 {
2230 duplicates += 1;
2231 continue;
2232 }
2233 let sent = {
2236 let mut known = known.lock();
2237 known.observe(&event);
2238 tx.send(event)
2239 };
2240 if sent.is_err() {
2241 debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2242 return;
2243 }
2244 recovered += 1;
2245 }
2246 unrecovered.recovered(inst, gap);
2247 }
2248 info!(
2249 recovered,
2250 duplicates, is_isolated, "BinanceMargin fill recovery complete"
2251 );
2252 };
2253 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2257 .await
2258 .is_err()
2259 {
2260 let started = started.load(Ordering::Relaxed);
2261 for ((inst, gap), _) in due[..started]
2262 .iter()
2263 .zip(&settled)
2264 .filter(|(_, settled)| !**settled)
2265 {
2266 gap_failed(
2267 unrecovered,
2268 tx,
2269 ExchangeId::BinanceMargin,
2270 inst,
2271 gap,
2272 FillRecoveryFailure::TimedOut {
2273 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2274 },
2275 );
2276 }
2277 }
2278}
2279
2280fn open_reconnect_recovery(
2285 unrecovered: &mut UnrecoveredFills,
2286 unchecked: &mut UncheckedOrders,
2287 known: &SharedKnownLiveOrders,
2288 instruments: &[InstrumentNameExchange],
2289 disconnected: DateTime<Utc>,
2290) {
2291 unrecovered.open(instruments, disconnected, Utc::now());
2292 let held = known.lock().instruments_among(instruments);
2293 unchecked.open(held);
2294}
2295
2296#[allow(
2303 clippy::cognitive_complexity,
2304 reason = "inherent reconnect-loop complexity (token + connect + subscribe + callback + recovery \
2305 + monitor + cleanup + backoff); mirrors spot's `connection_manager`, not worth splitting further"
2306)]
2307async fn margin_connection_manager(
2308 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2309 dedup: SharedDedupCache,
2310 ws_config: ConfigurationWebsocketApi,
2311 rest_config: Arc<ConfigurationRestApi>,
2312 rest: Arc<RestApi>,
2313 rate_limiter: Arc<RateLimitTracker>,
2314 known: SharedKnownLiveOrders,
2315 instruments: Vec<InstrumentNameExchange>,
2316 initial: Option<(Arc<WsApiBase>, UserListenToken)>,
2317) {
2318 enum DisconnectReason {
2319 Signal,
2320 HeartbeatTimeout,
2321 TokenRefresh,
2322 ConsumerDropped,
2323 }
2324
2325 let mut backoff = ExponentialBackoff::new();
2326 let mut disconnect_time: Option<DateTime<Utc>> = None;
2327 let mut unrecovered = UnrecoveredFills::default();
2329 let mut unchecked = UncheckedOrders::default();
2331 let (mut current_ws, mut current_token) = match initial {
2332 Some((ws, token)) => (Some(ws), Some(token)),
2333 None => (None, None),
2334 };
2335
2336 loop {
2337 let token = match current_token.take() {
2339 Some(t) => t,
2340 None => match acquire_user_listen_token(&rest_config, &rate_limiter, None).await {
2341 Ok(t) => t,
2342 Err(e) => {
2343 error!(%e, "BinanceMargin userListenToken acquisition failed");
2344 if !backoff.wait().await {
2345 error!("BinanceMargin max reconnect attempts exhausted");
2346 emit_stream_terminated(
2347 &tx,
2348 ExchangeId::BinanceMargin,
2349 StreamTerminationReason::ReconnectBudgetExhausted {
2350 attempts: backoff.attempts(),
2351 last_error: e.to_string(),
2352 },
2353 );
2354 break;
2355 }
2356 continue;
2357 }
2358 },
2359 };
2360
2361 let ws = match current_ws.take() {
2363 Some(ws) => ws,
2364 None => match connect_margin_ws(&ws_config).await {
2365 Ok(ws) => ws,
2366 Err(e) => {
2367 error!(%e, "BinanceMargin WS connect failed");
2368 if !backoff.wait().await {
2369 error!("BinanceMargin max reconnect attempts exhausted");
2370 emit_stream_terminated(
2371 &tx,
2372 ExchangeId::BinanceMargin,
2373 StreamTerminationReason::ReconnectBudgetExhausted {
2374 attempts: backoff.attempts(),
2375 last_error: e.to_string(),
2376 },
2377 );
2378 break;
2379 }
2380 continue;
2381 }
2382 },
2383 };
2384
2385 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2387 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2389 let subscription = register_user_data_listener(
2392 &ws,
2393 tx.clone(),
2394 dedup.clone(),
2395 known.clone(),
2396 heartbeat_flag.clone(),
2397 signal_tx,
2398 cross_account_position_handler,
2399 );
2400
2401 if let Err(e) = subscribe_listen_token(&ws, &token.token).await {
2403 warn!(%e, "BinanceMargin user-data subscribe failed, cleaning up and retrying");
2404 subscription.unsubscribe();
2405 if let Err(de) = ws.disconnect().await {
2406 warn!(%de, "BinanceMargin failed to disconnect after subscribe failure");
2407 }
2408 if !backoff.wait().await {
2410 error!("BinanceMargin max reconnect attempts exhausted");
2411 emit_stream_terminated(
2412 &tx,
2413 ExchangeId::BinanceMargin,
2414 StreamTerminationReason::ReconnectBudgetExhausted {
2415 attempts: backoff.attempts(),
2416 last_error: e.to_string(),
2417 },
2418 );
2419 break;
2420 }
2421 continue;
2422 }
2423 info!("BinanceMargin account_stream connected and subscribed");
2424 backoff.reset();
2428
2429 let token_deadline =
2432 tokio::time::Instant::now() + token_renew_after(token.expiration_time_ms);
2433
2434 if let Some(dt) = disconnect_time.take() {
2439 open_reconnect_recovery(&mut unrecovered, &mut unchecked, &known, &instruments, dt);
2440 }
2441 recover_margin_fills(
2444 &rest,
2445 &rate_limiter,
2446 &mut unrecovered,
2447 &tx,
2448 &dedup,
2449 &known,
2450 false,
2451 )
2452 .await;
2453 recover_margin_ended_orders(
2454 &rest,
2455 &rate_limiter,
2456 &known,
2457 &mut unchecked,
2458 &unrecovered,
2459 &tx,
2460 false,
2461 )
2462 .await;
2463
2464 let reason = {
2466 let mut signal_rx = signal_rx;
2467 let retry_gaps = async {
2473 loop {
2474 let next_check = unchecked.next_due(&unrecovered);
2475 match unrecovered.next_due().into_iter().chain(next_check).min() {
2476 Some(due) => tokio::time::sleep_until(due).await,
2477 None => std::future::pending::<()>().await,
2478 }
2479 recover_margin_fills(
2480 &rest,
2481 &rate_limiter,
2482 &mut unrecovered,
2483 &tx,
2484 &dedup,
2485 &known,
2486 false,
2487 )
2488 .await;
2489 recover_margin_ended_orders(
2490 &rest,
2491 &rate_limiter,
2492 &known,
2493 &mut unchecked,
2494 &unrecovered,
2495 &tx,
2496 false,
2497 )
2498 .await;
2499 }
2500 };
2501 tokio::pin!(retry_gaps);
2502 loop {
2503 tokio::select! {
2504 biased;
2507 _ = tx.closed() => {
2508 debug!("BinanceMargin account_stream consumer dropped, terminating");
2509 break DisconnectReason::ConsumerDropped;
2510 }
2511 _ = &mut signal_rx => {
2512 warn!("BinanceMargin WS disconnected, will attempt reconnect");
2513 break DisconnectReason::Signal;
2514 }
2515 () = tokio::time::sleep_until(token_deadline) => {
2516 info!("BinanceMargin userListenToken nearing expiry, renewing");
2517 break DisconnectReason::TokenRefresh;
2518 }
2519 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2520 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2521 continue;
2525 }
2526 warn!("BinanceMargin heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2527 break DisconnectReason::HeartbeatTimeout;
2528 }
2529 () = &mut retry_gaps => {}
2531 }
2532 }
2533 };
2534 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2535
2536 if should_reconnect {
2537 disconnect_time = Some(match reason {
2538 DisconnectReason::HeartbeatTimeout => {
2539 Utc::now()
2540 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2541 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2542 }
2543 DisconnectReason::TokenRefresh => {
2544 Utc::now()
2549 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2550 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2551 }
2552 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2553 });
2554 }
2555
2556 subscription.unsubscribe();
2558 if let Err(e) = ws.disconnect().await {
2559 warn!(%e, "BinanceMargin failed to disconnect WebSocket");
2560 }
2561
2562 if !should_reconnect || tx.is_closed() {
2563 debug!("BinanceMargin connection manager exiting");
2565 break;
2566 }
2567
2568 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2571 error!("BinanceMargin max reconnect attempts exhausted, stream terminating");
2572 let last_error = match reason {
2575 DisconnectReason::HeartbeatTimeout => {
2576 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2577 }
2578 _ => "WebSocket disconnected (server close/error)".to_string(),
2579 };
2580 emit_stream_terminated(
2581 &tx,
2582 ExchangeId::BinanceMargin,
2583 StreamTerminationReason::ReconnectBudgetExhausted {
2584 attempts: backoff.attempts(),
2585 last_error,
2586 },
2587 );
2588 break;
2589 }
2590 }
2591}
2592
2593fn build_base_quote_map(
2605 assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
2606) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
2607 let mut map = HashMap::with_capacity(assets.len());
2608 for entry in assets {
2609 let (Some(symbol), Some(base), Some(quote)) = (
2610 entry.symbol.as_deref(),
2611 entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
2612 entry
2613 .quote_asset
2614 .as_deref()
2615 .and_then(|q| q.asset.as_deref()),
2616 ) else {
2617 warn!(
2618 "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
2619 skipping base/quote map entry"
2620 );
2621 continue;
2622 };
2623 map.insert(
2624 InstrumentNameExchange::new(symbol),
2625 (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
2626 );
2627 }
2628 map
2629}
2630
2631async fn acquire_all_isolated_tokens(
2644 rest_config: &Arc<ConfigurationRestApi>,
2645 rate_limiter: &Arc<RateLimitTracker>,
2646 symbols: &[InstrumentNameExchange],
2647) -> Result<Vec<(InstrumentNameExchange, UserListenToken)>, UnindexedClientError> {
2648 use futures::{StreamExt as _, TryStreamExt as _};
2649
2650 futures::stream::iter(symbols.iter().cloned().map(|sym| {
2651 let rest_config = rest_config.clone();
2652 let rate_limiter = rate_limiter.clone();
2653 async move {
2654 let token = acquire_user_listen_token(&rest_config, &rate_limiter, Some(&sym)).await?;
2655 Ok::<_, UnindexedClientError>((sym, token))
2656 }
2657 }))
2658 .buffer_unordered(8)
2659 .try_collect()
2660 .await
2661}
2662
2663fn earliest_token_expiry_ms(tokens: &[(InstrumentNameExchange, UserListenToken)]) -> i64 {
2668 tokens
2669 .iter()
2670 .map(|(_, t)| t.expiration_time_ms)
2671 .min()
2672 .unwrap_or(i64::MAX)
2673}
2674
2675struct IsolatedLiveConn {
2678 ws: Arc<WsApiBase>,
2679 subscription: Subscription,
2680 signal_rx: oneshot::Receiver<()>,
2681 heartbeat_flag: Arc<AtomicBool>,
2682 earliest_expiry_ms: i64,
2685}
2686
2687async fn isolated_connect_and_subscribe(
2697 ws_config: &ConfigurationWebsocketApi,
2698 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2699 dedup: &SharedDedupCache,
2700 known: &SharedKnownLiveOrders,
2701 base_quote: &Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2702 tokens: &[(InstrumentNameExchange, UserListenToken)],
2703) -> anyhow::Result<IsolatedLiveConn> {
2704 let ws = connect_margin_ws(ws_config).await?;
2705 let sub_map = Arc::new(Mutex::new(
2711 HashMap::<i64, InstrumentNameExchange>::with_capacity(tokens.len()),
2712 ));
2713 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2714 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2716
2717 let handle_position = {
2718 let sub_map = sub_map.clone();
2719 let base_quote = base_quote.clone();
2720 move |position, subscription_id, buf: &mut Vec<UnindexedAccountEvent>| {
2721 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
2722 }
2723 };
2724 let subscription = register_user_data_listener(
2726 &ws,
2727 tx.clone(),
2728 dedup.clone(),
2729 known.clone(),
2730 heartbeat_flag.clone(),
2731 signal_tx,
2732 handle_position,
2733 );
2734
2735 let earliest_expiry_ms = earliest_token_expiry_ms(tokens);
2736 for (sym, token) in tokens {
2737 match subscribe_listen_token_capture(&ws, &token.token).await {
2738 Ok(Some(id)) => {
2739 sub_map
2740 .lock()
2741 .unwrap_or_else(|p| p.into_inner())
2742 .insert(id, sym.clone());
2743 }
2744 Ok(None) => warn!(
2745 symbol = %sym.name(),
2746 "BinanceMargin isolated: subscribe ack carried no subscriptionId — live per-pair \
2747 balances unavailable for this symbol (fills/orders unaffected; balances via snapshot)"
2748 ),
2749 Err(e) => {
2750 warn!(%e, symbol = %sym.name(), "BinanceMargin isolated subscribe failed");
2753 subscription.unsubscribe();
2754 if let Err(de) = ws.disconnect().await {
2755 warn!(%de, "BinanceMargin isolated: disconnect after subscribe failure failed");
2756 }
2757 return Err(e);
2758 }
2759 }
2760 }
2761
2762 Ok(IsolatedLiveConn {
2763 ws,
2764 subscription,
2765 signal_rx,
2766 heartbeat_flag,
2767 earliest_expiry_ms,
2768 })
2769}
2770
2771#[allow(
2780 clippy::cognitive_complexity,
2781 reason = "inherent reconnect-loop complexity (tokens + connect + subscribe + monitor + cleanup \
2782 + backoff); mirrors the cross manager, not worth splitting further"
2783)]
2784async fn isolated_connection_manager(
2785 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2786 dedup: SharedDedupCache,
2787 ws_config: ConfigurationWebsocketApi,
2788 rest_config: Arc<ConfigurationRestApi>,
2789 rest: Arc<RestApi>,
2790 rate_limiter: Arc<RateLimitTracker>,
2791 known: SharedKnownLiveOrders,
2792 symbols: Vec<InstrumentNameExchange>,
2793 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2794 initial: Option<IsolatedLiveConn>,
2795) {
2796 enum DisconnectReason {
2797 Signal,
2798 HeartbeatTimeout,
2799 TokenRefresh,
2800 ConsumerDropped,
2801 }
2802
2803 let mut backoff = ExponentialBackoff::new();
2804 let mut disconnect_time: Option<DateTime<Utc>> = None;
2805 let mut unrecovered = UnrecoveredFills::default();
2807 let mut unchecked = UncheckedOrders::default();
2809 let mut current = initial;
2810
2811 loop {
2812 let IsolatedLiveConn {
2814 ws,
2815 subscription,
2816 signal_rx,
2817 heartbeat_flag,
2818 earliest_expiry_ms,
2819 } = match current.take() {
2820 Some(live) => live,
2822 None => {
2823 let tokens = match acquire_all_isolated_tokens(
2826 &rest_config,
2827 &rate_limiter,
2828 &symbols,
2829 )
2830 .await
2831 {
2832 Ok(t) => t,
2833 Err(e) => {
2834 error!(%e, "BinanceMargin isolated userListenToken acquisition failed");
2835 if !backoff.wait().await {
2836 error!("BinanceMargin isolated max reconnect attempts exhausted");
2837 emit_stream_terminated(
2838 &tx,
2839 ExchangeId::BinanceMargin,
2840 StreamTerminationReason::ReconnectBudgetExhausted {
2841 attempts: backoff.attempts(),
2842 last_error: e.to_string(),
2843 },
2844 );
2845 break;
2846 }
2847 continue;
2848 }
2849 };
2850 match isolated_connect_and_subscribe(
2851 &ws_config,
2852 &tx,
2853 &dedup,
2854 &known,
2855 &base_quote,
2856 &tokens,
2857 )
2858 .await
2859 {
2860 Ok(live) => live,
2861 Err(e) => {
2862 error!(%e, "BinanceMargin isolated connect/subscribe failed");
2863 if !backoff.wait().await {
2864 error!("BinanceMargin isolated max reconnect attempts exhausted");
2865 emit_stream_terminated(
2866 &tx,
2867 ExchangeId::BinanceMargin,
2868 StreamTerminationReason::ReconnectBudgetExhausted {
2869 attempts: backoff.attempts(),
2870 last_error: e.to_string(),
2871 },
2872 );
2873 break;
2874 }
2875 continue;
2876 }
2877 }
2878 }
2879 };
2880
2881 info!(
2882 symbols = symbols.len(),
2883 "BinanceMargin isolated account_stream connected and subscribed"
2884 );
2885 backoff.reset();
2888
2889 let token_deadline = tokio::time::Instant::now() + token_renew_after(earliest_expiry_ms);
2892
2893 if let Some(dt) = disconnect_time.take() {
2896 open_reconnect_recovery(&mut unrecovered, &mut unchecked, &known, &symbols, dt);
2897 }
2898 recover_margin_fills(
2900 &rest,
2901 &rate_limiter,
2902 &mut unrecovered,
2903 &tx,
2904 &dedup,
2905 &known,
2906 true,
2907 )
2908 .await;
2909 recover_margin_ended_orders(
2910 &rest,
2911 &rate_limiter,
2912 &known,
2913 &mut unchecked,
2914 &unrecovered,
2915 &tx,
2916 true,
2917 )
2918 .await;
2919
2920 let reason = {
2923 let mut signal_rx = signal_rx;
2924 let retry_gaps = async {
2927 loop {
2928 let next_check = unchecked.next_due(&unrecovered);
2929 match unrecovered.next_due().into_iter().chain(next_check).min() {
2930 Some(due) => tokio::time::sleep_until(due).await,
2931 None => std::future::pending::<()>().await,
2932 }
2933 recover_margin_fills(
2934 &rest,
2935 &rate_limiter,
2936 &mut unrecovered,
2937 &tx,
2938 &dedup,
2939 &known,
2940 true,
2941 )
2942 .await;
2943 recover_margin_ended_orders(
2944 &rest,
2945 &rate_limiter,
2946 &known,
2947 &mut unchecked,
2948 &unrecovered,
2949 &tx,
2950 true,
2951 )
2952 .await;
2953 }
2954 };
2955 tokio::pin!(retry_gaps);
2956 loop {
2957 tokio::select! {
2958 biased;
2959 _ = tx.closed() => {
2960 debug!("BinanceMargin isolated consumer dropped, terminating");
2961 break DisconnectReason::ConsumerDropped;
2962 }
2963 _ = &mut signal_rx => {
2964 warn!("BinanceMargin isolated WS disconnected, will attempt reconnect");
2965 break DisconnectReason::Signal;
2966 }
2967 () = tokio::time::sleep_until(token_deadline) => {
2968 info!("BinanceMargin isolated userListenToken nearing expiry, renewing all");
2969 break DisconnectReason::TokenRefresh;
2970 }
2971 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2972 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2973 continue;
2974 }
2975 warn!("BinanceMargin isolated heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2976 break DisconnectReason::HeartbeatTimeout;
2977 }
2978 () = &mut retry_gaps => {}
2980 }
2981 }
2982 };
2983 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2984
2985 if should_reconnect {
2986 disconnect_time = Some(match reason {
2987 DisconnectReason::HeartbeatTimeout => {
2988 Utc::now()
2989 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2990 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2991 }
2992 DisconnectReason::TokenRefresh => {
2993 Utc::now()
2997 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2998 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2999 }
3000 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
3001 });
3002 }
3003
3004 subscription.unsubscribe();
3006 if let Err(e) = ws.disconnect().await {
3007 warn!(%e, "BinanceMargin isolated failed to disconnect WebSocket");
3008 }
3009
3010 if !should_reconnect || tx.is_closed() {
3011 debug!("BinanceMargin isolated connection manager exiting");
3013 break;
3014 }
3015
3016 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
3019 error!("BinanceMargin isolated max reconnect attempts exhausted, stream terminating");
3020 let last_error = match reason {
3023 DisconnectReason::HeartbeatTimeout => {
3024 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
3025 }
3026 _ => "WebSocket disconnected (server close/error)".to_string(),
3027 };
3028 emit_stream_terminated(
3029 &tx,
3030 ExchangeId::BinanceMargin,
3031 StreamTerminationReason::ReconnectBudgetExhausted {
3032 attempts: backoff.attempts(),
3033 last_error,
3034 },
3035 );
3036 break;
3037 }
3038 }
3039}
3040
3041async fn fetch_margin_open_orders_for_instrument(
3048 rest: Arc<RestApi>,
3049 rate_limiter: Arc<RateLimitTracker>,
3050 instrument: InstrumentNameExchange,
3051 is_isolated: bool,
3052 kind: RequestKind,
3053) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
3054 let symbol_str = instrument.name().to_string();
3056 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
3057 let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
3058 let sym = symbol_str.clone();
3059 let isolated = isolated.clone();
3060 Box::pin(async move {
3061 let params = QueryMarginAccountsOpenOrdersParams::builder()
3062 .symbol(sym)
3063 .is_isolated(isolated)
3064 .build()?;
3065 rest.query_margin_accounts_open_orders(params).await
3066 })
3067 })
3068 .await
3069 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
3070
3071 let orders_data = response.data().await.map_err(response_decode_error)?;
3072
3073 let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceMargin, &instrument);
3074
3075 Ok((instrument, listing))
3076}
3077
3078async fn fetch_margin_all_open_orders(
3085 rest: Arc<RestApi>,
3086 rate_limiter: Arc<RateLimitTracker>,
3087 is_isolated: bool,
3088) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
3089 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
3090 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
3091 let isolated = isolated.clone();
3092 Box::pin(async move {
3093 let params = QueryMarginAccountsOpenOrdersParams::builder()
3094 .is_isolated(isolated)
3095 .build()?;
3096 rest.query_margin_accounts_open_orders(params).await
3097 })
3098 })
3099 .await
3100 .map_err(|e| classify_rest_query_error(&e, None))?;
3101
3102 let orders_data = response.data().await.map_err(response_decode_error)?;
3103
3104 let orders = orders_data
3105 .into_iter()
3106 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin))
3107 .collect();
3108
3109 Ok(orders)
3110}
3111
3112async fn fetch_margin_order_lookup(
3119 rest: Arc<RestApi>,
3120 rate_limiter: Arc<RateLimitTracker>,
3121 key: UnindexedOrderKey,
3122 is_isolated: bool,
3123 kind: RequestKind,
3124 recorded_kind: Option<OrderKind>,
3125) -> Result<OrderLookup, UnindexedClientError> {
3126 let symbol = key.instrument.name().to_string();
3128 let cid = key.cid.0.to_string();
3129 let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
3130 let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
3131 let (symbol, cid, isolated) = (symbol.clone(), cid.clone(), isolated.clone());
3132 Box::pin(async move {
3133 let params = QueryMarginAccountsOrderParams::builder(symbol)
3134 .orig_client_order_id(cid)
3135 .is_isolated(isolated)
3136 .build()?;
3137 rest.query_margin_accounts_order(params).await
3138 })
3139 })
3140 .await
3141 {
3142 Ok(response) => response,
3143 Err(e) if is_unknown_order(&e) => {
3144 debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceMargin does not know this order");
3145 return Ok(OrderLookup::Unknown);
3146 }
3147 Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
3148 };
3149
3150 let row = response.data().await.map_err(response_decode_error)?;
3151
3152 Ok(
3153 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, recorded_kind)
3154 .map_or(OrderLookup::NotEnded, |order| {
3155 OrderLookup::Ended(Box::new(order))
3156 }),
3157 )
3158}
3159
3160async fn listed_margin_open_cids(
3163 rest: Arc<RestApi>,
3164 rate_limiter: Arc<RateLimitTracker>,
3165 instruments: Vec<InstrumentNameExchange>,
3166 is_isolated: bool,
3167) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3168 let mut listed = FnvHashSet::default();
3169 for instrument in instruments {
3170 let (_, listing) = fetch_margin_open_orders_for_instrument(
3171 rest.clone(),
3172 rate_limiter.clone(),
3173 instrument,
3174 is_isolated,
3175 RequestKind::Essential,
3176 )
3177 .await?;
3178 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
3179 }
3180 Ok(listed)
3181}
3182
3183async fn recover_margin_ended_orders(
3186 rest: &Arc<RestApi>,
3187 rate_limiter: &Arc<RateLimitTracker>,
3188 known: &SharedKnownLiveOrders,
3189 unchecked: &mut UncheckedOrders,
3190 unrecovered: &UnrecoveredFills,
3191 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3192 is_isolated: bool,
3193) {
3194 recover_ended_orders(
3195 ExchangeId::BinanceMargin,
3196 known,
3197 unchecked,
3198 unrecovered,
3199 tx,
3200 OpenListing::PerInstrument,
3201 |instruments| {
3202 listed_margin_open_cids(rest.clone(), rate_limiter.clone(), instruments, is_isolated)
3203 },
3204 |key| {
3205 let recorded_kind = known.lock().kind_of(&key.cid);
3207 fetch_margin_order_lookup(
3208 rest.clone(),
3209 rate_limiter.clone(),
3210 key,
3211 is_isolated,
3212 RequestKind::Essential,
3213 recorded_kind,
3214 )
3215 },
3216 )
3217 .await;
3218}
3219
3220const MARGIN_MY_TRADES_WINDOW_MS: i64 = 23 * 60 * 60 * 1000;
3226
3227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3230enum MarginTradesQuery {
3231 Window { start: i64, end: i64 },
3233 FromId(i64),
3235 Order(i64),
3237 OrderFromId { order_id: i64, from_id: i64 },
3239}
3240
3241impl MarginTradesQuery {
3242 fn first(from: MyTradesFrom, now_ms: i64) -> Option<Self> {
3246 match from {
3247 MyTradesFrom::Span { start, .. } => {
3248 (start <= now_ms).then(|| Self::window(start, now_ms))
3249 }
3250 MyTradesFrom::Order(order_id) => Some(Self::Order(order_id)),
3251 }
3252 }
3253
3254 fn window(start: i64, now_ms: i64) -> Self {
3256 Self::Window {
3257 start,
3258 end: start.saturating_add(MARGIN_MY_TRADES_WINDOW_MS).min(now_ms),
3259 }
3260 }
3261
3262 fn next(self, page_len: usize, last_id: Option<i64>, now_ms: i64) -> Option<Self> {
3265 let full = page_len >= BINANCE_MAX_TRADES;
3266 let after_last = || last_id?.checked_add(1);
3267 match self {
3268 Self::Window { end, .. } if page_len == 0 => {
3273 (end < now_ms).then(|| Self::window(end, now_ms))
3274 }
3275 Self::Window { end, .. } if end >= now_ms && !full => None,
3277 Self::Window { .. } => after_last().map(Self::FromId),
3280 Self::FromId(_) if full => after_last().map(Self::FromId),
3281 Self::Order(order_id) | Self::OrderFromId { order_id, .. } if full => {
3282 after_last().map(|from_id| Self::OrderFromId { order_id, from_id })
3283 }
3284 Self::FromId(_) | Self::Order(_) | Self::OrderFromId { .. } => None,
3285 }
3286 }
3287}
3288
3289async fn paginate_margin_my_trades(
3311 rest: &Arc<RestApi>,
3312 rate_limiter: &Arc<RateLimitTracker>,
3313 instrument: &InstrumentNameExchange,
3314 from: MyTradesFrom,
3315 is_isolated: bool,
3316 kind: RequestKind,
3317) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
3318 let symbol_str = instrument.name().to_string();
3320 let isolated = QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(is_isolated);
3321 #[allow(clippy::cast_possible_wrap)]
3326 let limit = BINANCE_MAX_TRADES as i64;
3327 let now_ms = match from {
3330 MyTradesFrom::Span { end, .. } => end.min(Utc::now().timestamp_millis()),
3331 MyTradesFrom::Order(_) => Utc::now().timestamp_millis(),
3332 };
3333 let mut all_pages = Vec::new();
3334 let mut requests = 0u32;
3335 let mut next = MarginTradesQuery::first(from, now_ms);
3336 while let Some(query) = next {
3337 requests += 1;
3338 let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
3339 let sym = symbol_str.clone();
3340 let isolated = isolated.clone();
3341 Box::pin(async move {
3342 let builder = QueryMarginAccountsTradeListParams::builder(sym)
3343 .is_isolated(isolated)
3344 .limit(limit);
3345 let params = match query {
3346 MarginTradesQuery::Window { start, end } => {
3347 builder.start_time(start).end_time(end)
3348 }
3349 MarginTradesQuery::FromId(id) => builder.from_id(id),
3350 MarginTradesQuery::Order(order_id) => builder.order_id(order_id),
3351 MarginTradesQuery::OrderFromId { order_id, from_id } => {
3352 builder.order_id(order_id).from_id(from_id)
3353 }
3354 }
3355 .build()?;
3356 rest.query_margin_accounts_trade_list(params).await
3357 })
3358 })
3359 .await
3360 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
3361
3362 let mut page = response.data().await.map_err(response_decode_error)?;
3363
3364 let page_len = page.len();
3365 let last_id = page.last().and_then(|t| t.id);
3366 let past_end = match from {
3368 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
3369 MyTradesFrom::Order(_) => false,
3370 };
3371 all_pages.extend(page);
3372 if past_end {
3373 break;
3374 }
3375
3376 if page_len > 0 && last_id.is_none() {
3377 warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, cannot page past it");
3378 break;
3379 }
3380 next = query.next(page_len, last_id, now_ms);
3381 }
3382 debug!(
3383 %instrument,
3384 requests,
3385 trades = all_pages.len(),
3386 "BinanceMargin paginate_my_trades: walk complete"
3387 );
3388 Ok(all_pages)
3389}
3390
3391fn convert_margin_balance_entry(
3401 b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
3402 now: DateTime<Utc>,
3403) -> Option<AssetBalance<AssetNameExchange>> {
3404 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
3405 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3406 Some(v) => v,
3407 None => {
3408 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
3409 return None;
3410 }
3411 };
3412 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3413 Some(v) => v,
3414 None => {
3415 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
3416 return None;
3417 }
3418 };
3419 let borrowed = b
3424 .borrowed
3425 .as_deref()
3426 .and_then(|s| Decimal::from_str(s).ok())
3427 .unwrap_or(Decimal::ZERO);
3428 let interest = b
3429 .interest
3430 .as_deref()
3431 .and_then(|s| Decimal::from_str(s).ok())
3432 .unwrap_or(Decimal::ZERO);
3433 Some(AssetBalance::new(
3434 asset_name,
3435 Balance::new_margin(free + locked, free, borrowed, interest),
3436 now,
3437 ))
3438}
3439
3440fn filter_and_convert_margin_balances(
3443 user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
3444 assets: &[AssetNameExchange],
3445) -> Vec<AssetBalance<AssetNameExchange>> {
3446 let now = Utc::now();
3447 if assets.is_empty() {
3449 return user_assets
3450 .into_iter()
3451 .filter_map(|b| convert_margin_balance_entry(b, now))
3452 .collect();
3453 }
3454 if assets.len() <= 16 {
3456 return user_assets
3457 .into_iter()
3458 .filter_map(|b| {
3459 let asset_name_str = b.asset.as_deref()?;
3460 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
3461 return None;
3462 }
3463 convert_margin_balance_entry(b, now)
3464 })
3465 .collect();
3466 }
3467 use std::collections::HashSet;
3468 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
3469 user_assets
3470 .into_iter()
3471 .filter_map(|b| {
3472 let asset_name_str = b.asset.as_deref()?;
3473 if !asset_set.contains(asset_name_str) {
3474 return None;
3475 }
3476 convert_margin_balance_entry(b, now)
3477 })
3478 .collect()
3479}
3480
3481fn active_order_snapshot(
3488 o: Order<ExchangeId, InstrumentNameExchange, Open>,
3489) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
3490{
3491 Order {
3492 key: o.key,
3493 side: o.side,
3494 price: o.price,
3495 quantity: o.quantity,
3496 kind: o.kind,
3497 time_in_force: o.time_in_force,
3498 state: OrderState::active(o.state),
3499 }
3500}
3501
3502fn convert_isolated_asset_balance(
3512 asset: Option<&str>,
3513 free: Option<&str>,
3514 locked: Option<&str>,
3515 borrowed: Option<&str>,
3516 interest: Option<&str>,
3517 now: DateTime<Utc>,
3518) -> Option<AssetBalance<AssetNameExchange>> {
3519 let asset_name = AssetNameExchange::new(asset?);
3520 let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
3521 Some(v) => v,
3522 None => {
3523 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
3524 return None;
3525 }
3526 };
3527 let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
3528 Some(v) => v,
3529 None => {
3530 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
3531 return None;
3532 }
3533 };
3534 let borrowed = borrowed
3535 .and_then(|s| Decimal::from_str(s).ok())
3536 .unwrap_or(Decimal::ZERO);
3537 let interest = interest
3538 .and_then(|s| Decimal::from_str(s).ok())
3539 .unwrap_or(Decimal::ZERO);
3540 Some(AssetBalance::new(
3541 asset_name,
3542 Balance::new_margin(free + locked, free, borrowed, interest),
3543 now,
3544 ))
3545}
3546
3547fn convert_isolated_margin_assets(
3555 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
3556) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
3557 let now = Utc::now();
3560 let mut map = HashMap::with_capacity(assets.len());
3561 for entry in assets {
3562 let Some(symbol) = entry.symbol.as_deref() else {
3563 warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
3564 continue;
3565 };
3566 let instrument = InstrumentNameExchange::new(symbol);
3567
3568 let Some(base_raw) = entry.base_asset.as_deref() else {
3569 warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
3570 continue;
3571 };
3572 let Some(quote_raw) = entry.quote_asset.as_deref() else {
3573 warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
3574 continue;
3575 };
3576
3577 let Some(base) = convert_isolated_asset_balance(
3578 base_raw.asset.as_deref(),
3579 base_raw.free.as_deref(),
3580 base_raw.locked.as_deref(),
3581 base_raw.borrowed.as_deref(),
3582 base_raw.interest.as_deref(),
3583 now,
3584 ) else {
3585 warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
3586 continue;
3587 };
3588 let Some(quote) = convert_isolated_asset_balance(
3589 quote_raw.asset.as_deref(),
3590 quote_raw.free.as_deref(),
3591 quote_raw.locked.as_deref(),
3592 quote_raw.borrowed.as_deref(),
3593 quote_raw.interest.as_deref(),
3594 now,
3595 ) else {
3596 warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
3597 continue;
3598 };
3599
3600 let risk = IsolatedMarginRisk {
3601 margin_level: entry
3602 .margin_level
3603 .as_deref()
3604 .and_then(|s| Decimal::from_str(s).ok()),
3605 margin_ratio: entry
3606 .margin_ratio
3607 .as_deref()
3608 .and_then(|s| Decimal::from_str(s).ok()),
3609 liquidation_price: entry
3610 .liquidate_price
3611 .as_deref()
3612 .and_then(|s| Decimal::from_str(s).ok()),
3613 };
3614
3615 map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
3616 }
3617 map
3618}
3619
3620fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
3623 symbols
3624 .chunks(5)
3625 .map(|chunk| {
3626 chunk
3627 .iter()
3628 .map(|s| s.name().as_str())
3629 .collect::<Vec<_>>()
3630 .join(",")
3631 })
3632 .collect()
3633}
3634
3635async fn fetch_isolated_margin_account_info(
3641 rest: Arc<RestApi>,
3642 rate_limiter: Arc<RateLimitTracker>,
3643 symbols: Vec<InstrumentNameExchange>,
3644) -> Result<Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>, UnindexedClientError> {
3645 use futures::{StreamExt as _, TryStreamExt as _};
3646
3647 let chunks = chunk_symbols(&symbols);
3648
3649 futures::stream::iter(chunks.into_iter().map(|symbols_param| {
3653 let rest = rest.clone();
3654 let rate_limiter = rate_limiter.clone();
3655 async move {
3656 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
3657 let symbols_param = symbols_param.clone();
3658 Box::pin(async move {
3659 let params = QueryIsolatedMarginAccountInfoParams::builder()
3660 .symbols(symbols_param)
3661 .build()?;
3662 rest.query_isolated_margin_account_info(params).await
3663 })
3664 })
3665 .await
3666 .map_err(|e| classify_rest_query_error(&e, None))?;
3667
3668 let info = response.data().await.map_err(response_decode_error)?;
3669 Ok::<_, UnindexedClientError>(info.assets.unwrap_or_default())
3670 }
3671 }))
3672 .buffer_unordered(8)
3673 .try_fold(
3674 Vec::with_capacity(symbols.len()),
3675 |mut acc, assets| async move {
3676 acc.extend(assets);
3677 Ok(acc)
3678 },
3679 )
3680 .await
3681}
3682
3683fn convert_margin_trade(
3690 t: &QueryMarginAccountsTradeListResponseInner,
3691 instrument: &InstrumentNameExchange,
3692) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3693 let trade_id_raw = match t.id {
3694 Some(id) => id,
3695 None => {
3696 warn!(%instrument, "BinanceMargin trade missing id");
3697 return None;
3698 }
3699 };
3700 let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
3701 let order_id = match t.order_id {
3702 Some(id) => OrderId(format_smolstr!("{id}")),
3703 None => {
3704 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
3705 return None;
3706 }
3707 };
3708 let side = match t.is_buyer {
3709 Some(true) => Side::Buy,
3710 Some(false) => Side::Sell,
3711 None => {
3712 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
3713 return None;
3714 }
3715 };
3716 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3717 Some(v) => v,
3718 None => {
3719 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
3720 return None;
3721 }
3722 };
3723 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3724 Some(v) => v,
3725 None => {
3726 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
3727 return None;
3728 }
3729 };
3730 let commission = t
3731 .commission
3732 .as_deref()
3733 .and_then(|s| Decimal::from_str(s).ok())
3734 .unwrap_or(Decimal::ZERO);
3735 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
3736 Some(ts) => ts,
3737 None => {
3738 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
3739 Utc::now()
3740 }
3741 };
3742
3743 let fee_asset = t
3747 .commission_asset
3748 .as_deref()
3749 .map(AssetNameExchange::from)
3750 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
3751
3752 Some(Trade::new(
3753 trade_id,
3754 order_id,
3755 instrument.clone(),
3756 StrategyId::unknown(), time_exchange,
3758 side,
3759 price,
3760 quantity,
3761 None,
3763 AssetFees::new(fee_asset, commission, None),
3764 ))
3765}
3766
3767#[derive(Debug)]
3773enum BuildOrderError {
3774 Unsupported,
3776 Build(String),
3778 TrailingDelta(TrailingDeltaError),
3780}
3781
3782trait IsolatedFlag {
3793 fn from_flag(is_isolated: bool) -> Self;
3795}
3796
3797macro_rules! impl_isolated_flag {
3802 ($($t:ty),* $(,)?) => {
3803 $(
3804 impl IsolatedFlag for $t {
3805 fn from_flag(is_isolated: bool) -> Self {
3806 if is_isolated { Self::True } else { Self::False }
3807 }
3808 }
3809 )*
3810 };
3811}
3812
3813impl_isolated_flag!(
3814 MarginAccountNewOrderIsIsolatedEnum,
3815 MarginAccountCancelOrderIsIsolatedEnum,
3816 QueryMarginAccountsOpenOrdersIsIsolatedEnum,
3817 QueryMarginAccountsOrderIsIsolatedEnum,
3818 QueryMarginAccountsTradeListIsIsolatedEnum,
3819);
3820
3821fn build_cancel_order_params(
3829 symbol: String,
3830 id: Option<&OrderId>,
3831 cid: &ClientOrderId,
3832 is_isolated: bool,
3833) -> Result<MarginAccountCancelOrderParams, String> {
3834 let mut builder = MarginAccountCancelOrderParams::builder(symbol).is_isolated(
3835 MarginAccountCancelOrderIsIsolatedEnum::from_flag(is_isolated),
3836 );
3837
3838 match id {
3839 Some(order_id) => match order_id.0.parse::<i64>() {
3840 Ok(id) => builder = builder.order_id(id),
3841 Err(_) => {
3842 error!(
3844 order_id = %order_id.0,
3845 "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
3846 );
3847 builder = builder.orig_client_order_id(cid.0.to_string());
3848 }
3849 },
3850 None => builder = builder.orig_client_order_id(cid.0.to_string()),
3851 }
3852
3853 builder.build().map_err(|e| e.to_string())
3854}
3855
3856#[allow(clippy::too_many_arguments)] fn build_new_order_params(
3865 symbol: String,
3866 side: Side,
3867 price: Option<Decimal>,
3868 quantity: Decimal,
3869 kind: OrderKind,
3870 time_in_force: TimeInForce,
3871 new_client_order_id: String,
3872 side_effect: MarginSideEffect,
3873 is_isolated: bool,
3874) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3875 let binance_side = match side {
3876 Side::Buy => MarginAccountNewOrderSideEnum::Buy,
3877 Side::Sell => MarginAccountNewOrderSideEnum::Sell,
3878 };
3879
3880 let (binance_type, binance_tif) =
3881 convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
3882
3883 let mut builder = MarginAccountNewOrderParams::builder(symbol, binance_side, binance_type)
3885 .quantity(quantity)
3886 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::from_flag(is_isolated))
3887 .side_effect_type(side_effect.as_sdk_enum())
3888 .new_client_order_id(new_client_order_id)
3889 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
3890
3891 if side_effect == MarginSideEffect::AutoBorrowRepay {
3894 builder = builder.auto_repay_at_cancel(true);
3895 }
3896
3897 if let Some(tif) = binance_tif {
3898 builder = builder.time_in_force(tif);
3899 }
3900
3901 match kind {
3905 OrderKind::Limit => {
3906 builder = builder.price(price);
3907 }
3908 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
3909 builder = builder.stop_price(trigger_price);
3910 }
3911 OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
3912 builder = builder.price(price).stop_price(trigger_price);
3913 }
3914 OrderKind::TrailingStop {
3915 offset,
3916 offset_type,
3917 } => {
3918 let basis_points = trailing_delta_basis_points(offset, offset_type)
3919 .map_err(BuildOrderError::TrailingDelta)?;
3920 builder = builder.trailing_delta(i64::from(basis_points));
3921 }
3922 _ => {}
3926 }
3927
3928 builder
3929 .build()
3930 .map_err(|e| BuildOrderError::Build(e.to_string()))
3931}
3932
3933fn convert_order_kind_tif_margin(
3943 kind: OrderKind,
3944 tif: TimeInForce,
3945) -> Option<(
3946 MarginAccountNewOrderTypeEnum,
3947 Option<MarginAccountNewOrderTimeInForceEnum>,
3948)> {
3949 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
3950 let margin_type = match binance_type {
3951 BinanceOrderType::Market => MarginAccountNewOrderTypeEnum::Market,
3952 BinanceOrderType::Limit => MarginAccountNewOrderTypeEnum::Limit,
3953 BinanceOrderType::LimitMaker => MarginAccountNewOrderTypeEnum::LimitMaker,
3954 BinanceOrderType::StopLoss => MarginAccountNewOrderTypeEnum::StopLoss,
3955 BinanceOrderType::StopLossLimit => MarginAccountNewOrderTypeEnum::StopLossLimit,
3956 BinanceOrderType::TakeProfit => MarginAccountNewOrderTypeEnum::TakeProfit,
3957 BinanceOrderType::TakeProfitLimit => MarginAccountNewOrderTypeEnum::TakeProfitLimit,
3958 };
3959 let margin_tif = binance_tif.map(|t| match t {
3960 BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
3961 BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
3962 BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
3963 });
3964 Some((margin_type, margin_tif))
3965}
3966
3967#[cfg(test)]
3968#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3970 use super::*;
3971 use crate::client::binance::shared::convert_open_order;
3972 use crate::order::state::ActiveOrderState;
3973 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOpenOrdersResponseInner;
3974
3975 #[test]
3976 fn margin_side_effect_default_is_auto_borrow_repay() {
3977 assert_eq!(
3978 MarginSideEffect::default(),
3979 MarginSideEffect::AutoBorrowRepay
3980 );
3981 }
3982
3983 #[test]
3984 fn margin_side_effect_wire_strings() {
3985 assert_eq!(
3986 MarginSideEffect::AutoBorrowRepay.as_binance_str(),
3987 "AUTO_BORROW_REPAY"
3988 );
3989 assert_eq!(
3990 MarginSideEffect::NoBorrow.as_binance_str(),
3991 "NO_SIDE_EFFECT"
3992 );
3993 }
3994
3995 #[test]
3996 fn margin_side_effect_serde_round_trip() {
3997 assert_eq!(
3999 serde_json::to_string(&MarginSideEffect::AutoBorrowRepay).unwrap(),
4000 r#""auto_borrow_repay""#
4001 );
4002 assert_eq!(
4003 serde_json::to_string(&MarginSideEffect::NoBorrow).unwrap(),
4004 r#""no_borrow""#
4005 );
4006 assert_eq!(
4007 serde_json::from_str::<MarginSideEffect>(r#""auto_borrow_repay""#).unwrap(),
4008 MarginSideEffect::AutoBorrowRepay
4009 );
4010 assert_eq!(
4011 serde_json::from_str::<MarginSideEffect>(r#""no_borrow""#).unwrap(),
4012 MarginSideEffect::NoBorrow
4013 );
4014 }
4015
4016 #[test]
4017 fn config_debug_redacts_secrets() {
4018 let config = BinanceMarginConfig::new(
4019 "my_api_key".to_string(),
4020 "my_secret_key".to_string(),
4021 false,
4022 false,
4023 Vec::new(),
4024 MarginSideEffect::default(),
4025 );
4026 let debug = format!("{config:?}");
4027 assert!(!debug.contains("my_api_key"));
4028 assert!(!debug.contains("my_secret_key"));
4029 assert!(debug.contains("***"));
4030 }
4031
4032 #[test]
4033 fn cross_margin_uses_common_case_defaults() {
4034 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
4035 assert!(!config.testnet);
4036 assert!(!config.is_isolated);
4037 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
4038 assert_eq!(config.api_key(), "k");
4039 }
4040
4041 #[test]
4042 fn config_deserializes_with_defaults() {
4043 let config: BinanceMarginConfig =
4045 serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
4046 .expect("deserialize");
4047 assert!(!config.is_isolated);
4048 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
4049 }
4050
4051 #[test]
4052 fn config_deserializes_explicit_side_effect() {
4053 let config: BinanceMarginConfig = serde_json::from_str(
4054 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"side_effect":"no_borrow"}"#,
4055 )
4056 .expect("deserialize");
4057 assert!(config.is_isolated);
4058 assert_eq!(config.side_effect, MarginSideEffect::NoBorrow);
4059 }
4060
4061 #[test]
4062 fn isolated_ctor_sets_symbols_and_flag() {
4063 let symbols = vec![
4064 InstrumentNameExchange::new("BTCUSDT"),
4065 InstrumentNameExchange::new("ETHUSDT"),
4066 ];
4067 let config =
4068 BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
4069 assert!(config.is_isolated);
4070 assert_eq!(config.isolated_symbols, symbols);
4071 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
4072 }
4073
4074 #[test]
4075 fn isolated_config_with_symbols_constructs() {
4076 let config = BinanceMarginConfig::isolated(
4078 "k".to_string(),
4079 "s".to_string(),
4080 vec![InstrumentNameExchange::new("BTCUSDT")],
4081 );
4082 let _client = BinanceMargin::new(config); }
4084
4085 #[test]
4086 #[should_panic(expected = "non-empty isolated_symbols")]
4087 fn isolated_empty_symbols_panics_at_new() {
4088 let config = BinanceMarginConfig::new(
4090 "k".to_string(),
4091 "s".to_string(),
4092 false,
4093 true,
4094 Vec::new(),
4095 MarginSideEffect::default(),
4096 );
4097 let _ = BinanceMargin::new(config);
4098 }
4099
4100 #[test]
4101 #[should_panic(expected = "non-empty isolated_symbols")]
4102 fn isolated_empty_symbols_panics_via_deserialize_path() {
4103 let config: BinanceMarginConfig = serde_json::from_str(
4106 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
4107 )
4108 .expect("deserialize");
4109 assert!(config.isolated_symbols.is_empty());
4110 let _ = BinanceMargin::new(config);
4111 }
4112
4113 #[test]
4114 fn isolated_flag_maps_mode() {
4115 assert_eq!(
4118 MarginAccountNewOrderIsIsolatedEnum::from_flag(true).as_str(),
4119 "TRUE"
4120 );
4121 assert_eq!(
4122 MarginAccountNewOrderIsIsolatedEnum::from_flag(false).as_str(),
4123 "FALSE"
4124 );
4125 assert_eq!(
4126 MarginAccountCancelOrderIsIsolatedEnum::from_flag(true).as_str(),
4127 "TRUE"
4128 );
4129 assert_eq!(
4130 MarginAccountCancelOrderIsIsolatedEnum::from_flag(false).as_str(),
4131 "FALSE"
4132 );
4133 assert_eq!(
4134 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(true).as_str(),
4135 "TRUE"
4136 );
4137 assert_eq!(
4138 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(false).as_str(),
4139 "FALSE"
4140 );
4141 assert_eq!(
4142 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(true).as_str(),
4143 "TRUE"
4144 );
4145 assert_eq!(
4146 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(false).as_str(),
4147 "FALSE"
4148 );
4149 }
4150
4151 #[test]
4156 fn cancel_params_cross_with_parseable_order_id() {
4157 let p = build_cancel_order_params(
4158 "BTCUSDT".to_string(),
4159 Some(&OrderId::new("12345")),
4160 &ClientOrderId::new("cid-1"),
4161 false,
4162 )
4163 .expect("build");
4164 assert_eq!(p.symbol, "BTCUSDT");
4165 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("FALSE"));
4166 assert_eq!(p.order_id, Some(12345));
4167 assert_eq!(p.orig_client_order_id, None);
4168 }
4169
4170 #[test]
4171 fn cancel_params_isolated_is_config_driven() {
4172 let p = build_cancel_order_params(
4173 "BTCUSDT".to_string(),
4174 Some(&OrderId::new("12345")),
4175 &ClientOrderId::new("cid-1"),
4176 true,
4177 )
4178 .expect("build");
4179 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("TRUE"));
4180 assert_eq!(p.order_id, Some(12345));
4181 }
4182
4183 #[test]
4184 fn cancel_params_non_i64_order_id_falls_back_to_cid() {
4185 let p = build_cancel_order_params(
4186 "BTCUSDT".to_string(),
4187 Some(&OrderId::new("not-an-i64")),
4188 &ClientOrderId::new("cid-1"),
4189 false,
4190 )
4191 .expect("build");
4192 assert_eq!(p.order_id, None);
4193 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
4194 }
4195
4196 #[test]
4197 fn cancel_params_absent_order_id_uses_cid() {
4198 let p = build_cancel_order_params(
4199 "BTCUSDT".to_string(),
4200 None,
4201 &ClientOrderId::new("cid-1"),
4202 false,
4203 )
4204 .expect("build");
4205 assert_eq!(p.order_id, None);
4206 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
4207 }
4208
4209 use crate::order::TrailingOffsetType;
4214
4215 fn params(
4217 side: Side,
4218 price: Option<Decimal>,
4219 kind: OrderKind,
4220 tif: TimeInForce,
4221 side_effect: MarginSideEffect,
4222 ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
4223 build_new_order_params(
4224 "BTCUSDT".to_string(),
4225 side,
4226 price,
4227 Decimal::from(2),
4228 kind,
4229 tif,
4230 "cid-1".to_string(),
4231 side_effect,
4232 false, )
4234 }
4235
4236 fn gtc() -> TimeInForce {
4237 TimeInForce::GoodUntilCancelled { post_only: false }
4238 }
4239
4240 #[test]
4241 fn new_order_limit_maps_core_fields() {
4242 let p = params(
4243 Side::Buy,
4244 Some(Decimal::from(50_000)),
4245 OrderKind::Limit,
4246 gtc(),
4247 MarginSideEffect::AutoBorrowRepay,
4248 )
4249 .expect("build");
4250
4251 assert_eq!(p.symbol, "BTCUSDT");
4252 assert_eq!(p.side.as_str(), "BUY");
4253 assert_eq!(p.r#type.as_str(), "LIMIT");
4254 assert_eq!(p.quantity, Some(Decimal::from(2)));
4255 assert_eq!(p.price, Some(Decimal::from(50_000)));
4256 assert_eq!(p.stop_price, None);
4257 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
4258 assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
4259 assert_eq!(
4261 p.new_order_resp_type.as_ref().map(|r| r.as_str()),
4262 Some("FULL")
4263 );
4264 }
4265
4266 #[test]
4267 fn new_order_is_isolated_is_config_driven() {
4268 let cross = build_new_order_params(
4270 "BTCUSDT".to_string(),
4271 Side::Sell,
4272 Some(Decimal::from(10)),
4273 Decimal::from(2),
4274 OrderKind::Limit,
4275 gtc(),
4276 "cid-1".to_string(),
4277 MarginSideEffect::AutoBorrowRepay,
4278 false,
4279 )
4280 .expect("build");
4281 assert_eq!(
4282 cross.is_isolated.as_ref().map(|i| i.as_str()),
4283 Some("FALSE")
4284 );
4285 assert_eq!(cross.side.as_str(), "SELL");
4286
4287 let isolated = build_new_order_params(
4289 "BTCUSDT".to_string(),
4290 Side::Sell,
4291 Some(Decimal::from(10)),
4292 Decimal::from(2),
4293 OrderKind::Limit,
4294 gtc(),
4295 "cid-1".to_string(),
4296 MarginSideEffect::AutoBorrowRepay,
4297 true,
4298 )
4299 .expect("build");
4300 assert_eq!(
4301 isolated.is_isolated.as_ref().map(|i| i.as_str()),
4302 Some("TRUE")
4303 );
4304 }
4305
4306 #[test]
4307 fn side_effect_and_auto_repay_gating() {
4308 let auto = params(
4310 Side::Buy,
4311 Some(Decimal::from(1)),
4312 OrderKind::Limit,
4313 gtc(),
4314 MarginSideEffect::AutoBorrowRepay,
4315 )
4316 .expect("build");
4317 assert_eq!(
4318 auto.side_effect_type.as_ref().map(|s| s.as_str()),
4319 Some("AUTO_BORROW_REPAY")
4320 );
4321 assert_eq!(auto.auto_repay_at_cancel, Some(true));
4322
4323 let no_borrow = params(
4325 Side::Buy,
4326 Some(Decimal::from(1)),
4327 OrderKind::Limit,
4328 gtc(),
4329 MarginSideEffect::NoBorrow,
4330 )
4331 .expect("build");
4332 assert_eq!(
4333 no_borrow.side_effect_type.as_ref().map(|s| s.as_str()),
4334 Some("NO_SIDE_EFFECT")
4335 );
4336 assert_eq!(no_borrow.auto_repay_at_cancel, None);
4337 }
4338
4339 #[test]
4340 fn new_order_market_has_no_price_or_tif() {
4341 let p = params(
4342 Side::Buy,
4343 None,
4344 OrderKind::Market,
4345 gtc(),
4346 MarginSideEffect::AutoBorrowRepay,
4347 )
4348 .expect("build");
4349 assert_eq!(p.r#type.as_str(), "MARKET");
4350 assert_eq!(p.price, None);
4351 assert_eq!(p.stop_price, None);
4352 assert!(p.time_in_force.is_none());
4353 }
4354
4355 #[test]
4356 fn post_only_limit_maps_to_limit_maker() {
4357 let p = params(
4358 Side::Buy,
4359 Some(Decimal::from(10)),
4360 OrderKind::Limit,
4361 TimeInForce::GoodUntilCancelled { post_only: true },
4362 MarginSideEffect::AutoBorrowRepay,
4363 )
4364 .expect("build");
4365 assert_eq!(p.r#type.as_str(), "LIMIT_MAKER");
4366 assert!(p.time_in_force.is_none());
4368 }
4369
4370 #[test]
4371 fn conditional_kinds_set_stop_price() {
4372 let trigger = Decimal::from(48_000);
4373
4374 let stop = params(
4375 Side::Sell,
4376 None,
4377 OrderKind::Stop {
4378 trigger_price: trigger,
4379 },
4380 gtc(),
4381 MarginSideEffect::AutoBorrowRepay,
4382 )
4383 .expect("build");
4384 assert_eq!(stop.r#type.as_str(), "STOP_LOSS");
4385 assert_eq!(stop.stop_price, Some(trigger));
4386 assert_eq!(stop.price, None);
4387
4388 let stop_limit = params(
4389 Side::Sell,
4390 Some(Decimal::from(47_900)),
4391 OrderKind::StopLimit {
4392 trigger_price: trigger,
4393 },
4394 gtc(),
4395 MarginSideEffect::AutoBorrowRepay,
4396 )
4397 .expect("build");
4398 assert_eq!(stop_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
4399 assert_eq!(stop_limit.stop_price, Some(trigger));
4400 assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
4401 assert_eq!(
4402 stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
4403 Some("GTC")
4404 );
4405
4406 let take_profit = params(
4407 Side::Sell,
4408 None,
4409 OrderKind::TakeProfit {
4410 trigger_price: trigger,
4411 },
4412 gtc(),
4413 MarginSideEffect::AutoBorrowRepay,
4414 )
4415 .expect("build");
4416 assert_eq!(take_profit.r#type.as_str(), "TAKE_PROFIT");
4417 assert_eq!(take_profit.stop_price, Some(trigger));
4418 }
4419
4420 #[test]
4423 fn a_trailing_stop_maps_to_stop_loss_with_trailing_delta() {
4424 let cases = [
4425 (Decimal::from(25), TrailingOffsetType::BasisPoints, 25),
4426 (Decimal::new(15, 1), TrailingOffsetType::Percentage, 150),
4427 ];
4428 for (offset, offset_type, trailing_delta) in cases {
4429 let p = params(
4430 Side::Sell,
4431 None,
4432 OrderKind::TrailingStop {
4433 offset,
4434 offset_type,
4435 },
4436 gtc(),
4437 MarginSideEffect::NoBorrow,
4438 )
4439 .expect("build");
4440 assert_eq!(p.r#type.as_str(), "STOP_LOSS");
4441 assert_eq!(p.trailing_delta, Some(trailing_delta), "{offset_type:?}");
4442 assert_eq!(p.stop_price, None);
4443 assert_eq!(p.price, None);
4444 assert!(p.time_in_force.is_none());
4445 }
4446 }
4447
4448 #[test]
4449 fn absolute_trailing_offsets_and_trailing_stop_limits_are_unsupported() {
4450 let absolute = params(
4451 Side::Sell,
4452 None,
4453 OrderKind::TrailingStop {
4454 offset: Decimal::from(100),
4455 offset_type: TrailingOffsetType::Absolute,
4456 },
4457 gtc(),
4458 MarginSideEffect::AutoBorrowRepay,
4459 );
4460 assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
4461
4462 let trailing_stop_limit = params(
4463 Side::Sell,
4464 Some(Decimal::from(100)),
4465 OrderKind::TrailingStopLimit {
4466 offset: Decimal::from(100),
4467 offset_type: TrailingOffsetType::BasisPoints,
4468 stop_price: Decimal::from(95),
4469 limit_offset: Decimal::from(10),
4470 },
4471 gtc(),
4472 MarginSideEffect::AutoBorrowRepay,
4473 );
4474 assert!(matches!(
4475 trailing_stop_limit,
4476 Err(BuildOrderError::Unsupported)
4477 ));
4478 }
4479
4480 #[test]
4482 fn a_fractional_trailing_offset_is_refused() {
4483 let fractional = params(
4484 Side::Sell,
4485 None,
4486 OrderKind::TrailingStop {
4487 offset: Decimal::new(155, 3),
4488 offset_type: TrailingOffsetType::Percentage,
4489 },
4490 gtc(),
4491 MarginSideEffect::AutoBorrowRepay,
4492 );
4493 assert!(matches!(
4494 fractional,
4495 Err(BuildOrderError::TrailingDelta(TrailingDeltaError::Invalid(
4496 _
4497 )))
4498 ));
4499 }
4500
4501 #[test]
4502 fn tif_variants_map_to_margin_enum() {
4503 for (tif, expected) in [
4504 (TimeInForce::ImmediateOrCancel, "IOC"),
4505 (TimeInForce::FillOrKill, "FOK"),
4506 (gtc(), "GTC"),
4507 ] {
4508 let p = params(
4509 Side::Buy,
4510 Some(Decimal::from(1)),
4511 OrderKind::Limit,
4512 tif,
4513 MarginSideEffect::AutoBorrowRepay,
4514 )
4515 .expect("build");
4516 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
4517 }
4518 }
4519
4520 fn user_asset(
4525 asset: &str,
4526 free: &str,
4527 locked: &str,
4528 borrowed: &str,
4529 interest: &str,
4530 ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
4531 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4532 a.asset = Some(asset.to_string());
4533 a.free = Some(free.to_string());
4534 a.locked = Some(locked.to_string());
4535 a.borrowed = Some(borrowed.to_string());
4536 a.interest = Some(interest.to_string());
4537 a
4538 }
4539
4540 #[test]
4541 fn margin_balance_entry_maps_debt() {
4542 let ab =
4544 convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
4545 .expect("convert");
4546 assert_eq!(ab.asset.name().as_str(), "USDT");
4547 assert_eq!(ab.balance.total, Decimal::from(12));
4548 assert_eq!(ab.balance.free, Decimal::from(10));
4549 assert!(
4550 ab.balance.margin.is_some(),
4551 "REST snapshot must populate margin"
4552 );
4553 assert_eq!(ab.balance.net_asset(), Decimal::from(9));
4555 }
4556
4557 #[test]
4558 fn margin_balance_short_is_negative_net() {
4559 let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
4561 .expect("convert");
4562 assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
4563 }
4564
4565 #[test]
4566 fn margin_balance_missing_debt_defaults_to_zero() {
4567 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4570 a.asset = Some("ETH".to_string());
4571 a.free = Some("4".to_string());
4572 a.locked = Some("0".to_string());
4573 let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
4574 assert!(ab.balance.margin.is_some());
4575 assert_eq!(ab.balance.net_asset(), Decimal::from(4));
4576 }
4577
4578 #[test]
4579 fn margin_balance_missing_free_is_dropped() {
4580 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4582 a.asset = Some("USDT".to_string());
4583 a.locked = Some("0".to_string());
4584 assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
4585 }
4586
4587 #[test]
4588 fn margin_balance_filtering() {
4589 let assets = vec![
4590 user_asset("USDT", "10", "0", "0", "0"),
4591 user_asset("BTC", "1", "0", "0", "0"),
4592 user_asset("ETH", "5", "0", "0", "0"),
4593 ];
4594 assert_eq!(
4596 filter_and_convert_margin_balances(assets.clone(), &[]).len(),
4597 3
4598 );
4599 let filtered = filter_and_convert_margin_balances(
4601 assets,
4602 &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
4603 );
4604 assert_eq!(filtered.len(), 2);
4605 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
4606 }
4607
4608 #[test]
4609 fn margin_balance_filtering_large_slice_uses_hashset_branch() {
4610 let assets = vec![
4612 user_asset("BTC", "1", "0", "0", "0"),
4613 user_asset("ETH", "5", "0", "0", "0"),
4614 user_asset("DOGE", "9", "0", "0", "0"), ];
4616 let mut requested: Vec<AssetNameExchange> = (0..17)
4618 .map(|i| AssetNameExchange::new(format!("A{i:02}")))
4619 .collect();
4620 requested.push(AssetNameExchange::new("BTC"));
4621 requested.push(AssetNameExchange::new("ETH"));
4622 assert!(
4623 requested.len() > 16,
4624 "must exceed the linear-scan threshold"
4625 );
4626
4627 let filtered = filter_and_convert_margin_balances(assets, &requested);
4628 assert_eq!(filtered.len(), 2);
4629 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
4630 }
4631
4632 use binance_sdk::margin_trading::rest_api::{
4637 QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
4638 QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
4639 };
4640
4641 fn d(s: &str) -> Decimal {
4642 Decimal::from_str(s).unwrap()
4643 }
4644
4645 fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
4646 let mut b = IsoBase::new();
4647 b.asset = Some(asset.to_string());
4648 b.free = Some(free.to_string());
4649 b.locked = Some(locked.to_string());
4650 b.borrowed = Some(borrowed.to_string());
4651 b.interest = Some(interest.to_string());
4652 b
4653 }
4654
4655 fn iso_quote(
4656 asset: &str,
4657 free: &str,
4658 locked: &str,
4659 borrowed: &str,
4660 interest: &str,
4661 ) -> IsoQuote {
4662 let mut q = IsoQuote::new();
4663 q.asset = Some(asset.to_string());
4664 q.free = Some(free.to_string());
4665 q.locked = Some(locked.to_string());
4666 q.borrowed = Some(borrowed.to_string());
4667 q.interest = Some(interest.to_string());
4668 q
4669 }
4670
4671 fn iso_entry(
4672 symbol: &str,
4673 base: IsoBase,
4674 quote: IsoQuote,
4675 margin_level: Option<&str>,
4676 margin_ratio: Option<&str>,
4677 liquidate_price: Option<&str>,
4678 ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
4679 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4680 e.symbol = Some(symbol.to_string());
4681 e.base_asset = Some(Box::new(base));
4682 e.quote_asset = Some(Box::new(quote));
4683 e.margin_level = margin_level.map(str::to_string);
4684 e.margin_ratio = margin_ratio.map(str::to_string);
4685 e.liquidate_price = liquidate_price.map(str::to_string);
4686 e
4687 }
4688
4689 fn isolated_client(symbols: &[&str]) -> BinanceMargin {
4690 let config = BinanceMarginConfig::isolated(
4691 "k".to_string(),
4692 "s".to_string(),
4693 symbols
4694 .iter()
4695 .map(|s| InstrumentNameExchange::new(*s))
4696 .collect(),
4697 );
4698 BinanceMargin::new(config)
4699 }
4700
4701 #[test]
4702 fn isolated_assets_map_base_quote_and_risk() {
4703 let entry = iso_entry(
4706 "BTCUSDT",
4707 iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
4708 iso_quote("USDT", "1000", "50", "300", "1.5"),
4709 Some("3.5"),
4710 Some("0.12"),
4711 Some("48000"),
4712 );
4713 let map = convert_isolated_margin_assets(vec![entry]);
4714 let state = map
4715 .get(&InstrumentNameExchange::new("BTCUSDT"))
4716 .expect("entry present");
4717
4718 assert_eq!(state.base.asset.name().as_str(), "BTC");
4719 assert_eq!(state.base.balance.total, d("0.6"));
4720 assert_eq!(state.base.balance.free, d("0.5"));
4721 assert_eq!(state.base.balance.net_asset(), d("0.4"));
4722
4723 assert_eq!(state.quote.asset.name().as_str(), "USDT");
4724 assert_eq!(state.quote.balance.total, d("1050"));
4725 assert_eq!(state.quote.balance.net_asset(), d("750"));
4726
4727 assert_eq!(state.risk.margin_level, Some(d("3.5")));
4728 assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
4729 assert_eq!(state.risk.liquidation_price, Some(d("48000")));
4730 }
4731
4732 #[test]
4733 fn isolated_base_short_is_negative_net() {
4734 let entry = iso_entry(
4736 "BTCUSDT",
4737 iso_base("BTC", "0", "0", "1.5", "0.001"),
4738 iso_quote("USDT", "100", "0", "0", "0"),
4739 None,
4740 None,
4741 None,
4742 );
4743 let map = convert_isolated_margin_assets(vec![entry]);
4744 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4745 assert_eq!(state.base.balance.net_asset(), d("-1.5"));
4746 }
4747
4748 #[test]
4749 fn isolated_missing_debt_defaults_zero_and_risk_optional() {
4750 let mut base = IsoBase::new();
4753 base.asset = Some("BTC".to_string());
4754 base.free = Some("0.5".to_string());
4755 base.locked = Some("0".to_string());
4756 let mut quote = IsoQuote::new();
4757 quote.asset = Some("USDT".to_string());
4758 quote.free = Some("100".to_string());
4759 quote.locked = Some("0".to_string());
4760
4761 let map = convert_isolated_margin_assets(vec![iso_entry(
4762 "BTCUSDT", base, quote, None, None, None,
4763 )]);
4764 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4765 assert!(state.base.balance.margin.is_some());
4766 assert_eq!(state.base.balance.net_asset(), d("0.5"));
4767 assert_eq!(state.risk.margin_level, None);
4768 assert_eq!(state.risk.liquidation_price, None);
4769 }
4770
4771 #[test]
4772 fn isolated_unparseable_risk_is_none_but_entry_kept() {
4773 let entry = iso_entry(
4774 "BTCUSDT",
4775 iso_base("BTC", "1", "0", "0", "0"),
4776 iso_quote("USDT", "1", "0", "0", "0"),
4777 Some("not_a_number"),
4778 None,
4779 None,
4780 );
4781 let map = convert_isolated_margin_assets(vec![entry]);
4782 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4783 assert_eq!(state.risk.margin_level, None);
4784 }
4785
4786 #[test]
4787 fn isolated_missing_base_free_drops_entry() {
4788 let mut base = IsoBase::new();
4790 base.asset = Some("BTC".to_string());
4791 base.locked = Some("0".to_string()); let entry = iso_entry(
4793 "BTCUSDT",
4794 base,
4795 iso_quote("USDT", "100", "0", "0", "0"),
4796 None,
4797 None,
4798 None,
4799 );
4800 assert!(convert_isolated_margin_assets(vec![entry]).is_empty());
4801 }
4802
4803 #[test]
4804 fn isolated_missing_symbol_drops_entry() {
4805 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4806 e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
4807 e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
4808 assert!(convert_isolated_margin_assets(vec![e]).is_empty());
4810 }
4811
4812 #[test]
4813 fn chunk_symbols_batches_by_five() {
4814 let syms: Vec<InstrumentNameExchange> = (0..12)
4815 .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
4816 .collect();
4817 let chunks = chunk_symbols(&syms);
4818 assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
4819 assert_eq!(chunks[0].split(',').count(), 5);
4820 assert_eq!(chunks[1].split(',').count(), 5);
4821 assert_eq!(chunks[2].split(',').count(), 2);
4822
4823 let three: Vec<_> = (0..3)
4825 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4826 .collect();
4827 assert_eq!(chunk_symbols(&three).len(), 1);
4828
4829 let five: Vec<_> = (0..5)
4831 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4832 .collect();
4833 let c = chunk_symbols(&five);
4834 assert_eq!(c.len(), 1);
4835 assert_eq!(c[0].split(',').count(), 5);
4836
4837 assert!(chunk_symbols(&[]).is_empty());
4839 }
4840
4841 #[test]
4842 fn effective_isolated_set_empty_returns_all_configured() {
4843 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4844 assert_eq!(
4845 client.effective_isolated_set(&[]),
4846 vec![
4847 InstrumentNameExchange::new("BTCUSDT"),
4848 InstrumentNameExchange::new("ETHUSDT"),
4849 ]
4850 );
4851 }
4852
4853 #[test]
4854 fn effective_isolated_set_intersects_requested() {
4855 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4856 assert_eq!(
4857 client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
4858 vec![InstrumentNameExchange::new("ETHUSDT")]
4859 );
4860 }
4861
4862 #[test]
4863 fn effective_isolated_set_skips_out_of_set() {
4864 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4865 assert_eq!(
4867 client.effective_isolated_set(&[
4868 InstrumentNameExchange::new("BTCUSDT"),
4869 InstrumentNameExchange::new("DOGEUSDT"),
4870 ]),
4871 vec![InstrumentNameExchange::new("BTCUSDT")]
4872 );
4873 }
4874
4875 #[tokio::test]
4876 async fn fetch_balances_isolated_returns_empty() {
4877 let client = isolated_client(&["BTCUSDT"]);
4880 assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
4881 }
4882
4883 fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
4884 let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
4885 o.order_id = order_id;
4886 o.client_order_id = Some("cid-9".to_string());
4887 o.side = Some(side.to_string());
4888 o.price = Some("50000".to_string());
4889 o.orig_qty = Some("2".to_string());
4890 o.executed_qty = Some("0.5".to_string());
4891 o.r#type = Some("LIMIT".to_string());
4892 o.time_in_force = Some("GTC".to_string());
4893 o.time = Some(1_700_000_000_000);
4894 o.status = Some("PARTIALLY_FILLED".to_string());
4895 o
4896 }
4897
4898 #[test]
4899 fn margin_open_order_converts() {
4900 let inst = InstrumentNameExchange::new("BTCUSDT");
4901 let order = convert_open_order(
4902 &open_order(Some(42), "BUY"),
4903 ExchangeId::BinanceMargin,
4904 &inst,
4905 )
4906 .expect("convert");
4907 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4908 assert_eq!(
4909 order.state.id.assigned().map(|id| id.0.as_str()),
4910 Some("42")
4911 );
4912 assert_eq!(order.key.cid.0.as_str(), "cid-9");
4913 assert_eq!(order.side, Side::Buy);
4914 assert_eq!(order.price, Some(Decimal::from(50_000)));
4915 assert_eq!(order.quantity, Decimal::from(2));
4916 assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
4917 assert_eq!(order.kind, OrderKind::Limit);
4918 }
4919
4920 #[test]
4923 fn margin_open_order_that_is_not_live_is_dropped() {
4924 let inst = InstrumentNameExchange::new("BTCUSDT");
4925 let mut o = open_order(Some(42), "BUY");
4926 o.status = Some("CANCELED".to_string());
4927 assert!(convert_open_order(&o, ExchangeId::BinanceMargin, &inst).is_none());
4928 }
4929
4930 #[test]
4931 fn margin_open_order_missing_order_id_is_dropped() {
4932 let inst = InstrumentNameExchange::new("BTCUSDT");
4933 assert!(
4934 convert_open_order(&open_order(None, "BUY"), ExchangeId::BinanceMargin, &inst)
4935 .is_none()
4936 );
4937 }
4938
4939 #[test]
4945 fn margin_open_order_prefers_update_time_over_creation_time() {
4946 let inst = InstrumentNameExchange::new("BTCUSDT");
4947
4948 let mut updated = open_order(Some(42), "BUY");
4949 updated.update_time = Some(1_700_000_005_000);
4950 let order =
4951 convert_open_order(&updated, ExchangeId::BinanceMargin, &inst).expect("convert");
4952 assert_eq!(
4953 order.state.time_exchange,
4954 Utc.timestamp_millis_opt(1_700_000_005_000)
4955 .single()
4956 .unwrap(),
4957 "update_time must win over time"
4958 );
4959 }
4960
4961 #[test]
4964 fn margin_open_order_falls_back_to_creation_time_when_update_time_absent() {
4965 let inst = InstrumentNameExchange::new("BTCUSDT");
4966
4967 let base = open_order(Some(42), "BUY");
4968 assert!(base.update_time.is_none(), "fixture must omit update_time");
4969 let order = convert_open_order(&base, ExchangeId::BinanceMargin, &inst).expect("convert");
4970 assert_eq!(
4971 order.state.time_exchange,
4972 Utc.timestamp_millis_opt(1_700_000_000_000)
4973 .single()
4974 .unwrap(),
4975 "absent update_time falls back to time"
4976 );
4977 }
4978
4979 #[test]
4980 fn margin_open_order_owned_symbol_recovers_instrument() {
4981 let mut o = open_order(Some(42), "SELL");
4983 o.symbol = Some("ETHUSDT".to_string());
4984 let order =
4985 convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).expect("convert");
4986 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4987 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4988 assert_eq!(order.side, Side::Sell);
4989 }
4990
4991 #[test]
4992 fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
4993 let o = open_order(Some(42), "BUY");
4995 assert!(o.symbol.is_none());
4996 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).is_none());
4997 }
4998
4999 fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
5000 let mut t = QueryMarginAccountsTradeListResponseInner::new();
5001 t.id = id;
5002 t.order_id = Some(7);
5003 t.is_buyer = is_buyer;
5004 t.price = Some("48000".to_string());
5005 t.qty = Some("0.25".to_string());
5006 t.commission = Some("0.001".to_string());
5007 t.commission_asset = Some("BNB".to_string());
5008 t.time = Some(1_700_000_000_000);
5009 t
5010 }
5011
5012 #[test]
5013 fn margin_trade_converts() {
5014 let inst = InstrumentNameExchange::new("BTCUSDT");
5015 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
5016 assert_eq!(tr.id.0.as_str(), "11");
5017 assert_eq!(tr.order_id.0.as_str(), "7");
5018 assert_eq!(tr.side, Side::Sell);
5019 assert_eq!(tr.price, Decimal::from(48_000));
5020 assert_eq!(tr.quantity, Decimal::new(25, 2));
5021 assert_eq!(tr.fees.asset.name().as_str(), "BNB");
5023 assert_eq!(tr.fees.fees, Decimal::new(1, 3));
5024 }
5025
5026 #[test]
5027 fn margin_trade_missing_fields_are_dropped() {
5028 let inst = InstrumentNameExchange::new("BTCUSDT");
5029 assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
5031 assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
5032 }
5033
5034 #[test]
5037 fn token_renew_after_applies_margin_and_floor() {
5038 let now_ms = Utc::now().timestamp_millis();
5039 let far = token_renew_after(now_ms + 86_400_000).as_secs();
5041 assert!(
5042 far > 80_000 && far <= 86_400,
5043 "expected ~24h-minus-margin, got {far}"
5044 );
5045 assert_eq!(
5047 token_renew_after(now_ms - 10_000).as_secs(),
5048 TOKEN_MIN_LIFETIME_SECS
5049 );
5050 assert_eq!(
5052 token_renew_after(now_ms + 60_000).as_secs(),
5053 TOKEN_MIN_LIFETIME_SECS
5054 );
5055 }
5056
5057 fn push(event: serde_json::Value) -> String {
5062 serde_json::json!({ "subscriptionId": 1, "event": event }).to_string()
5063 }
5064
5065 #[test]
5066 fn margin_ws_rpc_ack_yields_no_events() {
5067 let frame = serde_json::json!({ "id": "abc", "status": 200, "result": {} }).to_string();
5069 let mut buf = Vec::new();
5070 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5071 assert!(buf.is_empty());
5072 }
5073
5074 #[test]
5077 fn margin_ws_unrecognised_frames_yield_no_events() {
5078 for frame in [
5079 r#"{"e":"eventStreamTerminated","E":1}"#,
5080 r#"{"subscriptionId":1,"event":{"E":1}}"#,
5081 ] {
5082 let mut buf = Vec::new();
5083 assert!(!convert_margin_user_data_events(frame, &mut buf), "{frame}");
5084 assert!(buf.is_empty(), "{frame}");
5085 }
5086 }
5087
5088 #[test]
5091 fn margin_ws_trailing_stop_report_decodes_as_a_trailing_stop() {
5092 for trailing_delta in [serde_json::json!(25), serde_json::json!("25")] {
5093 let frame = push(serde_json::json!({
5094 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "STOP_LOSS",
5095 "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "cid-1", "t": -1_i64,
5096 "p": "0.00000000", "P": "0.00000000", "d": trailing_delta, "D": 1_700_000_000_000_i64,
5097 "q": "2", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
5098 }));
5099 let mut buf = Vec::new();
5100 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5101 let [event] = buf.as_slice() else {
5102 panic!("one snapshot expected for {frame}, got {buf:?}");
5103 };
5104 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
5105 panic!("expected an order snapshot, got {event:?}");
5106 };
5107 assert_eq!(
5108 snapshot.0.kind,
5109 OrderKind::TrailingStop {
5110 offset: Decimal::from(25),
5111 offset_type: TrailingOffsetType::BasisPoints,
5112 }
5113 );
5114 assert_eq!(snapshot.0.price, None);
5115 }
5116 }
5117
5118 #[test]
5126 fn margin_ws_execution_report_trade_maps_to_trade_and_order_snapshot() {
5127 let frame = push(serde_json::json!({
5128 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5129 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "cid-1",
5130 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
5131 "p": "48000", "q": "2", "f": "GTC",
5132 "n": "0.001", "N": "BNB", "T": 1_700_000_000_000_i64,
5133 }));
5134 let mut buf = Vec::new();
5135 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5136 assert_eq!(
5137 buf.len(),
5138 2,
5139 "a fill must produce the execution and the order snapshot, got: {buf:?}"
5140 );
5141 assert_eq!(buf[0].exchange, ExchangeId::BinanceMargin);
5142 match &buf[0].kind {
5143 AccountEventKind::Trade(t) => {
5144 assert_eq!(t.instrument.name().as_str(), "BTCUSDT");
5145 assert_eq!(t.side, Side::Buy);
5146 assert_eq!(t.price, Decimal::from(48_000));
5147 assert_eq!(t.quantity, Decimal::new(5, 1));
5148 assert_eq!(t.fees.asset.name().as_str(), "BNB"); assert_eq!(t.fees.fees, Decimal::new(1, 3));
5150 }
5151 other => panic!("expected Trade, got {other:?}"),
5152 }
5153 assert_eq!(buf[1].exchange, ExchangeId::BinanceMargin);
5154 match &buf[1].kind {
5155 AccountEventKind::OrderSnapshot(snap) => {
5156 assert_eq!(
5157 snap.0.quantity,
5158 Decimal::from(2),
5159 "the order's total quantity"
5160 );
5161 match &snap.0.state {
5162 OrderState::Active(ActiveOrderState::Open(open)) => {
5163 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("12345"));
5164 assert_eq!(
5165 open.filled_quantity,
5166 Decimal::new(5, 1),
5167 "the snapshot must carry the order's cumulative filled quantity (z)"
5168 );
5169 assert_eq!(
5170 open.time_exchange.timestamp_millis(),
5171 1_700_000_000_000,
5172 "stamped with the execution's transaction time (T), which is what \
5173 gives the engine's staleness gate an ordering key"
5174 );
5175 }
5176 other => panic!("expected an Open order, got {other:?}"),
5177 }
5178 }
5179 other => panic!("expected OrderSnapshot, got {other:?}"),
5180 }
5181 }
5182
5183 #[test]
5187 fn margin_trade_for_an_order_no_longer_working_emits_no_snapshot() {
5188 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
5189 let frame = push(serde_json::json!({
5190 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5191 "x": "TRADE", "X": status, "i": 12_345_i64, "c": "cid-1",
5192 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
5193 "p": "48000", "q": "2", "f": "GTC", "T": 1_700_000_000_000_i64,
5194 }));
5195 let mut buf = Vec::new();
5196 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5197 assert_eq!(
5198 buf.len(),
5199 1,
5200 "status {status} must produce the execution alone, got: {buf:?}"
5201 );
5202 assert!(
5203 matches!(buf[0].kind, AccountEventKind::Trade(_)),
5204 "the execution must still be reported for status {status}"
5205 );
5206 }
5207 }
5208
5209 #[test]
5210 fn margin_ws_new_report_maps_to_active_order_snapshot() {
5211 let frame = push(serde_json::json!({
5212 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "LIMIT",
5213 "x": "NEW", "X": "NEW", "i": 7_i64, "c": "cid-2",
5214 "p": "48000", "q": "1", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
5215 }));
5216 let mut buf = Vec::new();
5217 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5218 assert_eq!(buf.len(), 1);
5219 match &buf[0].kind {
5220 AccountEventKind::OrderSnapshot(snap) => {
5221 assert_eq!(snap.0.side, Side::Sell);
5222 assert_eq!(snap.0.price, Some(Decimal::from(48_000)));
5223 assert_eq!(snap.0.quantity, Decimal::from(1));
5224 assert!(
5225 matches!(snap.0.state, OrderState::Active(_)),
5226 "NEW should be an active (resting) order"
5227 );
5228 }
5229 other => panic!("expected OrderSnapshot, got {other:?}"),
5230 }
5231 }
5232
5233 #[test]
5234 fn margin_ws_canceled_report_maps_to_order_cancelled() {
5235 let frame = push(serde_json::json!({
5236 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5237 "x": "CANCELED", "X": "CANCELED", "i": 8_i64, "c": "cid-3",
5238 "z": "0", "T": 1_700_000_000_000_i64,
5239 }));
5240 let mut buf = Vec::new();
5241 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5242 assert_eq!(buf.len(), 1);
5243 match &buf[0].kind {
5244 AccountEventKind::OrderCancelled(resp) => assert!(resp.state.is_ok()),
5245 other => panic!("expected OrderCancelled, got {other:?}"),
5246 }
5247 }
5248
5249 #[test]
5250 fn margin_ws_execution_report_missing_symbol_is_dropped() {
5251 let frame = push(serde_json::json!({
5253 "e": "executionReport", "S": "BUY", "x": "TRADE", "i": 1_i64, "t": 1_i64,
5254 "l": "1", "L": "100", "T": 1_700_000_000_000_i64,
5255 }));
5256 let mut buf = Vec::new();
5257 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5258 assert!(buf.is_empty());
5259 }
5260
5261 #[test]
5262 fn margin_ws_outbound_account_position_maps_to_balance_stream_updates() {
5263 let frame = push(serde_json::json!({
5264 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5265 "B": [
5266 { "a": "USDT", "f": "100.0", "l": "5.0" },
5267 { "a": "BTC", "f": "0.5", "l": "0" },
5268 ],
5269 }));
5270 let mut buf = Vec::new();
5271 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5272 assert_eq!(buf.len(), 2);
5273 for ev in &buf {
5275 assert!(matches!(ev.kind, AccountEventKind::BalanceStreamUpdate(_)));
5276 }
5277 }
5278
5279 #[test]
5280 fn margin_ws_stream_terminated_signals_reconnect() {
5281 let frame = push(serde_json::json!({ "e": "eventStreamTerminated" }));
5282 let mut buf = Vec::new();
5283 assert!(
5284 convert_margin_user_data_events(&frame, &mut buf),
5285 "eventStreamTerminated must signal reconnect"
5286 );
5287 assert!(buf.is_empty());
5288 }
5289
5290 #[test]
5291 fn margin_ws_margin_specific_events_are_observable_only() {
5292 let mut buf = Vec::new();
5295 assert!(!convert_margin_user_data_events(
5296 &push(documented_liability_change("USER_LIABILITY_CHANGE")),
5297 &mut buf
5298 ));
5299 assert!(!convert_margin_user_data_events(
5300 &push(documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE")),
5301 &mut buf
5302 ));
5303 assert!(buf.is_empty());
5304 }
5305
5306 fn documented_liability_change(e: &str) -> serde_json::Value {
5308 serde_json::json!({
5309 "e": e, "E": 1_573_200_697_110_i64, "a": "BTC", "t": "BORROW",
5310 "p": "1.03453430", "i": "0",
5311 })
5312 }
5313
5314 fn documented_margin_level_change(e: &str) -> serde_json::Value {
5316 serde_json::json!({
5317 "e": e, "E": 1_573_200_697_110_i64, "l": "1.25", "s": "MARGIN_CALL",
5318 })
5319 }
5320
5321 #[test]
5322 fn margin_ws_margin_specific_events_match_their_documented_names() {
5323 for (e, frame, expected) in [
5328 (
5329 "USER_LIABILITY_CHANGE",
5330 documented_liability_change("USER_LIABILITY_CHANGE"),
5331 "USER_LIABILITY_CHANGE (observable",
5332 ),
5333 (
5334 "MARGIN_LEVEL_STATUS_CHANGE",
5335 documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE"),
5336 "MARGIN_LEVEL_STATUS_CHANGE (liquidation risk",
5337 ),
5338 (
5340 "userLiabilityChange",
5341 documented_liability_change("userLiabilityChange"),
5342 "USER_LIABILITY_CHANGE (observable",
5343 ),
5344 (
5345 "marginLevelStatusChange",
5346 documented_margin_level_change("marginLevelStatusChange"),
5347 "MARGIN_LEVEL_STATUS_CHANGE (liquidation risk",
5348 ),
5349 ] {
5350 let mut buf = Vec::new();
5351 let (terminated, messages) =
5352 log_messages(|| convert_margin_user_data_events(&push(frame), &mut buf));
5353 assert!(!terminated, "{e}");
5354 assert!(buf.is_empty(), "{e}");
5355 assert!(
5356 messages.iter().any(|m| m.contains(expected)),
5357 "{e}: expected its own arm's log, got {messages:?}"
5358 );
5359 assert!(
5360 !messages.iter().any(|m| m.contains("undeserializable")
5361 || m.contains("unhandled")
5362 || m.contains("does not handle")),
5363 "{e}: {messages:?}"
5364 );
5365 }
5366 }
5367
5368 fn log_messages<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
5372 use std::sync::{Arc, Mutex};
5373 use tracing_subscriber::layer::SubscriberExt;
5374
5375 #[derive(Default)]
5376 struct Messages(Arc<Mutex<Vec<String>>>);
5377
5378 struct Message<'a>(&'a mut String);
5379
5380 impl tracing::field::Visit for Message<'_> {
5381 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
5382 if field.name() == "message" {
5383 *self.0 = format!("{value:?}");
5384 }
5385 }
5386 }
5387
5388 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Messages {
5389 fn on_event(
5390 &self,
5391 event: &tracing::Event<'_>,
5392 _: tracing_subscriber::layer::Context<'_, S>,
5393 ) {
5394 let mut message = String::new();
5395 event.record(&mut Message(&mut message));
5396 self.0.lock().unwrap().push(message);
5397 }
5398 }
5399
5400 let layer = Messages::default();
5401 let messages = Arc::clone(&layer.0);
5402 let value =
5404 tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), f);
5405 let messages = messages.lock().unwrap().clone();
5406 (value, messages)
5407 }
5408
5409 #[test]
5410 fn margin_ws_unhandled_event_types_are_ignored_without_teardown() {
5411 for (e, expected) in [
5414 ("listStatus", "ignoring listStatus"),
5415 ("someFutureEvent", "of a type this client does not handle"),
5417 ] {
5418 let mut buf = Vec::new();
5419 let frame = push(serde_json::json!({ "e": e, "E": 1_700_000_000_000_i64 }));
5420 let (terminated, messages) =
5421 log_messages(|| convert_margin_user_data_events(&frame, &mut buf));
5422 assert!(!terminated, "{e}");
5423 assert!(buf.is_empty(), "{e}");
5424 assert!(
5425 messages.iter().any(|m| m.contains(expected)),
5426 "{e}: {messages:?}"
5427 );
5428 }
5429 }
5430
5431 #[test]
5434 fn margin_trade_event_dedup_by_key() {
5435 let frame = push(serde_json::json!({
5438 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5439 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 4_242_i64,
5440 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
5441 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
5442 }));
5443 let mut buf = Vec::new();
5444 convert_margin_user_data_events(&frame, &mut buf);
5445 let event = buf.first().expect("a Trade event");
5448 assert!(
5449 matches!(event.kind, AccountEventKind::Trade(_)),
5450 "the execution is the first of the two events a fill produces"
5451 );
5452
5453 let cache = new_dedup_cache();
5454 let first = dedup_key_from_event(event).expect("Trade events have a dedup key");
5456 assert!(!is_duplicate(&cache, first), "first sighting is fresh");
5457 let second = dedup_key_from_event(event).expect("Trade events have a dedup key");
5458 assert!(
5459 is_duplicate(&cache, second),
5460 "second sighting is a duplicate"
5461 );
5462 }
5463
5464 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
5472 let cache = new_dedup_cache();
5473 let mut delivered = Vec::new();
5474 let mut buf = Vec::new();
5475 for frame in frames {
5476 let _ = convert_margin_user_data_events(frame, &mut buf);
5477 for ev in buf.drain(..) {
5478 if let Some(key) = dedup_key_from_event(&ev)
5479 && is_duplicate(&cache, key)
5480 {
5481 continue;
5482 }
5483 delivered.push(ev);
5484 }
5485 }
5486 delivered
5487 }
5488
5489 fn ws_new(cumulative_filled: &str) -> String {
5490 push(serde_json::json!({
5491 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5492 "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "client-1",
5493 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
5494 "T": 1_700_000_000_000_i64,
5495 }))
5496 }
5497
5498 fn ws_partial_fill(cumulative_filled: &str) -> String {
5499 push(serde_json::json!({
5500 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5501 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "client-1",
5502 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
5503 "l": "1", "L": "100", "t": 555_i64, "n": "0.1", "N": "USDT",
5504 "T": 1_700_000_001_000_i64,
5505 }))
5506 }
5507
5508 fn open_filled_quantities(events: &[UnindexedAccountEvent]) -> Vec<Decimal> {
5509 events
5510 .iter()
5511 .filter_map(|ev| match &ev.kind {
5512 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
5513 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
5514 _ => None,
5515 },
5516 _ => None,
5517 })
5518 .collect()
5519 }
5520
5521 #[test]
5529 fn margin_ws_fill_snapshot_survives_the_dedup_gate() {
5530 let delivered = drive_ws_pipeline(&[&ws_new("0"), &ws_partial_fill("1")]);
5531
5532 assert_eq!(
5533 delivered.len(),
5534 3,
5535 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
5536 );
5537 assert_eq!(
5538 open_filled_quantities(&delivered),
5539 vec![Decimal::ZERO, Decimal::ONE],
5540 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
5541 );
5542 }
5543
5544 #[test]
5547 fn margin_ws_replayed_frame_is_still_deduplicated() {
5548 let delivered = drive_ws_pipeline(&[
5549 &ws_new("0"),
5550 &ws_partial_fill("1"),
5551 &ws_new("0"),
5552 &ws_partial_fill("1"),
5553 ]);
5554
5555 assert_eq!(
5556 delivered.len(),
5557 3,
5558 "re-delivering both frames must add nothing, got: {delivered:?}"
5559 );
5560 }
5561
5562 #[test]
5568 fn margin_ws_an_ack_and_its_fill_reporting_one_state_deliver_that_state_once() {
5569 let delivered = drive_ws_pipeline(&[&ws_new("1"), &ws_partial_fill("1")]);
5570
5571 assert_eq!(
5572 delivered.len(),
5573 2,
5574 "the ack snapshot and the execution, with the fill's duplicate snapshot collapsed, \
5575 got: {delivered:?}"
5576 );
5577 assert!(
5578 delivered
5579 .iter()
5580 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
5581 "the execution must reach the consumer regardless"
5582 );
5583 assert_eq!(
5584 open_filled_quantities(&delivered),
5585 vec![Decimal::ONE],
5586 "one snapshot, carrying the state both reports agree on"
5587 );
5588 }
5589
5590 fn push_with_sub(subscription_id: i64, event: serde_json::Value) -> String {
5595 serde_json::json!({ "subscriptionId": subscription_id, "event": event }).to_string()
5596 }
5597
5598 fn isolated_handler(
5601 sub_map: Arc<Mutex<HashMap<i64, InstrumentNameExchange>>>,
5602 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
5603 ) -> impl FnMut(OutboundAccountPosition, Option<i64>, &mut Vec<UnindexedAccountEvent>) {
5604 move |position, subscription_id, buf| {
5605 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
5606 }
5607 }
5608
5609 fn btcusdt() -> InstrumentNameExchange {
5610 InstrumentNameExchange::new("BTCUSDT")
5611 }
5612
5613 fn token(expiration_time_ms: i64) -> UserListenToken {
5614 UserListenToken {
5615 token: "t".to_string(),
5616 expiration_time_ms,
5617 }
5618 }
5619
5620 #[test]
5621 fn listen_token_query_cross_vs_isolated() {
5622 assert!(build_listen_token_query(None).is_empty());
5624
5625 let q = build_listen_token_query(Some(&btcusdt()));
5627 assert_eq!(q.get("isIsolated").and_then(|v| v.as_str()), Some("TRUE"));
5628 assert_eq!(q.get("symbol").and_then(|v| v.as_str()), Some("BTCUSDT"));
5629 assert_eq!(q.len(), 2);
5630 }
5631
5632 #[test]
5633 fn earliest_token_expiry_picks_min() {
5634 let tokens = vec![
5635 (btcusdt(), token(3_000)),
5636 (InstrumentNameExchange::new("ETHUSDT"), token(1_000)),
5637 (InstrumentNameExchange::new("BNBUSDT"), token(2_000)),
5638 ];
5639 assert_eq!(earliest_token_expiry_ms(&tokens), 1_000);
5640 assert_eq!(earliest_token_expiry_ms(&[]), i64::MAX);
5642 }
5643
5644 #[test]
5645 fn base_quote_map_extracts_base_and_quote() {
5646 let entries = vec![
5647 iso_entry(
5648 "BTCUSDT",
5649 iso_base("BTC", "0", "0", "0", "0"),
5650 iso_quote("USDT", "0", "0", "0", "0"),
5651 None,
5652 None,
5653 None,
5654 ),
5655 iso_entry(
5656 "ETHBTC",
5657 iso_base("ETH", "0", "0", "0", "0"),
5658 iso_quote("BTC", "0", "0", "0", "0"),
5659 None,
5660 None,
5661 None,
5662 ),
5663 ];
5664 let map = build_base_quote_map(&entries);
5665 assert_eq!(
5666 map.get(&btcusdt())
5667 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5668 Some(("BTC", "USDT"))
5669 );
5670 assert_eq!(
5673 map.get(&InstrumentNameExchange::new("ETHBTC"))
5674 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5675 Some(("ETH", "BTC"))
5676 );
5677 }
5678
5679 #[test]
5680 fn isolated_outbound_position_routes_to_instrument_balance_update() {
5681 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5682 let base_quote = Arc::new(HashMap::from([(
5683 btcusdt(),
5684 (
5685 AssetNameExchange::new("BTC"),
5686 AssetNameExchange::new("USDT"),
5687 ),
5688 )]));
5689 let mut handler = isolated_handler(sub_map, base_quote);
5690
5691 let frame = push_with_sub(
5693 7,
5694 serde_json::json!({
5695 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5696 "B": [
5697 { "a": "USDT", "f": "1000.0", "l": "50.0" },
5698 { "a": "BTC", "f": "0.5", "l": "0.1" },
5699 ],
5700 }),
5701 );
5702 let mut buf = Vec::new();
5703 assert!(!convert_margin_user_data_events_with(
5704 &frame,
5705 &mut buf,
5706 &mut handler
5707 ));
5708 assert_eq!(buf.len(), 1, "one InstrumentBalanceUpdate for the pair");
5709 match &buf[0].kind {
5710 AccountEventKind::InstrumentBalanceUpdate(ibu) => {
5711 assert_eq!(ibu.instrument.name().as_str(), "BTCUSDT");
5712 assert_eq!(ibu.base.asset.name().as_str(), "BTC");
5713 assert_eq!(ibu.base.update.free, Decimal::new(5, 1));
5714 assert_eq!(ibu.base.update.locked, Decimal::new(1, 1));
5715 assert_eq!(ibu.quote.asset.name().as_str(), "USDT");
5716 assert_eq!(ibu.quote.update.free, Decimal::from(1000));
5717 assert_eq!(ibu.quote.update.locked, Decimal::from(50));
5718 }
5719 other => panic!("expected InstrumentBalanceUpdate, got {other:?}"),
5720 }
5721 assert!(!matches!(
5723 buf[0].kind,
5724 AccountEventKind::BalanceStreamUpdate(_)
5725 ));
5726 }
5727
5728 #[test]
5729 fn isolated_outbound_position_unknown_subscription_dropped() {
5730 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5732 let base_quote = Arc::new(HashMap::from([(
5733 btcusdt(),
5734 (
5735 AssetNameExchange::new("BTC"),
5736 AssetNameExchange::new("USDT"),
5737 ),
5738 )]));
5739 let mut handler = isolated_handler(sub_map, base_quote);
5740
5741 let frame = push_with_sub(
5742 99,
5743 serde_json::json!({
5744 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5745 "B": [{ "a": "BTC", "f": "1", "l": "0" }, { "a": "USDT", "f": "1", "l": "0" }],
5746 }),
5747 );
5748 let mut buf = Vec::new();
5749 assert!(!convert_margin_user_data_events_with(
5750 &frame,
5751 &mut buf,
5752 &mut handler
5753 ));
5754 assert!(buf.is_empty(), "unmapped subscriptionId frame is dropped");
5755 }
5756
5757 #[test]
5758 fn isolated_outbound_position_missing_side_dropped() {
5759 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5762 let base_quote = Arc::new(HashMap::from([(
5763 btcusdt(),
5764 (
5765 AssetNameExchange::new("BTC"),
5766 AssetNameExchange::new("USDT"),
5767 ),
5768 )]));
5769 let mut handler = isolated_handler(sub_map, base_quote);
5770
5771 let frame = push_with_sub(
5772 7,
5773 serde_json::json!({
5774 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5775 "B": [{ "a": "BTC", "f": "0.5", "l": "0" }],
5776 }),
5777 );
5778 let mut buf = Vec::new();
5779 assert!(!convert_margin_user_data_events_with(
5780 &frame,
5781 &mut buf,
5782 &mut handler
5783 ));
5784 assert!(buf.is_empty(), "missing quote side → frame dropped");
5785 }
5786
5787 #[test]
5788 fn isolated_execution_report_routes_by_inner_symbol_independent_of_map() {
5789 let sub_map = Arc::new(Mutex::new(HashMap::<i64, InstrumentNameExchange>::new()));
5793 let base_quote = Arc::new(HashMap::new());
5794 let mut handler = isolated_handler(sub_map, base_quote);
5795
5796 let frame = push_with_sub(
5797 42,
5798 serde_json::json!({
5799 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5800 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 5_i64,
5801 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
5802 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
5803 }),
5804 );
5805 let mut buf = Vec::new();
5806 assert!(!convert_margin_user_data_events_with(
5807 &frame,
5808 &mut buf,
5809 &mut handler
5810 ));
5811 assert_eq!(buf.len(), 2, "the execution and its order snapshot");
5812 match &buf[0].kind {
5813 AccountEventKind::Trade(t) => assert_eq!(t.instrument.name().as_str(), "BTCUSDT"),
5814 other => panic!("expected Trade, got {other:?}"),
5815 }
5816 match &buf[1].kind {
5817 AccountEventKind::OrderSnapshot(snap) => {
5818 assert_eq!(snap.0.key.instrument.name().as_str(), "BTCUSDT");
5819 }
5820 other => panic!("expected OrderSnapshot, got {other:?}"),
5821 }
5822 }
5823
5824 struct MarginMyTradesVenue {
5835 trades: Vec<(i64, i64, i64)>,
5837 end_exclusive: bool,
5838 }
5839
5840 impl wiremock::Respond for MarginMyTradesVenue {
5841 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
5842 const DAY_MS: i64 = 24 * 60 * 60 * 1000;
5843 let query: std::collections::HashMap<String, String> =
5844 request.url.query_pairs().into_owned().collect();
5845 let param = |name: &str| query.get(name).map(|v| v.parse::<i64>().unwrap());
5846 let limit = usize::try_from(param("limit").unwrap_or(500)).unwrap();
5847 let order = param("orderId");
5848
5849 let mut selected: Vec<&(i64, i64, i64)> = self
5850 .trades
5851 .iter()
5852 .filter(|(_, _, order_id)| order.is_none_or(|order| *order_id == order))
5853 .collect();
5854 if let Some(from_id) = param("fromId") {
5855 selected.retain(|(id, _, _)| *id >= from_id);
5856 } else if order.is_none() {
5857 let start = param("startTime").expect("a query by time sends startTime");
5858 let end = match param("endTime") {
5859 Some(end) if end - start >= DAY_MS => {
5860 return wiremock::ResponseTemplate::new(400).set_body_json(
5861 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
5862 );
5863 }
5864 Some(end) if self.end_exclusive => end - 1,
5865 Some(end) => end,
5866 None => start + DAY_MS - 1,
5867 };
5868 selected.retain(|(_, time, _)| (start..=end).contains(time));
5869 }
5870
5871 let page: Vec<serde_json::Value> = selected
5872 .into_iter()
5873 .take(limit)
5874 .map(|(id, time, order_id)| {
5875 serde_json::json!({
5876 "id": id, "symbol": "BTCUSDT", "time": time, "orderId": order_id,
5877 "price": "50000", "qty": "0.01", "commission": "0",
5878 "commissionAsset": "USDT", "isBuyer": true, "isMaker": false,
5879 "isBestMatch": true, "isIsolated": true,
5880 })
5881 })
5882 .collect();
5883 wiremock::ResponseTemplate::new(200).set_body_json(page)
5884 }
5885 }
5886
5887 fn since(start: i64) -> MyTradesFrom {
5889 MyTradesFrom::Span {
5890 start,
5891 end: i64::MAX,
5892 }
5893 }
5894
5895 async fn read_margin_trades(
5898 venue: MarginMyTradesVenue,
5899 from: MyTradesFrom,
5900 ) -> (Vec<i64>, Vec<std::collections::HashMap<String, String>>) {
5901 let server = wiremock::MockServer::start().await;
5902 wiremock::Mock::given(wiremock::matchers::method("GET"))
5903 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5904 .respond_with(venue)
5905 .mount(&server)
5906 .await;
5907 let rest = Arc::new(MarginTradingRestApi::from_config(
5908 ConfigurationRestApi::builder()
5909 .api_key("key")
5910 .api_secret("secret")
5911 .base_path(server.uri())
5912 .build()
5913 .unwrap(),
5914 ));
5915
5916 let read = paginate_margin_my_trades(
5917 &rest,
5918 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5919 &InstrumentNameExchange::new("BTCUSDT"),
5920 from,
5921 true,
5922 RequestKind::Query,
5923 )
5924 .await
5925 .unwrap();
5926
5927 let requests = server
5928 .received_requests()
5929 .await
5930 .unwrap()
5931 .iter()
5932 .map(|request| request.url.query_pairs().into_owned().collect())
5933 .collect();
5934 (read.into_iter().map(|t| t.id.unwrap()).collect(), requests)
5935 }
5936
5937 fn venue(trades: Vec<(i64, i64)>) -> MarginMyTradesVenue {
5939 MarginMyTradesVenue {
5940 trades: trades
5941 .into_iter()
5942 .map(|(id, time)| (id, time, id))
5943 .collect(),
5944 end_exclusive: false,
5945 }
5946 }
5947
5948 const HOUR_MS: i64 = 60 * 60 * 1000;
5949
5950 #[tokio::test]
5953 async fn margin_trades_read_a_lone_fill_beyond_the_first_day() {
5954 let now = Utc::now().timestamp_millis();
5955 let (ids, _) = read_margin_trades(
5956 venue(vec![(7, now - 72 * HOUR_MS)]),
5957 since(now - 120 * HOUR_MS),
5958 )
5959 .await;
5960
5961 assert_eq!(ids, vec![7]);
5962 }
5963
5964 #[tokio::test]
5967 async fn margin_trades_read_fills_across_several_windows_once_each() {
5968 let now = Utc::now().timestamp_millis();
5969 let trades = vec![
5970 (1, now - 119 * HOUR_MS),
5971 (2, now - 72 * HOUR_MS),
5972 (3, now - HOUR_MS),
5973 ];
5974 let (ids, _) = read_margin_trades(venue(trades), since(now - 144 * HOUR_MS)).await;
5975
5976 assert_eq!(ids, vec![1, 2, 3]);
5977 }
5978
5979 #[tokio::test]
5982 async fn margin_trades_read_a_trade_on_a_window_boundary_once() {
5983 for end_exclusive in [false, true] {
5984 let now = Utc::now().timestamp_millis();
5985 let from = now - 48 * HOUR_MS;
5986 let venue = MarginMyTradesVenue {
5987 end_exclusive,
5988 ..venue(vec![(5, from + MARGIN_MY_TRADES_WINDOW_MS)])
5989 };
5990 let (ids, _) = read_margin_trades(venue, since(from)).await;
5991
5992 assert_eq!(ids, vec![5], "end_exclusive: {end_exclusive}");
5993 }
5994 }
5995
5996 #[tokio::test]
5998 async fn margin_trades_page_on_by_id_past_a_full_window() {
5999 let now = Utc::now().timestamp_millis();
6000 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
6001 let trades = (1..=count)
6002 .map(|id| (id, now - 48 * HOUR_MS + id))
6003 .collect();
6004 let (ids, _) = read_margin_trades(venue(trades), since(now - 50 * HOUR_MS)).await;
6005
6006 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
6007 }
6008
6009 #[tokio::test]
6012 async fn margin_trades_page_on_past_a_full_final_window() {
6013 let now = Utc::now().timestamp_millis();
6014 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
6015 let trades = (1..=count).map(|id| (id, now - HOUR_MS + id)).collect();
6016 let (ids, requests) = read_margin_trades(venue(trades), since(now - 2 * HOUR_MS)).await;
6017
6018 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
6019 assert_eq!(requests.len(), 2);
6020 }
6021
6022 #[tokio::test]
6025 async fn margin_trades_quiet_lookback_sends_one_request_per_window() {
6026 let now = Utc::now().timestamp_millis();
6027 let (ids, requests) =
6028 read_margin_trades(venue(Vec::new()), since(now - 72 * HOUR_MS)).await;
6029
6030 assert!(ids.is_empty());
6031 assert_eq!(requests.len(), 4);
6033 }
6034
6035 #[tokio::test]
6038 async fn margin_trades_read_a_short_gap_in_one_request() {
6039 let now = Utc::now().timestamp_millis();
6040 let (ids, requests) =
6041 read_margin_trades(venue(vec![(9, now - HOUR_MS)]), since(now - 2 * HOUR_MS)).await;
6042
6043 assert_eq!(ids, vec![9]);
6044 assert_eq!(requests.len(), 1);
6045 }
6046
6047 #[tokio::test]
6049 async fn margin_trades_from_the_future_send_no_request() {
6050 let now = Utc::now().timestamp_millis();
6051 let (ids, requests) = read_margin_trades(venue(Vec::new()), since(now + HOUR_MS)).await;
6052
6053 assert!(ids.is_empty());
6054 assert!(requests.is_empty());
6055 }
6056
6057 #[tokio::test]
6060 async fn margin_trades_by_order_page_by_id_without_time_bounds() {
6061 let now = Utc::now().timestamp_millis();
6062 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
6063 let trades = (1..=2 * count)
6065 .map(|id| {
6066 (
6067 id,
6068 now - 48 * HOUR_MS + id,
6069 if id % 2 == 0 { 42 } else { 7 },
6070 )
6071 })
6072 .collect();
6073 let venue = MarginMyTradesVenue {
6074 trades,
6075 end_exclusive: false,
6076 };
6077 let (ids, requests) = read_margin_trades(venue, MyTradesFrom::Order(42)).await;
6078
6079 assert_eq!(ids, (1..=count).map(|n| 2 * n).collect::<Vec<_>>());
6080 assert_eq!(requests.len(), 2);
6081 assert!(
6082 requests.iter().all(
6083 |query| query.get("orderId").map(String::as_str) == Some("42")
6084 && !query.contains_key("startTime")
6085 && !query.contains_key("endTime")
6086 )
6087 );
6088 }
6089
6090 async fn margin_trades_error(response: wiremock::ResponseTemplate) -> UnindexedClientError {
6093 let server = wiremock::MockServer::start().await;
6094 wiremock::Mock::given(wiremock::matchers::method("GET"))
6095 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6096 .respond_with(response)
6097 .mount(&server)
6098 .await;
6099 let rest = Arc::new(MarginTradingRestApi::from_config(
6100 ConfigurationRestApi::builder()
6101 .api_key("key")
6102 .api_secret("secret")
6103 .base_path(server.uri())
6104 .build()
6105 .unwrap(),
6106 ));
6107
6108 paginate_margin_my_trades(
6109 &rest,
6110 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6111 &InstrumentNameExchange::new("BTCUSDT"),
6112 since(Utc::now().timestamp_millis() - HOUR_MS),
6113 true,
6114 RequestKind::Query,
6115 )
6116 .await
6117 .unwrap_err()
6118 }
6119
6120 #[tokio::test]
6123 async fn margin_trades_report_a_venue_rejection_as_a_non_transient_request_rejection() {
6124 let err = margin_trades_error(wiremock::ResponseTemplate::new(400).set_body_json(
6125 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
6126 ))
6127 .await;
6128
6129 let UnindexedClientError::Api(ApiError::RequestRejected(msg)) = &err else {
6130 panic!("expected RequestRejected, got {err:?}");
6131 };
6132 assert!(msg.contains("-1127"), "code missing from {msg:?}");
6133 assert!(!err.is_transient());
6134 }
6135
6136 #[tokio::test]
6138 async fn margin_trades_report_an_undecodable_response_as_internal() {
6139 let err = margin_trades_error(
6140 wiremock::ResponseTemplate::new(200)
6141 .set_body_json(serde_json::json!({"unexpected": 1})),
6142 )
6143 .await;
6144
6145 assert!(
6146 matches!(err, UnindexedClientError::Internal(_)),
6147 "got {err:?}"
6148 );
6149 assert!(!err.is_transient());
6150 }
6151
6152 #[tokio::test]
6155 async fn sapi_used_ip_weight_near_the_limit_pauses_queries() {
6156 for (used, pauses) in [("10799", false), ("10800", true), ("not a number", false)] {
6157 let server = wiremock::MockServer::start().await;
6158 wiremock::Mock::given(wiremock::matchers::method("GET"))
6159 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6160 .respond_with(
6161 wiremock::ResponseTemplate::new(200)
6162 .insert_header("x-sapi-used-ip-weight-1m", used)
6163 .set_body_json(serde_json::json!([])),
6164 )
6165 .mount(&server)
6166 .await;
6167 let rest = Arc::new(MarginTradingRestApi::from_config(
6168 ConfigurationRestApi::builder()
6169 .api_key("key")
6170 .api_secret("secret")
6171 .base_path(server.uri())
6172 .build()
6173 .unwrap(),
6174 ));
6175 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
6176
6177 fetch_margin_all_open_orders(rest, Arc::clone(&tracker), false)
6178 .await
6179 .unwrap();
6180
6181 let query_waits = tokio::time::timeout(
6182 Duration::from_millis(50),
6183 tracker.wait_if_blocked(RequestKind::Query),
6184 )
6185 .await
6186 .is_err();
6187 assert_eq!(query_waits, pauses, "used IP weight {used}");
6188 tokio::time::timeout(
6189 Duration::from_millis(50),
6190 tracker.wait_if_blocked(RequestKind::Order),
6191 )
6192 .await
6193 .expect("orders never wait on the pause");
6194 }
6195 }
6196
6197 #[tokio::test]
6199 async fn margin_fill_recovery_does_not_wait_for_the_weight_pause() {
6200 let server = wiremock::MockServer::start().await;
6201 wiremock::Mock::given(wiremock::matchers::method("GET"))
6202 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6203 .respond_with(
6205 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
6206 "symbol": "BTCUSDT",
6207 "id": 1,
6208 "orderId": 7,
6209 "price": "100",
6210 "qty": "1",
6211 "commission": "0",
6212 "commissionAsset": "USDT",
6213 "time": Utc::now().timestamp_millis(),
6214 "isBuyer": true,
6215 "isMaker": false,
6216 }])),
6217 )
6218 .expect(2)
6219 .mount(&server)
6220 .await;
6221 let rest = Arc::new(MarginTradingRestApi::from_config(
6222 ConfigurationRestApi::builder()
6223 .api_key("key")
6224 .api_secret("secret")
6225 .base_path(server.uri())
6226 .build()
6227 .unwrap(),
6228 ));
6229 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
6230 tracker.throttle(Duration::from_secs(60));
6231 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
6232 let mut unrecovered = UnrecoveredFills::default();
6233 unrecovered.open(
6234 &[InstrumentNameExchange::new("BTCUSDT")],
6235 Utc::now() - chrono::Duration::minutes(1),
6236 Utc::now(),
6237 );
6238
6239 tokio::time::timeout(
6241 Duration::from_millis(800),
6242 recover_margin_fills(
6243 &rest,
6244 &tracker,
6245 &mut unrecovered,
6246 &tx,
6247 &new_dedup_cache(),
6248 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6249 false,
6250 ),
6251 )
6252 .await
6253 .expect("recovery does not wait for the pause");
6254 }
6255
6256 #[tokio::test]
6259 async fn margin_trades_read_over_a_span_stop_at_its_end() {
6260 let now = Utc::now().timestamp_millis();
6261 let trades = vec![
6262 (1, now - 50 * HOUR_MS),
6263 (2, now - 49 * HOUR_MS),
6264 (3, now - 10 * HOUR_MS),
6265 ];
6266 let (ids, _) = read_margin_trades(
6267 venue(trades),
6268 MyTradesFrom::Span {
6269 start: now - 60 * HOUR_MS,
6270 end: now - 20 * HOUR_MS,
6271 },
6272 )
6273 .await;
6274
6275 assert_eq!(ids, vec![1, 2]);
6276 }
6277
6278 #[tokio::test]
6281 async fn a_margin_gap_whose_every_read_fails_is_reported_when_given_up() {
6282 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6283 let reconnect = Utc::now() - chrono::Duration::minutes(5);
6284 let server = wiremock::MockServer::start().await;
6285 wiremock::Mock::given(wiremock::matchers::method("GET"))
6286 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6287 .respond_with(
6288 wiremock::ResponseTemplate::new(400)
6289 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
6290 )
6291 .mount(&server)
6292 .await;
6293 let rest = Arc::new(MarginTradingRestApi::from_config(
6294 ConfigurationRestApi::builder()
6295 .api_key("key")
6296 .api_secret("secret")
6297 .base_path(server.uri())
6298 .build()
6299 .expect("valid config"),
6300 ));
6301 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
6302 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6303 let dedup = new_dedup_cache();
6304 let btc = InstrumentNameExchange::new("BTCUSDT");
6305 let mut unrecovered = UnrecoveredFills::default();
6306 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
6307
6308 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
6309 recover_margin_fills(
6310 &rest,
6311 &tracker,
6312 &mut unrecovered,
6313 &tx,
6314 &dedup,
6315 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6316 false,
6317 )
6318 .await;
6319 unrecovered.make_due();
6320 }
6321
6322 assert!(unrecovered.is_empty(), "the gap is given up");
6323 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6324 let [event] = forwarded.as_slice() else {
6325 panic!("only the give-up is sent: {forwarded:?}");
6326 };
6327 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
6328 panic!("expected FillRecoveryGaveUp, got {event:?}");
6329 };
6330 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
6331 assert_eq!(
6332 gave_up.scope,
6333 crate::FillRecoveryScope::Instruments(vec![btc])
6334 );
6335 assert_eq!(
6336 gave_up.start.timestamp_millis(),
6337 disconnect.timestamp_millis()
6338 );
6339 assert_eq!(
6340 gave_up.attempts,
6341 crate::client::order_recovery::MAX_GAP_RETRIES + 1
6342 );
6343 assert!(
6344 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
6345 "the last read's error: {:?}",
6346 gave_up.reason
6347 );
6348 }
6349
6350 #[tokio::test]
6353 async fn a_failed_margin_recovery_keeps_its_closed_gap_for_the_retry() {
6354 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6355 let reconnect = Utc::now() - chrono::Duration::minutes(5);
6356 let server = wiremock::MockServer::start().await;
6357 wiremock::Mock::given(wiremock::matchers::method("GET"))
6358 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6359 .respond_with(
6360 wiremock::ResponseTemplate::new(400)
6361 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
6362 )
6363 .up_to_n_times(1)
6364 .with_priority(1)
6365 .mount(&server)
6366 .await;
6367 wiremock::Mock::given(wiremock::matchers::method("GET"))
6368 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6369 .respond_with(venue(vec![
6370 (
6371 1,
6372 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
6373 ),
6374 (
6375 2,
6376 (Utc::now() - chrono::Duration::minutes(1)).timestamp_millis(),
6377 ),
6378 ]))
6379 .mount(&server)
6380 .await;
6381 let rest = Arc::new(MarginTradingRestApi::from_config(
6382 ConfigurationRestApi::builder()
6383 .api_key("key")
6384 .api_secret("secret")
6385 .base_path(server.uri())
6386 .build()
6387 .unwrap(),
6388 ));
6389 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
6390 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6391 let dedup = new_dedup_cache();
6392 let mut unrecovered = UnrecoveredFills::default();
6393 unrecovered.open(
6394 &[InstrumentNameExchange::new("BTCUSDT")],
6395 disconnect,
6396 reconnect,
6397 );
6398
6399 recover_margin_fills(
6400 &rest,
6401 &tracker,
6402 &mut unrecovered,
6403 &tx,
6404 &dedup,
6405 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6406 false,
6407 )
6408 .await;
6409 assert!(!unrecovered.is_empty(), "the failed gap is kept");
6410 assert!(rx.try_recv().is_err(), "nothing forwarded");
6411
6412 unrecovered.make_due();
6413 recover_margin_fills(
6414 &rest,
6415 &tracker,
6416 &mut unrecovered,
6417 &tx,
6418 &dedup,
6419 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6420 false,
6421 )
6422 .await;
6423 assert!(unrecovered.is_empty(), "the retry recovered it");
6424 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6425 assert_eq!(
6426 forwarded.len(),
6427 1,
6428 "only the fill inside the gap: {forwarded:?}"
6429 );
6430 }
6431
6432 #[tokio::test]
6435 async fn a_timed_out_margin_recovery_charges_only_the_gaps_it_started() {
6436 let server = wiremock::MockServer::start().await;
6437 wiremock::Mock::given(wiremock::matchers::method("GET"))
6438 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6439 .respond_with(
6440 wiremock::ResponseTemplate::new(200)
6441 .set_body_json(serde_json::json!([]))
6442 .set_delay(Duration::from_secs(3_600)),
6443 )
6444 .mount(&server)
6445 .await;
6446 let rest = Arc::new(MarginTradingRestApi::from_config(
6447 ConfigurationRestApi::builder()
6448 .api_key("key")
6449 .api_secret("secret")
6450 .base_path(server.uri())
6451 .timeout(3_600_000_u64)
6452 .retries(0_u32)
6453 .build()
6454 .unwrap(),
6455 ));
6456 let instruments: Vec<_> = (0..9)
6457 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
6458 .collect();
6459 let mut unrecovered = UnrecoveredFills::default();
6460 unrecovered.open(
6461 &instruments,
6462 Utc::now() - chrono::Duration::minutes(10),
6463 Utc::now(),
6464 );
6465 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
6466 tokio::time::pause();
6468
6469 recover_margin_fills(
6470 &rest,
6471 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6472 &mut unrecovered,
6473 &tx,
6474 &new_dedup_cache(),
6475 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6476 false,
6477 )
6478 .await;
6479
6480 let now = tokio::time::Instant::now();
6481 assert_eq!(
6482 unrecovered.due(now).len(),
6483 1,
6484 "the gap never started stays due"
6485 );
6486 assert_eq!(
6487 unrecovered
6488 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
6489 .len(),
6490 9,
6491 "the eight started wait for their first retry"
6492 );
6493 }
6494
6495 fn margin_order_row(cid: &str, status: &str) -> serde_json::Value {
6501 serde_json::json!({
6502 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
6503 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
6504 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "isIsolated": false,
6505 "time": 1_700_000_000_000_i64, "updateTime": 1_700_000_060_000_i64,
6506 })
6507 }
6508
6509 fn margin_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
6510 OrderKey::new(
6511 ExchangeId::BinanceMargin,
6512 InstrumentNameExchange::new(instrument),
6513 StrategyId::new("strategy"),
6514 ClientOrderId::new(cid),
6515 )
6516 }
6517
6518 #[test]
6519 fn a_margin_order_row_says_how_the_order_ended() {
6520 use crate::order::state::InactiveOrderState;
6521 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOrderResponse;
6522 let key = margin_key("BTCUSDT", "a");
6523 let ended = |row: serde_json::Value| {
6524 let row: QueryMarginAccountsOrderResponse = serde_json::from_value(row).unwrap();
6525 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, None)
6526 .map(|order| order.state)
6527 };
6528
6529 let mut filled = margin_order_row("a", "FILLED");
6530 filled["executedQty"] = "2".into();
6531 filled["cummulativeQuoteQty"] = "210".into();
6532 let Some(InactiveOrderState::FullyFilled(filled)) = ended(filled) else {
6533 panic!("FILLED is fully filled");
6534 };
6535 assert_eq!(filled.avg_price, Some(Decimal::from(105)));
6536 assert!(matches!(
6537 ended(margin_order_row("a", "CANCELED")),
6538 Some(InactiveOrderState::Cancelled(cancelled)) if cancelled.filled_quantity == Some(Decimal::ONE)
6539 ));
6540 assert_eq!(ended(margin_order_row("a", "NEW")), None);
6541
6542 let mut unknown_quote = margin_order_row("a", "FILLED");
6544 unknown_quote["executedQty"] = "2".into();
6545 unknown_quote["cummulativeQuoteQty"] = "-1".into();
6546 let Some(InactiveOrderState::FullyFilled(filled)) = ended(unknown_quote) else {
6547 panic!("still fully filled");
6548 };
6549 assert_eq!(filled.avg_price, None, "no negative average price");
6550 }
6551
6552 fn margin_rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
6553 Arc::new(MarginTradingRestApi::from_config(
6554 ConfigurationRestApi::builder()
6555 .api_key("key")
6556 .api_secret("secret")
6557 .base_path(server.uri())
6558 .build()
6559 .unwrap(),
6560 ))
6561 }
6562
6563 fn margin_client_at(server: &wiremock::MockServer, is_isolated: bool) -> BinanceMargin {
6565 let config = if is_isolated {
6566 BinanceMarginConfig::isolated(
6567 "key".into(),
6568 "secret".into(),
6569 vec![InstrumentNameExchange::new("BTCUSDT")],
6570 )
6571 } else {
6572 BinanceMarginConfig::cross_margin("key".into(), "secret".into())
6573 };
6574 let mut client = <BinanceMargin as ExecutionClient>::new(config);
6575 client.rest = margin_rest_at(server);
6576 client
6577 }
6578
6579 async fn mount_margin_order(
6581 server: &wiremock::MockServer,
6582 cid: &str,
6583 response: wiremock::ResponseTemplate,
6584 ) {
6585 wiremock::Mock::given(wiremock::matchers::method("GET"))
6586 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6587 .and(wiremock::matchers::query_param("origClientOrderId", cid))
6588 .respond_with(response)
6589 .mount(server)
6590 .await;
6591 }
6592
6593 fn margin_venue_error(code: i64) -> wiremock::ResponseTemplate {
6594 wiremock::ResponseTemplate::new(400)
6595 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
6596 }
6597
6598 fn margin_row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
6599 wiremock::ResponseTemplate::new(200).set_body_json(margin_order_row(cid, status))
6600 }
6601
6602 async fn margin_requests(server: &wiremock::MockServer) -> Vec<(String, Option<String>)> {
6604 server
6605 .received_requests()
6606 .await
6607 .unwrap()
6608 .iter()
6609 .map(|request| {
6610 let isolated = request
6611 .url
6612 .query_pairs()
6613 .find(|(key, _)| key == "isIsolated")
6614 .map(|(_, value)| value.into_owned());
6615 (request.url.path().to_owned(), isolated)
6616 })
6617 .collect()
6618 }
6619
6620 #[tokio::test]
6621 async fn margin_fetch_ended_orders_asks_the_configured_account_and_omits_the_rest() {
6622 use crate::order::state::InactiveOrderState;
6623 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
6624 let server = wiremock::MockServer::start().await;
6625 mount_margin_order(&server, "filled", margin_row_response("filled", "FILLED")).await;
6626 mount_margin_order(&server, "live", margin_row_response("live", "NEW")).await;
6627 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
6628 mount_margin_order(&server, "no-symbol", margin_venue_error(-1121)).await;
6629 let client = margin_client_at(&server, is_isolated);
6630 let keys = ["filled", "live", "unknown"].map(|cid| margin_key("BTCUSDT", cid));
6631
6632 let ended = client
6633 .fetch_ended_orders(
6634 &[keys.to_vec(), vec![margin_key("NOSUCH", "no-symbol")]].concat(),
6635 )
6636 .await
6637 .unwrap();
6638
6639 let [order] = ended.as_slice() else {
6640 panic!("one order ended: {ended:?}");
6641 };
6642 assert_eq!(order.key, keys[0], "the strategy asked survives");
6643 assert!(matches!(order.state, InactiveOrderState::FullyFilled(_)));
6644 let requests = margin_requests(&server).await;
6645 assert_eq!(requests.len(), 4);
6646 for (path, isolated) in requests {
6647 assert_eq!(path, "/sapi/v1/margin/order");
6648 assert_eq!(
6649 isolated.as_deref(),
6650 Some(flag),
6651 "is_isolated = {is_isolated}"
6652 );
6653 }
6654 }
6655 }
6656
6657 #[tokio::test]
6658 async fn margin_fetch_ended_orders_fails_when_any_lookup_fails() {
6659 let server = wiremock::MockServer::start().await;
6660 mount_margin_order(
6661 &server,
6662 "cancelled",
6663 margin_row_response("cancelled", "CANCELED"),
6664 )
6665 .await;
6666 mount_margin_order(&server, "refused", margin_venue_error(-1100)).await;
6667 let client = margin_client_at(&server, false);
6668
6669 let result = client
6670 .fetch_ended_orders(&[
6671 margin_key("BTCUSDT", "cancelled"),
6672 margin_key("BTCUSDT", "refused"),
6673 ])
6674 .await;
6675
6676 assert!(
6677 matches!(
6678 result,
6679 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
6680 ),
6681 "{result:?}"
6682 );
6683 }
6684
6685 fn held_margin_orders() -> SharedKnownLiveOrders {
6687 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6688 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
6689 known.lock().live(
6690 &margin_key("BTCUSDT", cid),
6691 Decimal::TWO,
6692 OrderKind::Limit,
6693 &Open::new(
6694 VenueOrderId::Assigned(OrderId::new(n.to_string())),
6695 Utc::now(),
6696 Decimal::ZERO,
6697 ),
6698 );
6699 }
6700 known
6701 }
6702
6703 #[tokio::test]
6708 async fn an_ended_margin_trailing_stop_is_reported_with_the_kind_it_was_held_with() {
6709 let server = wiremock::MockServer::start().await;
6710 wiremock::Mock::given(wiremock::matchers::method("GET"))
6711 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6712 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
6713 .mount(&server)
6714 .await;
6715 let mut row = margin_order_row("trailing", "CANCELED");
6716 row["type"] = "STOP_LOSS".into();
6717 row["price"] = "0.00000000".into();
6718 row["stopPrice"] = "0.00000000".into();
6719 mount_margin_order(
6720 &server,
6721 "trailing",
6722 wiremock::ResponseTemplate::new(200).set_body_json(row),
6723 )
6724 .await;
6725 let trailing_stop = OrderKind::TrailingStop {
6726 offset: Decimal::from(25),
6727 offset_type: TrailingOffsetType::BasisPoints,
6728 };
6729 let hold = |known: &SharedKnownLiveOrders| {
6730 known.lock().live(
6731 &margin_key("BTCUSDT", "trailing"),
6732 Decimal::TWO,
6733 trailing_stop,
6734 &Open::new(
6735 VenueOrderId::Assigned(OrderId::new("7")),
6736 Utc::now(),
6737 Decimal::ZERO,
6738 ),
6739 );
6740 };
6741
6742 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6743 hold(&known);
6744 let mut unchecked = UncheckedOrders::default();
6745 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
6746 let (tx, mut rx) = mpsc::unbounded_channel();
6747 recover_margin_ended_orders(
6748 &margin_rest_at(&server),
6749 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6750 &known,
6751 &mut unchecked,
6752 &UnrecoveredFills::default(),
6753 &tx,
6754 false,
6755 )
6756 .await;
6757 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6758 let [event] = sent.as_slice() else {
6759 panic!("the trailing stop ended: {sent:?}");
6760 };
6761 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
6762 panic!("an order snapshot: {event:?}");
6763 };
6764 assert_eq!(snapshot.0.kind, trailing_stop);
6765 assert!(matches!(
6766 snapshot.0.state,
6767 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
6768 ));
6769 assert!(!known.lock().contains(&ClientOrderId::new("trailing")));
6770
6771 let client = margin_client_at(&server, false);
6772 hold(&client.known_live);
6773 let ended = client
6774 .fetch_ended_orders(&[margin_key("BTCUSDT", "trailing")])
6775 .await
6776 .expect("lookup");
6777 let [order] = ended.as_slice() else {
6778 panic!("the trailing stop ended: {ended:?}");
6779 };
6780 assert_eq!(order.kind, trailing_stop);
6781 }
6782
6783 #[tokio::test]
6784 async fn a_margin_reconnect_reports_how_an_unlisted_order_ended_in_the_configured_account() {
6785 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
6786 let server = wiremock::MockServer::start().await;
6787 wiremock::Mock::given(wiremock::matchers::method("GET"))
6788 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6789 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
6790 .respond_with(
6791 wiremock::ResponseTemplate::new(200)
6792 .set_body_json(serde_json::json!([margin_order_row("live", "NEW")])),
6793 )
6794 .mount(&server)
6795 .await;
6796 mount_margin_order(&server, "gone", margin_row_response("gone", "CANCELED")).await;
6797 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
6798 let known = held_margin_orders();
6799 let mut unchecked = UncheckedOrders::default();
6800 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
6801 let (tx, mut rx) = mpsc::unbounded_channel();
6802
6803 recover_margin_ended_orders(
6804 &margin_rest_at(&server),
6805 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6806 &known,
6807 &mut unchecked,
6808 &UnrecoveredFills::default(),
6809 &tx,
6810 is_isolated,
6811 )
6812 .await;
6813
6814 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6815 let [event] = sent.as_slice() else {
6816 panic!("one order ended: {sent:?}");
6817 };
6818 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
6819 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
6820 panic!("an order snapshot: {event:?}");
6821 };
6822 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
6823 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
6824 assert!(matches!(
6825 snapshot.0.state,
6826 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
6827 ));
6828 let mut requests = margin_requests(&server).await;
6829 requests.sort();
6830 let expected = |path: &str| (path.to_owned(), Some(flag.to_owned()));
6831 assert_eq!(
6832 requests,
6833 [
6834 expected("/sapi/v1/margin/openOrders"),
6835 expected("/sapi/v1/margin/order"),
6836 expected("/sapi/v1/margin/order"),
6837 ],
6838 "one listing, and a lookup of each unlisted order only"
6839 );
6840 let known = known.lock();
6841 assert!(known.contains(&ClientOrderId::new("live")));
6842 assert!(!known.contains(&ClientOrderId::new("gone")));
6843 assert!(
6844 !known.contains(&ClientOrderId::new("unknown")),
6845 "asking again cannot help"
6846 );
6847 assert!(unchecked.is_empty());
6848 }
6849 }
6850
6851 fn margin_open_request<'a>(
6852 instrument: &'a InstrumentNameExchange,
6853 cid: &str,
6854 ) -> OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange> {
6855 OrderRequestOpen {
6856 key: OrderKey::new(
6857 ExchangeId::BinanceMargin,
6858 instrument,
6859 StrategyId::new("strategy"),
6860 ClientOrderId::new(cid),
6861 ),
6862 state: crate::order::request::RequestOpen {
6863 side: Side::Buy,
6864 price: Some(Decimal::from(100)),
6865 quantity: Decimal::TWO,
6866 kind: OrderKind::Limit,
6867 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
6868 position_id: None,
6869 reduce_only: false,
6870 market: None,
6871 },
6872 }
6873 }
6874
6875 #[tokio::test]
6878 async fn a_margin_trailing_stop_is_sent_with_trailing_delta() {
6879 let server = wiremock::MockServer::start().await;
6880 let sent_as_trailing_stop = |request: &wiremock::Request| {
6881 let mut body_as_query = request.url.clone();
6883 body_as_query.set_query(Some(&String::from_utf8_lossy(&request.body)));
6884 let params: Vec<(String, String)> = request
6885 .url
6886 .query_pairs()
6887 .chain(body_as_query.query_pairs())
6888 .map(|(k, v)| (k.into_owned(), v.into_owned()))
6889 .collect();
6890 let has = |key: &str, value: &str| params.iter().any(|(k, v)| k == key && v == value);
6891 has("type", "STOP_LOSS")
6892 && has("trailingDelta", "25")
6893 && has("sideEffectType", "AUTO_BORROW_REPAY")
6894 && !params.iter().any(|(k, _)| k == "stopPrice" || k == "price")
6895 };
6896 wiremock::Mock::given(wiremock::matchers::method("POST"))
6897 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6898 .and(sent_as_trailing_stop)
6899 .respond_with(
6900 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6901 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "trailing",
6902 "transactTime": 1_700_000_000_000_i64, "price": "0", "origQty": "2",
6903 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6904 "timeInForce": "GTC", "type": "STOP_LOSS", "side": "SELL",
6905 })),
6906 )
6907 .expect(1)
6908 .mount(&server)
6909 .await;
6910 let client = margin_client_at(&server, false);
6911 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6912 let trailing_request = |cid, offset| {
6913 let mut request = margin_open_request(&btcusdt, cid);
6914 request.state.side = Side::Sell;
6915 request.state.price = None;
6916 request.state.kind = OrderKind::TrailingStop {
6917 offset,
6918 offset_type: TrailingOffsetType::BasisPoints,
6919 };
6920 request
6921 };
6922
6923 let placed = client
6924 .open_order(trailing_request("trailing", Decimal::from(25)))
6925 .await;
6926 assert!(
6927 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6928 "{placed:?}"
6929 );
6930
6931 let refused = client
6932 .open_order(trailing_request("fractional", Decimal::new(25, 1)))
6933 .await;
6934 assert!(
6935 matches!(
6936 refused.state,
6937 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
6938 OrderError::InvalidPrecision(_)
6939 ))
6940 ),
6941 "{refused:?}"
6942 );
6943 }
6944
6945 #[tokio::test]
6946 async fn placing_listing_and_cancelling_margin_orders_feed_the_known_live_set() {
6947 let server = wiremock::MockServer::start().await;
6948 wiremock::Mock::given(wiremock::matchers::method("POST"))
6949 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6950 .respond_with(
6951 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6952 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "placed",
6953 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
6954 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6955 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6956 })),
6957 )
6958 .mount(&server)
6959 .await;
6960 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6961 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6962 .respond_with(
6963 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6964 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
6965 "clientOrderId": "cancel", "price": "100", "origQty": "2", "executedQty": "0",
6966 "cummulativeQuoteQty": "0", "status": "CANCELED", "timeInForce": "GTC",
6967 "type": "LIMIT", "side": "BUY",
6968 })),
6969 )
6970 .mount(&server)
6971 .await;
6972 wiremock::Mock::given(wiremock::matchers::method("GET"))
6973 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6974 .respond_with(
6975 wiremock::ResponseTemplate::new(200)
6976 .set_body_json(serde_json::json!([margin_order_row("listed", "NEW")])),
6977 )
6978 .mount(&server)
6979 .await;
6980 let client = margin_client_at(&server, false);
6981 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6982
6983 let placed = client
6984 .open_order(margin_open_request(&btcusdt, "placed"))
6985 .await;
6986 assert!(
6987 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6988 "{placed:?}"
6989 );
6990 assert!(
6991 client
6992 .known_live
6993 .lock()
6994 .contains(&ClientOrderId::new("placed"))
6995 );
6996
6997 client.fetch_open_orders(&[]).await.unwrap();
6998 assert!(
6999 client
7000 .known_live
7001 .lock()
7002 .contains(&ClientOrderId::new("listed"))
7003 );
7004
7005 let cancelled = client
7006 .cancel_order(OrderRequestCancel {
7007 key: OrderKey::new(
7008 ExchangeId::BinanceMargin,
7009 &btcusdt,
7010 StrategyId::new("strategy"),
7011 ClientOrderId::new("placed"),
7012 ),
7013 state: crate::order::request::RequestCancel {
7014 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
7015 },
7016 })
7017 .await;
7018 assert!(cancelled.state.is_ok(), "{cancelled:?}");
7019 assert!(
7020 !client
7021 .known_live
7022 .lock()
7023 .contains(&ClientOrderId::new("placed"))
7024 );
7025 }
7026
7027 #[tokio::test]
7029 async fn a_cancel_response_without_executed_qty_reports_the_fill_unknown() {
7030 let server = wiremock::MockServer::start().await;
7031 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
7032 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7033 .respond_with(
7034 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
7035 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
7036 "clientOrderId": "cancel", "price": "100", "origQty": "2",
7037 "status": "CANCELED", "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
7038 })),
7039 )
7040 .mount(&server)
7041 .await;
7042 let client = margin_client_at(&server, false);
7043
7044 let cancelled = client
7045 .cancel_order(OrderRequestCancel {
7046 key: OrderKey::new(
7047 ExchangeId::BinanceMargin,
7048 &InstrumentNameExchange::new("BTCUSDT"),
7049 StrategyId::new("strategy"),
7050 ClientOrderId::new("placed"),
7051 ),
7052 state: crate::order::request::RequestCancel {
7053 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
7054 },
7055 })
7056 .await;
7057 let Ok(cancelled) = cancelled.state else {
7058 panic!("expected Ok, got {cancelled:?}");
7059 };
7060 assert_eq!(cancelled.filled_quantity, None);
7061 }
7062
7063 #[test]
7064 fn a_reconnect_opens_a_gap_on_each_stream_instrument_and_checks_those_with_orders_held() {
7065 let [btc, eth, xrp] = ["BTCUSDT", "ETHUSDT", "XRPUSDT"].map(InstrumentNameExchange::new);
7066 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
7067 for (instrument, cid) in [(&btc, "btc"), (&xrp, "xrp")] {
7068 known.lock().live(
7069 &margin_key(instrument.name(), cid),
7070 Decimal::TWO,
7071 OrderKind::Limit,
7072 &Open::new(
7073 VenueOrderId::Assigned(OrderId::new(cid)),
7074 Utc::now(),
7075 Decimal::ZERO,
7076 ),
7077 );
7078 }
7079 let mut unrecovered = UnrecoveredFills::default();
7080 let mut unchecked = UncheckedOrders::default();
7081
7082 open_reconnect_recovery(
7083 &mut unrecovered,
7084 &mut unchecked,
7085 &known,
7086 &[btc.clone(), eth.clone()],
7087 Utc::now() - chrono::Duration::minutes(1),
7088 );
7089
7090 assert!(unrecovered.covers(&btc) && unrecovered.covers(ð));
7091 assert!(!unrecovered.covers(&xrp), "not the stream's");
7092 assert!(unchecked.contains(&btc));
7093 assert!(!unchecked.contains(ð), "no order held");
7094 assert!(!unchecked.contains(&xrp), "its fills are not recovered");
7095 }
7096
7097 #[tokio::test]
7100 async fn a_recovered_margin_fill_that_completes_a_held_order_ends_it() {
7101 let disconnect = Utc::now() - chrono::Duration::minutes(10);
7102 let server = wiremock::MockServer::start().await;
7103 wiremock::Mock::given(wiremock::matchers::method("GET"))
7104 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7105 .respond_with(venue(vec![
7106 (
7107 1,
7108 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
7109 ),
7110 (
7111 2,
7112 (disconnect + chrono::Duration::minutes(2)).timestamp_millis(),
7113 ),
7114 ]))
7115 .mount(&server)
7116 .await;
7117 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
7120 for (cid, order_id, quantity) in [("one", "1", "0.01"), ("two", "2", "0.02")] {
7121 known.lock().live(
7122 &margin_key("BTCUSDT", cid),
7123 Decimal::from_str(quantity).unwrap(),
7124 OrderKind::Limit,
7125 &Open::new(
7126 VenueOrderId::Assigned(OrderId::new(order_id)),
7127 Utc::now(),
7128 Decimal::ZERO,
7129 ),
7130 );
7131 }
7132 let mut unrecovered = UnrecoveredFills::default();
7133 unrecovered.open(
7134 &[InstrumentNameExchange::new("BTCUSDT")],
7135 disconnect,
7136 Utc::now(),
7137 );
7138 let (tx, mut rx) = mpsc::unbounded_channel();
7139
7140 recover_margin_fills(
7141 &margin_rest_at(&server),
7142 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
7143 &mut unrecovered,
7144 &tx,
7145 &new_dedup_cache(),
7146 &known,
7147 false,
7148 )
7149 .await;
7150
7151 assert_eq!(
7152 std::iter::from_fn(|| rx.try_recv().ok()).count(),
7153 2,
7154 "both fills"
7155 );
7156 let known = known.lock();
7157 assert!(!known.contains(&ClientOrderId::new("one")), "completed");
7158 assert!(known.contains(&ClientOrderId::new("two")), "part-filled");
7159 }
7160
7161 #[tokio::test]
7164 async fn a_margin_order_that_expired_on_placement_is_not_reported_or_held_open() {
7165 use rust_decimal_macros::dec;
7166
7167 for (cid, filled, quote) in [("unfilled", "0", "0"), ("partly", "1", "100")] {
7168 let server = wiremock::MockServer::start().await;
7169 wiremock::Mock::given(wiremock::matchers::method("POST"))
7170 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7171 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
7172 serde_json::json!({
7173 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
7174 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
7175 "executedQty": filled, "cummulativeQuoteQty": quote,
7176 "status": "EXPIRED", "timeInForce": "IOC", "type": "LIMIT", "side": "BUY",
7177 }),
7178 ))
7179 .mount(&server)
7180 .await;
7181 let client = margin_client_at(&server, false);
7182 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7183
7184 let placed = client.open_order(margin_open_request(&btcusdt, cid)).await;
7185
7186 let OrderState::Inactive(crate::order::state::InactiveOrderState::Expired(expired)) =
7187 &placed.state
7188 else {
7189 panic!("{cid}: expired, not open: {placed:?}");
7190 };
7191 assert_eq!(expired.filled_quantity, Decimal::from_str(filled).ok());
7192 let late = Open::new(
7193 VenueOrderId::Assigned(OrderId::new("7")),
7194 Utc::now(),
7195 Decimal::ZERO,
7196 );
7197 let mut known = client.known_live.lock();
7198 known.live(&placed.key, dec!(2), OrderKind::Limit, &late);
7199 assert!(
7200 !known.contains(&ClientOrderId::new(cid)),
7201 "{cid}: not held, even after a late live report"
7202 );
7203 }
7204 }
7205}