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, UnrecoveredFills, UserDataFrame, WeightPool,
58 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_unrecognised_frame,
62 new_dedup_cache, parse_user_data_frame, placed_order_state, recovered_order_totals,
63 response_decode_error, rest_call_with_retry, trailing_delta_basis_points,
64};
65use crate::{
66 AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
67 InstrumentBalanceUpdate, IsolatedInstrumentState, IsolatedMarginRisk, UnindexedAccountEvent,
68 UnindexedAccountSnapshot,
69 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
70 client::{
71 ExecutionClient, OrderStatusClient,
72 order_recovery::{
73 KnownLiveOrders, OpenListing, OrderLookup, SharedKnownLiveOrders, UncheckedOrders,
74 fetch_ended_by_key, recover_ended_orders,
75 },
76 },
77 emit_stream_terminated,
78 error::{
79 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
80 },
81 order::{
82 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
83 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
84 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
85 state::{Cancelled, Open, OrderState, UnindexedOrderState},
86 },
87 position::PositionReport,
88 trade::{AssetFees, Trade, TradeId, TradesRead},
89};
90use binance_sdk::{
91 common::{
92 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
93 constants::{MARGIN_TRADING_REST_API_PROD_URL, SPOT_WS_API_PROD_URL},
94 models::WebsocketEvent,
95 websocket::{
96 SendWebsocketMessageResult, Subscription, WebsocketApi as WsApiBase,
97 WebsocketMessageSendOptions,
98 },
99 },
100 margin_trading::{
101 MarginTradingRestApi,
102 rest_api::{
103 MarginAccountCancelOrderIsIsolatedEnum, MarginAccountCancelOrderParams,
104 MarginAccountNewOrderIsIsolatedEnum, MarginAccountNewOrderNewOrderRespTypeEnum,
105 MarginAccountNewOrderParams, MarginAccountNewOrderSideEffectTypeEnum,
106 MarginAccountNewOrderSideEnum, MarginAccountNewOrderTimeInForceEnum,
107 MarginAccountNewOrderTypeEnum, QueryCrossMarginAccountDetailsParams,
108 QueryCrossMarginAccountDetailsResponseUserAssetsInner,
109 QueryIsolatedMarginAccountInfoParams,
110 QueryIsolatedMarginAccountInfoResponseAssetsInner,
111 QueryMarginAccountsOpenOrdersIsIsolatedEnum, QueryMarginAccountsOpenOrdersParams,
112 QueryMarginAccountsOrderIsIsolatedEnum, QueryMarginAccountsOrderParams,
113 QueryMarginAccountsTradeListIsIsolatedEnum, QueryMarginAccountsTradeListParams,
114 QueryMarginAccountsTradeListResponseInner, RestApi,
115 },
116 websocket_streams::{
117 MarginLevelStatusChange, OutboundAccountPosition, UserLiabilityChange,
118 },
119 },
120};
121use chrono::{DateTime, TimeZone, Utc};
122use fnv::FnvHashSet;
123use futures::stream::BoxStream;
124use rust_decimal::Decimal;
125use rustrade_instrument::{
126 Side,
127 asset::name::AssetNameExchange,
128 exchange::ExchangeId,
129 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
130};
131use serde::{Deserialize, Serialize};
132use smol_str::{SmolStr, format_smolstr};
133use std::{
134 collections::{BTreeMap, HashMap},
135 str::FromStr,
136 sync::{
137 Arc, Mutex,
138 atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
139 },
140 time::Duration,
141};
142use tokio::sync::{mpsc, oneshot};
143use tracing::{debug, error, info, trace, warn};
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum MarginSideEffect {
165 #[default]
173 AutoBorrowRepay,
174 NoBorrow,
178}
179
180impl MarginSideEffect {
181 pub fn as_binance_str(self) -> &'static str {
183 match self {
184 MarginSideEffect::AutoBorrowRepay => "AUTO_BORROW_REPAY",
185 MarginSideEffect::NoBorrow => "NO_SIDE_EFFECT",
186 }
187 }
188
189 fn as_sdk_enum(self) -> MarginAccountNewOrderSideEffectTypeEnum {
195 match self {
196 MarginSideEffect::AutoBorrowRepay => {
197 MarginAccountNewOrderSideEffectTypeEnum::AutoBorrowRepay
198 }
199 MarginSideEffect::NoBorrow => MarginAccountNewOrderSideEffectTypeEnum::NoSideEffect,
200 }
201 }
202}
203
204#[derive(Clone, Deserialize)]
214pub struct BinanceMarginConfig {
215 api_key: String,
218 secret_key: String,
219 pub testnet: bool,
225 #[serde(default)]
232 pub is_isolated: bool,
233 #[serde(default)]
244 pub isolated_symbols: Vec<InstrumentNameExchange>,
245 #[serde(default)]
249 pub side_effect: MarginSideEffect,
250}
251
252impl std::fmt::Debug for BinanceMarginConfig {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 f.debug_struct("BinanceMarginConfig")
256 .field("api_key", &"***")
257 .field("secret_key", &"***")
258 .field("testnet", &self.testnet)
259 .field("is_isolated", &self.is_isolated)
260 .field("isolated_symbols", &self.isolated_symbols)
261 .field("side_effect", &self.side_effect)
262 .finish()
263 }
264}
265
266impl BinanceMarginConfig {
267 pub fn new(
277 api_key: String,
278 secret_key: String,
279 testnet: bool,
280 is_isolated: bool,
281 isolated_symbols: Vec<InstrumentNameExchange>,
282 side_effect: MarginSideEffect,
283 ) -> Self {
284 Self {
285 api_key,
286 secret_key,
287 testnet,
288 is_isolated,
289 isolated_symbols,
290 side_effect,
291 }
292 }
293
294 pub fn cross_margin(api_key: String, secret_key: String) -> Self {
301 Self::new(
302 api_key,
303 secret_key,
304 false,
305 false,
306 Vec::new(),
307 MarginSideEffect::default(),
308 )
309 }
310
311 pub fn isolated(
319 api_key: String,
320 secret_key: String,
321 symbols: Vec<InstrumentNameExchange>,
322 ) -> Self {
323 Self::new(
324 api_key,
325 secret_key,
326 false,
327 true,
328 symbols,
329 MarginSideEffect::default(),
330 )
331 }
332
333 pub fn api_key(&self) -> &str {
335 &self.api_key
336 }
337}
338
339#[derive(Clone)]
427pub struct BinanceMargin {
428 config: Arc<BinanceMarginConfig>,
429 rest: Arc<RestApi>,
431 rest_config: Arc<ConfigurationRestApi>,
435 ws_config: ConfigurationWebsocketApi,
439 rate_limiter: Arc<RateLimitTracker>,
441 known_live: SharedKnownLiveOrders,
444}
445
446impl std::fmt::Debug for BinanceMargin {
447 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448 f.debug_struct("BinanceMargin")
449 .field("testnet", &self.config.testnet)
450 .field("is_isolated", &self.config.is_isolated)
451 .field("side_effect", &self.config.side_effect)
452 .finish_non_exhaustive()
453 }
454}
455
456impl BinanceMargin {
457 fn remember_live<'a>(
459 &self,
460 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
461 ) {
462 let mut known = self.known_live.lock();
463 for order in orders {
464 known.live(&order.key, order.quantity, order.kind, &order.state);
465 }
466 }
467
468 #[allow(clippy::expect_used)] fn build_rest_config(config: &BinanceMarginConfig) -> ConfigurationRestApi {
478 ConfigurationRestApi::builder()
479 .api_key(config.api_key.clone())
480 .api_secret(config.secret_key.clone())
481 .base_path(MARGIN_TRADING_REST_API_PROD_URL)
482 .build()
483 .expect("failed to build Binance margin REST configuration")
484 }
485
486 #[allow(clippy::expect_used)] fn build_ws_config(config: &BinanceMarginConfig) -> ConfigurationWebsocketApi {
497 ConfigurationWebsocketApi::builder()
498 .api_key(config.api_key.clone())
499 .api_secret(config.secret_key.clone())
500 .ws_url(SPOT_WS_API_PROD_URL)
504 .build()
505 .expect("failed to build Binance margin WebSocket configuration")
506 }
507
508 fn effective_isolated_set(
521 &self,
522 instruments: &[InstrumentNameExchange],
523 ) -> Vec<InstrumentNameExchange> {
524 if instruments.is_empty() {
525 return self.config.isolated_symbols.clone();
526 }
527 instruments
528 .iter()
529 .filter(|inst| {
530 let in_set = self.config.isolated_symbols.contains(*inst);
531 if !in_set {
532 warn!(
533 instrument = %inst.name(),
534 "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
535 );
536 }
537 in_set
538 })
539 .cloned()
540 .collect()
541 }
542
543 async fn isolated_account_snapshot(
547 &self,
548 instruments: &[InstrumentNameExchange],
549 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
550 use futures::{StreamExt as _, TryStreamExt as _};
551
552 let effective = self.effective_isolated_set(instruments);
553 if effective.is_empty() {
554 return Ok(AccountSnapshot::new(
557 ExchangeId::BinanceMargin,
558 Vec::new(),
559 Vec::new(),
560 ));
561 }
562
563 let mut balances_by_symbol = convert_isolated_margin_assets(
565 fetch_isolated_margin_account_info(
566 self.rest.clone(),
567 self.rate_limiter.clone(),
568 effective.clone(),
569 )
570 .await?,
571 );
572
573 let instrument_snapshots: Vec<_> =
576 futures::stream::iter(effective.into_iter().map(|instrument| {
577 fetch_margin_open_orders_for_instrument(
578 self.rest.clone(),
579 self.rate_limiter.clone(),
580 instrument,
581 true,
582 RequestKind::Query,
583 )
584 }))
585 .buffer_unordered(8)
586 .map(|result| {
587 let (inst, listing) = result?;
588 self.remember_live(&listing.orders);
589 let wrapped = listing
590 .orders
591 .into_iter()
592 .map(active_order_snapshot)
593 .collect();
594 let isolated = balances_by_symbol.remove(&inst);
595 if isolated.is_none() {
596 warn!(
600 instrument = %inst.name(),
601 "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
602 );
603 }
604 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
605 inst,
606 wrapped,
607 listing.complete,
608 PositionReport::Unreported,
609 isolated,
610 ))
611 })
612 .try_collect()
613 .await?;
614
615 Ok(AccountSnapshot::new(
618 ExchangeId::BinanceMargin,
619 Vec::new(),
620 instrument_snapshots,
621 ))
622 }
623}
624
625impl ExecutionClient for BinanceMargin {
626 const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
627
628 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
631 &[InstrumentKindDiscriminant::Spot];
632 type Config = BinanceMarginConfig;
633 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
634
635 fn new(config: Self::Config) -> Self {
648 if config.testnet {
649 warn!(
650 "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
651 using production endpoints"
652 );
653 }
654 assert!(
655 !(config.is_isolated && config.isolated_symbols.is_empty()),
656 "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
657 );
658 let rest_config = Self::build_rest_config(&config);
663 let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
664 let rest_config = Arc::new(rest_config);
665 let ws_config = Self::build_ws_config(&config);
666 Self {
667 config: Arc::new(config),
668 rest,
669 rest_config,
670 ws_config,
671 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
672 known_live: KnownLiveOrders::shared(ExchangeId::BinanceMargin),
673 }
674 }
675
676 async fn open_order(
692 &self,
693 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
694 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
695 let instrument = request.key.instrument.clone();
696 let side = request.state.side;
697 let price = request.state.price;
698 let quantity = request.state.quantity;
699 let kind = request.state.kind;
700 let time_in_force = request.state.time_in_force;
701 let cid = request.key.cid.clone();
702
703 let order_key = OrderKey::new(
704 ExchangeId::BinanceMargin,
705 instrument.clone(),
706 request.key.strategy.clone(),
707 cid.clone(),
708 );
709
710 let inactive = |state: UnindexedOrderState| Order {
713 key: order_key.clone(),
714 side,
715 price,
716 quantity,
717 kind,
718 time_in_force,
719 state,
720 };
721
722 let params = match build_new_order_params(
723 instrument.name().to_string(),
724 side,
725 price,
726 quantity,
727 kind,
728 time_in_force,
729 cid.0.to_string(),
730 self.config.side_effect,
731 self.config.is_isolated,
732 ) {
733 Ok(params) => params,
734 Err(BuildOrderError::Unsupported) => {
735 return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
736 format!(
737 "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
738 ),
739 )));
740 }
741 Err(BuildOrderError::TrailingDelta(refused)) => {
742 return inactive(OrderState::inactive(refused.into_order_error()));
743 }
744 Err(BuildOrderError::Build(msg)) => {
745 error!(%msg, "BinanceMargin failed to build new order params");
746 return inactive(OrderState::inactive(OrderError::Rejected(
747 ApiError::OrderRejected(msg),
748 )));
749 }
750 };
751
752 let response =
753 match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
754 let params = params.clone();
755 Box::pin(async move { rest.margin_account_new_order(params).await })
756 })
757 .await
758 {
759 Ok(response) => response,
760 Err(e) => {
761 return inactive(OrderState::inactive(classify_rest_order_error(
762 &e,
763 &instrument,
764 )));
765 }
766 };
767
768 let data = match response.data().await {
769 Ok(data) => data,
770 Err(e) => {
771 return inactive(OrderState::inactive(OrderError::Rejected(
774 ApiError::OrderRejected(e.to_string()),
775 )));
776 }
777 };
778
779 let time_exchange = data
780 .transact_time
781 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
782 .unwrap_or_else(Utc::now);
783
784 let exchange_order_id = match data.order_id {
785 Some(id) => OrderId(format_smolstr!("{id}")),
786 None => {
787 error!("BinanceMargin open_order response missing orderId");
788 return inactive(OrderState::inactive(OrderError::Rejected(
789 ApiError::OrderRejected("open_order response missing orderId".into()),
790 )));
791 }
792 };
793
794 let state = placed_order_state(
797 ExchangeId::BinanceMargin,
798 &instrument,
799 quantity,
800 PlacementResponse {
801 status: data.status.as_deref(),
802 order_id: exchange_order_id,
803 time_exchange,
804 executed_qty: data.executed_qty.as_deref(),
805 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
806 },
807 );
808 self.known_live
809 .lock()
810 .placed(&order_key, quantity, kind, &state);
811
812 Order {
813 key: order_key,
814 side,
815 price,
816 quantity,
817 kind,
818 time_in_force,
819 state,
820 }
821 }
822
823 async fn cancel_order(
833 &self,
834 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
835 ) -> UnindexedOrderResponseCancel {
836 let instrument = request.key.instrument.clone();
837 let key = OrderKey {
838 exchange: request.key.exchange,
839 instrument: instrument.clone(),
840 strategy: request.key.strategy.clone(),
841 cid: request.key.cid.clone(),
842 };
843
844 let params = match build_cancel_order_params(
845 instrument.name().to_string(),
846 request.state.id.as_ref().and_then(VenueOrderId::assigned),
847 &request.key.cid,
848 self.config.is_isolated,
849 ) {
850 Ok(p) => p,
851 Err(e) => {
852 error!(%e, "BinanceMargin failed to build cancel order params");
853 return UnindexedOrderResponseCancel {
854 key,
855 state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
856 };
857 }
858 };
859
860 let response =
861 match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
862 let params = params.clone();
863 Box::pin(async move { rest.margin_account_cancel_order(params).await })
864 })
865 .await
866 {
867 Ok(response) => response,
868 Err(e) => {
869 return UnindexedOrderResponseCancel {
870 key,
871 state: Err(classify_rest_order_error(&e, &instrument)),
872 };
873 }
874 };
875
876 let data = match response.data().await {
877 Ok(data) => data,
878 Err(e) => {
879 return UnindexedOrderResponseCancel {
882 key,
883 state: Err(OrderError::Rejected(ApiError::OrderRejected(e.to_string()))),
884 };
885 }
886 };
887
888 let time_exchange = Utc::now();
890
891 let exchange_order_id = match data.order_id {
892 Some(id) => OrderId(SmolStr::new(id)),
895 None => {
896 error!("BinanceMargin cancel response missing orderId");
897 return UnindexedOrderResponseCancel {
898 key,
899 state: Err(OrderError::Rejected(ApiError::OrderRejected(
900 "cancel response missing orderId".into(),
901 ))),
902 };
903 }
904 };
905
906 let filled_qty = binance_filled_qty(
907 ExchangeId::BinanceMargin,
908 &exchange_order_id,
909 data.executed_qty.as_deref(),
910 );
911
912 self.known_live.lock().ended(&key.cid);
913 UnindexedOrderResponseCancel {
914 key,
915 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
916 }
917 }
918
919 async fn account_snapshot(
934 &self,
935 assets: &[AssetNameExchange],
936 instruments: &[InstrumentNameExchange],
937 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
938 if self.config.is_isolated {
939 return self.isolated_account_snapshot(instruments).await;
940 }
941 let response =
942 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
943 Box::pin(async move {
944 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
945 rest.query_cross_margin_account_details(params).await
946 })
947 })
948 .await
949 .map_err(|e| classify_rest_query_error(&e, None))?;
950
951 let account = response.data().await.map_err(response_decode_error)?;
952
953 let balances =
954 filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets);
955
956 use futures::{StreamExt as _, TryStreamExt as _};
961 let instrument_snapshots: Vec<_> =
962 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
963 fetch_margin_open_orders_for_instrument(
964 self.rest.clone(),
965 self.rate_limiter.clone(),
966 instrument,
967 false,
969 RequestKind::Query,
970 )
971 }))
972 .buffer_unordered(8)
973 .map(|result| {
974 let (inst, listing) = result?;
975 self.remember_live(&listing.orders);
976 let wrapped = listing
977 .orders
978 .into_iter()
979 .map(active_order_snapshot)
980 .collect();
981 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
982 inst,
983 wrapped,
984 listing.complete,
985 PositionReport::Unreported,
986 None,
987 ))
988 })
989 .try_collect()
990 .await?;
991
992 Ok(AccountSnapshot::new(
993 ExchangeId::BinanceMargin,
994 balances,
995 instrument_snapshots,
996 ))
997 }
998
999 async fn fetch_balances(
1009 &self,
1010 assets: &[AssetNameExchange],
1011 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1012 if self.config.is_isolated {
1013 return Ok(Vec::new());
1014 }
1015 let response =
1016 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1017 Box::pin(async move {
1018 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
1019 rest.query_cross_margin_account_details(params).await
1020 })
1021 })
1022 .await
1023 .map_err(|e| classify_rest_query_error(&e, None))?;
1024
1025 let account = response.data().await.map_err(response_decode_error)?;
1026
1027 Ok(filter_and_convert_margin_balances(
1028 account.user_assets.unwrap_or_default(),
1029 assets,
1030 ))
1031 }
1032
1033 async fn fetch_open_orders(
1052 &self,
1053 instruments: &[InstrumentNameExchange],
1054 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1055 use futures::{StreamExt as _, TryStreamExt as _};
1056
1057 if self.config.is_isolated {
1061 let effective = self.effective_isolated_set(instruments);
1062 let capacity = effective.len();
1063 return futures::stream::iter(effective.into_iter().map(|instrument| {
1064 fetch_margin_open_orders_for_instrument(
1065 self.rest.clone(),
1066 self.rate_limiter.clone(),
1067 instrument,
1068 true,
1069 RequestKind::Query,
1070 )
1071 }))
1072 .buffer_unordered(8)
1073 .try_fold(
1074 Vec::with_capacity(capacity),
1075 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
1076 (_, listing)| async move {
1077 acc.extend(listing.orders);
1078 Ok(acc)
1079 },
1080 )
1081 .await
1082 .inspect(|orders| self.remember_live(orders));
1083 }
1084
1085 if instruments.is_empty() {
1088 let orders =
1089 fetch_margin_all_open_orders(self.rest.clone(), self.rate_limiter.clone(), false)
1090 .await?;
1091 self.remember_live(&orders);
1092 return Ok(orders);
1093 }
1094 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1095 fetch_margin_open_orders_for_instrument(
1096 self.rest.clone(),
1097 self.rate_limiter.clone(),
1098 instrument,
1099 false,
1100 RequestKind::Query,
1101 )
1102 }))
1103 .buffer_unordered(8)
1104 .try_fold(
1105 Vec::with_capacity(instruments.len()),
1106 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1107 acc.extend(listing.orders);
1108 Ok(acc)
1109 },
1110 )
1111 .await
1112 .inspect(|orders| self.remember_live(orders))
1113 }
1114
1115 async fn fetch_trades(
1144 &self,
1145 start: DateTime<Utc>,
1146 end: DateTime<Utc>,
1147 instruments: &[InstrumentNameExchange],
1148 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1149 use futures::StreamExt as _;
1150
1151 if start > end {
1152 return Ok(TradesRead::complete(Vec::new()));
1153 }
1154
1155 let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1158 self.effective_isolated_set(instruments)
1159 } else {
1160 instruments.to_vec()
1161 };
1162 if effective.is_empty() {
1163 debug!(
1166 is_isolated = self.config.is_isolated,
1167 "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1168 );
1169 return Ok(TradesRead::complete(Vec::new()));
1170 }
1171 let span = MyTradesFrom::Span {
1173 start: start.timestamp_millis(),
1174 end: end.timestamp_millis(),
1175 };
1176 let is_isolated = self.config.is_isolated;
1177 let mut all_trades = Vec::new();
1178
1179 let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1182 let rest = self.rest.clone();
1183 let rate_limiter = self.rate_limiter.clone();
1184 async move {
1185 let pages = paginate_margin_my_trades(
1186 &rest,
1187 &rate_limiter,
1188 &inst,
1189 span,
1190 is_isolated,
1191 RequestKind::Query,
1192 )
1193 .await?;
1194 Ok::<_, UnindexedClientError>((inst, pages))
1195 }
1196 }))
1197 .buffer_unordered(8);
1198 while let Some(result) = stream.next().await {
1199 let (instrument, trades_data) = result?;
1200 for t in trades_data {
1201 if let Some(trade) = convert_margin_trade(&t, &instrument)
1202 && (start..=end).contains(&trade.time_exchange)
1203 {
1204 all_trades.push(trade);
1205 }
1206 }
1207 }
1208
1209 Ok(TradesRead::complete(all_trades))
1210 }
1211
1212 async fn account_stream(
1319 &self,
1320 _assets: &[AssetNameExchange],
1323 instruments: &[InstrumentNameExchange],
1324 ) -> Result<Self::AccountStream, UnindexedClientError> {
1325 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1326 let dedup = new_dedup_cache();
1327 let rest = self.rest.clone();
1328 let rest_config = self.rest_config.clone();
1329 let ws_config = self.ws_config.clone();
1330 let rate_limiter = self.rate_limiter.clone();
1331 let known_live = self.known_live.clone();
1332
1333 let cm_handle = if self.config.is_isolated {
1334 let symbols = self.config.isolated_symbols.clone();
1338 debug_assert!(
1341 symbols.iter().all(|i| i.name().len() <= 23),
1342 "instrument name exceeds SmolStr inline capacity: {:?}",
1343 symbols.iter().find(|i| i.name().len() > 23)
1344 );
1345
1346 let assets = fetch_isolated_margin_account_info(
1352 rest.clone(),
1353 rate_limiter.clone(),
1354 symbols.clone(),
1355 )
1356 .await?;
1357 let base_quote = Arc::new(build_base_quote_map(&assets));
1358 for sym in &symbols {
1359 if !base_quote.contains_key(sym) {
1360 warn!(
1361 symbol = %sym.name(),
1362 "BinanceMargin isolated: account info returned no base/quote for configured \
1363 symbol — its live per-pair balance frames will be dropped (fills/orders \
1364 unaffected; balances available via account_snapshot)"
1365 );
1366 }
1367 }
1368
1369 let tokens = acquire_all_isolated_tokens(&rest_config, &rate_limiter, &symbols).await?;
1373 let live = isolated_connect_and_subscribe(
1374 &ws_config,
1375 &tx,
1376 &dedup,
1377 &known_live,
1378 &base_quote,
1379 &tokens,
1380 )
1381 .await
1382 .map_err(|e| {
1383 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1384 })?;
1385
1386 tokio::spawn(isolated_connection_manager(
1387 tx,
1388 dedup,
1389 ws_config,
1390 rest_config,
1391 rest,
1392 rate_limiter,
1393 known_live,
1394 symbols,
1395 base_quote,
1396 Some(live),
1397 ))
1398 } else {
1399 let instruments = instruments.to_vec();
1401 debug_assert!(
1405 instruments.iter().all(|i| i.name().len() <= 23),
1406 "instrument name exceeds SmolStr inline capacity: {:?}",
1407 instruments.iter().find(|i| i.name().len() > 23)
1408 );
1409
1410 let initial_token =
1416 acquire_user_listen_token(&rest_config, &rate_limiter, None).await?;
1417 let initial_ws = connect_margin_ws(&ws_config).await.map_err(|e| {
1418 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1419 })?;
1420
1421 tokio::spawn(margin_connection_manager(
1422 tx,
1423 dedup,
1424 ws_config,
1425 rest_config,
1426 rest,
1427 rate_limiter,
1428 known_live,
1429 instruments,
1430 Some((initial_ws, initial_token)),
1431 ))
1432 };
1433
1434 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1435 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1436 Ok(futures::StreamExt::boxed(guarded_stream))
1437 }
1438}
1439
1440impl OrderStatusClient for BinanceMargin {
1457 async fn fetch_ended_orders(
1458 &self,
1459 orders: &[UnindexedOrderKey],
1460 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1461 fetch_ended_by_key(orders, |key| {
1462 let recorded_kind = self.known_live.lock().kind_of(&key.cid);
1463 fetch_margin_order_lookup(
1464 self.rest.clone(),
1465 self.rate_limiter.clone(),
1466 key,
1467 self.config.is_isolated,
1468 RequestKind::Query,
1469 recorded_kind,
1470 )
1471 })
1472 .await
1473 }
1474}
1475
1476const TOKEN_RENEW_MARGIN_SECS: i64 = 300; const TOKEN_MIN_LIFETIME_SECS: u64 = 60;
1484
1485struct UserListenToken {
1487 token: String,
1488 expiration_time_ms: i64,
1489}
1490
1491fn build_listen_token_query(
1495 symbol: Option<&InstrumentNameExchange>,
1496) -> BTreeMap<String, serde_json::Value> {
1497 let mut query = BTreeMap::new();
1498 if let Some(sym) = symbol {
1499 query.insert(
1500 "isIsolated".to_string(),
1501 serde_json::Value::String("TRUE".to_string()),
1502 );
1503 query.insert(
1504 "symbol".to_string(),
1505 serde_json::Value::String(sym.name().to_string()),
1506 );
1507 }
1508 query
1509}
1510
1511#[derive(Deserialize)]
1513struct UserListenTokenResponse {
1514 #[serde(alias = "listenToken")]
1516 token: String,
1517 #[serde(rename = "expirationTime")]
1518 expiration_time: i64,
1519}
1520
1521async fn acquire_user_listen_token(
1536 rest_config: &Arc<ConfigurationRestApi>,
1537 rate_limiter: &RateLimitTracker,
1538 symbol: Option<&InstrumentNameExchange>,
1539) -> Result<UserListenToken, UnindexedClientError> {
1540 let query = build_listen_token_query(symbol);
1543 let response = rest_call_with_retry(rest_config, rate_limiter, RequestKind::Essential, |cfg| {
1544 let query = query.clone();
1545 Box::pin(async move {
1546 binance_sdk::common::utils::send_request::<UserListenTokenResponse>(
1547 &cfg,
1548 "/sapi/v1/userListenToken",
1549 reqwest::Method::POST,
1550 query,
1553 BTreeMap::new(),
1554 None,
1555 true, )
1557 .await
1558 })
1559 })
1560 .await
1561 .map_err(|e| classify_rest_query_error(&e, symbol))?;
1562
1563 let data = response.data().await.map_err(response_decode_error)?;
1564 Ok(UserListenToken {
1565 token: data.token,
1566 expiration_time_ms: data.expiration_time,
1567 })
1568}
1569
1570fn token_renew_after(expiration_time_ms: i64) -> Duration {
1573 let now_ms = Utc::now().timestamp_millis();
1574 let remaining_secs = expiration_time_ms
1578 .saturating_sub(now_ms)
1579 .saturating_div(1_000)
1580 .saturating_sub(TOKEN_RENEW_MARGIN_SECS);
1581 let secs = u64::try_from(remaining_secs).unwrap_or(0);
1582 if secs < TOKEN_MIN_LIFETIME_SECS {
1585 warn!(
1586 computed_secs = secs,
1587 floor_secs = TOKEN_MIN_LIFETIME_SECS,
1588 "BinanceMargin token renewal wait clamped to floor (near-expiry or odd expirationTime)"
1589 );
1590 }
1591 Duration::from_secs(secs.max(TOKEN_MIN_LIFETIME_SECS))
1592}
1593
1594async fn connect_margin_ws(
1601 ws_config: &ConfigurationWebsocketApi,
1602) -> anyhow::Result<Arc<WsApiBase>> {
1603 let ws = WsApiBase::new(ws_config.clone(), vec![]);
1604 tokio::time::timeout(
1607 Duration::from_secs(CONNECT_TIMEOUT_SECS),
1608 ws.clone().connect(),
1609 )
1610 .await
1611 .map_err(|_| {
1612 anyhow::anyhow!("BinanceMargin WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
1613 })??;
1614 Ok(ws)
1615}
1616
1617async fn subscribe_listen_token(ws: &Arc<WsApiBase>, token: &str) -> anyhow::Result<()> {
1624 let mut payload = BTreeMap::new();
1627 payload.insert(
1628 "listenToken".to_string(),
1629 serde_json::Value::String(token.to_string()),
1630 );
1631 ws.send_message::<serde_json::Value>(
1632 "userDataStream.subscribe.listenToken",
1633 payload,
1634 WebsocketMessageSendOptions::new(),
1635 )
1636 .await
1637 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1638 Ok(())
1639}
1640
1641async fn subscribe_listen_token_capture(
1654 ws: &Arc<WsApiBase>,
1655 token: &str,
1656) -> anyhow::Result<Option<i64>> {
1657 #[derive(Deserialize)]
1660 struct SubscribeAck {
1661 #[serde(rename = "subscriptionId", default)]
1662 subscription_id: Option<i64>,
1663 }
1664
1665 let mut payload = BTreeMap::new();
1666 payload.insert(
1667 "listenToken".to_string(),
1668 serde_json::Value::String(token.to_string()),
1669 );
1670 let result = ws
1671 .send_message::<SubscribeAck>(
1672 "userDataStream.subscribe.listenToken",
1673 payload,
1674 WebsocketMessageSendOptions::new(),
1675 )
1676 .await
1677 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1678 let ack = match result {
1679 SendWebsocketMessageResult::Single(resp) => resp.data()?,
1680 SendWebsocketMessageResult::Multiple(responses) => responses
1682 .into_iter()
1683 .next()
1684 .ok_or_else(|| anyhow::anyhow!("empty subscribe ack"))?
1685 .data()?,
1686 };
1687 Ok(ack.subscription_id)
1688}
1689
1690fn cross_account_position_handler(
1694 position: OutboundAccountPosition,
1695 _subscription_id: Option<i64>,
1696 buf: &mut Vec<UnindexedAccountEvent>,
1697) {
1698 convert_margin_account_position(position, buf);
1699}
1700
1701#[cfg(test)]
1706fn convert_margin_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
1707 convert_margin_user_data_events_with(frame, buf, &mut cross_account_position_handler)
1708}
1709
1710fn convert_margin_user_data_events_with(
1725 frame: &str,
1726 buf: &mut Vec<UnindexedAccountEvent>,
1727 handle_position: &mut impl FnMut(
1728 OutboundAccountPosition,
1729 Option<i64>,
1730 &mut Vec<UnindexedAccountEvent>,
1731 ),
1732) -> bool {
1733 let (subscription_id, event_type, event_raw) = match parse_user_data_frame(frame) {
1734 UserDataFrame::Response => return false,
1736 UserDataFrame::Event {
1737 subscription_id,
1738 event_type,
1739 event,
1740 } => (subscription_id, event_type, event),
1741 UserDataFrame::Unrecognised => {
1742 static SEEN: AtomicU64 = AtomicU64::new(0);
1743 log_unrecognised_frame("BinanceMargin", &SEEN, frame);
1744 return false;
1745 }
1746 };
1747 match event_type {
1748 "executionReport" => {
1749 match serde_json::from_str::<ExecutionReport>(event_raw) {
1752 Ok(report) => convert_execution_report(&report, ExchangeId::BinanceMargin, buf),
1753 Err(e) => {
1754 warn!(error = %e, frame = frame_excerpt(event_raw), "BinanceMargin: undeserializable executionReport, dropping")
1755 }
1756 }
1757 false
1758 }
1759 "outboundAccountPosition" => {
1760 match serde_json::from_str::<OutboundAccountPosition>(event_raw) {
1761 Ok(position) => handle_position(position, subscription_id, buf),
1763 Err(e) => {
1764 warn!(error = %e, frame = frame_excerpt(event_raw), "BinanceMargin: undeserializable outboundAccountPosition, dropping")
1765 }
1766 }
1767 false
1768 }
1769 "balanceUpdate" => {
1770 false
1774 }
1775 "userLiabilityChange" => {
1776 match serde_json::from_str::<UserLiabilityChange>(event_raw) {
1783 Ok(c) => {
1784 if tracing::enabled!(tracing::Level::INFO) {
1785 info!(
1786 asset = c.a.as_deref().unwrap_or("?"),
1787 kind = c.t.as_deref().unwrap_or("?"),
1788 principal = c.p.as_deref().unwrap_or("?"),
1789 interest = c.i.as_deref().unwrap_or("?"),
1790 "BinanceMargin userLiabilityChange (observable; not applied to balance state)"
1791 );
1792 }
1793 }
1794 Err(e) => warn!(
1795 error = %e,
1796 frame = frame_excerpt(event_raw),
1797 "BinanceMargin: undeserializable userLiabilityChange, dropping"
1798 ),
1799 }
1800 false
1801 }
1802 "marginLevelStatusChange" => {
1803 if tracing::enabled!(tracing::Level::WARN) {
1808 match serde_json::from_str::<MarginLevelStatusChange>(event_raw) {
1809 Ok(c) => warn!(
1810 margin_level = c.l.as_deref().unwrap_or("?"),
1811 status = c.s.as_deref().unwrap_or("?"),
1812 "BinanceMargin marginLevelStatusChange (liquidation risk; observable, no policy)"
1813 ),
1814 Err(e) => warn!(
1815 error = %e,
1816 frame = frame_excerpt(event_raw),
1817 "BinanceMargin: undeserializable marginLevelStatusChange, dropping"
1818 ),
1819 }
1820 }
1821 false
1822 }
1823 "eventStreamTerminated" => {
1824 warn!("BinanceMargin user data stream terminated by exchange, signalling reconnect");
1825 true
1826 }
1827 other => {
1828 trace!(
1829 event_type = other,
1830 "BinanceMargin ignoring unhandled user data event"
1831 );
1832 false
1833 }
1834 }
1835}
1836
1837fn convert_margin_account_position(
1842 position: OutboundAccountPosition,
1843 buf: &mut Vec<UnindexedAccountEvent>,
1844) {
1845 let time_exchange = position
1846 .u
1847 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1848 .unwrap_or_else(Utc::now);
1849
1850 for b in position.b_uppercase.unwrap_or_default() {
1851 let asset = match b.a {
1852 Some(a) => AssetNameExchange::new(a),
1853 None => {
1854 warn!("BinanceMargin account position entry missing asset name");
1855 continue;
1856 }
1857 };
1858 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1859 Some(v) => v,
1860 None => {
1861 warn!(%asset, "BinanceMargin account position missing/unparseable 'free' field");
1862 continue;
1863 }
1864 };
1865 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1866 Some(v) => v,
1867 None => {
1868 warn!(%asset, "BinanceMargin account position missing/unparseable 'locked' field");
1869 continue;
1870 }
1871 };
1872 let update =
1873 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
1874 buf.push(UnindexedAccountEvent::new(
1875 ExchangeId::BinanceMargin,
1876 AccountEventKind::BalanceStreamUpdate(
1877 rustrade_integration::collection::snapshot::Snapshot::new(update),
1878 ),
1879 ));
1880 }
1881}
1882
1883fn route_isolated_account_position(
1894 position: OutboundAccountPosition,
1895 subscription_id: Option<i64>,
1896 sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
1897 base_quote: &HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>,
1898 buf: &mut Vec<UnindexedAccountEvent>,
1899) {
1900 let Some(sub_id) = subscription_id else {
1901 warn!(
1902 "BinanceMargin isolated: outboundAccountPosition without subscriptionId — dropping \
1903 (per-instrument balance routing requires it)"
1904 );
1905 return;
1906 };
1907 let instrument = {
1908 let map = sub_map.lock().unwrap_or_else(|p| p.into_inner());
1911 match map.get(&sub_id) {
1912 Some(inst) => inst.clone(),
1913 None => {
1914 warn!(
1915 subscription_id = sub_id,
1916 "BinanceMargin isolated: outboundAccountPosition for unmapped subscriptionId — dropping"
1917 );
1918 return;
1919 }
1920 }
1921 };
1922 let Some((base_asset, quote_asset)) = base_quote.get(&instrument) else {
1923 warn!(
1924 instrument = %instrument.name(),
1925 "BinanceMargin isolated: no base/quote known for instrument — dropping balance frame"
1926 );
1927 return;
1928 };
1929
1930 let time_exchange = position
1931 .u
1932 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1933 .unwrap_or_else(Utc::now);
1934
1935 let mut base_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
1936 let mut quote_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
1937 for b in position.b_uppercase.unwrap_or_default() {
1938 let Some(asset) = b.a.as_deref() else {
1939 warn!(instrument = %instrument.name(), "BinanceMargin isolated account position entry missing asset name");
1940 continue;
1941 };
1942 let side = if asset == base_asset.name().as_str() {
1944 &mut base_update
1945 } else if asset == quote_asset.name().as_str() {
1946 &mut quote_update
1947 } else {
1948 continue;
1949 };
1950 let (Some(free), Some(locked)) = (
1951 b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()),
1952 b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()),
1953 ) else {
1954 warn!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position missing/unparseable free/locked");
1955 continue;
1956 };
1957 *side = Some(AssetBalanceUpdate::new(
1958 AssetNameExchange::new(asset),
1959 BalanceUpdate::new(free, locked),
1960 time_exchange,
1961 ));
1962 }
1963
1964 let (Some(base), Some(quote)) = (base_update, quote_update) else {
1968 warn!(
1969 instrument = %instrument.name(),
1970 "BinanceMargin isolated: outboundAccountPosition missing base or quote side — dropping (no partial InstrumentBalanceUpdate)"
1971 );
1972 return;
1973 };
1974 buf.push(UnindexedAccountEvent::new(
1975 ExchangeId::BinanceMargin,
1976 AccountEventKind::InstrumentBalanceUpdate(InstrumentBalanceUpdate::new(
1977 instrument, base, quote,
1978 )),
1979 ));
1980}
1981
1982fn register_user_data_listener(
1994 ws: &Arc<WsApiBase>,
1995 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1996 dedup: SharedDedupCache,
1997 known: SharedKnownLiveOrders,
1998 heartbeat_flag: Arc<AtomicBool>,
1999 signal_tx: oneshot::Sender<()>,
2000 mut handle_position: impl FnMut(
2001 OutboundAccountPosition,
2002 Option<i64>,
2003 &mut Vec<UnindexedAccountEvent>,
2004 ) + Send
2005 + 'static,
2006) -> Subscription {
2007 let mut signal_tx_opt = Some(signal_tx);
2008 let mut event_tx = Some(tx);
2009 let mut event_buf = Vec::with_capacity(32);
2013
2014 ws.common.events.subscribe(move |event| {
2018 let Some(ref sender) = event_tx else { return };
2019 match event {
2020 WebsocketEvent::Message(json_str) => {
2021 heartbeat_flag.store(true, Ordering::Release);
2022 let terminated = convert_margin_user_data_events_with(
2025 &json_str,
2026 &mut event_buf,
2027 &mut handle_position,
2028 );
2029 for ev in event_buf.drain(..) {
2030 if let Some(key) = dedup_key_from_event(&ev)
2031 && is_duplicate(&dedup, key)
2032 {
2033 trace!("BinanceMargin dedup: skipping duplicate event");
2034 continue;
2035 }
2036 let held = KnownLiveOrders::observes(&ev.kind).then(|| {
2040 let mut held = known.lock();
2041 held.observe(&ev);
2042 held
2043 });
2044 if sender.send(ev).is_err() {
2045 drop(held);
2046 warn!("BinanceMargin account_stream receiver dropped, suppressing sends");
2047 event_tx.take();
2048 if let Some(s) = signal_tx_opt.take() {
2049 let _ = s.send(());
2050 }
2051 return;
2052 }
2053 }
2054 if terminated {
2055 event_tx.take();
2056 if let Some(s) = signal_tx_opt.take() {
2057 let _ = s.send(());
2058 }
2059 }
2060 }
2061 WebsocketEvent::Ping | WebsocketEvent::Pong => {
2062 heartbeat_flag.store(true, Ordering::Release);
2063 }
2064 WebsocketEvent::Error(e) => {
2065 warn!(%e, "BinanceMargin WebSocket error, will attempt reconnect");
2066 event_tx.take();
2067 if let Some(s) = signal_tx_opt.take() {
2068 let _ = s.send(());
2069 }
2070 }
2071 WebsocketEvent::Close(code, reason) => {
2072 warn!(code, %reason, "BinanceMargin WebSocket closed");
2073 event_tx.take();
2074 if let Some(s) = signal_tx_opt.take() {
2075 let _ = s.send(());
2076 }
2077 }
2078 _ => {
2079 trace!("BinanceMargin ignoring unhandled WebsocketEvent variant");
2080 }
2081 }
2082 })
2083}
2084
2085async fn recover_margin_fills(
2105 rest: &Arc<RestApi>,
2106 rate_limiter: &Arc<RateLimitTracker>,
2107 unrecovered: &mut UnrecoveredFills,
2108 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2109 dedup: &SharedDedupCache,
2110 known: &SharedKnownLiveOrders,
2111 is_isolated: bool,
2112) {
2113 use futures::StreamExt as _;
2114
2115 let due = unrecovered.due(tokio::time::Instant::now());
2116 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
2117 return;
2118 };
2119 info!(
2120 gaps = due.len(),
2121 oldest = %gap_time(oldest),
2122 is_isolated,
2123 "BinanceMargin recovering fills missed while disconnected"
2124 );
2125
2126 let mut settled = vec![false; due.len()];
2129 let started = AtomicUsize::new(0);
2130 let recovery = async {
2131 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
2132 let mut recovered = 0u32;
2133 let mut duplicates = 0u32;
2134
2135 let mut stream =
2136 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
2137 started.store(index + 1, Ordering::Relaxed);
2138 let rest = rest.clone();
2139 let rl = rate_limiter.clone();
2140 async move {
2141 let span = MyTradesFrom::Span {
2142 start: gap.start_ms,
2143 end: gap.end_ms,
2144 };
2145 let raw = match paginate_margin_my_trades(
2146 &rest,
2147 &rl,
2148 &inst,
2149 span,
2150 is_isolated,
2151 RequestKind::Essential,
2152 )
2153 .await
2154 {
2155 Ok(pages) => pages,
2156 Err(e) => return (index, Err(e)),
2157 };
2158 let totals = recovered_order_totals(
2162 ExchangeId::BinanceMargin,
2163 &inst,
2164 &raw,
2165 order_executions_deadline,
2166 |order_id| {
2167 paginate_margin_my_trades(
2168 &rest,
2169 &rl,
2170 &inst,
2171 MyTradesFrom::Order(order_id),
2172 is_isolated,
2173 RequestKind::Essential,
2174 )
2175 },
2176 )
2177 .await;
2178 let trades = raw
2179 .iter()
2180 .filter_map(|t| {
2181 let mut trade = convert_margin_trade(t, &inst)?;
2182 trade.order_filled_quantity =
2183 t.id.and_then(|id| totals.get(&id).copied());
2184 Some(trade)
2185 })
2186 .collect::<Vec<_>>();
2187 (index, Ok(trades))
2188 }
2189 }))
2190 .buffer_unordered(8);
2191 while let Some((index, result)) = stream.next().await {
2192 let (inst, gap) = &due[index];
2193 settled[index] = true;
2194 let trades = match result {
2195 Ok(trades) => trades,
2196 Err(e) => {
2197 gap_failed(
2198 unrecovered,
2199 tx,
2200 ExchangeId::BinanceMargin,
2201 inst,
2202 gap,
2203 FillRecoveryFailure::Request(e.to_string()),
2204 );
2205 continue;
2206 }
2207 };
2208 for trade in trades {
2209 let event = UnindexedAccountEvent::new(
2210 ExchangeId::BinanceMargin,
2211 AccountEventKind::Trade(trade),
2212 );
2213 if let Some(key) = dedup_key_from_event(&event)
2214 && is_duplicate(dedup, key)
2215 {
2216 duplicates += 1;
2217 continue;
2218 }
2219 let sent = {
2222 let mut known = known.lock();
2223 known.observe(&event);
2224 tx.send(event)
2225 };
2226 if sent.is_err() {
2227 debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2228 return;
2229 }
2230 recovered += 1;
2231 }
2232 unrecovered.recovered(inst, gap);
2233 }
2234 info!(
2235 recovered,
2236 duplicates, is_isolated, "BinanceMargin fill recovery complete"
2237 );
2238 };
2239 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2243 .await
2244 .is_err()
2245 {
2246 let started = started.load(Ordering::Relaxed);
2247 for ((inst, gap), _) in due[..started]
2248 .iter()
2249 .zip(&settled)
2250 .filter(|(_, settled)| !**settled)
2251 {
2252 gap_failed(
2253 unrecovered,
2254 tx,
2255 ExchangeId::BinanceMargin,
2256 inst,
2257 gap,
2258 FillRecoveryFailure::TimedOut {
2259 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2260 },
2261 );
2262 }
2263 }
2264}
2265
2266fn open_reconnect_recovery(
2271 unrecovered: &mut UnrecoveredFills,
2272 unchecked: &mut UncheckedOrders,
2273 known: &SharedKnownLiveOrders,
2274 instruments: &[InstrumentNameExchange],
2275 disconnected: DateTime<Utc>,
2276) {
2277 unrecovered.open(instruments, disconnected, Utc::now());
2278 let held = known.lock().instruments_among(instruments);
2279 unchecked.open(held);
2280}
2281
2282#[allow(
2289 clippy::cognitive_complexity,
2290 reason = "inherent reconnect-loop complexity (token + connect + subscribe + callback + recovery \
2291 + monitor + cleanup + backoff); mirrors spot's `connection_manager`, not worth splitting further"
2292)]
2293async fn margin_connection_manager(
2294 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2295 dedup: SharedDedupCache,
2296 ws_config: ConfigurationWebsocketApi,
2297 rest_config: Arc<ConfigurationRestApi>,
2298 rest: Arc<RestApi>,
2299 rate_limiter: Arc<RateLimitTracker>,
2300 known: SharedKnownLiveOrders,
2301 instruments: Vec<InstrumentNameExchange>,
2302 initial: Option<(Arc<WsApiBase>, UserListenToken)>,
2303) {
2304 enum DisconnectReason {
2305 Signal,
2306 HeartbeatTimeout,
2307 TokenRefresh,
2308 ConsumerDropped,
2309 }
2310
2311 let mut backoff = ExponentialBackoff::new();
2312 let mut disconnect_time: Option<DateTime<Utc>> = None;
2313 let mut unrecovered = UnrecoveredFills::default();
2315 let mut unchecked = UncheckedOrders::default();
2317 let (mut current_ws, mut current_token) = match initial {
2318 Some((ws, token)) => (Some(ws), Some(token)),
2319 None => (None, None),
2320 };
2321
2322 loop {
2323 let token = match current_token.take() {
2325 Some(t) => t,
2326 None => match acquire_user_listen_token(&rest_config, &rate_limiter, None).await {
2327 Ok(t) => t,
2328 Err(e) => {
2329 error!(%e, "BinanceMargin userListenToken acquisition failed");
2330 if !backoff.wait().await {
2331 error!("BinanceMargin max reconnect attempts exhausted");
2332 emit_stream_terminated(
2333 &tx,
2334 ExchangeId::BinanceMargin,
2335 StreamTerminationReason::ReconnectBudgetExhausted {
2336 attempts: backoff.attempts(),
2337 last_error: e.to_string(),
2338 },
2339 );
2340 break;
2341 }
2342 continue;
2343 }
2344 },
2345 };
2346
2347 let ws = match current_ws.take() {
2349 Some(ws) => ws,
2350 None => match connect_margin_ws(&ws_config).await {
2351 Ok(ws) => ws,
2352 Err(e) => {
2353 error!(%e, "BinanceMargin WS connect failed");
2354 if !backoff.wait().await {
2355 error!("BinanceMargin max reconnect attempts exhausted");
2356 emit_stream_terminated(
2357 &tx,
2358 ExchangeId::BinanceMargin,
2359 StreamTerminationReason::ReconnectBudgetExhausted {
2360 attempts: backoff.attempts(),
2361 last_error: e.to_string(),
2362 },
2363 );
2364 break;
2365 }
2366 continue;
2367 }
2368 },
2369 };
2370
2371 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2373 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2375 let subscription = register_user_data_listener(
2378 &ws,
2379 tx.clone(),
2380 dedup.clone(),
2381 known.clone(),
2382 heartbeat_flag.clone(),
2383 signal_tx,
2384 cross_account_position_handler,
2385 );
2386
2387 if let Err(e) = subscribe_listen_token(&ws, &token.token).await {
2389 warn!(%e, "BinanceMargin user-data subscribe failed, cleaning up and retrying");
2390 subscription.unsubscribe();
2391 if let Err(de) = ws.disconnect().await {
2392 warn!(%de, "BinanceMargin failed to disconnect after subscribe failure");
2393 }
2394 if !backoff.wait().await {
2396 error!("BinanceMargin max reconnect attempts exhausted");
2397 emit_stream_terminated(
2398 &tx,
2399 ExchangeId::BinanceMargin,
2400 StreamTerminationReason::ReconnectBudgetExhausted {
2401 attempts: backoff.attempts(),
2402 last_error: e.to_string(),
2403 },
2404 );
2405 break;
2406 }
2407 continue;
2408 }
2409 info!("BinanceMargin account_stream connected and subscribed");
2410 backoff.reset();
2414
2415 let token_deadline =
2418 tokio::time::Instant::now() + token_renew_after(token.expiration_time_ms);
2419
2420 if let Some(dt) = disconnect_time.take() {
2425 open_reconnect_recovery(&mut unrecovered, &mut unchecked, &known, &instruments, dt);
2426 }
2427 recover_margin_fills(
2430 &rest,
2431 &rate_limiter,
2432 &mut unrecovered,
2433 &tx,
2434 &dedup,
2435 &known,
2436 false,
2437 )
2438 .await;
2439 recover_margin_ended_orders(
2440 &rest,
2441 &rate_limiter,
2442 &known,
2443 &mut unchecked,
2444 &unrecovered,
2445 &tx,
2446 false,
2447 )
2448 .await;
2449
2450 let reason = {
2452 let mut signal_rx = signal_rx;
2453 let retry_gaps = async {
2459 loop {
2460 let next_check = unchecked.next_due(&unrecovered);
2461 match unrecovered.next_due().into_iter().chain(next_check).min() {
2462 Some(due) => tokio::time::sleep_until(due).await,
2463 None => std::future::pending::<()>().await,
2464 }
2465 recover_margin_fills(
2466 &rest,
2467 &rate_limiter,
2468 &mut unrecovered,
2469 &tx,
2470 &dedup,
2471 &known,
2472 false,
2473 )
2474 .await;
2475 recover_margin_ended_orders(
2476 &rest,
2477 &rate_limiter,
2478 &known,
2479 &mut unchecked,
2480 &unrecovered,
2481 &tx,
2482 false,
2483 )
2484 .await;
2485 }
2486 };
2487 tokio::pin!(retry_gaps);
2488 loop {
2489 tokio::select! {
2490 biased;
2493 _ = tx.closed() => {
2494 debug!("BinanceMargin account_stream consumer dropped, terminating");
2495 break DisconnectReason::ConsumerDropped;
2496 }
2497 _ = &mut signal_rx => {
2498 warn!("BinanceMargin WS disconnected, will attempt reconnect");
2499 break DisconnectReason::Signal;
2500 }
2501 () = tokio::time::sleep_until(token_deadline) => {
2502 info!("BinanceMargin userListenToken nearing expiry, renewing");
2503 break DisconnectReason::TokenRefresh;
2504 }
2505 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2506 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2507 continue;
2511 }
2512 warn!("BinanceMargin heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2513 break DisconnectReason::HeartbeatTimeout;
2514 }
2515 () = &mut retry_gaps => {}
2517 }
2518 }
2519 };
2520 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2521
2522 if should_reconnect {
2523 disconnect_time = Some(match reason {
2524 DisconnectReason::HeartbeatTimeout => {
2525 Utc::now()
2526 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2527 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2528 }
2529 DisconnectReason::TokenRefresh => {
2530 Utc::now()
2535 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2536 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2537 }
2538 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2539 });
2540 }
2541
2542 subscription.unsubscribe();
2544 if let Err(e) = ws.disconnect().await {
2545 warn!(%e, "BinanceMargin failed to disconnect WebSocket");
2546 }
2547
2548 if !should_reconnect || tx.is_closed() {
2549 debug!("BinanceMargin connection manager exiting");
2551 break;
2552 }
2553
2554 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2557 error!("BinanceMargin max reconnect attempts exhausted, stream terminating");
2558 let last_error = match reason {
2561 DisconnectReason::HeartbeatTimeout => {
2562 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2563 }
2564 _ => "WebSocket disconnected (server close/error)".to_string(),
2565 };
2566 emit_stream_terminated(
2567 &tx,
2568 ExchangeId::BinanceMargin,
2569 StreamTerminationReason::ReconnectBudgetExhausted {
2570 attempts: backoff.attempts(),
2571 last_error,
2572 },
2573 );
2574 break;
2575 }
2576 }
2577}
2578
2579fn build_base_quote_map(
2591 assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
2592) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
2593 let mut map = HashMap::with_capacity(assets.len());
2594 for entry in assets {
2595 let (Some(symbol), Some(base), Some(quote)) = (
2596 entry.symbol.as_deref(),
2597 entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
2598 entry
2599 .quote_asset
2600 .as_deref()
2601 .and_then(|q| q.asset.as_deref()),
2602 ) else {
2603 warn!(
2604 "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
2605 skipping base/quote map entry"
2606 );
2607 continue;
2608 };
2609 map.insert(
2610 InstrumentNameExchange::new(symbol),
2611 (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
2612 );
2613 }
2614 map
2615}
2616
2617async fn acquire_all_isolated_tokens(
2630 rest_config: &Arc<ConfigurationRestApi>,
2631 rate_limiter: &Arc<RateLimitTracker>,
2632 symbols: &[InstrumentNameExchange],
2633) -> Result<Vec<(InstrumentNameExchange, UserListenToken)>, UnindexedClientError> {
2634 use futures::{StreamExt as _, TryStreamExt as _};
2635
2636 futures::stream::iter(symbols.iter().cloned().map(|sym| {
2637 let rest_config = rest_config.clone();
2638 let rate_limiter = rate_limiter.clone();
2639 async move {
2640 let token = acquire_user_listen_token(&rest_config, &rate_limiter, Some(&sym)).await?;
2641 Ok::<_, UnindexedClientError>((sym, token))
2642 }
2643 }))
2644 .buffer_unordered(8)
2645 .try_collect()
2646 .await
2647}
2648
2649fn earliest_token_expiry_ms(tokens: &[(InstrumentNameExchange, UserListenToken)]) -> i64 {
2654 tokens
2655 .iter()
2656 .map(|(_, t)| t.expiration_time_ms)
2657 .min()
2658 .unwrap_or(i64::MAX)
2659}
2660
2661struct IsolatedLiveConn {
2664 ws: Arc<WsApiBase>,
2665 subscription: Subscription,
2666 signal_rx: oneshot::Receiver<()>,
2667 heartbeat_flag: Arc<AtomicBool>,
2668 earliest_expiry_ms: i64,
2671}
2672
2673async fn isolated_connect_and_subscribe(
2683 ws_config: &ConfigurationWebsocketApi,
2684 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2685 dedup: &SharedDedupCache,
2686 known: &SharedKnownLiveOrders,
2687 base_quote: &Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2688 tokens: &[(InstrumentNameExchange, UserListenToken)],
2689) -> anyhow::Result<IsolatedLiveConn> {
2690 let ws = connect_margin_ws(ws_config).await?;
2691 let sub_map = Arc::new(Mutex::new(
2697 HashMap::<i64, InstrumentNameExchange>::with_capacity(tokens.len()),
2698 ));
2699 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2700 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2702
2703 let handle_position = {
2704 let sub_map = sub_map.clone();
2705 let base_quote = base_quote.clone();
2706 move |position, subscription_id, buf: &mut Vec<UnindexedAccountEvent>| {
2707 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
2708 }
2709 };
2710 let subscription = register_user_data_listener(
2712 &ws,
2713 tx.clone(),
2714 dedup.clone(),
2715 known.clone(),
2716 heartbeat_flag.clone(),
2717 signal_tx,
2718 handle_position,
2719 );
2720
2721 let earliest_expiry_ms = earliest_token_expiry_ms(tokens);
2722 for (sym, token) in tokens {
2723 match subscribe_listen_token_capture(&ws, &token.token).await {
2724 Ok(Some(id)) => {
2725 sub_map
2726 .lock()
2727 .unwrap_or_else(|p| p.into_inner())
2728 .insert(id, sym.clone());
2729 }
2730 Ok(None) => warn!(
2731 symbol = %sym.name(),
2732 "BinanceMargin isolated: subscribe ack carried no subscriptionId — live per-pair \
2733 balances unavailable for this symbol (fills/orders unaffected; balances via snapshot)"
2734 ),
2735 Err(e) => {
2736 warn!(%e, symbol = %sym.name(), "BinanceMargin isolated subscribe failed");
2739 subscription.unsubscribe();
2740 if let Err(de) = ws.disconnect().await {
2741 warn!(%de, "BinanceMargin isolated: disconnect after subscribe failure failed");
2742 }
2743 return Err(e);
2744 }
2745 }
2746 }
2747
2748 Ok(IsolatedLiveConn {
2749 ws,
2750 subscription,
2751 signal_rx,
2752 heartbeat_flag,
2753 earliest_expiry_ms,
2754 })
2755}
2756
2757#[allow(
2766 clippy::cognitive_complexity,
2767 reason = "inherent reconnect-loop complexity (tokens + connect + subscribe + monitor + cleanup \
2768 + backoff); mirrors the cross manager, not worth splitting further"
2769)]
2770async fn isolated_connection_manager(
2771 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2772 dedup: SharedDedupCache,
2773 ws_config: ConfigurationWebsocketApi,
2774 rest_config: Arc<ConfigurationRestApi>,
2775 rest: Arc<RestApi>,
2776 rate_limiter: Arc<RateLimitTracker>,
2777 known: SharedKnownLiveOrders,
2778 symbols: Vec<InstrumentNameExchange>,
2779 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2780 initial: Option<IsolatedLiveConn>,
2781) {
2782 enum DisconnectReason {
2783 Signal,
2784 HeartbeatTimeout,
2785 TokenRefresh,
2786 ConsumerDropped,
2787 }
2788
2789 let mut backoff = ExponentialBackoff::new();
2790 let mut disconnect_time: Option<DateTime<Utc>> = None;
2791 let mut unrecovered = UnrecoveredFills::default();
2793 let mut unchecked = UncheckedOrders::default();
2795 let mut current = initial;
2796
2797 loop {
2798 let IsolatedLiveConn {
2800 ws,
2801 subscription,
2802 signal_rx,
2803 heartbeat_flag,
2804 earliest_expiry_ms,
2805 } = match current.take() {
2806 Some(live) => live,
2808 None => {
2809 let tokens = match acquire_all_isolated_tokens(
2812 &rest_config,
2813 &rate_limiter,
2814 &symbols,
2815 )
2816 .await
2817 {
2818 Ok(t) => t,
2819 Err(e) => {
2820 error!(%e, "BinanceMargin isolated userListenToken acquisition failed");
2821 if !backoff.wait().await {
2822 error!("BinanceMargin isolated max reconnect attempts exhausted");
2823 emit_stream_terminated(
2824 &tx,
2825 ExchangeId::BinanceMargin,
2826 StreamTerminationReason::ReconnectBudgetExhausted {
2827 attempts: backoff.attempts(),
2828 last_error: e.to_string(),
2829 },
2830 );
2831 break;
2832 }
2833 continue;
2834 }
2835 };
2836 match isolated_connect_and_subscribe(
2837 &ws_config,
2838 &tx,
2839 &dedup,
2840 &known,
2841 &base_quote,
2842 &tokens,
2843 )
2844 .await
2845 {
2846 Ok(live) => live,
2847 Err(e) => {
2848 error!(%e, "BinanceMargin isolated connect/subscribe failed");
2849 if !backoff.wait().await {
2850 error!("BinanceMargin isolated max reconnect attempts exhausted");
2851 emit_stream_terminated(
2852 &tx,
2853 ExchangeId::BinanceMargin,
2854 StreamTerminationReason::ReconnectBudgetExhausted {
2855 attempts: backoff.attempts(),
2856 last_error: e.to_string(),
2857 },
2858 );
2859 break;
2860 }
2861 continue;
2862 }
2863 }
2864 }
2865 };
2866
2867 info!(
2868 symbols = symbols.len(),
2869 "BinanceMargin isolated account_stream connected and subscribed"
2870 );
2871 backoff.reset();
2874
2875 let token_deadline = tokio::time::Instant::now() + token_renew_after(earliest_expiry_ms);
2878
2879 if let Some(dt) = disconnect_time.take() {
2882 open_reconnect_recovery(&mut unrecovered, &mut unchecked, &known, &symbols, dt);
2883 }
2884 recover_margin_fills(
2886 &rest,
2887 &rate_limiter,
2888 &mut unrecovered,
2889 &tx,
2890 &dedup,
2891 &known,
2892 true,
2893 )
2894 .await;
2895 recover_margin_ended_orders(
2896 &rest,
2897 &rate_limiter,
2898 &known,
2899 &mut unchecked,
2900 &unrecovered,
2901 &tx,
2902 true,
2903 )
2904 .await;
2905
2906 let reason = {
2909 let mut signal_rx = signal_rx;
2910 let retry_gaps = async {
2913 loop {
2914 let next_check = unchecked.next_due(&unrecovered);
2915 match unrecovered.next_due().into_iter().chain(next_check).min() {
2916 Some(due) => tokio::time::sleep_until(due).await,
2917 None => std::future::pending::<()>().await,
2918 }
2919 recover_margin_fills(
2920 &rest,
2921 &rate_limiter,
2922 &mut unrecovered,
2923 &tx,
2924 &dedup,
2925 &known,
2926 true,
2927 )
2928 .await;
2929 recover_margin_ended_orders(
2930 &rest,
2931 &rate_limiter,
2932 &known,
2933 &mut unchecked,
2934 &unrecovered,
2935 &tx,
2936 true,
2937 )
2938 .await;
2939 }
2940 };
2941 tokio::pin!(retry_gaps);
2942 loop {
2943 tokio::select! {
2944 biased;
2945 _ = tx.closed() => {
2946 debug!("BinanceMargin isolated consumer dropped, terminating");
2947 break DisconnectReason::ConsumerDropped;
2948 }
2949 _ = &mut signal_rx => {
2950 warn!("BinanceMargin isolated WS disconnected, will attempt reconnect");
2951 break DisconnectReason::Signal;
2952 }
2953 () = tokio::time::sleep_until(token_deadline) => {
2954 info!("BinanceMargin isolated userListenToken nearing expiry, renewing all");
2955 break DisconnectReason::TokenRefresh;
2956 }
2957 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2958 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2959 continue;
2960 }
2961 warn!("BinanceMargin isolated heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2962 break DisconnectReason::HeartbeatTimeout;
2963 }
2964 () = &mut retry_gaps => {}
2966 }
2967 }
2968 };
2969 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2970
2971 if should_reconnect {
2972 disconnect_time = Some(match reason {
2973 DisconnectReason::HeartbeatTimeout => {
2974 Utc::now()
2975 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2976 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2977 }
2978 DisconnectReason::TokenRefresh => {
2979 Utc::now()
2983 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2984 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2985 }
2986 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2987 });
2988 }
2989
2990 subscription.unsubscribe();
2992 if let Err(e) = ws.disconnect().await {
2993 warn!(%e, "BinanceMargin isolated failed to disconnect WebSocket");
2994 }
2995
2996 if !should_reconnect || tx.is_closed() {
2997 debug!("BinanceMargin isolated connection manager exiting");
2999 break;
3000 }
3001
3002 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
3005 error!("BinanceMargin isolated max reconnect attempts exhausted, stream terminating");
3006 let last_error = match reason {
3009 DisconnectReason::HeartbeatTimeout => {
3010 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
3011 }
3012 _ => "WebSocket disconnected (server close/error)".to_string(),
3013 };
3014 emit_stream_terminated(
3015 &tx,
3016 ExchangeId::BinanceMargin,
3017 StreamTerminationReason::ReconnectBudgetExhausted {
3018 attempts: backoff.attempts(),
3019 last_error,
3020 },
3021 );
3022 break;
3023 }
3024 }
3025}
3026
3027async fn fetch_margin_open_orders_for_instrument(
3034 rest: Arc<RestApi>,
3035 rate_limiter: Arc<RateLimitTracker>,
3036 instrument: InstrumentNameExchange,
3037 is_isolated: bool,
3038 kind: RequestKind,
3039) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
3040 let symbol_str = instrument.name().to_string();
3042 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
3043 let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
3044 let sym = symbol_str.clone();
3045 let isolated = isolated.clone();
3046 Box::pin(async move {
3047 let params = QueryMarginAccountsOpenOrdersParams::builder()
3048 .symbol(sym)
3049 .is_isolated(isolated)
3050 .build()?;
3051 rest.query_margin_accounts_open_orders(params).await
3052 })
3053 })
3054 .await
3055 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
3056
3057 let orders_data = response.data().await.map_err(response_decode_error)?;
3058
3059 let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceMargin, &instrument);
3060
3061 Ok((instrument, listing))
3062}
3063
3064async fn fetch_margin_all_open_orders(
3071 rest: Arc<RestApi>,
3072 rate_limiter: Arc<RateLimitTracker>,
3073 is_isolated: bool,
3074) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
3075 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
3076 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
3077 let isolated = isolated.clone();
3078 Box::pin(async move {
3079 let params = QueryMarginAccountsOpenOrdersParams::builder()
3080 .is_isolated(isolated)
3081 .build()?;
3082 rest.query_margin_accounts_open_orders(params).await
3083 })
3084 })
3085 .await
3086 .map_err(|e| classify_rest_query_error(&e, None))?;
3087
3088 let orders_data = response.data().await.map_err(response_decode_error)?;
3089
3090 let orders = orders_data
3091 .into_iter()
3092 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin))
3093 .collect();
3094
3095 Ok(orders)
3096}
3097
3098async fn fetch_margin_order_lookup(
3105 rest: Arc<RestApi>,
3106 rate_limiter: Arc<RateLimitTracker>,
3107 key: UnindexedOrderKey,
3108 is_isolated: bool,
3109 kind: RequestKind,
3110 recorded_kind: Option<OrderKind>,
3111) -> Result<OrderLookup, UnindexedClientError> {
3112 let symbol = key.instrument.name().to_string();
3114 let cid = key.cid.0.to_string();
3115 let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
3116 let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
3117 let (symbol, cid, isolated) = (symbol.clone(), cid.clone(), isolated.clone());
3118 Box::pin(async move {
3119 let params = QueryMarginAccountsOrderParams::builder(symbol)
3120 .orig_client_order_id(cid)
3121 .is_isolated(isolated)
3122 .build()?;
3123 rest.query_margin_accounts_order(params).await
3124 })
3125 })
3126 .await
3127 {
3128 Ok(response) => response,
3129 Err(e) if is_unknown_order(&e) => {
3130 debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceMargin does not know this order");
3131 return Ok(OrderLookup::Unknown);
3132 }
3133 Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
3134 };
3135
3136 let row = response.data().await.map_err(response_decode_error)?;
3137
3138 Ok(
3139 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, recorded_kind)
3140 .map_or(OrderLookup::NotEnded, |order| {
3141 OrderLookup::Ended(Box::new(order))
3142 }),
3143 )
3144}
3145
3146async fn listed_margin_open_cids(
3149 rest: Arc<RestApi>,
3150 rate_limiter: Arc<RateLimitTracker>,
3151 instruments: Vec<InstrumentNameExchange>,
3152 is_isolated: bool,
3153) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3154 let mut listed = FnvHashSet::default();
3155 for instrument in instruments {
3156 let (_, listing) = fetch_margin_open_orders_for_instrument(
3157 rest.clone(),
3158 rate_limiter.clone(),
3159 instrument,
3160 is_isolated,
3161 RequestKind::Essential,
3162 )
3163 .await?;
3164 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
3165 }
3166 Ok(listed)
3167}
3168
3169async fn recover_margin_ended_orders(
3172 rest: &Arc<RestApi>,
3173 rate_limiter: &Arc<RateLimitTracker>,
3174 known: &SharedKnownLiveOrders,
3175 unchecked: &mut UncheckedOrders,
3176 unrecovered: &UnrecoveredFills,
3177 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3178 is_isolated: bool,
3179) {
3180 recover_ended_orders(
3181 ExchangeId::BinanceMargin,
3182 known,
3183 unchecked,
3184 unrecovered,
3185 tx,
3186 OpenListing::PerInstrument,
3187 |instruments| {
3188 listed_margin_open_cids(rest.clone(), rate_limiter.clone(), instruments, is_isolated)
3189 },
3190 |key| {
3191 let recorded_kind = known.lock().kind_of(&key.cid);
3193 fetch_margin_order_lookup(
3194 rest.clone(),
3195 rate_limiter.clone(),
3196 key,
3197 is_isolated,
3198 RequestKind::Essential,
3199 recorded_kind,
3200 )
3201 },
3202 )
3203 .await;
3204}
3205
3206const MARGIN_MY_TRADES_WINDOW_MS: i64 = 23 * 60 * 60 * 1000;
3212
3213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3216enum MarginTradesQuery {
3217 Window { start: i64, end: i64 },
3219 FromId(i64),
3221 Order(i64),
3223 OrderFromId { order_id: i64, from_id: i64 },
3225}
3226
3227impl MarginTradesQuery {
3228 fn first(from: MyTradesFrom, now_ms: i64) -> Option<Self> {
3232 match from {
3233 MyTradesFrom::Span { start, .. } => {
3234 (start <= now_ms).then(|| Self::window(start, now_ms))
3235 }
3236 MyTradesFrom::Order(order_id) => Some(Self::Order(order_id)),
3237 }
3238 }
3239
3240 fn window(start: i64, now_ms: i64) -> Self {
3242 Self::Window {
3243 start,
3244 end: start.saturating_add(MARGIN_MY_TRADES_WINDOW_MS).min(now_ms),
3245 }
3246 }
3247
3248 fn next(self, page_len: usize, last_id: Option<i64>, now_ms: i64) -> Option<Self> {
3251 let full = page_len >= BINANCE_MAX_TRADES;
3252 let after_last = || last_id?.checked_add(1);
3253 match self {
3254 Self::Window { end, .. } if page_len == 0 => {
3259 (end < now_ms).then(|| Self::window(end, now_ms))
3260 }
3261 Self::Window { end, .. } if end >= now_ms && !full => None,
3263 Self::Window { .. } => after_last().map(Self::FromId),
3266 Self::FromId(_) if full => after_last().map(Self::FromId),
3267 Self::Order(order_id) | Self::OrderFromId { order_id, .. } if full => {
3268 after_last().map(|from_id| Self::OrderFromId { order_id, from_id })
3269 }
3270 Self::FromId(_) | Self::Order(_) | Self::OrderFromId { .. } => None,
3271 }
3272 }
3273}
3274
3275async fn paginate_margin_my_trades(
3297 rest: &Arc<RestApi>,
3298 rate_limiter: &Arc<RateLimitTracker>,
3299 instrument: &InstrumentNameExchange,
3300 from: MyTradesFrom,
3301 is_isolated: bool,
3302 kind: RequestKind,
3303) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
3304 let symbol_str = instrument.name().to_string();
3306 let isolated = QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(is_isolated);
3307 #[allow(clippy::cast_possible_wrap)]
3312 let limit = BINANCE_MAX_TRADES as i64;
3313 let now_ms = match from {
3316 MyTradesFrom::Span { end, .. } => end.min(Utc::now().timestamp_millis()),
3317 MyTradesFrom::Order(_) => Utc::now().timestamp_millis(),
3318 };
3319 let mut all_pages = Vec::new();
3320 let mut requests = 0u32;
3321 let mut next = MarginTradesQuery::first(from, now_ms);
3322 while let Some(query) = next {
3323 requests += 1;
3324 let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
3325 let sym = symbol_str.clone();
3326 let isolated = isolated.clone();
3327 Box::pin(async move {
3328 let builder = QueryMarginAccountsTradeListParams::builder(sym)
3329 .is_isolated(isolated)
3330 .limit(limit);
3331 let params = match query {
3332 MarginTradesQuery::Window { start, end } => {
3333 builder.start_time(start).end_time(end)
3334 }
3335 MarginTradesQuery::FromId(id) => builder.from_id(id),
3336 MarginTradesQuery::Order(order_id) => builder.order_id(order_id),
3337 MarginTradesQuery::OrderFromId { order_id, from_id } => {
3338 builder.order_id(order_id).from_id(from_id)
3339 }
3340 }
3341 .build()?;
3342 rest.query_margin_accounts_trade_list(params).await
3343 })
3344 })
3345 .await
3346 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
3347
3348 let mut page = response.data().await.map_err(response_decode_error)?;
3349
3350 let page_len = page.len();
3351 let last_id = page.last().and_then(|t| t.id);
3352 let past_end = match from {
3354 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
3355 MyTradesFrom::Order(_) => false,
3356 };
3357 all_pages.extend(page);
3358 if past_end {
3359 break;
3360 }
3361
3362 if page_len > 0 && last_id.is_none() {
3363 warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, cannot page past it");
3364 break;
3365 }
3366 next = query.next(page_len, last_id, now_ms);
3367 }
3368 debug!(
3369 %instrument,
3370 requests,
3371 trades = all_pages.len(),
3372 "BinanceMargin paginate_my_trades: walk complete"
3373 );
3374 Ok(all_pages)
3375}
3376
3377fn convert_margin_balance_entry(
3387 b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
3388 now: DateTime<Utc>,
3389) -> Option<AssetBalance<AssetNameExchange>> {
3390 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
3391 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3392 Some(v) => v,
3393 None => {
3394 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
3395 return None;
3396 }
3397 };
3398 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3399 Some(v) => v,
3400 None => {
3401 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
3402 return None;
3403 }
3404 };
3405 let borrowed = b
3410 .borrowed
3411 .as_deref()
3412 .and_then(|s| Decimal::from_str(s).ok())
3413 .unwrap_or(Decimal::ZERO);
3414 let interest = b
3415 .interest
3416 .as_deref()
3417 .and_then(|s| Decimal::from_str(s).ok())
3418 .unwrap_or(Decimal::ZERO);
3419 Some(AssetBalance::new(
3420 asset_name,
3421 Balance::new_margin(free + locked, free, borrowed, interest),
3422 now,
3423 ))
3424}
3425
3426fn filter_and_convert_margin_balances(
3429 user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
3430 assets: &[AssetNameExchange],
3431) -> Vec<AssetBalance<AssetNameExchange>> {
3432 let now = Utc::now();
3433 if assets.is_empty() {
3435 return user_assets
3436 .into_iter()
3437 .filter_map(|b| convert_margin_balance_entry(b, now))
3438 .collect();
3439 }
3440 if assets.len() <= 16 {
3442 return user_assets
3443 .into_iter()
3444 .filter_map(|b| {
3445 let asset_name_str = b.asset.as_deref()?;
3446 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
3447 return None;
3448 }
3449 convert_margin_balance_entry(b, now)
3450 })
3451 .collect();
3452 }
3453 use std::collections::HashSet;
3454 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
3455 user_assets
3456 .into_iter()
3457 .filter_map(|b| {
3458 let asset_name_str = b.asset.as_deref()?;
3459 if !asset_set.contains(asset_name_str) {
3460 return None;
3461 }
3462 convert_margin_balance_entry(b, now)
3463 })
3464 .collect()
3465}
3466
3467fn active_order_snapshot(
3474 o: Order<ExchangeId, InstrumentNameExchange, Open>,
3475) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
3476{
3477 Order {
3478 key: o.key,
3479 side: o.side,
3480 price: o.price,
3481 quantity: o.quantity,
3482 kind: o.kind,
3483 time_in_force: o.time_in_force,
3484 state: OrderState::active(o.state),
3485 }
3486}
3487
3488fn convert_isolated_asset_balance(
3498 asset: Option<&str>,
3499 free: Option<&str>,
3500 locked: Option<&str>,
3501 borrowed: Option<&str>,
3502 interest: Option<&str>,
3503 now: DateTime<Utc>,
3504) -> Option<AssetBalance<AssetNameExchange>> {
3505 let asset_name = AssetNameExchange::new(asset?);
3506 let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
3507 Some(v) => v,
3508 None => {
3509 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
3510 return None;
3511 }
3512 };
3513 let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
3514 Some(v) => v,
3515 None => {
3516 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
3517 return None;
3518 }
3519 };
3520 let borrowed = borrowed
3521 .and_then(|s| Decimal::from_str(s).ok())
3522 .unwrap_or(Decimal::ZERO);
3523 let interest = interest
3524 .and_then(|s| Decimal::from_str(s).ok())
3525 .unwrap_or(Decimal::ZERO);
3526 Some(AssetBalance::new(
3527 asset_name,
3528 Balance::new_margin(free + locked, free, borrowed, interest),
3529 now,
3530 ))
3531}
3532
3533fn convert_isolated_margin_assets(
3541 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
3542) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
3543 let now = Utc::now();
3546 let mut map = HashMap::with_capacity(assets.len());
3547 for entry in assets {
3548 let Some(symbol) = entry.symbol.as_deref() else {
3549 warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
3550 continue;
3551 };
3552 let instrument = InstrumentNameExchange::new(symbol);
3553
3554 let Some(base_raw) = entry.base_asset.as_deref() else {
3555 warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
3556 continue;
3557 };
3558 let Some(quote_raw) = entry.quote_asset.as_deref() else {
3559 warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
3560 continue;
3561 };
3562
3563 let Some(base) = convert_isolated_asset_balance(
3564 base_raw.asset.as_deref(),
3565 base_raw.free.as_deref(),
3566 base_raw.locked.as_deref(),
3567 base_raw.borrowed.as_deref(),
3568 base_raw.interest.as_deref(),
3569 now,
3570 ) else {
3571 warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
3572 continue;
3573 };
3574 let Some(quote) = convert_isolated_asset_balance(
3575 quote_raw.asset.as_deref(),
3576 quote_raw.free.as_deref(),
3577 quote_raw.locked.as_deref(),
3578 quote_raw.borrowed.as_deref(),
3579 quote_raw.interest.as_deref(),
3580 now,
3581 ) else {
3582 warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
3583 continue;
3584 };
3585
3586 let risk = IsolatedMarginRisk {
3587 margin_level: entry
3588 .margin_level
3589 .as_deref()
3590 .and_then(|s| Decimal::from_str(s).ok()),
3591 margin_ratio: entry
3592 .margin_ratio
3593 .as_deref()
3594 .and_then(|s| Decimal::from_str(s).ok()),
3595 liquidation_price: entry
3596 .liquidate_price
3597 .as_deref()
3598 .and_then(|s| Decimal::from_str(s).ok()),
3599 };
3600
3601 map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
3602 }
3603 map
3604}
3605
3606fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
3609 symbols
3610 .chunks(5)
3611 .map(|chunk| {
3612 chunk
3613 .iter()
3614 .map(|s| s.name().as_str())
3615 .collect::<Vec<_>>()
3616 .join(",")
3617 })
3618 .collect()
3619}
3620
3621async fn fetch_isolated_margin_account_info(
3627 rest: Arc<RestApi>,
3628 rate_limiter: Arc<RateLimitTracker>,
3629 symbols: Vec<InstrumentNameExchange>,
3630) -> Result<Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>, UnindexedClientError> {
3631 use futures::{StreamExt as _, TryStreamExt as _};
3632
3633 let chunks = chunk_symbols(&symbols);
3634
3635 futures::stream::iter(chunks.into_iter().map(|symbols_param| {
3639 let rest = rest.clone();
3640 let rate_limiter = rate_limiter.clone();
3641 async move {
3642 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
3643 let symbols_param = symbols_param.clone();
3644 Box::pin(async move {
3645 let params = QueryIsolatedMarginAccountInfoParams::builder()
3646 .symbols(symbols_param)
3647 .build()?;
3648 rest.query_isolated_margin_account_info(params).await
3649 })
3650 })
3651 .await
3652 .map_err(|e| classify_rest_query_error(&e, None))?;
3653
3654 let info = response.data().await.map_err(response_decode_error)?;
3655 Ok::<_, UnindexedClientError>(info.assets.unwrap_or_default())
3656 }
3657 }))
3658 .buffer_unordered(8)
3659 .try_fold(
3660 Vec::with_capacity(symbols.len()),
3661 |mut acc, assets| async move {
3662 acc.extend(assets);
3663 Ok(acc)
3664 },
3665 )
3666 .await
3667}
3668
3669fn convert_margin_trade(
3676 t: &QueryMarginAccountsTradeListResponseInner,
3677 instrument: &InstrumentNameExchange,
3678) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3679 let trade_id_raw = match t.id {
3680 Some(id) => id,
3681 None => {
3682 warn!(%instrument, "BinanceMargin trade missing id");
3683 return None;
3684 }
3685 };
3686 let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
3687 let order_id = match t.order_id {
3688 Some(id) => OrderId(format_smolstr!("{id}")),
3689 None => {
3690 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
3691 return None;
3692 }
3693 };
3694 let side = match t.is_buyer {
3695 Some(true) => Side::Buy,
3696 Some(false) => Side::Sell,
3697 None => {
3698 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
3699 return None;
3700 }
3701 };
3702 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3703 Some(v) => v,
3704 None => {
3705 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
3706 return None;
3707 }
3708 };
3709 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3710 Some(v) => v,
3711 None => {
3712 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
3713 return None;
3714 }
3715 };
3716 let commission = t
3717 .commission
3718 .as_deref()
3719 .and_then(|s| Decimal::from_str(s).ok())
3720 .unwrap_or(Decimal::ZERO);
3721 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
3722 Some(ts) => ts,
3723 None => {
3724 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
3725 Utc::now()
3726 }
3727 };
3728
3729 let fee_asset = t
3733 .commission_asset
3734 .as_deref()
3735 .map(AssetNameExchange::from)
3736 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
3737
3738 Some(Trade::new(
3739 trade_id,
3740 order_id,
3741 instrument.clone(),
3742 StrategyId::unknown(), time_exchange,
3744 side,
3745 price,
3746 quantity,
3747 None,
3749 AssetFees::new(fee_asset, commission, None),
3750 ))
3751}
3752
3753#[derive(Debug)]
3759enum BuildOrderError {
3760 Unsupported,
3762 Build(String),
3764 TrailingDelta(TrailingDeltaError),
3766}
3767
3768trait IsolatedFlag {
3779 fn from_flag(is_isolated: bool) -> Self;
3781}
3782
3783macro_rules! impl_isolated_flag {
3788 ($($t:ty),* $(,)?) => {
3789 $(
3790 impl IsolatedFlag for $t {
3791 fn from_flag(is_isolated: bool) -> Self {
3792 if is_isolated { Self::True } else { Self::False }
3793 }
3794 }
3795 )*
3796 };
3797}
3798
3799impl_isolated_flag!(
3800 MarginAccountNewOrderIsIsolatedEnum,
3801 MarginAccountCancelOrderIsIsolatedEnum,
3802 QueryMarginAccountsOpenOrdersIsIsolatedEnum,
3803 QueryMarginAccountsOrderIsIsolatedEnum,
3804 QueryMarginAccountsTradeListIsIsolatedEnum,
3805);
3806
3807fn build_cancel_order_params(
3815 symbol: String,
3816 id: Option<&OrderId>,
3817 cid: &ClientOrderId,
3818 is_isolated: bool,
3819) -> Result<MarginAccountCancelOrderParams, String> {
3820 let mut builder = MarginAccountCancelOrderParams::builder(symbol).is_isolated(
3821 MarginAccountCancelOrderIsIsolatedEnum::from_flag(is_isolated),
3822 );
3823
3824 match id {
3825 Some(order_id) => match order_id.0.parse::<i64>() {
3826 Ok(id) => builder = builder.order_id(id),
3827 Err(_) => {
3828 error!(
3830 order_id = %order_id.0,
3831 "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
3832 );
3833 builder = builder.orig_client_order_id(cid.0.to_string());
3834 }
3835 },
3836 None => builder = builder.orig_client_order_id(cid.0.to_string()),
3837 }
3838
3839 builder.build().map_err(|e| e.to_string())
3840}
3841
3842#[allow(clippy::too_many_arguments)] fn build_new_order_params(
3851 symbol: String,
3852 side: Side,
3853 price: Option<Decimal>,
3854 quantity: Decimal,
3855 kind: OrderKind,
3856 time_in_force: TimeInForce,
3857 new_client_order_id: String,
3858 side_effect: MarginSideEffect,
3859 is_isolated: bool,
3860) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3861 let binance_side = match side {
3862 Side::Buy => MarginAccountNewOrderSideEnum::Buy,
3863 Side::Sell => MarginAccountNewOrderSideEnum::Sell,
3864 };
3865
3866 let (binance_type, binance_tif) =
3867 convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
3868
3869 let mut builder = MarginAccountNewOrderParams::builder(symbol, binance_side, binance_type)
3871 .quantity(quantity)
3872 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::from_flag(is_isolated))
3873 .side_effect_type(side_effect.as_sdk_enum())
3874 .new_client_order_id(new_client_order_id)
3875 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
3876
3877 if side_effect == MarginSideEffect::AutoBorrowRepay {
3880 builder = builder.auto_repay_at_cancel(true);
3881 }
3882
3883 if let Some(tif) = binance_tif {
3884 builder = builder.time_in_force(tif);
3885 }
3886
3887 match kind {
3891 OrderKind::Limit => {
3892 builder = builder.price(price);
3893 }
3894 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
3895 builder = builder.stop_price(trigger_price);
3896 }
3897 OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
3898 builder = builder.price(price).stop_price(trigger_price);
3899 }
3900 OrderKind::TrailingStop {
3901 offset,
3902 offset_type,
3903 } => {
3904 let basis_points = trailing_delta_basis_points(offset, offset_type)
3905 .map_err(BuildOrderError::TrailingDelta)?;
3906 builder = builder.trailing_delta(i64::from(basis_points));
3907 }
3908 _ => {}
3912 }
3913
3914 builder
3915 .build()
3916 .map_err(|e| BuildOrderError::Build(e.to_string()))
3917}
3918
3919fn convert_order_kind_tif_margin(
3929 kind: OrderKind,
3930 tif: TimeInForce,
3931) -> Option<(
3932 MarginAccountNewOrderTypeEnum,
3933 Option<MarginAccountNewOrderTimeInForceEnum>,
3934)> {
3935 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
3936 let margin_type = match binance_type {
3937 BinanceOrderType::Market => MarginAccountNewOrderTypeEnum::Market,
3938 BinanceOrderType::Limit => MarginAccountNewOrderTypeEnum::Limit,
3939 BinanceOrderType::LimitMaker => MarginAccountNewOrderTypeEnum::LimitMaker,
3940 BinanceOrderType::StopLoss => MarginAccountNewOrderTypeEnum::StopLoss,
3941 BinanceOrderType::StopLossLimit => MarginAccountNewOrderTypeEnum::StopLossLimit,
3942 BinanceOrderType::TakeProfit => MarginAccountNewOrderTypeEnum::TakeProfit,
3943 BinanceOrderType::TakeProfitLimit => MarginAccountNewOrderTypeEnum::TakeProfitLimit,
3944 };
3945 let margin_tif = binance_tif.map(|t| match t {
3946 BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
3947 BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
3948 BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
3949 });
3950 Some((margin_type, margin_tif))
3951}
3952
3953#[cfg(test)]
3954#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3956 use super::*;
3957 use crate::client::binance::shared::convert_open_order;
3958 use crate::order::state::ActiveOrderState;
3959 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOpenOrdersResponseInner;
3960
3961 #[test]
3962 fn margin_side_effect_default_is_auto_borrow_repay() {
3963 assert_eq!(
3964 MarginSideEffect::default(),
3965 MarginSideEffect::AutoBorrowRepay
3966 );
3967 }
3968
3969 #[test]
3970 fn margin_side_effect_wire_strings() {
3971 assert_eq!(
3972 MarginSideEffect::AutoBorrowRepay.as_binance_str(),
3973 "AUTO_BORROW_REPAY"
3974 );
3975 assert_eq!(
3976 MarginSideEffect::NoBorrow.as_binance_str(),
3977 "NO_SIDE_EFFECT"
3978 );
3979 }
3980
3981 #[test]
3982 fn margin_side_effect_serde_round_trip() {
3983 assert_eq!(
3985 serde_json::to_string(&MarginSideEffect::AutoBorrowRepay).unwrap(),
3986 r#""auto_borrow_repay""#
3987 );
3988 assert_eq!(
3989 serde_json::to_string(&MarginSideEffect::NoBorrow).unwrap(),
3990 r#""no_borrow""#
3991 );
3992 assert_eq!(
3993 serde_json::from_str::<MarginSideEffect>(r#""auto_borrow_repay""#).unwrap(),
3994 MarginSideEffect::AutoBorrowRepay
3995 );
3996 assert_eq!(
3997 serde_json::from_str::<MarginSideEffect>(r#""no_borrow""#).unwrap(),
3998 MarginSideEffect::NoBorrow
3999 );
4000 }
4001
4002 #[test]
4003 fn config_debug_redacts_secrets() {
4004 let config = BinanceMarginConfig::new(
4005 "my_api_key".to_string(),
4006 "my_secret_key".to_string(),
4007 false,
4008 false,
4009 Vec::new(),
4010 MarginSideEffect::default(),
4011 );
4012 let debug = format!("{config:?}");
4013 assert!(!debug.contains("my_api_key"));
4014 assert!(!debug.contains("my_secret_key"));
4015 assert!(debug.contains("***"));
4016 }
4017
4018 #[test]
4019 fn cross_margin_uses_common_case_defaults() {
4020 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
4021 assert!(!config.testnet);
4022 assert!(!config.is_isolated);
4023 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
4024 assert_eq!(config.api_key(), "k");
4025 }
4026
4027 #[test]
4028 fn config_deserializes_with_defaults() {
4029 let config: BinanceMarginConfig =
4031 serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
4032 .expect("deserialize");
4033 assert!(!config.is_isolated);
4034 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
4035 }
4036
4037 #[test]
4038 fn config_deserializes_explicit_side_effect() {
4039 let config: BinanceMarginConfig = serde_json::from_str(
4040 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"side_effect":"no_borrow"}"#,
4041 )
4042 .expect("deserialize");
4043 assert!(config.is_isolated);
4044 assert_eq!(config.side_effect, MarginSideEffect::NoBorrow);
4045 }
4046
4047 #[test]
4048 fn isolated_ctor_sets_symbols_and_flag() {
4049 let symbols = vec![
4050 InstrumentNameExchange::new("BTCUSDT"),
4051 InstrumentNameExchange::new("ETHUSDT"),
4052 ];
4053 let config =
4054 BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
4055 assert!(config.is_isolated);
4056 assert_eq!(config.isolated_symbols, symbols);
4057 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
4058 }
4059
4060 #[test]
4061 fn isolated_config_with_symbols_constructs() {
4062 let config = BinanceMarginConfig::isolated(
4064 "k".to_string(),
4065 "s".to_string(),
4066 vec![InstrumentNameExchange::new("BTCUSDT")],
4067 );
4068 let _client = BinanceMargin::new(config); }
4070
4071 #[test]
4072 #[should_panic(expected = "non-empty isolated_symbols")]
4073 fn isolated_empty_symbols_panics_at_new() {
4074 let config = BinanceMarginConfig::new(
4076 "k".to_string(),
4077 "s".to_string(),
4078 false,
4079 true,
4080 Vec::new(),
4081 MarginSideEffect::default(),
4082 );
4083 let _ = BinanceMargin::new(config);
4084 }
4085
4086 #[test]
4087 #[should_panic(expected = "non-empty isolated_symbols")]
4088 fn isolated_empty_symbols_panics_via_deserialize_path() {
4089 let config: BinanceMarginConfig = serde_json::from_str(
4092 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
4093 )
4094 .expect("deserialize");
4095 assert!(config.isolated_symbols.is_empty());
4096 let _ = BinanceMargin::new(config);
4097 }
4098
4099 #[test]
4100 fn isolated_flag_maps_mode() {
4101 assert_eq!(
4104 MarginAccountNewOrderIsIsolatedEnum::from_flag(true).as_str(),
4105 "TRUE"
4106 );
4107 assert_eq!(
4108 MarginAccountNewOrderIsIsolatedEnum::from_flag(false).as_str(),
4109 "FALSE"
4110 );
4111 assert_eq!(
4112 MarginAccountCancelOrderIsIsolatedEnum::from_flag(true).as_str(),
4113 "TRUE"
4114 );
4115 assert_eq!(
4116 MarginAccountCancelOrderIsIsolatedEnum::from_flag(false).as_str(),
4117 "FALSE"
4118 );
4119 assert_eq!(
4120 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(true).as_str(),
4121 "TRUE"
4122 );
4123 assert_eq!(
4124 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(false).as_str(),
4125 "FALSE"
4126 );
4127 assert_eq!(
4128 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(true).as_str(),
4129 "TRUE"
4130 );
4131 assert_eq!(
4132 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(false).as_str(),
4133 "FALSE"
4134 );
4135 }
4136
4137 #[test]
4142 fn cancel_params_cross_with_parseable_order_id() {
4143 let p = build_cancel_order_params(
4144 "BTCUSDT".to_string(),
4145 Some(&OrderId::new("12345")),
4146 &ClientOrderId::new("cid-1"),
4147 false,
4148 )
4149 .expect("build");
4150 assert_eq!(p.symbol, "BTCUSDT");
4151 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("FALSE"));
4152 assert_eq!(p.order_id, Some(12345));
4153 assert_eq!(p.orig_client_order_id, None);
4154 }
4155
4156 #[test]
4157 fn cancel_params_isolated_is_config_driven() {
4158 let p = build_cancel_order_params(
4159 "BTCUSDT".to_string(),
4160 Some(&OrderId::new("12345")),
4161 &ClientOrderId::new("cid-1"),
4162 true,
4163 )
4164 .expect("build");
4165 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("TRUE"));
4166 assert_eq!(p.order_id, Some(12345));
4167 }
4168
4169 #[test]
4170 fn cancel_params_non_i64_order_id_falls_back_to_cid() {
4171 let p = build_cancel_order_params(
4172 "BTCUSDT".to_string(),
4173 Some(&OrderId::new("not-an-i64")),
4174 &ClientOrderId::new("cid-1"),
4175 false,
4176 )
4177 .expect("build");
4178 assert_eq!(p.order_id, None);
4179 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
4180 }
4181
4182 #[test]
4183 fn cancel_params_absent_order_id_uses_cid() {
4184 let p = build_cancel_order_params(
4185 "BTCUSDT".to_string(),
4186 None,
4187 &ClientOrderId::new("cid-1"),
4188 false,
4189 )
4190 .expect("build");
4191 assert_eq!(p.order_id, None);
4192 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
4193 }
4194
4195 use crate::order::TrailingOffsetType;
4200
4201 fn params(
4203 side: Side,
4204 price: Option<Decimal>,
4205 kind: OrderKind,
4206 tif: TimeInForce,
4207 side_effect: MarginSideEffect,
4208 ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
4209 build_new_order_params(
4210 "BTCUSDT".to_string(),
4211 side,
4212 price,
4213 Decimal::from(2),
4214 kind,
4215 tif,
4216 "cid-1".to_string(),
4217 side_effect,
4218 false, )
4220 }
4221
4222 fn gtc() -> TimeInForce {
4223 TimeInForce::GoodUntilCancelled { post_only: false }
4224 }
4225
4226 #[test]
4227 fn new_order_limit_maps_core_fields() {
4228 let p = params(
4229 Side::Buy,
4230 Some(Decimal::from(50_000)),
4231 OrderKind::Limit,
4232 gtc(),
4233 MarginSideEffect::AutoBorrowRepay,
4234 )
4235 .expect("build");
4236
4237 assert_eq!(p.symbol, "BTCUSDT");
4238 assert_eq!(p.side.as_str(), "BUY");
4239 assert_eq!(p.r#type.as_str(), "LIMIT");
4240 assert_eq!(p.quantity, Some(Decimal::from(2)));
4241 assert_eq!(p.price, Some(Decimal::from(50_000)));
4242 assert_eq!(p.stop_price, None);
4243 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
4244 assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
4245 assert_eq!(
4247 p.new_order_resp_type.as_ref().map(|r| r.as_str()),
4248 Some("FULL")
4249 );
4250 }
4251
4252 #[test]
4253 fn new_order_is_isolated_is_config_driven() {
4254 let cross = build_new_order_params(
4256 "BTCUSDT".to_string(),
4257 Side::Sell,
4258 Some(Decimal::from(10)),
4259 Decimal::from(2),
4260 OrderKind::Limit,
4261 gtc(),
4262 "cid-1".to_string(),
4263 MarginSideEffect::AutoBorrowRepay,
4264 false,
4265 )
4266 .expect("build");
4267 assert_eq!(
4268 cross.is_isolated.as_ref().map(|i| i.as_str()),
4269 Some("FALSE")
4270 );
4271 assert_eq!(cross.side.as_str(), "SELL");
4272
4273 let isolated = build_new_order_params(
4275 "BTCUSDT".to_string(),
4276 Side::Sell,
4277 Some(Decimal::from(10)),
4278 Decimal::from(2),
4279 OrderKind::Limit,
4280 gtc(),
4281 "cid-1".to_string(),
4282 MarginSideEffect::AutoBorrowRepay,
4283 true,
4284 )
4285 .expect("build");
4286 assert_eq!(
4287 isolated.is_isolated.as_ref().map(|i| i.as_str()),
4288 Some("TRUE")
4289 );
4290 }
4291
4292 #[test]
4293 fn side_effect_and_auto_repay_gating() {
4294 let auto = params(
4296 Side::Buy,
4297 Some(Decimal::from(1)),
4298 OrderKind::Limit,
4299 gtc(),
4300 MarginSideEffect::AutoBorrowRepay,
4301 )
4302 .expect("build");
4303 assert_eq!(
4304 auto.side_effect_type.as_ref().map(|s| s.as_str()),
4305 Some("AUTO_BORROW_REPAY")
4306 );
4307 assert_eq!(auto.auto_repay_at_cancel, Some(true));
4308
4309 let no_borrow = params(
4311 Side::Buy,
4312 Some(Decimal::from(1)),
4313 OrderKind::Limit,
4314 gtc(),
4315 MarginSideEffect::NoBorrow,
4316 )
4317 .expect("build");
4318 assert_eq!(
4319 no_borrow.side_effect_type.as_ref().map(|s| s.as_str()),
4320 Some("NO_SIDE_EFFECT")
4321 );
4322 assert_eq!(no_borrow.auto_repay_at_cancel, None);
4323 }
4324
4325 #[test]
4326 fn new_order_market_has_no_price_or_tif() {
4327 let p = params(
4328 Side::Buy,
4329 None,
4330 OrderKind::Market,
4331 gtc(),
4332 MarginSideEffect::AutoBorrowRepay,
4333 )
4334 .expect("build");
4335 assert_eq!(p.r#type.as_str(), "MARKET");
4336 assert_eq!(p.price, None);
4337 assert_eq!(p.stop_price, None);
4338 assert!(p.time_in_force.is_none());
4339 }
4340
4341 #[test]
4342 fn post_only_limit_maps_to_limit_maker() {
4343 let p = params(
4344 Side::Buy,
4345 Some(Decimal::from(10)),
4346 OrderKind::Limit,
4347 TimeInForce::GoodUntilCancelled { post_only: true },
4348 MarginSideEffect::AutoBorrowRepay,
4349 )
4350 .expect("build");
4351 assert_eq!(p.r#type.as_str(), "LIMIT_MAKER");
4352 assert!(p.time_in_force.is_none());
4354 }
4355
4356 #[test]
4357 fn conditional_kinds_set_stop_price() {
4358 let trigger = Decimal::from(48_000);
4359
4360 let stop = params(
4361 Side::Sell,
4362 None,
4363 OrderKind::Stop {
4364 trigger_price: trigger,
4365 },
4366 gtc(),
4367 MarginSideEffect::AutoBorrowRepay,
4368 )
4369 .expect("build");
4370 assert_eq!(stop.r#type.as_str(), "STOP_LOSS");
4371 assert_eq!(stop.stop_price, Some(trigger));
4372 assert_eq!(stop.price, None);
4373
4374 let stop_limit = params(
4375 Side::Sell,
4376 Some(Decimal::from(47_900)),
4377 OrderKind::StopLimit {
4378 trigger_price: trigger,
4379 },
4380 gtc(),
4381 MarginSideEffect::AutoBorrowRepay,
4382 )
4383 .expect("build");
4384 assert_eq!(stop_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
4385 assert_eq!(stop_limit.stop_price, Some(trigger));
4386 assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
4387 assert_eq!(
4388 stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
4389 Some("GTC")
4390 );
4391
4392 let take_profit = params(
4393 Side::Sell,
4394 None,
4395 OrderKind::TakeProfit {
4396 trigger_price: trigger,
4397 },
4398 gtc(),
4399 MarginSideEffect::AutoBorrowRepay,
4400 )
4401 .expect("build");
4402 assert_eq!(take_profit.r#type.as_str(), "TAKE_PROFIT");
4403 assert_eq!(take_profit.stop_price, Some(trigger));
4404 }
4405
4406 #[test]
4409 fn a_trailing_stop_maps_to_stop_loss_with_trailing_delta() {
4410 let cases = [
4411 (Decimal::from(25), TrailingOffsetType::BasisPoints, 25),
4412 (Decimal::new(15, 1), TrailingOffsetType::Percentage, 150),
4413 ];
4414 for (offset, offset_type, trailing_delta) in cases {
4415 let p = params(
4416 Side::Sell,
4417 None,
4418 OrderKind::TrailingStop {
4419 offset,
4420 offset_type,
4421 },
4422 gtc(),
4423 MarginSideEffect::NoBorrow,
4424 )
4425 .expect("build");
4426 assert_eq!(p.r#type.as_str(), "STOP_LOSS");
4427 assert_eq!(p.trailing_delta, Some(trailing_delta), "{offset_type:?}");
4428 assert_eq!(p.stop_price, None);
4429 assert_eq!(p.price, None);
4430 assert!(p.time_in_force.is_none());
4431 }
4432 }
4433
4434 #[test]
4435 fn absolute_trailing_offsets_and_trailing_stop_limits_are_unsupported() {
4436 let absolute = params(
4437 Side::Sell,
4438 None,
4439 OrderKind::TrailingStop {
4440 offset: Decimal::from(100),
4441 offset_type: TrailingOffsetType::Absolute,
4442 },
4443 gtc(),
4444 MarginSideEffect::AutoBorrowRepay,
4445 );
4446 assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
4447
4448 let trailing_stop_limit = params(
4449 Side::Sell,
4450 Some(Decimal::from(100)),
4451 OrderKind::TrailingStopLimit {
4452 offset: Decimal::from(100),
4453 offset_type: TrailingOffsetType::BasisPoints,
4454 stop_price: Decimal::from(95),
4455 limit_offset: Decimal::from(10),
4456 },
4457 gtc(),
4458 MarginSideEffect::AutoBorrowRepay,
4459 );
4460 assert!(matches!(
4461 trailing_stop_limit,
4462 Err(BuildOrderError::Unsupported)
4463 ));
4464 }
4465
4466 #[test]
4468 fn a_fractional_trailing_offset_is_refused() {
4469 let fractional = params(
4470 Side::Sell,
4471 None,
4472 OrderKind::TrailingStop {
4473 offset: Decimal::new(155, 3),
4474 offset_type: TrailingOffsetType::Percentage,
4475 },
4476 gtc(),
4477 MarginSideEffect::AutoBorrowRepay,
4478 );
4479 assert!(matches!(
4480 fractional,
4481 Err(BuildOrderError::TrailingDelta(TrailingDeltaError::Invalid(
4482 _
4483 )))
4484 ));
4485 }
4486
4487 #[test]
4488 fn tif_variants_map_to_margin_enum() {
4489 for (tif, expected) in [
4490 (TimeInForce::ImmediateOrCancel, "IOC"),
4491 (TimeInForce::FillOrKill, "FOK"),
4492 (gtc(), "GTC"),
4493 ] {
4494 let p = params(
4495 Side::Buy,
4496 Some(Decimal::from(1)),
4497 OrderKind::Limit,
4498 tif,
4499 MarginSideEffect::AutoBorrowRepay,
4500 )
4501 .expect("build");
4502 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
4503 }
4504 }
4505
4506 fn user_asset(
4511 asset: &str,
4512 free: &str,
4513 locked: &str,
4514 borrowed: &str,
4515 interest: &str,
4516 ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
4517 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4518 a.asset = Some(asset.to_string());
4519 a.free = Some(free.to_string());
4520 a.locked = Some(locked.to_string());
4521 a.borrowed = Some(borrowed.to_string());
4522 a.interest = Some(interest.to_string());
4523 a
4524 }
4525
4526 #[test]
4527 fn margin_balance_entry_maps_debt() {
4528 let ab =
4530 convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
4531 .expect("convert");
4532 assert_eq!(ab.asset.name().as_str(), "USDT");
4533 assert_eq!(ab.balance.total, Decimal::from(12));
4534 assert_eq!(ab.balance.free, Decimal::from(10));
4535 assert!(
4536 ab.balance.margin.is_some(),
4537 "REST snapshot must populate margin"
4538 );
4539 assert_eq!(ab.balance.net_asset(), Decimal::from(9));
4541 }
4542
4543 #[test]
4544 fn margin_balance_short_is_negative_net() {
4545 let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
4547 .expect("convert");
4548 assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
4549 }
4550
4551 #[test]
4552 fn margin_balance_missing_debt_defaults_to_zero() {
4553 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4556 a.asset = Some("ETH".to_string());
4557 a.free = Some("4".to_string());
4558 a.locked = Some("0".to_string());
4559 let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
4560 assert!(ab.balance.margin.is_some());
4561 assert_eq!(ab.balance.net_asset(), Decimal::from(4));
4562 }
4563
4564 #[test]
4565 fn margin_balance_missing_free_is_dropped() {
4566 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4568 a.asset = Some("USDT".to_string());
4569 a.locked = Some("0".to_string());
4570 assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
4571 }
4572
4573 #[test]
4574 fn margin_balance_filtering() {
4575 let assets = vec![
4576 user_asset("USDT", "10", "0", "0", "0"),
4577 user_asset("BTC", "1", "0", "0", "0"),
4578 user_asset("ETH", "5", "0", "0", "0"),
4579 ];
4580 assert_eq!(
4582 filter_and_convert_margin_balances(assets.clone(), &[]).len(),
4583 3
4584 );
4585 let filtered = filter_and_convert_margin_balances(
4587 assets,
4588 &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
4589 );
4590 assert_eq!(filtered.len(), 2);
4591 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
4592 }
4593
4594 #[test]
4595 fn margin_balance_filtering_large_slice_uses_hashset_branch() {
4596 let assets = vec![
4598 user_asset("BTC", "1", "0", "0", "0"),
4599 user_asset("ETH", "5", "0", "0", "0"),
4600 user_asset("DOGE", "9", "0", "0", "0"), ];
4602 let mut requested: Vec<AssetNameExchange> = (0..17)
4604 .map(|i| AssetNameExchange::new(format!("A{i:02}")))
4605 .collect();
4606 requested.push(AssetNameExchange::new("BTC"));
4607 requested.push(AssetNameExchange::new("ETH"));
4608 assert!(
4609 requested.len() > 16,
4610 "must exceed the linear-scan threshold"
4611 );
4612
4613 let filtered = filter_and_convert_margin_balances(assets, &requested);
4614 assert_eq!(filtered.len(), 2);
4615 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
4616 }
4617
4618 use binance_sdk::margin_trading::rest_api::{
4623 QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
4624 QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
4625 };
4626
4627 fn d(s: &str) -> Decimal {
4628 Decimal::from_str(s).unwrap()
4629 }
4630
4631 fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
4632 let mut b = IsoBase::new();
4633 b.asset = Some(asset.to_string());
4634 b.free = Some(free.to_string());
4635 b.locked = Some(locked.to_string());
4636 b.borrowed = Some(borrowed.to_string());
4637 b.interest = Some(interest.to_string());
4638 b
4639 }
4640
4641 fn iso_quote(
4642 asset: &str,
4643 free: &str,
4644 locked: &str,
4645 borrowed: &str,
4646 interest: &str,
4647 ) -> IsoQuote {
4648 let mut q = IsoQuote::new();
4649 q.asset = Some(asset.to_string());
4650 q.free = Some(free.to_string());
4651 q.locked = Some(locked.to_string());
4652 q.borrowed = Some(borrowed.to_string());
4653 q.interest = Some(interest.to_string());
4654 q
4655 }
4656
4657 fn iso_entry(
4658 symbol: &str,
4659 base: IsoBase,
4660 quote: IsoQuote,
4661 margin_level: Option<&str>,
4662 margin_ratio: Option<&str>,
4663 liquidate_price: Option<&str>,
4664 ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
4665 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4666 e.symbol = Some(symbol.to_string());
4667 e.base_asset = Some(Box::new(base));
4668 e.quote_asset = Some(Box::new(quote));
4669 e.margin_level = margin_level.map(str::to_string);
4670 e.margin_ratio = margin_ratio.map(str::to_string);
4671 e.liquidate_price = liquidate_price.map(str::to_string);
4672 e
4673 }
4674
4675 fn isolated_client(symbols: &[&str]) -> BinanceMargin {
4676 let config = BinanceMarginConfig::isolated(
4677 "k".to_string(),
4678 "s".to_string(),
4679 symbols
4680 .iter()
4681 .map(|s| InstrumentNameExchange::new(*s))
4682 .collect(),
4683 );
4684 BinanceMargin::new(config)
4685 }
4686
4687 #[test]
4688 fn isolated_assets_map_base_quote_and_risk() {
4689 let entry = iso_entry(
4692 "BTCUSDT",
4693 iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
4694 iso_quote("USDT", "1000", "50", "300", "1.5"),
4695 Some("3.5"),
4696 Some("0.12"),
4697 Some("48000"),
4698 );
4699 let map = convert_isolated_margin_assets(vec![entry]);
4700 let state = map
4701 .get(&InstrumentNameExchange::new("BTCUSDT"))
4702 .expect("entry present");
4703
4704 assert_eq!(state.base.asset.name().as_str(), "BTC");
4705 assert_eq!(state.base.balance.total, d("0.6"));
4706 assert_eq!(state.base.balance.free, d("0.5"));
4707 assert_eq!(state.base.balance.net_asset(), d("0.4"));
4708
4709 assert_eq!(state.quote.asset.name().as_str(), "USDT");
4710 assert_eq!(state.quote.balance.total, d("1050"));
4711 assert_eq!(state.quote.balance.net_asset(), d("750"));
4712
4713 assert_eq!(state.risk.margin_level, Some(d("3.5")));
4714 assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
4715 assert_eq!(state.risk.liquidation_price, Some(d("48000")));
4716 }
4717
4718 #[test]
4719 fn isolated_base_short_is_negative_net() {
4720 let entry = iso_entry(
4722 "BTCUSDT",
4723 iso_base("BTC", "0", "0", "1.5", "0.001"),
4724 iso_quote("USDT", "100", "0", "0", "0"),
4725 None,
4726 None,
4727 None,
4728 );
4729 let map = convert_isolated_margin_assets(vec![entry]);
4730 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4731 assert_eq!(state.base.balance.net_asset(), d("-1.5"));
4732 }
4733
4734 #[test]
4735 fn isolated_missing_debt_defaults_zero_and_risk_optional() {
4736 let mut base = IsoBase::new();
4739 base.asset = Some("BTC".to_string());
4740 base.free = Some("0.5".to_string());
4741 base.locked = Some("0".to_string());
4742 let mut quote = IsoQuote::new();
4743 quote.asset = Some("USDT".to_string());
4744 quote.free = Some("100".to_string());
4745 quote.locked = Some("0".to_string());
4746
4747 let map = convert_isolated_margin_assets(vec![iso_entry(
4748 "BTCUSDT", base, quote, None, None, None,
4749 )]);
4750 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4751 assert!(state.base.balance.margin.is_some());
4752 assert_eq!(state.base.balance.net_asset(), d("0.5"));
4753 assert_eq!(state.risk.margin_level, None);
4754 assert_eq!(state.risk.liquidation_price, None);
4755 }
4756
4757 #[test]
4758 fn isolated_unparseable_risk_is_none_but_entry_kept() {
4759 let entry = iso_entry(
4760 "BTCUSDT",
4761 iso_base("BTC", "1", "0", "0", "0"),
4762 iso_quote("USDT", "1", "0", "0", "0"),
4763 Some("not_a_number"),
4764 None,
4765 None,
4766 );
4767 let map = convert_isolated_margin_assets(vec![entry]);
4768 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4769 assert_eq!(state.risk.margin_level, None);
4770 }
4771
4772 #[test]
4773 fn isolated_missing_base_free_drops_entry() {
4774 let mut base = IsoBase::new();
4776 base.asset = Some("BTC".to_string());
4777 base.locked = Some("0".to_string()); let entry = iso_entry(
4779 "BTCUSDT",
4780 base,
4781 iso_quote("USDT", "100", "0", "0", "0"),
4782 None,
4783 None,
4784 None,
4785 );
4786 assert!(convert_isolated_margin_assets(vec![entry]).is_empty());
4787 }
4788
4789 #[test]
4790 fn isolated_missing_symbol_drops_entry() {
4791 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4792 e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
4793 e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
4794 assert!(convert_isolated_margin_assets(vec![e]).is_empty());
4796 }
4797
4798 #[test]
4799 fn chunk_symbols_batches_by_five() {
4800 let syms: Vec<InstrumentNameExchange> = (0..12)
4801 .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
4802 .collect();
4803 let chunks = chunk_symbols(&syms);
4804 assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
4805 assert_eq!(chunks[0].split(',').count(), 5);
4806 assert_eq!(chunks[1].split(',').count(), 5);
4807 assert_eq!(chunks[2].split(',').count(), 2);
4808
4809 let three: Vec<_> = (0..3)
4811 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4812 .collect();
4813 assert_eq!(chunk_symbols(&three).len(), 1);
4814
4815 let five: Vec<_> = (0..5)
4817 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4818 .collect();
4819 let c = chunk_symbols(&five);
4820 assert_eq!(c.len(), 1);
4821 assert_eq!(c[0].split(',').count(), 5);
4822
4823 assert!(chunk_symbols(&[]).is_empty());
4825 }
4826
4827 #[test]
4828 fn effective_isolated_set_empty_returns_all_configured() {
4829 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4830 assert_eq!(
4831 client.effective_isolated_set(&[]),
4832 vec![
4833 InstrumentNameExchange::new("BTCUSDT"),
4834 InstrumentNameExchange::new("ETHUSDT"),
4835 ]
4836 );
4837 }
4838
4839 #[test]
4840 fn effective_isolated_set_intersects_requested() {
4841 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4842 assert_eq!(
4843 client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
4844 vec![InstrumentNameExchange::new("ETHUSDT")]
4845 );
4846 }
4847
4848 #[test]
4849 fn effective_isolated_set_skips_out_of_set() {
4850 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4851 assert_eq!(
4853 client.effective_isolated_set(&[
4854 InstrumentNameExchange::new("BTCUSDT"),
4855 InstrumentNameExchange::new("DOGEUSDT"),
4856 ]),
4857 vec![InstrumentNameExchange::new("BTCUSDT")]
4858 );
4859 }
4860
4861 #[tokio::test]
4862 async fn fetch_balances_isolated_returns_empty() {
4863 let client = isolated_client(&["BTCUSDT"]);
4866 assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
4867 }
4868
4869 fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
4870 let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
4871 o.order_id = order_id;
4872 o.client_order_id = Some("cid-9".to_string());
4873 o.side = Some(side.to_string());
4874 o.price = Some("50000".to_string());
4875 o.orig_qty = Some("2".to_string());
4876 o.executed_qty = Some("0.5".to_string());
4877 o.r#type = Some("LIMIT".to_string());
4878 o.time_in_force = Some("GTC".to_string());
4879 o.time = Some(1_700_000_000_000);
4880 o.status = Some("PARTIALLY_FILLED".to_string());
4881 o
4882 }
4883
4884 #[test]
4885 fn margin_open_order_converts() {
4886 let inst = InstrumentNameExchange::new("BTCUSDT");
4887 let order = convert_open_order(
4888 &open_order(Some(42), "BUY"),
4889 ExchangeId::BinanceMargin,
4890 &inst,
4891 )
4892 .expect("convert");
4893 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4894 assert_eq!(
4895 order.state.id.assigned().map(|id| id.0.as_str()),
4896 Some("42")
4897 );
4898 assert_eq!(order.key.cid.0.as_str(), "cid-9");
4899 assert_eq!(order.side, Side::Buy);
4900 assert_eq!(order.price, Some(Decimal::from(50_000)));
4901 assert_eq!(order.quantity, Decimal::from(2));
4902 assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
4903 assert_eq!(order.kind, OrderKind::Limit);
4904 }
4905
4906 #[test]
4909 fn margin_open_order_that_is_not_live_is_dropped() {
4910 let inst = InstrumentNameExchange::new("BTCUSDT");
4911 let mut o = open_order(Some(42), "BUY");
4912 o.status = Some("CANCELED".to_string());
4913 assert!(convert_open_order(&o, ExchangeId::BinanceMargin, &inst).is_none());
4914 }
4915
4916 #[test]
4917 fn margin_open_order_missing_order_id_is_dropped() {
4918 let inst = InstrumentNameExchange::new("BTCUSDT");
4919 assert!(
4920 convert_open_order(&open_order(None, "BUY"), ExchangeId::BinanceMargin, &inst)
4921 .is_none()
4922 );
4923 }
4924
4925 #[test]
4931 fn margin_open_order_prefers_update_time_over_creation_time() {
4932 let inst = InstrumentNameExchange::new("BTCUSDT");
4933
4934 let mut updated = open_order(Some(42), "BUY");
4935 updated.update_time = Some(1_700_000_005_000);
4936 let order =
4937 convert_open_order(&updated, ExchangeId::BinanceMargin, &inst).expect("convert");
4938 assert_eq!(
4939 order.state.time_exchange,
4940 Utc.timestamp_millis_opt(1_700_000_005_000)
4941 .single()
4942 .unwrap(),
4943 "update_time must win over time"
4944 );
4945 }
4946
4947 #[test]
4950 fn margin_open_order_falls_back_to_creation_time_when_update_time_absent() {
4951 let inst = InstrumentNameExchange::new("BTCUSDT");
4952
4953 let base = open_order(Some(42), "BUY");
4954 assert!(base.update_time.is_none(), "fixture must omit update_time");
4955 let order = convert_open_order(&base, ExchangeId::BinanceMargin, &inst).expect("convert");
4956 assert_eq!(
4957 order.state.time_exchange,
4958 Utc.timestamp_millis_opt(1_700_000_000_000)
4959 .single()
4960 .unwrap(),
4961 "absent update_time falls back to time"
4962 );
4963 }
4964
4965 #[test]
4966 fn margin_open_order_owned_symbol_recovers_instrument() {
4967 let mut o = open_order(Some(42), "SELL");
4969 o.symbol = Some("ETHUSDT".to_string());
4970 let order =
4971 convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).expect("convert");
4972 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4973 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4974 assert_eq!(order.side, Side::Sell);
4975 }
4976
4977 #[test]
4978 fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
4979 let o = open_order(Some(42), "BUY");
4981 assert!(o.symbol.is_none());
4982 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).is_none());
4983 }
4984
4985 fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
4986 let mut t = QueryMarginAccountsTradeListResponseInner::new();
4987 t.id = id;
4988 t.order_id = Some(7);
4989 t.is_buyer = is_buyer;
4990 t.price = Some("48000".to_string());
4991 t.qty = Some("0.25".to_string());
4992 t.commission = Some("0.001".to_string());
4993 t.commission_asset = Some("BNB".to_string());
4994 t.time = Some(1_700_000_000_000);
4995 t
4996 }
4997
4998 #[test]
4999 fn margin_trade_converts() {
5000 let inst = InstrumentNameExchange::new("BTCUSDT");
5001 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
5002 assert_eq!(tr.id.0.as_str(), "11");
5003 assert_eq!(tr.order_id.0.as_str(), "7");
5004 assert_eq!(tr.side, Side::Sell);
5005 assert_eq!(tr.price, Decimal::from(48_000));
5006 assert_eq!(tr.quantity, Decimal::new(25, 2));
5007 assert_eq!(tr.fees.asset.name().as_str(), "BNB");
5009 assert_eq!(tr.fees.fees, Decimal::new(1, 3));
5010 }
5011
5012 #[test]
5013 fn margin_trade_missing_fields_are_dropped() {
5014 let inst = InstrumentNameExchange::new("BTCUSDT");
5015 assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
5017 assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
5018 }
5019
5020 #[test]
5023 fn token_renew_after_applies_margin_and_floor() {
5024 let now_ms = Utc::now().timestamp_millis();
5025 let far = token_renew_after(now_ms + 86_400_000).as_secs();
5027 assert!(
5028 far > 80_000 && far <= 86_400,
5029 "expected ~24h-minus-margin, got {far}"
5030 );
5031 assert_eq!(
5033 token_renew_after(now_ms - 10_000).as_secs(),
5034 TOKEN_MIN_LIFETIME_SECS
5035 );
5036 assert_eq!(
5038 token_renew_after(now_ms + 60_000).as_secs(),
5039 TOKEN_MIN_LIFETIME_SECS
5040 );
5041 }
5042
5043 fn push(event: serde_json::Value) -> String {
5048 serde_json::json!({ "subscriptionId": 1, "event": event }).to_string()
5049 }
5050
5051 #[test]
5052 fn margin_ws_rpc_ack_yields_no_events() {
5053 let frame = serde_json::json!({ "id": "abc", "status": 200, "result": {} }).to_string();
5055 let mut buf = Vec::new();
5056 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5057 assert!(buf.is_empty());
5058 }
5059
5060 #[test]
5063 fn margin_ws_unrecognised_frames_yield_no_events() {
5064 for frame in [
5065 r#"{"e":"eventStreamTerminated","E":1}"#,
5066 r#"{"subscriptionId":1,"event":{"E":1}}"#,
5067 ] {
5068 let mut buf = Vec::new();
5069 assert!(!convert_margin_user_data_events(frame, &mut buf), "{frame}");
5070 assert!(buf.is_empty(), "{frame}");
5071 }
5072 }
5073
5074 #[test]
5077 fn margin_ws_trailing_stop_report_decodes_as_a_trailing_stop() {
5078 for trailing_delta in [serde_json::json!(25), serde_json::json!("25")] {
5079 let frame = push(serde_json::json!({
5080 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "STOP_LOSS",
5081 "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "cid-1", "t": -1_i64,
5082 "p": "0.00000000", "P": "0.00000000", "d": trailing_delta, "D": 1_700_000_000_000_i64,
5083 "q": "2", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
5084 }));
5085 let mut buf = Vec::new();
5086 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5087 let [event] = buf.as_slice() else {
5088 panic!("one snapshot expected for {frame}, got {buf:?}");
5089 };
5090 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
5091 panic!("expected an order snapshot, got {event:?}");
5092 };
5093 assert_eq!(
5094 snapshot.0.kind,
5095 OrderKind::TrailingStop {
5096 offset: Decimal::from(25),
5097 offset_type: TrailingOffsetType::BasisPoints,
5098 }
5099 );
5100 assert_eq!(snapshot.0.price, None);
5101 }
5102 }
5103
5104 #[test]
5112 fn margin_ws_execution_report_trade_maps_to_trade_and_order_snapshot() {
5113 let frame = push(serde_json::json!({
5114 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5115 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "cid-1",
5116 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
5117 "p": "48000", "q": "2", "f": "GTC",
5118 "n": "0.001", "N": "BNB", "T": 1_700_000_000_000_i64,
5119 }));
5120 let mut buf = Vec::new();
5121 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5122 assert_eq!(
5123 buf.len(),
5124 2,
5125 "a fill must produce the execution and the order snapshot, got: {buf:?}"
5126 );
5127 assert_eq!(buf[0].exchange, ExchangeId::BinanceMargin);
5128 match &buf[0].kind {
5129 AccountEventKind::Trade(t) => {
5130 assert_eq!(t.instrument.name().as_str(), "BTCUSDT");
5131 assert_eq!(t.side, Side::Buy);
5132 assert_eq!(t.price, Decimal::from(48_000));
5133 assert_eq!(t.quantity, Decimal::new(5, 1));
5134 assert_eq!(t.fees.asset.name().as_str(), "BNB"); assert_eq!(t.fees.fees, Decimal::new(1, 3));
5136 }
5137 other => panic!("expected Trade, got {other:?}"),
5138 }
5139 assert_eq!(buf[1].exchange, ExchangeId::BinanceMargin);
5140 match &buf[1].kind {
5141 AccountEventKind::OrderSnapshot(snap) => {
5142 assert_eq!(
5143 snap.0.quantity,
5144 Decimal::from(2),
5145 "the order's total quantity"
5146 );
5147 match &snap.0.state {
5148 OrderState::Active(ActiveOrderState::Open(open)) => {
5149 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("12345"));
5150 assert_eq!(
5151 open.filled_quantity,
5152 Decimal::new(5, 1),
5153 "the snapshot must carry the order's cumulative filled quantity (z)"
5154 );
5155 assert_eq!(
5156 open.time_exchange.timestamp_millis(),
5157 1_700_000_000_000,
5158 "stamped with the execution's transaction time (T), which is what \
5159 gives the engine's staleness gate an ordering key"
5160 );
5161 }
5162 other => panic!("expected an Open order, got {other:?}"),
5163 }
5164 }
5165 other => panic!("expected OrderSnapshot, got {other:?}"),
5166 }
5167 }
5168
5169 #[test]
5173 fn margin_trade_for_an_order_no_longer_working_emits_no_snapshot() {
5174 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
5175 let frame = push(serde_json::json!({
5176 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5177 "x": "TRADE", "X": status, "i": 12_345_i64, "c": "cid-1",
5178 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
5179 "p": "48000", "q": "2", "f": "GTC", "T": 1_700_000_000_000_i64,
5180 }));
5181 let mut buf = Vec::new();
5182 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5183 assert_eq!(
5184 buf.len(),
5185 1,
5186 "status {status} must produce the execution alone, got: {buf:?}"
5187 );
5188 assert!(
5189 matches!(buf[0].kind, AccountEventKind::Trade(_)),
5190 "the execution must still be reported for status {status}"
5191 );
5192 }
5193 }
5194
5195 #[test]
5196 fn margin_ws_new_report_maps_to_active_order_snapshot() {
5197 let frame = push(serde_json::json!({
5198 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "LIMIT",
5199 "x": "NEW", "X": "NEW", "i": 7_i64, "c": "cid-2",
5200 "p": "48000", "q": "1", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
5201 }));
5202 let mut buf = Vec::new();
5203 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5204 assert_eq!(buf.len(), 1);
5205 match &buf[0].kind {
5206 AccountEventKind::OrderSnapshot(snap) => {
5207 assert_eq!(snap.0.side, Side::Sell);
5208 assert_eq!(snap.0.price, Some(Decimal::from(48_000)));
5209 assert_eq!(snap.0.quantity, Decimal::from(1));
5210 assert!(
5211 matches!(snap.0.state, OrderState::Active(_)),
5212 "NEW should be an active (resting) order"
5213 );
5214 }
5215 other => panic!("expected OrderSnapshot, got {other:?}"),
5216 }
5217 }
5218
5219 #[test]
5220 fn margin_ws_canceled_report_maps_to_order_cancelled() {
5221 let frame = push(serde_json::json!({
5222 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5223 "x": "CANCELED", "X": "CANCELED", "i": 8_i64, "c": "cid-3",
5224 "z": "0", "T": 1_700_000_000_000_i64,
5225 }));
5226 let mut buf = Vec::new();
5227 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5228 assert_eq!(buf.len(), 1);
5229 match &buf[0].kind {
5230 AccountEventKind::OrderCancelled(resp) => assert!(resp.state.is_ok()),
5231 other => panic!("expected OrderCancelled, got {other:?}"),
5232 }
5233 }
5234
5235 #[test]
5236 fn margin_ws_execution_report_missing_symbol_is_dropped() {
5237 let frame = push(serde_json::json!({
5239 "e": "executionReport", "S": "BUY", "x": "TRADE", "i": 1_i64, "t": 1_i64,
5240 "l": "1", "L": "100", "T": 1_700_000_000_000_i64,
5241 }));
5242 let mut buf = Vec::new();
5243 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5244 assert!(buf.is_empty());
5245 }
5246
5247 #[test]
5248 fn margin_ws_outbound_account_position_maps_to_balance_stream_updates() {
5249 let frame = push(serde_json::json!({
5250 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5251 "B": [
5252 { "a": "USDT", "f": "100.0", "l": "5.0" },
5253 { "a": "BTC", "f": "0.5", "l": "0" },
5254 ],
5255 }));
5256 let mut buf = Vec::new();
5257 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5258 assert_eq!(buf.len(), 2);
5259 for ev in &buf {
5261 assert!(matches!(ev.kind, AccountEventKind::BalanceStreamUpdate(_)));
5262 }
5263 }
5264
5265 #[test]
5266 fn margin_ws_stream_terminated_signals_reconnect() {
5267 let frame = push(serde_json::json!({ "e": "eventStreamTerminated" }));
5268 let mut buf = Vec::new();
5269 assert!(
5270 convert_margin_user_data_events(&frame, &mut buf),
5271 "eventStreamTerminated must signal reconnect"
5272 );
5273 assert!(buf.is_empty());
5274 }
5275
5276 #[test]
5277 fn margin_ws_margin_specific_events_are_observable_only() {
5278 let mut buf = Vec::new();
5281 let liability = push(serde_json::json!({
5282 "e": "userLiabilityChange", "a": "USDT", "t": "BORROW", "p": "100", "i": "0.01",
5283 }));
5284 assert!(!convert_margin_user_data_events(&liability, &mut buf));
5285 let level = push(serde_json::json!({
5286 "e": "marginLevelStatusChange", "l": "1.5", "s": "MARGIN_LEVEL_2",
5287 }));
5288 assert!(!convert_margin_user_data_events(&level, &mut buf));
5289 assert!(buf.is_empty());
5290 }
5291
5292 #[test]
5295 fn margin_trade_event_dedup_by_key() {
5296 let frame = push(serde_json::json!({
5299 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5300 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 4_242_i64,
5301 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
5302 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
5303 }));
5304 let mut buf = Vec::new();
5305 convert_margin_user_data_events(&frame, &mut buf);
5306 let event = buf.first().expect("a Trade event");
5309 assert!(
5310 matches!(event.kind, AccountEventKind::Trade(_)),
5311 "the execution is the first of the two events a fill produces"
5312 );
5313
5314 let cache = new_dedup_cache();
5315 let first = dedup_key_from_event(event).expect("Trade events have a dedup key");
5317 assert!(!is_duplicate(&cache, first), "first sighting is fresh");
5318 let second = dedup_key_from_event(event).expect("Trade events have a dedup key");
5319 assert!(
5320 is_duplicate(&cache, second),
5321 "second sighting is a duplicate"
5322 );
5323 }
5324
5325 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
5333 let cache = new_dedup_cache();
5334 let mut delivered = Vec::new();
5335 let mut buf = Vec::new();
5336 for frame in frames {
5337 let _ = convert_margin_user_data_events(frame, &mut buf);
5338 for ev in buf.drain(..) {
5339 if let Some(key) = dedup_key_from_event(&ev)
5340 && is_duplicate(&cache, key)
5341 {
5342 continue;
5343 }
5344 delivered.push(ev);
5345 }
5346 }
5347 delivered
5348 }
5349
5350 fn ws_new(cumulative_filled: &str) -> String {
5351 push(serde_json::json!({
5352 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5353 "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "client-1",
5354 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
5355 "T": 1_700_000_000_000_i64,
5356 }))
5357 }
5358
5359 fn ws_partial_fill(cumulative_filled: &str) -> String {
5360 push(serde_json::json!({
5361 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5362 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "client-1",
5363 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
5364 "l": "1", "L": "100", "t": 555_i64, "n": "0.1", "N": "USDT",
5365 "T": 1_700_000_001_000_i64,
5366 }))
5367 }
5368
5369 fn open_filled_quantities(events: &[UnindexedAccountEvent]) -> Vec<Decimal> {
5370 events
5371 .iter()
5372 .filter_map(|ev| match &ev.kind {
5373 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
5374 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
5375 _ => None,
5376 },
5377 _ => None,
5378 })
5379 .collect()
5380 }
5381
5382 #[test]
5390 fn margin_ws_fill_snapshot_survives_the_dedup_gate() {
5391 let delivered = drive_ws_pipeline(&[&ws_new("0"), &ws_partial_fill("1")]);
5392
5393 assert_eq!(
5394 delivered.len(),
5395 3,
5396 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
5397 );
5398 assert_eq!(
5399 open_filled_quantities(&delivered),
5400 vec![Decimal::ZERO, Decimal::ONE],
5401 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
5402 );
5403 }
5404
5405 #[test]
5408 fn margin_ws_replayed_frame_is_still_deduplicated() {
5409 let delivered = drive_ws_pipeline(&[
5410 &ws_new("0"),
5411 &ws_partial_fill("1"),
5412 &ws_new("0"),
5413 &ws_partial_fill("1"),
5414 ]);
5415
5416 assert_eq!(
5417 delivered.len(),
5418 3,
5419 "re-delivering both frames must add nothing, got: {delivered:?}"
5420 );
5421 }
5422
5423 #[test]
5429 fn margin_ws_an_ack_and_its_fill_reporting_one_state_deliver_that_state_once() {
5430 let delivered = drive_ws_pipeline(&[&ws_new("1"), &ws_partial_fill("1")]);
5431
5432 assert_eq!(
5433 delivered.len(),
5434 2,
5435 "the ack snapshot and the execution, with the fill's duplicate snapshot collapsed, \
5436 got: {delivered:?}"
5437 );
5438 assert!(
5439 delivered
5440 .iter()
5441 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
5442 "the execution must reach the consumer regardless"
5443 );
5444 assert_eq!(
5445 open_filled_quantities(&delivered),
5446 vec![Decimal::ONE],
5447 "one snapshot, carrying the state both reports agree on"
5448 );
5449 }
5450
5451 fn push_with_sub(subscription_id: i64, event: serde_json::Value) -> String {
5456 serde_json::json!({ "subscriptionId": subscription_id, "event": event }).to_string()
5457 }
5458
5459 fn isolated_handler(
5462 sub_map: Arc<Mutex<HashMap<i64, InstrumentNameExchange>>>,
5463 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
5464 ) -> impl FnMut(OutboundAccountPosition, Option<i64>, &mut Vec<UnindexedAccountEvent>) {
5465 move |position, subscription_id, buf| {
5466 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
5467 }
5468 }
5469
5470 fn btcusdt() -> InstrumentNameExchange {
5471 InstrumentNameExchange::new("BTCUSDT")
5472 }
5473
5474 fn token(expiration_time_ms: i64) -> UserListenToken {
5475 UserListenToken {
5476 token: "t".to_string(),
5477 expiration_time_ms,
5478 }
5479 }
5480
5481 #[test]
5482 fn listen_token_query_cross_vs_isolated() {
5483 assert!(build_listen_token_query(None).is_empty());
5485
5486 let q = build_listen_token_query(Some(&btcusdt()));
5488 assert_eq!(q.get("isIsolated").and_then(|v| v.as_str()), Some("TRUE"));
5489 assert_eq!(q.get("symbol").and_then(|v| v.as_str()), Some("BTCUSDT"));
5490 assert_eq!(q.len(), 2);
5491 }
5492
5493 #[test]
5494 fn earliest_token_expiry_picks_min() {
5495 let tokens = vec![
5496 (btcusdt(), token(3_000)),
5497 (InstrumentNameExchange::new("ETHUSDT"), token(1_000)),
5498 (InstrumentNameExchange::new("BNBUSDT"), token(2_000)),
5499 ];
5500 assert_eq!(earliest_token_expiry_ms(&tokens), 1_000);
5501 assert_eq!(earliest_token_expiry_ms(&[]), i64::MAX);
5503 }
5504
5505 #[test]
5506 fn base_quote_map_extracts_base_and_quote() {
5507 let entries = vec![
5508 iso_entry(
5509 "BTCUSDT",
5510 iso_base("BTC", "0", "0", "0", "0"),
5511 iso_quote("USDT", "0", "0", "0", "0"),
5512 None,
5513 None,
5514 None,
5515 ),
5516 iso_entry(
5517 "ETHBTC",
5518 iso_base("ETH", "0", "0", "0", "0"),
5519 iso_quote("BTC", "0", "0", "0", "0"),
5520 None,
5521 None,
5522 None,
5523 ),
5524 ];
5525 let map = build_base_quote_map(&entries);
5526 assert_eq!(
5527 map.get(&btcusdt())
5528 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5529 Some(("BTC", "USDT"))
5530 );
5531 assert_eq!(
5534 map.get(&InstrumentNameExchange::new("ETHBTC"))
5535 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5536 Some(("ETH", "BTC"))
5537 );
5538 }
5539
5540 #[test]
5541 fn isolated_outbound_position_routes_to_instrument_balance_update() {
5542 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5543 let base_quote = Arc::new(HashMap::from([(
5544 btcusdt(),
5545 (
5546 AssetNameExchange::new("BTC"),
5547 AssetNameExchange::new("USDT"),
5548 ),
5549 )]));
5550 let mut handler = isolated_handler(sub_map, base_quote);
5551
5552 let frame = push_with_sub(
5554 7,
5555 serde_json::json!({
5556 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5557 "B": [
5558 { "a": "USDT", "f": "1000.0", "l": "50.0" },
5559 { "a": "BTC", "f": "0.5", "l": "0.1" },
5560 ],
5561 }),
5562 );
5563 let mut buf = Vec::new();
5564 assert!(!convert_margin_user_data_events_with(
5565 &frame,
5566 &mut buf,
5567 &mut handler
5568 ));
5569 assert_eq!(buf.len(), 1, "one InstrumentBalanceUpdate for the pair");
5570 match &buf[0].kind {
5571 AccountEventKind::InstrumentBalanceUpdate(ibu) => {
5572 assert_eq!(ibu.instrument.name().as_str(), "BTCUSDT");
5573 assert_eq!(ibu.base.asset.name().as_str(), "BTC");
5574 assert_eq!(ibu.base.update.free, Decimal::new(5, 1));
5575 assert_eq!(ibu.base.update.locked, Decimal::new(1, 1));
5576 assert_eq!(ibu.quote.asset.name().as_str(), "USDT");
5577 assert_eq!(ibu.quote.update.free, Decimal::from(1000));
5578 assert_eq!(ibu.quote.update.locked, Decimal::from(50));
5579 }
5580 other => panic!("expected InstrumentBalanceUpdate, got {other:?}"),
5581 }
5582 assert!(!matches!(
5584 buf[0].kind,
5585 AccountEventKind::BalanceStreamUpdate(_)
5586 ));
5587 }
5588
5589 #[test]
5590 fn isolated_outbound_position_unknown_subscription_dropped() {
5591 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5593 let base_quote = Arc::new(HashMap::from([(
5594 btcusdt(),
5595 (
5596 AssetNameExchange::new("BTC"),
5597 AssetNameExchange::new("USDT"),
5598 ),
5599 )]));
5600 let mut handler = isolated_handler(sub_map, base_quote);
5601
5602 let frame = push_with_sub(
5603 99,
5604 serde_json::json!({
5605 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5606 "B": [{ "a": "BTC", "f": "1", "l": "0" }, { "a": "USDT", "f": "1", "l": "0" }],
5607 }),
5608 );
5609 let mut buf = Vec::new();
5610 assert!(!convert_margin_user_data_events_with(
5611 &frame,
5612 &mut buf,
5613 &mut handler
5614 ));
5615 assert!(buf.is_empty(), "unmapped subscriptionId frame is dropped");
5616 }
5617
5618 #[test]
5619 fn isolated_outbound_position_missing_side_dropped() {
5620 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5623 let base_quote = Arc::new(HashMap::from([(
5624 btcusdt(),
5625 (
5626 AssetNameExchange::new("BTC"),
5627 AssetNameExchange::new("USDT"),
5628 ),
5629 )]));
5630 let mut handler = isolated_handler(sub_map, base_quote);
5631
5632 let frame = push_with_sub(
5633 7,
5634 serde_json::json!({
5635 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5636 "B": [{ "a": "BTC", "f": "0.5", "l": "0" }],
5637 }),
5638 );
5639 let mut buf = Vec::new();
5640 assert!(!convert_margin_user_data_events_with(
5641 &frame,
5642 &mut buf,
5643 &mut handler
5644 ));
5645 assert!(buf.is_empty(), "missing quote side → frame dropped");
5646 }
5647
5648 #[test]
5649 fn isolated_execution_report_routes_by_inner_symbol_independent_of_map() {
5650 let sub_map = Arc::new(Mutex::new(HashMap::<i64, InstrumentNameExchange>::new()));
5654 let base_quote = Arc::new(HashMap::new());
5655 let mut handler = isolated_handler(sub_map, base_quote);
5656
5657 let frame = push_with_sub(
5658 42,
5659 serde_json::json!({
5660 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5661 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 5_i64,
5662 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
5663 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
5664 }),
5665 );
5666 let mut buf = Vec::new();
5667 assert!(!convert_margin_user_data_events_with(
5668 &frame,
5669 &mut buf,
5670 &mut handler
5671 ));
5672 assert_eq!(buf.len(), 2, "the execution and its order snapshot");
5673 match &buf[0].kind {
5674 AccountEventKind::Trade(t) => assert_eq!(t.instrument.name().as_str(), "BTCUSDT"),
5675 other => panic!("expected Trade, got {other:?}"),
5676 }
5677 match &buf[1].kind {
5678 AccountEventKind::OrderSnapshot(snap) => {
5679 assert_eq!(snap.0.key.instrument.name().as_str(), "BTCUSDT");
5680 }
5681 other => panic!("expected OrderSnapshot, got {other:?}"),
5682 }
5683 }
5684
5685 struct MarginMyTradesVenue {
5696 trades: Vec<(i64, i64, i64)>,
5698 end_exclusive: bool,
5699 }
5700
5701 impl wiremock::Respond for MarginMyTradesVenue {
5702 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
5703 const DAY_MS: i64 = 24 * 60 * 60 * 1000;
5704 let query: std::collections::HashMap<String, String> =
5705 request.url.query_pairs().into_owned().collect();
5706 let param = |name: &str| query.get(name).map(|v| v.parse::<i64>().unwrap());
5707 let limit = usize::try_from(param("limit").unwrap_or(500)).unwrap();
5708 let order = param("orderId");
5709
5710 let mut selected: Vec<&(i64, i64, i64)> = self
5711 .trades
5712 .iter()
5713 .filter(|(_, _, order_id)| order.is_none_or(|order| *order_id == order))
5714 .collect();
5715 if let Some(from_id) = param("fromId") {
5716 selected.retain(|(id, _, _)| *id >= from_id);
5717 } else if order.is_none() {
5718 let start = param("startTime").expect("a query by time sends startTime");
5719 let end = match param("endTime") {
5720 Some(end) if end - start >= DAY_MS => {
5721 return wiremock::ResponseTemplate::new(400).set_body_json(
5722 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
5723 );
5724 }
5725 Some(end) if self.end_exclusive => end - 1,
5726 Some(end) => end,
5727 None => start + DAY_MS - 1,
5728 };
5729 selected.retain(|(_, time, _)| (start..=end).contains(time));
5730 }
5731
5732 let page: Vec<serde_json::Value> = selected
5733 .into_iter()
5734 .take(limit)
5735 .map(|(id, time, order_id)| {
5736 serde_json::json!({
5737 "id": id, "symbol": "BTCUSDT", "time": time, "orderId": order_id,
5738 "price": "50000", "qty": "0.01", "commission": "0",
5739 "commissionAsset": "USDT", "isBuyer": true, "isMaker": false,
5740 "isBestMatch": true, "isIsolated": true,
5741 })
5742 })
5743 .collect();
5744 wiremock::ResponseTemplate::new(200).set_body_json(page)
5745 }
5746 }
5747
5748 fn since(start: i64) -> MyTradesFrom {
5750 MyTradesFrom::Span {
5751 start,
5752 end: i64::MAX,
5753 }
5754 }
5755
5756 async fn read_margin_trades(
5759 venue: MarginMyTradesVenue,
5760 from: MyTradesFrom,
5761 ) -> (Vec<i64>, Vec<std::collections::HashMap<String, String>>) {
5762 let server = wiremock::MockServer::start().await;
5763 wiremock::Mock::given(wiremock::matchers::method("GET"))
5764 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5765 .respond_with(venue)
5766 .mount(&server)
5767 .await;
5768 let rest = Arc::new(MarginTradingRestApi::from_config(
5769 ConfigurationRestApi::builder()
5770 .api_key("key")
5771 .api_secret("secret")
5772 .base_path(server.uri())
5773 .build()
5774 .unwrap(),
5775 ));
5776
5777 let read = paginate_margin_my_trades(
5778 &rest,
5779 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5780 &InstrumentNameExchange::new("BTCUSDT"),
5781 from,
5782 true,
5783 RequestKind::Query,
5784 )
5785 .await
5786 .unwrap();
5787
5788 let requests = server
5789 .received_requests()
5790 .await
5791 .unwrap()
5792 .iter()
5793 .map(|request| request.url.query_pairs().into_owned().collect())
5794 .collect();
5795 (read.into_iter().map(|t| t.id.unwrap()).collect(), requests)
5796 }
5797
5798 fn venue(trades: Vec<(i64, i64)>) -> MarginMyTradesVenue {
5800 MarginMyTradesVenue {
5801 trades: trades
5802 .into_iter()
5803 .map(|(id, time)| (id, time, id))
5804 .collect(),
5805 end_exclusive: false,
5806 }
5807 }
5808
5809 const HOUR_MS: i64 = 60 * 60 * 1000;
5810
5811 #[tokio::test]
5814 async fn margin_trades_read_a_lone_fill_beyond_the_first_day() {
5815 let now = Utc::now().timestamp_millis();
5816 let (ids, _) = read_margin_trades(
5817 venue(vec![(7, now - 72 * HOUR_MS)]),
5818 since(now - 120 * HOUR_MS),
5819 )
5820 .await;
5821
5822 assert_eq!(ids, vec![7]);
5823 }
5824
5825 #[tokio::test]
5828 async fn margin_trades_read_fills_across_several_windows_once_each() {
5829 let now = Utc::now().timestamp_millis();
5830 let trades = vec![
5831 (1, now - 119 * HOUR_MS),
5832 (2, now - 72 * HOUR_MS),
5833 (3, now - HOUR_MS),
5834 ];
5835 let (ids, _) = read_margin_trades(venue(trades), since(now - 144 * HOUR_MS)).await;
5836
5837 assert_eq!(ids, vec![1, 2, 3]);
5838 }
5839
5840 #[tokio::test]
5843 async fn margin_trades_read_a_trade_on_a_window_boundary_once() {
5844 for end_exclusive in [false, true] {
5845 let now = Utc::now().timestamp_millis();
5846 let from = now - 48 * HOUR_MS;
5847 let venue = MarginMyTradesVenue {
5848 end_exclusive,
5849 ..venue(vec![(5, from + MARGIN_MY_TRADES_WINDOW_MS)])
5850 };
5851 let (ids, _) = read_margin_trades(venue, since(from)).await;
5852
5853 assert_eq!(ids, vec![5], "end_exclusive: {end_exclusive}");
5854 }
5855 }
5856
5857 #[tokio::test]
5859 async fn margin_trades_page_on_by_id_past_a_full_window() {
5860 let now = Utc::now().timestamp_millis();
5861 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5862 let trades = (1..=count)
5863 .map(|id| (id, now - 48 * HOUR_MS + id))
5864 .collect();
5865 let (ids, _) = read_margin_trades(venue(trades), since(now - 50 * HOUR_MS)).await;
5866
5867 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5868 }
5869
5870 #[tokio::test]
5873 async fn margin_trades_page_on_past_a_full_final_window() {
5874 let now = Utc::now().timestamp_millis();
5875 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5876 let trades = (1..=count).map(|id| (id, now - HOUR_MS + id)).collect();
5877 let (ids, requests) = read_margin_trades(venue(trades), since(now - 2 * HOUR_MS)).await;
5878
5879 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5880 assert_eq!(requests.len(), 2);
5881 }
5882
5883 #[tokio::test]
5886 async fn margin_trades_quiet_lookback_sends_one_request_per_window() {
5887 let now = Utc::now().timestamp_millis();
5888 let (ids, requests) =
5889 read_margin_trades(venue(Vec::new()), since(now - 72 * HOUR_MS)).await;
5890
5891 assert!(ids.is_empty());
5892 assert_eq!(requests.len(), 4);
5894 }
5895
5896 #[tokio::test]
5899 async fn margin_trades_read_a_short_gap_in_one_request() {
5900 let now = Utc::now().timestamp_millis();
5901 let (ids, requests) =
5902 read_margin_trades(venue(vec![(9, now - HOUR_MS)]), since(now - 2 * HOUR_MS)).await;
5903
5904 assert_eq!(ids, vec![9]);
5905 assert_eq!(requests.len(), 1);
5906 }
5907
5908 #[tokio::test]
5910 async fn margin_trades_from_the_future_send_no_request() {
5911 let now = Utc::now().timestamp_millis();
5912 let (ids, requests) = read_margin_trades(venue(Vec::new()), since(now + HOUR_MS)).await;
5913
5914 assert!(ids.is_empty());
5915 assert!(requests.is_empty());
5916 }
5917
5918 #[tokio::test]
5921 async fn margin_trades_by_order_page_by_id_without_time_bounds() {
5922 let now = Utc::now().timestamp_millis();
5923 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5924 let trades = (1..=2 * count)
5926 .map(|id| {
5927 (
5928 id,
5929 now - 48 * HOUR_MS + id,
5930 if id % 2 == 0 { 42 } else { 7 },
5931 )
5932 })
5933 .collect();
5934 let venue = MarginMyTradesVenue {
5935 trades,
5936 end_exclusive: false,
5937 };
5938 let (ids, requests) = read_margin_trades(venue, MyTradesFrom::Order(42)).await;
5939
5940 assert_eq!(ids, (1..=count).map(|n| 2 * n).collect::<Vec<_>>());
5941 assert_eq!(requests.len(), 2);
5942 assert!(
5943 requests.iter().all(
5944 |query| query.get("orderId").map(String::as_str) == Some("42")
5945 && !query.contains_key("startTime")
5946 && !query.contains_key("endTime")
5947 )
5948 );
5949 }
5950
5951 async fn margin_trades_error(response: wiremock::ResponseTemplate) -> UnindexedClientError {
5954 let server = wiremock::MockServer::start().await;
5955 wiremock::Mock::given(wiremock::matchers::method("GET"))
5956 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5957 .respond_with(response)
5958 .mount(&server)
5959 .await;
5960 let rest = Arc::new(MarginTradingRestApi::from_config(
5961 ConfigurationRestApi::builder()
5962 .api_key("key")
5963 .api_secret("secret")
5964 .base_path(server.uri())
5965 .build()
5966 .unwrap(),
5967 ));
5968
5969 paginate_margin_my_trades(
5970 &rest,
5971 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5972 &InstrumentNameExchange::new("BTCUSDT"),
5973 since(Utc::now().timestamp_millis() - HOUR_MS),
5974 true,
5975 RequestKind::Query,
5976 )
5977 .await
5978 .unwrap_err()
5979 }
5980
5981 #[tokio::test]
5984 async fn margin_trades_report_a_venue_rejection_as_a_non_transient_request_rejection() {
5985 let err = margin_trades_error(wiremock::ResponseTemplate::new(400).set_body_json(
5986 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
5987 ))
5988 .await;
5989
5990 let UnindexedClientError::Api(ApiError::RequestRejected(msg)) = &err else {
5991 panic!("expected RequestRejected, got {err:?}");
5992 };
5993 assert!(msg.contains("-1127"), "code missing from {msg:?}");
5994 assert!(!err.is_transient());
5995 }
5996
5997 #[tokio::test]
5999 async fn margin_trades_report_an_undecodable_response_as_internal() {
6000 let err = margin_trades_error(
6001 wiremock::ResponseTemplate::new(200)
6002 .set_body_json(serde_json::json!({"unexpected": 1})),
6003 )
6004 .await;
6005
6006 assert!(
6007 matches!(err, UnindexedClientError::Internal(_)),
6008 "got {err:?}"
6009 );
6010 assert!(!err.is_transient());
6011 }
6012
6013 #[tokio::test]
6016 async fn sapi_used_ip_weight_near_the_limit_pauses_queries() {
6017 for (used, pauses) in [("10799", false), ("10800", true), ("not a number", false)] {
6018 let server = wiremock::MockServer::start().await;
6019 wiremock::Mock::given(wiremock::matchers::method("GET"))
6020 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6021 .respond_with(
6022 wiremock::ResponseTemplate::new(200)
6023 .insert_header("x-sapi-used-ip-weight-1m", used)
6024 .set_body_json(serde_json::json!([])),
6025 )
6026 .mount(&server)
6027 .await;
6028 let rest = Arc::new(MarginTradingRestApi::from_config(
6029 ConfigurationRestApi::builder()
6030 .api_key("key")
6031 .api_secret("secret")
6032 .base_path(server.uri())
6033 .build()
6034 .unwrap(),
6035 ));
6036 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
6037
6038 fetch_margin_all_open_orders(rest, Arc::clone(&tracker), false)
6039 .await
6040 .unwrap();
6041
6042 let query_waits = tokio::time::timeout(
6043 Duration::from_millis(50),
6044 tracker.wait_if_blocked(RequestKind::Query),
6045 )
6046 .await
6047 .is_err();
6048 assert_eq!(query_waits, pauses, "used IP weight {used}");
6049 tokio::time::timeout(
6050 Duration::from_millis(50),
6051 tracker.wait_if_blocked(RequestKind::Order),
6052 )
6053 .await
6054 .expect("orders never wait on the pause");
6055 }
6056 }
6057
6058 #[tokio::test]
6060 async fn margin_fill_recovery_does_not_wait_for_the_weight_pause() {
6061 let server = wiremock::MockServer::start().await;
6062 wiremock::Mock::given(wiremock::matchers::method("GET"))
6063 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6064 .respond_with(
6066 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
6067 "symbol": "BTCUSDT",
6068 "id": 1,
6069 "orderId": 7,
6070 "price": "100",
6071 "qty": "1",
6072 "commission": "0",
6073 "commissionAsset": "USDT",
6074 "time": Utc::now().timestamp_millis(),
6075 "isBuyer": true,
6076 "isMaker": false,
6077 }])),
6078 )
6079 .expect(2)
6080 .mount(&server)
6081 .await;
6082 let rest = Arc::new(MarginTradingRestApi::from_config(
6083 ConfigurationRestApi::builder()
6084 .api_key("key")
6085 .api_secret("secret")
6086 .base_path(server.uri())
6087 .build()
6088 .unwrap(),
6089 ));
6090 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
6091 tracker.throttle(Duration::from_secs(60));
6092 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
6093 let mut unrecovered = UnrecoveredFills::default();
6094 unrecovered.open(
6095 &[InstrumentNameExchange::new("BTCUSDT")],
6096 Utc::now() - chrono::Duration::minutes(1),
6097 Utc::now(),
6098 );
6099
6100 tokio::time::timeout(
6102 Duration::from_millis(800),
6103 recover_margin_fills(
6104 &rest,
6105 &tracker,
6106 &mut unrecovered,
6107 &tx,
6108 &new_dedup_cache(),
6109 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6110 false,
6111 ),
6112 )
6113 .await
6114 .expect("recovery does not wait for the pause");
6115 }
6116
6117 #[tokio::test]
6120 async fn margin_trades_read_over_a_span_stop_at_its_end() {
6121 let now = Utc::now().timestamp_millis();
6122 let trades = vec![
6123 (1, now - 50 * HOUR_MS),
6124 (2, now - 49 * HOUR_MS),
6125 (3, now - 10 * HOUR_MS),
6126 ];
6127 let (ids, _) = read_margin_trades(
6128 venue(trades),
6129 MyTradesFrom::Span {
6130 start: now - 60 * HOUR_MS,
6131 end: now - 20 * HOUR_MS,
6132 },
6133 )
6134 .await;
6135
6136 assert_eq!(ids, vec![1, 2]);
6137 }
6138
6139 #[tokio::test]
6142 async fn a_margin_gap_whose_every_read_fails_is_reported_when_given_up() {
6143 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6144 let reconnect = Utc::now() - chrono::Duration::minutes(5);
6145 let server = wiremock::MockServer::start().await;
6146 wiremock::Mock::given(wiremock::matchers::method("GET"))
6147 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6148 .respond_with(
6149 wiremock::ResponseTemplate::new(400)
6150 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
6151 )
6152 .mount(&server)
6153 .await;
6154 let rest = Arc::new(MarginTradingRestApi::from_config(
6155 ConfigurationRestApi::builder()
6156 .api_key("key")
6157 .api_secret("secret")
6158 .base_path(server.uri())
6159 .build()
6160 .expect("valid config"),
6161 ));
6162 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
6163 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6164 let dedup = new_dedup_cache();
6165 let btc = InstrumentNameExchange::new("BTCUSDT");
6166 let mut unrecovered = UnrecoveredFills::default();
6167 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
6168
6169 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
6170 recover_margin_fills(
6171 &rest,
6172 &tracker,
6173 &mut unrecovered,
6174 &tx,
6175 &dedup,
6176 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6177 false,
6178 )
6179 .await;
6180 unrecovered.make_due();
6181 }
6182
6183 assert!(unrecovered.is_empty(), "the gap is given up");
6184 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6185 let [event] = forwarded.as_slice() else {
6186 panic!("only the give-up is sent: {forwarded:?}");
6187 };
6188 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
6189 panic!("expected FillRecoveryGaveUp, got {event:?}");
6190 };
6191 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
6192 assert_eq!(
6193 gave_up.scope,
6194 crate::FillRecoveryScope::Instruments(vec![btc])
6195 );
6196 assert_eq!(
6197 gave_up.start.timestamp_millis(),
6198 disconnect.timestamp_millis()
6199 );
6200 assert_eq!(
6201 gave_up.attempts,
6202 crate::client::order_recovery::MAX_GAP_RETRIES + 1
6203 );
6204 assert!(
6205 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
6206 "the last read's error: {:?}",
6207 gave_up.reason
6208 );
6209 }
6210
6211 #[tokio::test]
6214 async fn a_failed_margin_recovery_keeps_its_closed_gap_for_the_retry() {
6215 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6216 let reconnect = Utc::now() - chrono::Duration::minutes(5);
6217 let server = wiremock::MockServer::start().await;
6218 wiremock::Mock::given(wiremock::matchers::method("GET"))
6219 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6220 .respond_with(
6221 wiremock::ResponseTemplate::new(400)
6222 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
6223 )
6224 .up_to_n_times(1)
6225 .with_priority(1)
6226 .mount(&server)
6227 .await;
6228 wiremock::Mock::given(wiremock::matchers::method("GET"))
6229 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6230 .respond_with(venue(vec![
6231 (
6232 1,
6233 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
6234 ),
6235 (
6236 2,
6237 (Utc::now() - chrono::Duration::minutes(1)).timestamp_millis(),
6238 ),
6239 ]))
6240 .mount(&server)
6241 .await;
6242 let rest = Arc::new(MarginTradingRestApi::from_config(
6243 ConfigurationRestApi::builder()
6244 .api_key("key")
6245 .api_secret("secret")
6246 .base_path(server.uri())
6247 .build()
6248 .unwrap(),
6249 ));
6250 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
6251 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6252 let dedup = new_dedup_cache();
6253 let mut unrecovered = UnrecoveredFills::default();
6254 unrecovered.open(
6255 &[InstrumentNameExchange::new("BTCUSDT")],
6256 disconnect,
6257 reconnect,
6258 );
6259
6260 recover_margin_fills(
6261 &rest,
6262 &tracker,
6263 &mut unrecovered,
6264 &tx,
6265 &dedup,
6266 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6267 false,
6268 )
6269 .await;
6270 assert!(!unrecovered.is_empty(), "the failed gap is kept");
6271 assert!(rx.try_recv().is_err(), "nothing forwarded");
6272
6273 unrecovered.make_due();
6274 recover_margin_fills(
6275 &rest,
6276 &tracker,
6277 &mut unrecovered,
6278 &tx,
6279 &dedup,
6280 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6281 false,
6282 )
6283 .await;
6284 assert!(unrecovered.is_empty(), "the retry recovered it");
6285 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6286 assert_eq!(
6287 forwarded.len(),
6288 1,
6289 "only the fill inside the gap: {forwarded:?}"
6290 );
6291 }
6292
6293 #[tokio::test]
6296 async fn a_timed_out_margin_recovery_charges_only_the_gaps_it_started() {
6297 let server = wiremock::MockServer::start().await;
6298 wiremock::Mock::given(wiremock::matchers::method("GET"))
6299 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6300 .respond_with(
6301 wiremock::ResponseTemplate::new(200)
6302 .set_body_json(serde_json::json!([]))
6303 .set_delay(Duration::from_secs(3_600)),
6304 )
6305 .mount(&server)
6306 .await;
6307 let rest = Arc::new(MarginTradingRestApi::from_config(
6308 ConfigurationRestApi::builder()
6309 .api_key("key")
6310 .api_secret("secret")
6311 .base_path(server.uri())
6312 .timeout(3_600_000_u64)
6313 .retries(0_u32)
6314 .build()
6315 .unwrap(),
6316 ));
6317 let instruments: Vec<_> = (0..9)
6318 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
6319 .collect();
6320 let mut unrecovered = UnrecoveredFills::default();
6321 unrecovered.open(
6322 &instruments,
6323 Utc::now() - chrono::Duration::minutes(10),
6324 Utc::now(),
6325 );
6326 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
6327 tokio::time::pause();
6329
6330 recover_margin_fills(
6331 &rest,
6332 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6333 &mut unrecovered,
6334 &tx,
6335 &new_dedup_cache(),
6336 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6337 false,
6338 )
6339 .await;
6340
6341 let now = tokio::time::Instant::now();
6342 assert_eq!(
6343 unrecovered.due(now).len(),
6344 1,
6345 "the gap never started stays due"
6346 );
6347 assert_eq!(
6348 unrecovered
6349 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
6350 .len(),
6351 9,
6352 "the eight started wait for their first retry"
6353 );
6354 }
6355
6356 fn margin_order_row(cid: &str, status: &str) -> serde_json::Value {
6362 serde_json::json!({
6363 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
6364 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
6365 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "isIsolated": false,
6366 "time": 1_700_000_000_000_i64, "updateTime": 1_700_000_060_000_i64,
6367 })
6368 }
6369
6370 fn margin_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
6371 OrderKey::new(
6372 ExchangeId::BinanceMargin,
6373 InstrumentNameExchange::new(instrument),
6374 StrategyId::new("strategy"),
6375 ClientOrderId::new(cid),
6376 )
6377 }
6378
6379 #[test]
6380 fn a_margin_order_row_says_how_the_order_ended() {
6381 use crate::order::state::InactiveOrderState;
6382 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOrderResponse;
6383 let key = margin_key("BTCUSDT", "a");
6384 let ended = |row: serde_json::Value| {
6385 let row: QueryMarginAccountsOrderResponse = serde_json::from_value(row).unwrap();
6386 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, None)
6387 .map(|order| order.state)
6388 };
6389
6390 let mut filled = margin_order_row("a", "FILLED");
6391 filled["executedQty"] = "2".into();
6392 filled["cummulativeQuoteQty"] = "210".into();
6393 let Some(InactiveOrderState::FullyFilled(filled)) = ended(filled) else {
6394 panic!("FILLED is fully filled");
6395 };
6396 assert_eq!(filled.avg_price, Some(Decimal::from(105)));
6397 assert!(matches!(
6398 ended(margin_order_row("a", "CANCELED")),
6399 Some(InactiveOrderState::Cancelled(cancelled)) if cancelled.filled_quantity == Some(Decimal::ONE)
6400 ));
6401 assert_eq!(ended(margin_order_row("a", "NEW")), None);
6402
6403 let mut unknown_quote = margin_order_row("a", "FILLED");
6405 unknown_quote["executedQty"] = "2".into();
6406 unknown_quote["cummulativeQuoteQty"] = "-1".into();
6407 let Some(InactiveOrderState::FullyFilled(filled)) = ended(unknown_quote) else {
6408 panic!("still fully filled");
6409 };
6410 assert_eq!(filled.avg_price, None, "no negative average price");
6411 }
6412
6413 fn margin_rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
6414 Arc::new(MarginTradingRestApi::from_config(
6415 ConfigurationRestApi::builder()
6416 .api_key("key")
6417 .api_secret("secret")
6418 .base_path(server.uri())
6419 .build()
6420 .unwrap(),
6421 ))
6422 }
6423
6424 fn margin_client_at(server: &wiremock::MockServer, is_isolated: bool) -> BinanceMargin {
6426 let config = if is_isolated {
6427 BinanceMarginConfig::isolated(
6428 "key".into(),
6429 "secret".into(),
6430 vec![InstrumentNameExchange::new("BTCUSDT")],
6431 )
6432 } else {
6433 BinanceMarginConfig::cross_margin("key".into(), "secret".into())
6434 };
6435 let mut client = <BinanceMargin as ExecutionClient>::new(config);
6436 client.rest = margin_rest_at(server);
6437 client
6438 }
6439
6440 async fn mount_margin_order(
6442 server: &wiremock::MockServer,
6443 cid: &str,
6444 response: wiremock::ResponseTemplate,
6445 ) {
6446 wiremock::Mock::given(wiremock::matchers::method("GET"))
6447 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6448 .and(wiremock::matchers::query_param("origClientOrderId", cid))
6449 .respond_with(response)
6450 .mount(server)
6451 .await;
6452 }
6453
6454 fn margin_venue_error(code: i64) -> wiremock::ResponseTemplate {
6455 wiremock::ResponseTemplate::new(400)
6456 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
6457 }
6458
6459 fn margin_row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
6460 wiremock::ResponseTemplate::new(200).set_body_json(margin_order_row(cid, status))
6461 }
6462
6463 async fn margin_requests(server: &wiremock::MockServer) -> Vec<(String, Option<String>)> {
6465 server
6466 .received_requests()
6467 .await
6468 .unwrap()
6469 .iter()
6470 .map(|request| {
6471 let isolated = request
6472 .url
6473 .query_pairs()
6474 .find(|(key, _)| key == "isIsolated")
6475 .map(|(_, value)| value.into_owned());
6476 (request.url.path().to_owned(), isolated)
6477 })
6478 .collect()
6479 }
6480
6481 #[tokio::test]
6482 async fn margin_fetch_ended_orders_asks_the_configured_account_and_omits_the_rest() {
6483 use crate::order::state::InactiveOrderState;
6484 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
6485 let server = wiremock::MockServer::start().await;
6486 mount_margin_order(&server, "filled", margin_row_response("filled", "FILLED")).await;
6487 mount_margin_order(&server, "live", margin_row_response("live", "NEW")).await;
6488 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
6489 mount_margin_order(&server, "no-symbol", margin_venue_error(-1121)).await;
6490 let client = margin_client_at(&server, is_isolated);
6491 let keys = ["filled", "live", "unknown"].map(|cid| margin_key("BTCUSDT", cid));
6492
6493 let ended = client
6494 .fetch_ended_orders(
6495 &[keys.to_vec(), vec![margin_key("NOSUCH", "no-symbol")]].concat(),
6496 )
6497 .await
6498 .unwrap();
6499
6500 let [order] = ended.as_slice() else {
6501 panic!("one order ended: {ended:?}");
6502 };
6503 assert_eq!(order.key, keys[0], "the strategy asked survives");
6504 assert!(matches!(order.state, InactiveOrderState::FullyFilled(_)));
6505 let requests = margin_requests(&server).await;
6506 assert_eq!(requests.len(), 4);
6507 for (path, isolated) in requests {
6508 assert_eq!(path, "/sapi/v1/margin/order");
6509 assert_eq!(
6510 isolated.as_deref(),
6511 Some(flag),
6512 "is_isolated = {is_isolated}"
6513 );
6514 }
6515 }
6516 }
6517
6518 #[tokio::test]
6519 async fn margin_fetch_ended_orders_fails_when_any_lookup_fails() {
6520 let server = wiremock::MockServer::start().await;
6521 mount_margin_order(
6522 &server,
6523 "cancelled",
6524 margin_row_response("cancelled", "CANCELED"),
6525 )
6526 .await;
6527 mount_margin_order(&server, "refused", margin_venue_error(-1100)).await;
6528 let client = margin_client_at(&server, false);
6529
6530 let result = client
6531 .fetch_ended_orders(&[
6532 margin_key("BTCUSDT", "cancelled"),
6533 margin_key("BTCUSDT", "refused"),
6534 ])
6535 .await;
6536
6537 assert!(
6538 matches!(
6539 result,
6540 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
6541 ),
6542 "{result:?}"
6543 );
6544 }
6545
6546 fn held_margin_orders() -> SharedKnownLiveOrders {
6548 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6549 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
6550 known.lock().live(
6551 &margin_key("BTCUSDT", cid),
6552 Decimal::TWO,
6553 OrderKind::Limit,
6554 &Open::new(
6555 VenueOrderId::Assigned(OrderId::new(n.to_string())),
6556 Utc::now(),
6557 Decimal::ZERO,
6558 ),
6559 );
6560 }
6561 known
6562 }
6563
6564 #[tokio::test]
6569 async fn an_ended_margin_trailing_stop_is_reported_with_the_kind_it_was_held_with() {
6570 let server = wiremock::MockServer::start().await;
6571 wiremock::Mock::given(wiremock::matchers::method("GET"))
6572 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6573 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
6574 .mount(&server)
6575 .await;
6576 let mut row = margin_order_row("trailing", "CANCELED");
6577 row["type"] = "STOP_LOSS".into();
6578 row["price"] = "0.00000000".into();
6579 row["stopPrice"] = "0.00000000".into();
6580 mount_margin_order(
6581 &server,
6582 "trailing",
6583 wiremock::ResponseTemplate::new(200).set_body_json(row),
6584 )
6585 .await;
6586 let trailing_stop = OrderKind::TrailingStop {
6587 offset: Decimal::from(25),
6588 offset_type: TrailingOffsetType::BasisPoints,
6589 };
6590 let hold = |known: &SharedKnownLiveOrders| {
6591 known.lock().live(
6592 &margin_key("BTCUSDT", "trailing"),
6593 Decimal::TWO,
6594 trailing_stop,
6595 &Open::new(
6596 VenueOrderId::Assigned(OrderId::new("7")),
6597 Utc::now(),
6598 Decimal::ZERO,
6599 ),
6600 );
6601 };
6602
6603 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6604 hold(&known);
6605 let mut unchecked = UncheckedOrders::default();
6606 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
6607 let (tx, mut rx) = mpsc::unbounded_channel();
6608 recover_margin_ended_orders(
6609 &margin_rest_at(&server),
6610 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6611 &known,
6612 &mut unchecked,
6613 &UnrecoveredFills::default(),
6614 &tx,
6615 false,
6616 )
6617 .await;
6618 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6619 let [event] = sent.as_slice() else {
6620 panic!("the trailing stop ended: {sent:?}");
6621 };
6622 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
6623 panic!("an order snapshot: {event:?}");
6624 };
6625 assert_eq!(snapshot.0.kind, trailing_stop);
6626 assert!(matches!(
6627 snapshot.0.state,
6628 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
6629 ));
6630 assert!(!known.lock().contains(&ClientOrderId::new("trailing")));
6631
6632 let client = margin_client_at(&server, false);
6633 hold(&client.known_live);
6634 let ended = client
6635 .fetch_ended_orders(&[margin_key("BTCUSDT", "trailing")])
6636 .await
6637 .expect("lookup");
6638 let [order] = ended.as_slice() else {
6639 panic!("the trailing stop ended: {ended:?}");
6640 };
6641 assert_eq!(order.kind, trailing_stop);
6642 }
6643
6644 #[tokio::test]
6645 async fn a_margin_reconnect_reports_how_an_unlisted_order_ended_in_the_configured_account() {
6646 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
6647 let server = wiremock::MockServer::start().await;
6648 wiremock::Mock::given(wiremock::matchers::method("GET"))
6649 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6650 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
6651 .respond_with(
6652 wiremock::ResponseTemplate::new(200)
6653 .set_body_json(serde_json::json!([margin_order_row("live", "NEW")])),
6654 )
6655 .mount(&server)
6656 .await;
6657 mount_margin_order(&server, "gone", margin_row_response("gone", "CANCELED")).await;
6658 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
6659 let known = held_margin_orders();
6660 let mut unchecked = UncheckedOrders::default();
6661 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
6662 let (tx, mut rx) = mpsc::unbounded_channel();
6663
6664 recover_margin_ended_orders(
6665 &margin_rest_at(&server),
6666 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6667 &known,
6668 &mut unchecked,
6669 &UnrecoveredFills::default(),
6670 &tx,
6671 is_isolated,
6672 )
6673 .await;
6674
6675 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6676 let [event] = sent.as_slice() else {
6677 panic!("one order ended: {sent:?}");
6678 };
6679 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
6680 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
6681 panic!("an order snapshot: {event:?}");
6682 };
6683 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
6684 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
6685 assert!(matches!(
6686 snapshot.0.state,
6687 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
6688 ));
6689 let mut requests = margin_requests(&server).await;
6690 requests.sort();
6691 let expected = |path: &str| (path.to_owned(), Some(flag.to_owned()));
6692 assert_eq!(
6693 requests,
6694 [
6695 expected("/sapi/v1/margin/openOrders"),
6696 expected("/sapi/v1/margin/order"),
6697 expected("/sapi/v1/margin/order"),
6698 ],
6699 "one listing, and a lookup of each unlisted order only"
6700 );
6701 let known = known.lock();
6702 assert!(known.contains(&ClientOrderId::new("live")));
6703 assert!(!known.contains(&ClientOrderId::new("gone")));
6704 assert!(
6705 !known.contains(&ClientOrderId::new("unknown")),
6706 "asking again cannot help"
6707 );
6708 assert!(unchecked.is_empty());
6709 }
6710 }
6711
6712 fn margin_open_request<'a>(
6713 instrument: &'a InstrumentNameExchange,
6714 cid: &str,
6715 ) -> OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange> {
6716 OrderRequestOpen {
6717 key: OrderKey::new(
6718 ExchangeId::BinanceMargin,
6719 instrument,
6720 StrategyId::new("strategy"),
6721 ClientOrderId::new(cid),
6722 ),
6723 state: crate::order::request::RequestOpen {
6724 side: Side::Buy,
6725 price: Some(Decimal::from(100)),
6726 quantity: Decimal::TWO,
6727 kind: OrderKind::Limit,
6728 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
6729 position_id: None,
6730 reduce_only: false,
6731 market: None,
6732 },
6733 }
6734 }
6735
6736 #[tokio::test]
6739 async fn a_margin_trailing_stop_is_sent_with_trailing_delta() {
6740 let server = wiremock::MockServer::start().await;
6741 let sent_as_trailing_stop = |request: &wiremock::Request| {
6742 let mut body_as_query = request.url.clone();
6744 body_as_query.set_query(Some(&String::from_utf8_lossy(&request.body)));
6745 let params: Vec<(String, String)> = request
6746 .url
6747 .query_pairs()
6748 .chain(body_as_query.query_pairs())
6749 .map(|(k, v)| (k.into_owned(), v.into_owned()))
6750 .collect();
6751 let has = |key: &str, value: &str| params.iter().any(|(k, v)| k == key && v == value);
6752 has("type", "STOP_LOSS")
6753 && has("trailingDelta", "25")
6754 && has("sideEffectType", "AUTO_BORROW_REPAY")
6755 && !params.iter().any(|(k, _)| k == "stopPrice" || k == "price")
6756 };
6757 wiremock::Mock::given(wiremock::matchers::method("POST"))
6758 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6759 .and(sent_as_trailing_stop)
6760 .respond_with(
6761 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6762 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "trailing",
6763 "transactTime": 1_700_000_000_000_i64, "price": "0", "origQty": "2",
6764 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6765 "timeInForce": "GTC", "type": "STOP_LOSS", "side": "SELL",
6766 })),
6767 )
6768 .expect(1)
6769 .mount(&server)
6770 .await;
6771 let client = margin_client_at(&server, false);
6772 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6773 let trailing_request = |cid, offset| {
6774 let mut request = margin_open_request(&btcusdt, cid);
6775 request.state.side = Side::Sell;
6776 request.state.price = None;
6777 request.state.kind = OrderKind::TrailingStop {
6778 offset,
6779 offset_type: TrailingOffsetType::BasisPoints,
6780 };
6781 request
6782 };
6783
6784 let placed = client
6785 .open_order(trailing_request("trailing", Decimal::from(25)))
6786 .await;
6787 assert!(
6788 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6789 "{placed:?}"
6790 );
6791
6792 let refused = client
6793 .open_order(trailing_request("fractional", Decimal::new(25, 1)))
6794 .await;
6795 assert!(
6796 matches!(
6797 refused.state,
6798 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
6799 OrderError::InvalidPrecision(_)
6800 ))
6801 ),
6802 "{refused:?}"
6803 );
6804 }
6805
6806 #[tokio::test]
6807 async fn placing_listing_and_cancelling_margin_orders_feed_the_known_live_set() {
6808 let server = wiremock::MockServer::start().await;
6809 wiremock::Mock::given(wiremock::matchers::method("POST"))
6810 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6811 .respond_with(
6812 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6813 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "placed",
6814 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
6815 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6816 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6817 })),
6818 )
6819 .mount(&server)
6820 .await;
6821 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6822 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6823 .respond_with(
6824 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6825 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
6826 "clientOrderId": "cancel", "price": "100", "origQty": "2", "executedQty": "0",
6827 "cummulativeQuoteQty": "0", "status": "CANCELED", "timeInForce": "GTC",
6828 "type": "LIMIT", "side": "BUY",
6829 })),
6830 )
6831 .mount(&server)
6832 .await;
6833 wiremock::Mock::given(wiremock::matchers::method("GET"))
6834 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6835 .respond_with(
6836 wiremock::ResponseTemplate::new(200)
6837 .set_body_json(serde_json::json!([margin_order_row("listed", "NEW")])),
6838 )
6839 .mount(&server)
6840 .await;
6841 let client = margin_client_at(&server, false);
6842 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6843
6844 let placed = client
6845 .open_order(margin_open_request(&btcusdt, "placed"))
6846 .await;
6847 assert!(
6848 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6849 "{placed:?}"
6850 );
6851 assert!(
6852 client
6853 .known_live
6854 .lock()
6855 .contains(&ClientOrderId::new("placed"))
6856 );
6857
6858 client.fetch_open_orders(&[]).await.unwrap();
6859 assert!(
6860 client
6861 .known_live
6862 .lock()
6863 .contains(&ClientOrderId::new("listed"))
6864 );
6865
6866 let cancelled = client
6867 .cancel_order(OrderRequestCancel {
6868 key: OrderKey::new(
6869 ExchangeId::BinanceMargin,
6870 &btcusdt,
6871 StrategyId::new("strategy"),
6872 ClientOrderId::new("placed"),
6873 ),
6874 state: crate::order::request::RequestCancel {
6875 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
6876 },
6877 })
6878 .await;
6879 assert!(cancelled.state.is_ok(), "{cancelled:?}");
6880 assert!(
6881 !client
6882 .known_live
6883 .lock()
6884 .contains(&ClientOrderId::new("placed"))
6885 );
6886 }
6887
6888 #[tokio::test]
6890 async fn a_cancel_response_without_executed_qty_reports_the_fill_unknown() {
6891 let server = wiremock::MockServer::start().await;
6892 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6893 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6894 .respond_with(
6895 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6896 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
6897 "clientOrderId": "cancel", "price": "100", "origQty": "2",
6898 "status": "CANCELED", "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6899 })),
6900 )
6901 .mount(&server)
6902 .await;
6903 let client = margin_client_at(&server, false);
6904
6905 let cancelled = client
6906 .cancel_order(OrderRequestCancel {
6907 key: OrderKey::new(
6908 ExchangeId::BinanceMargin,
6909 &InstrumentNameExchange::new("BTCUSDT"),
6910 StrategyId::new("strategy"),
6911 ClientOrderId::new("placed"),
6912 ),
6913 state: crate::order::request::RequestCancel {
6914 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
6915 },
6916 })
6917 .await;
6918 let Ok(cancelled) = cancelled.state else {
6919 panic!("expected Ok, got {cancelled:?}");
6920 };
6921 assert_eq!(cancelled.filled_quantity, None);
6922 }
6923
6924 #[test]
6925 fn a_reconnect_opens_a_gap_on_each_stream_instrument_and_checks_those_with_orders_held() {
6926 let [btc, eth, xrp] = ["BTCUSDT", "ETHUSDT", "XRPUSDT"].map(InstrumentNameExchange::new);
6927 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6928 for (instrument, cid) in [(&btc, "btc"), (&xrp, "xrp")] {
6929 known.lock().live(
6930 &margin_key(instrument.name(), cid),
6931 Decimal::TWO,
6932 OrderKind::Limit,
6933 &Open::new(
6934 VenueOrderId::Assigned(OrderId::new(cid)),
6935 Utc::now(),
6936 Decimal::ZERO,
6937 ),
6938 );
6939 }
6940 let mut unrecovered = UnrecoveredFills::default();
6941 let mut unchecked = UncheckedOrders::default();
6942
6943 open_reconnect_recovery(
6944 &mut unrecovered,
6945 &mut unchecked,
6946 &known,
6947 &[btc.clone(), eth.clone()],
6948 Utc::now() - chrono::Duration::minutes(1),
6949 );
6950
6951 assert!(unrecovered.covers(&btc) && unrecovered.covers(ð));
6952 assert!(!unrecovered.covers(&xrp), "not the stream's");
6953 assert!(unchecked.contains(&btc));
6954 assert!(!unchecked.contains(ð), "no order held");
6955 assert!(!unchecked.contains(&xrp), "its fills are not recovered");
6956 }
6957
6958 #[tokio::test]
6961 async fn a_recovered_margin_fill_that_completes_a_held_order_ends_it() {
6962 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6963 let server = wiremock::MockServer::start().await;
6964 wiremock::Mock::given(wiremock::matchers::method("GET"))
6965 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6966 .respond_with(venue(vec![
6967 (
6968 1,
6969 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
6970 ),
6971 (
6972 2,
6973 (disconnect + chrono::Duration::minutes(2)).timestamp_millis(),
6974 ),
6975 ]))
6976 .mount(&server)
6977 .await;
6978 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6981 for (cid, order_id, quantity) in [("one", "1", "0.01"), ("two", "2", "0.02")] {
6982 known.lock().live(
6983 &margin_key("BTCUSDT", cid),
6984 Decimal::from_str(quantity).unwrap(),
6985 OrderKind::Limit,
6986 &Open::new(
6987 VenueOrderId::Assigned(OrderId::new(order_id)),
6988 Utc::now(),
6989 Decimal::ZERO,
6990 ),
6991 );
6992 }
6993 let mut unrecovered = UnrecoveredFills::default();
6994 unrecovered.open(
6995 &[InstrumentNameExchange::new("BTCUSDT")],
6996 disconnect,
6997 Utc::now(),
6998 );
6999 let (tx, mut rx) = mpsc::unbounded_channel();
7000
7001 recover_margin_fills(
7002 &margin_rest_at(&server),
7003 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
7004 &mut unrecovered,
7005 &tx,
7006 &new_dedup_cache(),
7007 &known,
7008 false,
7009 )
7010 .await;
7011
7012 assert_eq!(
7013 std::iter::from_fn(|| rx.try_recv().ok()).count(),
7014 2,
7015 "both fills"
7016 );
7017 let known = known.lock();
7018 assert!(!known.contains(&ClientOrderId::new("one")), "completed");
7019 assert!(known.contains(&ClientOrderId::new("two")), "part-filled");
7020 }
7021
7022 #[tokio::test]
7025 async fn a_margin_order_that_expired_on_placement_is_not_reported_or_held_open() {
7026 use rust_decimal_macros::dec;
7027
7028 for (cid, filled, quote) in [("unfilled", "0", "0"), ("partly", "1", "100")] {
7029 let server = wiremock::MockServer::start().await;
7030 wiremock::Mock::given(wiremock::matchers::method("POST"))
7031 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7032 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
7033 serde_json::json!({
7034 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
7035 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
7036 "executedQty": filled, "cummulativeQuoteQty": quote,
7037 "status": "EXPIRED", "timeInForce": "IOC", "type": "LIMIT", "side": "BUY",
7038 }),
7039 ))
7040 .mount(&server)
7041 .await;
7042 let client = margin_client_at(&server, false);
7043 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7044
7045 let placed = client.open_order(margin_open_request(&btcusdt, cid)).await;
7046
7047 let OrderState::Inactive(crate::order::state::InactiveOrderState::Expired(expired)) =
7048 &placed.state
7049 else {
7050 panic!("{cid}: expired, not open: {placed:?}");
7051 };
7052 assert_eq!(expired.filled_quantity, Decimal::from_str(filled).ok());
7053 let late = Open::new(
7054 VenueOrderId::Assigned(OrderId::new("7")),
7055 Utc::now(),
7056 Decimal::ZERO,
7057 );
7058 let mut known = client.known_live.lock();
7059 known.live(&placed.key, dec!(2), OrderKind::Limit, &late);
7060 assert!(
7061 !known.contains(&ClientOrderId::new(cid)),
7062 "{cid}: not held, even after a late live report"
7063 );
7064 }
7065 }
7066}