1use super::shared::{
53 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
54 CONNECT_TIMEOUT_SECS, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS, HEARTBEAT_TIMEOUT_SECS,
55 MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing, PlacementResponse, RateLimitTracker,
56 RequestKind, SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, UnrecoveredFills, UserDataFrame,
57 WeightPool, binance_filled_qty, classify_order_kind_tif, classify_rest_order_error,
58 classify_rest_query_error, convert_ended_order, convert_execution_report,
59 convert_open_order_listing, convert_open_order_owned_symbol, dedup_key_from_event, drop_after,
60 gap_failed, gap_time, is_duplicate, is_unknown_order, log_unrecognised_frame, new_dedup_cache,
61 parse_user_data_frame, placed_order_state, recovered_order_totals, response_decode_error,
62 rest_call_with_retry,
63};
64use crate::{
65 AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
66 InstrumentBalanceUpdate, IsolatedInstrumentState, IsolatedMarginRisk, UnindexedAccountEvent,
67 UnindexedAccountSnapshot,
68 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
69 client::{
70 ExecutionClient, OrderStatusClient,
71 order_recovery::{
72 KnownLiveOrders, OpenListing, OrderLookup, SharedKnownLiveOrders, UncheckedOrders,
73 fetch_ended_by_key, recover_ended_orders,
74 },
75 },
76 emit_stream_terminated,
77 error::{
78 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
79 },
80 order::{
81 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
82 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
83 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
84 state::{Cancelled, Open, OrderState, UnindexedOrderState},
85 },
86 position::PositionReport,
87 trade::{AssetFees, Trade, TradeId, TradesRead},
88};
89use binance_sdk::{
90 common::{
91 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
92 constants::{MARGIN_TRADING_REST_API_PROD_URL, SPOT_WS_API_PROD_URL},
93 models::WebsocketEvent,
94 websocket::{
95 SendWebsocketMessageResult, Subscription, WebsocketApi as WsApiBase,
96 WebsocketMessageSendOptions,
97 },
98 },
99 margin_trading::{
100 MarginTradingRestApi,
101 rest_api::{
102 MarginAccountCancelOrderIsIsolatedEnum, MarginAccountCancelOrderParams,
103 MarginAccountNewOrderIsIsolatedEnum, MarginAccountNewOrderNewOrderRespTypeEnum,
104 MarginAccountNewOrderParams, MarginAccountNewOrderSideEffectTypeEnum,
105 MarginAccountNewOrderSideEnum, MarginAccountNewOrderTimeInForceEnum,
106 MarginAccountNewOrderTypeEnum, QueryCrossMarginAccountDetailsParams,
107 QueryCrossMarginAccountDetailsResponseUserAssetsInner,
108 QueryIsolatedMarginAccountInfoParams,
109 QueryIsolatedMarginAccountInfoResponseAssetsInner,
110 QueryMarginAccountsOpenOrdersIsIsolatedEnum, QueryMarginAccountsOpenOrdersParams,
111 QueryMarginAccountsOrderIsIsolatedEnum, QueryMarginAccountsOrderParams,
112 QueryMarginAccountsTradeListIsIsolatedEnum, QueryMarginAccountsTradeListParams,
113 QueryMarginAccountsTradeListResponseInner, RestApi,
114 },
115 websocket_streams::{
116 ExecutionReport, MarginLevelStatusChange, OutboundAccountPosition, UserLiabilityChange,
117 },
118 },
119};
120use chrono::{DateTime, TimeZone, Utc};
121use fnv::FnvHashSet;
122use futures::stream::BoxStream;
123use rust_decimal::Decimal;
124use rustrade_instrument::{
125 Side,
126 asset::name::AssetNameExchange,
127 exchange::ExchangeId,
128 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
129};
130use serde::{Deserialize, Serialize};
131use smol_str::{SmolStr, format_smolstr};
132use std::{
133 collections::{BTreeMap, HashMap},
134 str::FromStr,
135 sync::{
136 Arc, Mutex,
137 atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
138 },
139 time::Duration,
140};
141use tokio::sync::{mpsc, oneshot};
142use tracing::{debug, error, info, trace, warn};
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum MarginSideEffect {
164 #[default]
172 AutoBorrowRepay,
173 NoBorrow,
177}
178
179impl MarginSideEffect {
180 pub fn as_binance_str(self) -> &'static str {
182 match self {
183 MarginSideEffect::AutoBorrowRepay => "AUTO_BORROW_REPAY",
184 MarginSideEffect::NoBorrow => "NO_SIDE_EFFECT",
185 }
186 }
187
188 fn as_sdk_enum(self) -> MarginAccountNewOrderSideEffectTypeEnum {
194 match self {
195 MarginSideEffect::AutoBorrowRepay => {
196 MarginAccountNewOrderSideEffectTypeEnum::AutoBorrowRepay
197 }
198 MarginSideEffect::NoBorrow => MarginAccountNewOrderSideEffectTypeEnum::NoSideEffect,
199 }
200 }
201}
202
203#[derive(Clone, Deserialize)]
213pub struct BinanceMarginConfig {
214 api_key: String,
217 secret_key: String,
218 pub testnet: bool,
224 #[serde(default)]
231 pub is_isolated: bool,
232 #[serde(default)]
243 pub isolated_symbols: Vec<InstrumentNameExchange>,
244 #[serde(default)]
248 pub side_effect: MarginSideEffect,
249}
250
251impl std::fmt::Debug for BinanceMarginConfig {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 f.debug_struct("BinanceMarginConfig")
255 .field("api_key", &"***")
256 .field("secret_key", &"***")
257 .field("testnet", &self.testnet)
258 .field("is_isolated", &self.is_isolated)
259 .field("isolated_symbols", &self.isolated_symbols)
260 .field("side_effect", &self.side_effect)
261 .finish()
262 }
263}
264
265impl BinanceMarginConfig {
266 pub fn new(
276 api_key: String,
277 secret_key: String,
278 testnet: bool,
279 is_isolated: bool,
280 isolated_symbols: Vec<InstrumentNameExchange>,
281 side_effect: MarginSideEffect,
282 ) -> Self {
283 Self {
284 api_key,
285 secret_key,
286 testnet,
287 is_isolated,
288 isolated_symbols,
289 side_effect,
290 }
291 }
292
293 pub fn cross_margin(api_key: String, secret_key: String) -> Self {
300 Self::new(
301 api_key,
302 secret_key,
303 false,
304 false,
305 Vec::new(),
306 MarginSideEffect::default(),
307 )
308 }
309
310 pub fn isolated(
318 api_key: String,
319 secret_key: String,
320 symbols: Vec<InstrumentNameExchange>,
321 ) -> Self {
322 Self::new(
323 api_key,
324 secret_key,
325 false,
326 true,
327 symbols,
328 MarginSideEffect::default(),
329 )
330 }
331
332 pub fn api_key(&self) -> &str {
334 &self.api_key
335 }
336}
337
338#[derive(Clone)]
407pub struct BinanceMargin {
408 config: Arc<BinanceMarginConfig>,
409 rest: Arc<RestApi>,
411 rest_config: Arc<ConfigurationRestApi>,
415 ws_config: ConfigurationWebsocketApi,
419 rate_limiter: Arc<RateLimitTracker>,
421 known_live: SharedKnownLiveOrders,
424}
425
426impl std::fmt::Debug for BinanceMargin {
427 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428 f.debug_struct("BinanceMargin")
429 .field("testnet", &self.config.testnet)
430 .field("is_isolated", &self.config.is_isolated)
431 .field("side_effect", &self.config.side_effect)
432 .finish_non_exhaustive()
433 }
434}
435
436impl BinanceMargin {
437 fn remember_live<'a>(
439 &self,
440 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
441 ) {
442 let mut known = self.known_live.lock();
443 for order in orders {
444 known.live(&order.key, order.quantity, &order.state);
445 }
446 }
447
448 #[allow(clippy::expect_used)] fn build_rest_config(config: &BinanceMarginConfig) -> ConfigurationRestApi {
458 ConfigurationRestApi::builder()
459 .api_key(config.api_key.clone())
460 .api_secret(config.secret_key.clone())
461 .base_path(MARGIN_TRADING_REST_API_PROD_URL)
462 .build()
463 .expect("failed to build Binance margin REST configuration")
464 }
465
466 #[allow(clippy::expect_used)] fn build_ws_config(config: &BinanceMarginConfig) -> ConfigurationWebsocketApi {
477 ConfigurationWebsocketApi::builder()
478 .api_key(config.api_key.clone())
479 .api_secret(config.secret_key.clone())
480 .ws_url(SPOT_WS_API_PROD_URL)
484 .build()
485 .expect("failed to build Binance margin WebSocket configuration")
486 }
487
488 fn effective_isolated_set(
501 &self,
502 instruments: &[InstrumentNameExchange],
503 ) -> Vec<InstrumentNameExchange> {
504 if instruments.is_empty() {
505 return self.config.isolated_symbols.clone();
506 }
507 instruments
508 .iter()
509 .filter(|inst| {
510 let in_set = self.config.isolated_symbols.contains(*inst);
511 if !in_set {
512 warn!(
513 instrument = %inst.name(),
514 "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
515 );
516 }
517 in_set
518 })
519 .cloned()
520 .collect()
521 }
522
523 async fn isolated_account_snapshot(
527 &self,
528 instruments: &[InstrumentNameExchange],
529 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
530 use futures::{StreamExt as _, TryStreamExt as _};
531
532 let effective = self.effective_isolated_set(instruments);
533 if effective.is_empty() {
534 return Ok(AccountSnapshot::new(
537 ExchangeId::BinanceMargin,
538 Vec::new(),
539 Vec::new(),
540 ));
541 }
542
543 let mut balances_by_symbol = convert_isolated_margin_assets(
545 fetch_isolated_margin_account_info(
546 self.rest.clone(),
547 self.rate_limiter.clone(),
548 effective.clone(),
549 )
550 .await?,
551 );
552
553 let instrument_snapshots: Vec<_> =
556 futures::stream::iter(effective.into_iter().map(|instrument| {
557 fetch_margin_open_orders_for_instrument(
558 self.rest.clone(),
559 self.rate_limiter.clone(),
560 instrument,
561 true,
562 RequestKind::Query,
563 )
564 }))
565 .buffer_unordered(8)
566 .map(|result| {
567 let (inst, listing) = result?;
568 self.remember_live(&listing.orders);
569 let wrapped = listing
570 .orders
571 .into_iter()
572 .map(active_order_snapshot)
573 .collect();
574 let isolated = balances_by_symbol.remove(&inst);
575 if isolated.is_none() {
576 warn!(
580 instrument = %inst.name(),
581 "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
582 );
583 }
584 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
585 inst,
586 wrapped,
587 listing.complete,
588 PositionReport::Unreported,
589 isolated,
590 ))
591 })
592 .try_collect()
593 .await?;
594
595 Ok(AccountSnapshot::new(
598 ExchangeId::BinanceMargin,
599 Vec::new(),
600 instrument_snapshots,
601 ))
602 }
603}
604
605impl ExecutionClient for BinanceMargin {
606 const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
607
608 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
611 &[InstrumentKindDiscriminant::Spot];
612 type Config = BinanceMarginConfig;
613 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
614
615 fn new(config: Self::Config) -> Self {
628 if config.testnet {
629 warn!(
630 "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
631 using production endpoints"
632 );
633 }
634 assert!(
635 !(config.is_isolated && config.isolated_symbols.is_empty()),
636 "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
637 );
638 let rest_config = Self::build_rest_config(&config);
643 let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
644 let rest_config = Arc::new(rest_config);
645 let ws_config = Self::build_ws_config(&config);
646 Self {
647 config: Arc::new(config),
648 rest,
649 rest_config,
650 ws_config,
651 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
652 known_live: KnownLiveOrders::shared(ExchangeId::BinanceMargin),
653 }
654 }
655
656 async fn open_order(
670 &self,
671 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
672 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
673 let instrument = request.key.instrument.clone();
674 let side = request.state.side;
675 let price = request.state.price;
676 let quantity = request.state.quantity;
677 let kind = request.state.kind;
678 let time_in_force = request.state.time_in_force;
679 let cid = request.key.cid.clone();
680
681 let order_key = OrderKey::new(
682 ExchangeId::BinanceMargin,
683 instrument.clone(),
684 request.key.strategy.clone(),
685 cid.clone(),
686 );
687
688 let inactive = |state: UnindexedOrderState| Order {
691 key: order_key.clone(),
692 side,
693 price,
694 quantity,
695 kind,
696 time_in_force,
697 state,
698 };
699
700 let params = match build_new_order_params(
701 instrument.name().to_string(),
702 side,
703 price,
704 quantity,
705 kind,
706 time_in_force,
707 cid.0.to_string(),
708 self.config.side_effect,
709 self.config.is_isolated,
710 ) {
711 Ok(params) => params,
712 Err(BuildOrderError::Unsupported) => {
713 return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
714 format!(
715 "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
716 ),
717 )));
718 }
719 Err(BuildOrderError::Build(msg)) => {
720 error!(%msg, "BinanceMargin failed to build new order params");
721 return inactive(OrderState::inactive(OrderError::Rejected(
722 ApiError::OrderRejected(msg),
723 )));
724 }
725 };
726
727 let response =
728 match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
729 let params = params.clone();
730 Box::pin(async move { rest.margin_account_new_order(params).await })
731 })
732 .await
733 {
734 Ok(response) => response,
735 Err(e) => {
736 return inactive(OrderState::inactive(classify_rest_order_error(
737 &e,
738 &instrument,
739 )));
740 }
741 };
742
743 let data = match response.data().await {
744 Ok(data) => data,
745 Err(e) => {
746 return inactive(OrderState::inactive(OrderError::Rejected(
749 ApiError::OrderRejected(e.to_string()),
750 )));
751 }
752 };
753
754 let time_exchange = data
755 .transact_time
756 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
757 .unwrap_or_else(Utc::now);
758
759 let exchange_order_id = match data.order_id {
760 Some(id) => OrderId(format_smolstr!("{id}")),
761 None => {
762 error!("BinanceMargin open_order response missing orderId");
763 return inactive(OrderState::inactive(OrderError::Rejected(
764 ApiError::OrderRejected("open_order response missing orderId".into()),
765 )));
766 }
767 };
768
769 let state = placed_order_state(
772 ExchangeId::BinanceMargin,
773 &instrument,
774 quantity,
775 PlacementResponse {
776 status: data.status.as_deref(),
777 order_id: exchange_order_id,
778 time_exchange,
779 executed_qty: data.executed_qty.as_deref(),
780 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
781 },
782 );
783 self.known_live.lock().placed(&order_key, quantity, &state);
784
785 Order {
786 key: order_key,
787 side,
788 price,
789 quantity,
790 kind,
791 time_in_force,
792 state,
793 }
794 }
795
796 async fn cancel_order(
806 &self,
807 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
808 ) -> UnindexedOrderResponseCancel {
809 let instrument = request.key.instrument.clone();
810 let key = OrderKey {
811 exchange: request.key.exchange,
812 instrument: instrument.clone(),
813 strategy: request.key.strategy.clone(),
814 cid: request.key.cid.clone(),
815 };
816
817 let params = match build_cancel_order_params(
818 instrument.name().to_string(),
819 request.state.id.as_ref().and_then(VenueOrderId::assigned),
820 &request.key.cid,
821 self.config.is_isolated,
822 ) {
823 Ok(p) => p,
824 Err(e) => {
825 error!(%e, "BinanceMargin failed to build cancel order params");
826 return UnindexedOrderResponseCancel {
827 key,
828 state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
829 };
830 }
831 };
832
833 let response =
834 match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
835 let params = params.clone();
836 Box::pin(async move { rest.margin_account_cancel_order(params).await })
837 })
838 .await
839 {
840 Ok(response) => response,
841 Err(e) => {
842 return UnindexedOrderResponseCancel {
843 key,
844 state: Err(classify_rest_order_error(&e, &instrument)),
845 };
846 }
847 };
848
849 let data = match response.data().await {
850 Ok(data) => data,
851 Err(e) => {
852 return UnindexedOrderResponseCancel {
855 key,
856 state: Err(OrderError::Rejected(ApiError::OrderRejected(e.to_string()))),
857 };
858 }
859 };
860
861 let time_exchange = Utc::now();
863
864 let exchange_order_id = match data.order_id {
865 Some(id) => OrderId(SmolStr::new(id)),
868 None => {
869 error!("BinanceMargin cancel response missing orderId");
870 return UnindexedOrderResponseCancel {
871 key,
872 state: Err(OrderError::Rejected(ApiError::OrderRejected(
873 "cancel response missing orderId".into(),
874 ))),
875 };
876 }
877 };
878
879 let filled_qty = binance_filled_qty(
880 ExchangeId::BinanceMargin,
881 &exchange_order_id,
882 data.executed_qty.as_deref(),
883 );
884
885 self.known_live.lock().ended(&key.cid);
886 UnindexedOrderResponseCancel {
887 key,
888 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
889 }
890 }
891
892 async fn account_snapshot(
907 &self,
908 assets: &[AssetNameExchange],
909 instruments: &[InstrumentNameExchange],
910 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
911 if self.config.is_isolated {
912 return self.isolated_account_snapshot(instruments).await;
913 }
914 let response =
915 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
916 Box::pin(async move {
917 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
918 rest.query_cross_margin_account_details(params).await
919 })
920 })
921 .await
922 .map_err(|e| classify_rest_query_error(&e, None))?;
923
924 let account = response.data().await.map_err(response_decode_error)?;
925
926 let balances =
927 filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets);
928
929 use futures::{StreamExt as _, TryStreamExt as _};
934 let instrument_snapshots: Vec<_> =
935 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
936 fetch_margin_open_orders_for_instrument(
937 self.rest.clone(),
938 self.rate_limiter.clone(),
939 instrument,
940 false,
942 RequestKind::Query,
943 )
944 }))
945 .buffer_unordered(8)
946 .map(|result| {
947 let (inst, listing) = result?;
948 self.remember_live(&listing.orders);
949 let wrapped = listing
950 .orders
951 .into_iter()
952 .map(active_order_snapshot)
953 .collect();
954 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
955 inst,
956 wrapped,
957 listing.complete,
958 PositionReport::Unreported,
959 None,
960 ))
961 })
962 .try_collect()
963 .await?;
964
965 Ok(AccountSnapshot::new(
966 ExchangeId::BinanceMargin,
967 balances,
968 instrument_snapshots,
969 ))
970 }
971
972 async fn fetch_balances(
982 &self,
983 assets: &[AssetNameExchange],
984 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
985 if self.config.is_isolated {
986 return Ok(Vec::new());
987 }
988 let response =
989 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
990 Box::pin(async move {
991 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
992 rest.query_cross_margin_account_details(params).await
993 })
994 })
995 .await
996 .map_err(|e| classify_rest_query_error(&e, None))?;
997
998 let account = response.data().await.map_err(response_decode_error)?;
999
1000 Ok(filter_and_convert_margin_balances(
1001 account.user_assets.unwrap_or_default(),
1002 assets,
1003 ))
1004 }
1005
1006 async fn fetch_open_orders(
1017 &self,
1018 instruments: &[InstrumentNameExchange],
1019 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1020 use futures::{StreamExt as _, TryStreamExt as _};
1021
1022 if self.config.is_isolated {
1026 let effective = self.effective_isolated_set(instruments);
1027 let capacity = effective.len();
1028 return futures::stream::iter(effective.into_iter().map(|instrument| {
1029 fetch_margin_open_orders_for_instrument(
1030 self.rest.clone(),
1031 self.rate_limiter.clone(),
1032 instrument,
1033 true,
1034 RequestKind::Query,
1035 )
1036 }))
1037 .buffer_unordered(8)
1038 .try_fold(
1039 Vec::with_capacity(capacity),
1040 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
1041 (_, listing)| async move {
1042 acc.extend(listing.orders);
1043 Ok(acc)
1044 },
1045 )
1046 .await
1047 .inspect(|orders| self.remember_live(orders));
1048 }
1049
1050 if instruments.is_empty() {
1053 let orders =
1054 fetch_margin_all_open_orders(self.rest.clone(), self.rate_limiter.clone(), false)
1055 .await?;
1056 self.remember_live(&orders);
1057 return Ok(orders);
1058 }
1059 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1060 fetch_margin_open_orders_for_instrument(
1061 self.rest.clone(),
1062 self.rate_limiter.clone(),
1063 instrument,
1064 false,
1065 RequestKind::Query,
1066 )
1067 }))
1068 .buffer_unordered(8)
1069 .try_fold(
1070 Vec::with_capacity(instruments.len()),
1071 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1072 acc.extend(listing.orders);
1073 Ok(acc)
1074 },
1075 )
1076 .await
1077 .inspect(|orders| self.remember_live(orders))
1078 }
1079
1080 async fn fetch_trades(
1109 &self,
1110 start: DateTime<Utc>,
1111 end: DateTime<Utc>,
1112 instruments: &[InstrumentNameExchange],
1113 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1114 use futures::StreamExt as _;
1115
1116 if start > end {
1117 return Ok(TradesRead::complete(Vec::new()));
1118 }
1119
1120 let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1123 self.effective_isolated_set(instruments)
1124 } else {
1125 instruments.to_vec()
1126 };
1127 if effective.is_empty() {
1128 debug!(
1131 is_isolated = self.config.is_isolated,
1132 "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1133 );
1134 return Ok(TradesRead::complete(Vec::new()));
1135 }
1136 let span = MyTradesFrom::Span {
1138 start: start.timestamp_millis(),
1139 end: end.timestamp_millis(),
1140 };
1141 let is_isolated = self.config.is_isolated;
1142 let mut all_trades = Vec::new();
1143
1144 let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1147 let rest = self.rest.clone();
1148 let rate_limiter = self.rate_limiter.clone();
1149 async move {
1150 let pages = paginate_margin_my_trades(
1151 &rest,
1152 &rate_limiter,
1153 &inst,
1154 span,
1155 is_isolated,
1156 RequestKind::Query,
1157 )
1158 .await?;
1159 Ok::<_, UnindexedClientError>((inst, pages))
1160 }
1161 }))
1162 .buffer_unordered(8);
1163 while let Some(result) = stream.next().await {
1164 let (instrument, trades_data) = result?;
1165 for t in trades_data {
1166 if let Some(trade) = convert_margin_trade(&t, &instrument)
1167 && (start..=end).contains(&trade.time_exchange)
1168 {
1169 all_trades.push(trade);
1170 }
1171 }
1172 }
1173
1174 Ok(TradesRead::complete(all_trades))
1175 }
1176
1177 async fn account_stream(
1284 &self,
1285 _assets: &[AssetNameExchange],
1288 instruments: &[InstrumentNameExchange],
1289 ) -> Result<Self::AccountStream, UnindexedClientError> {
1290 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1291 let dedup = new_dedup_cache();
1292 let rest = self.rest.clone();
1293 let rest_config = self.rest_config.clone();
1294 let ws_config = self.ws_config.clone();
1295 let rate_limiter = self.rate_limiter.clone();
1296 let known_live = self.known_live.clone();
1297
1298 let cm_handle = if self.config.is_isolated {
1299 let symbols = self.config.isolated_symbols.clone();
1303 debug_assert!(
1306 symbols.iter().all(|i| i.name().len() <= 23),
1307 "instrument name exceeds SmolStr inline capacity: {:?}",
1308 symbols.iter().find(|i| i.name().len() > 23)
1309 );
1310
1311 let assets = fetch_isolated_margin_account_info(
1317 rest.clone(),
1318 rate_limiter.clone(),
1319 symbols.clone(),
1320 )
1321 .await?;
1322 let base_quote = Arc::new(build_base_quote_map(&assets));
1323 for sym in &symbols {
1324 if !base_quote.contains_key(sym) {
1325 warn!(
1326 symbol = %sym.name(),
1327 "BinanceMargin isolated: account info returned no base/quote for configured \
1328 symbol — its live per-pair balance frames will be dropped (fills/orders \
1329 unaffected; balances available via account_snapshot)"
1330 );
1331 }
1332 }
1333
1334 let tokens = acquire_all_isolated_tokens(&rest_config, &rate_limiter, &symbols).await?;
1338 let live = isolated_connect_and_subscribe(
1339 &ws_config,
1340 &tx,
1341 &dedup,
1342 &known_live,
1343 &base_quote,
1344 &tokens,
1345 )
1346 .await
1347 .map_err(|e| {
1348 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1349 })?;
1350
1351 tokio::spawn(isolated_connection_manager(
1352 tx,
1353 dedup,
1354 ws_config,
1355 rest_config,
1356 rest,
1357 rate_limiter,
1358 known_live,
1359 symbols,
1360 base_quote,
1361 Some(live),
1362 ))
1363 } else {
1364 let instruments = instruments.to_vec();
1366 debug_assert!(
1370 instruments.iter().all(|i| i.name().len() <= 23),
1371 "instrument name exceeds SmolStr inline capacity: {:?}",
1372 instruments.iter().find(|i| i.name().len() > 23)
1373 );
1374
1375 let initial_token =
1381 acquire_user_listen_token(&rest_config, &rate_limiter, None).await?;
1382 let initial_ws = connect_margin_ws(&ws_config).await.map_err(|e| {
1383 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1384 })?;
1385
1386 tokio::spawn(margin_connection_manager(
1387 tx,
1388 dedup,
1389 ws_config,
1390 rest_config,
1391 rest,
1392 rate_limiter,
1393 known_live,
1394 instruments,
1395 Some((initial_ws, initial_token)),
1396 ))
1397 };
1398
1399 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1400 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1401 Ok(futures::StreamExt::boxed(guarded_stream))
1402 }
1403}
1404
1405impl OrderStatusClient for BinanceMargin {
1422 async fn fetch_ended_orders(
1423 &self,
1424 orders: &[UnindexedOrderKey],
1425 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1426 fetch_ended_by_key(orders, |key| {
1427 fetch_margin_order_lookup(
1428 self.rest.clone(),
1429 self.rate_limiter.clone(),
1430 key,
1431 self.config.is_isolated,
1432 RequestKind::Query,
1433 )
1434 })
1435 .await
1436 }
1437}
1438
1439const TOKEN_RENEW_MARGIN_SECS: i64 = 300; const TOKEN_MIN_LIFETIME_SECS: u64 = 60;
1447
1448struct UserListenToken {
1450 token: String,
1451 expiration_time_ms: i64,
1452}
1453
1454fn build_listen_token_query(
1458 symbol: Option<&InstrumentNameExchange>,
1459) -> BTreeMap<String, serde_json::Value> {
1460 let mut query = BTreeMap::new();
1461 if let Some(sym) = symbol {
1462 query.insert(
1463 "isIsolated".to_string(),
1464 serde_json::Value::String("TRUE".to_string()),
1465 );
1466 query.insert(
1467 "symbol".to_string(),
1468 serde_json::Value::String(sym.name().to_string()),
1469 );
1470 }
1471 query
1472}
1473
1474#[derive(Deserialize)]
1476struct UserListenTokenResponse {
1477 #[serde(alias = "listenToken")]
1479 token: String,
1480 #[serde(rename = "expirationTime")]
1481 expiration_time: i64,
1482}
1483
1484async fn acquire_user_listen_token(
1499 rest_config: &Arc<ConfigurationRestApi>,
1500 rate_limiter: &RateLimitTracker,
1501 symbol: Option<&InstrumentNameExchange>,
1502) -> Result<UserListenToken, UnindexedClientError> {
1503 let query = build_listen_token_query(symbol);
1506 let response = rest_call_with_retry(rest_config, rate_limiter, RequestKind::Essential, |cfg| {
1507 let query = query.clone();
1508 Box::pin(async move {
1509 binance_sdk::common::utils::send_request::<UserListenTokenResponse>(
1510 &cfg,
1511 "/sapi/v1/userListenToken",
1512 reqwest::Method::POST,
1513 query,
1516 BTreeMap::new(),
1517 None,
1518 true, )
1520 .await
1521 })
1522 })
1523 .await
1524 .map_err(|e| classify_rest_query_error(&e, symbol))?;
1525
1526 let data = response.data().await.map_err(response_decode_error)?;
1527 Ok(UserListenToken {
1528 token: data.token,
1529 expiration_time_ms: data.expiration_time,
1530 })
1531}
1532
1533fn token_renew_after(expiration_time_ms: i64) -> Duration {
1536 let now_ms = Utc::now().timestamp_millis();
1537 let remaining_secs = expiration_time_ms
1541 .saturating_sub(now_ms)
1542 .saturating_div(1_000)
1543 .saturating_sub(TOKEN_RENEW_MARGIN_SECS);
1544 let secs = u64::try_from(remaining_secs).unwrap_or(0);
1545 if secs < TOKEN_MIN_LIFETIME_SECS {
1548 warn!(
1549 computed_secs = secs,
1550 floor_secs = TOKEN_MIN_LIFETIME_SECS,
1551 "BinanceMargin token renewal wait clamped to floor (near-expiry or odd expirationTime)"
1552 );
1553 }
1554 Duration::from_secs(secs.max(TOKEN_MIN_LIFETIME_SECS))
1555}
1556
1557async fn connect_margin_ws(
1564 ws_config: &ConfigurationWebsocketApi,
1565) -> anyhow::Result<Arc<WsApiBase>> {
1566 let ws = WsApiBase::new(ws_config.clone(), vec![]);
1567 tokio::time::timeout(
1570 Duration::from_secs(CONNECT_TIMEOUT_SECS),
1571 ws.clone().connect(),
1572 )
1573 .await
1574 .map_err(|_| {
1575 anyhow::anyhow!("BinanceMargin WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
1576 })??;
1577 Ok(ws)
1578}
1579
1580async fn subscribe_listen_token(ws: &Arc<WsApiBase>, token: &str) -> anyhow::Result<()> {
1587 let mut payload = BTreeMap::new();
1590 payload.insert(
1591 "listenToken".to_string(),
1592 serde_json::Value::String(token.to_string()),
1593 );
1594 ws.send_message::<serde_json::Value>(
1595 "userDataStream.subscribe.listenToken",
1596 payload,
1597 WebsocketMessageSendOptions::new(),
1598 )
1599 .await
1600 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1601 Ok(())
1602}
1603
1604async fn subscribe_listen_token_capture(
1617 ws: &Arc<WsApiBase>,
1618 token: &str,
1619) -> anyhow::Result<Option<i64>> {
1620 #[derive(Deserialize)]
1623 struct SubscribeAck {
1624 #[serde(rename = "subscriptionId", default)]
1625 subscription_id: Option<i64>,
1626 }
1627
1628 let mut payload = BTreeMap::new();
1629 payload.insert(
1630 "listenToken".to_string(),
1631 serde_json::Value::String(token.to_string()),
1632 );
1633 let result = ws
1634 .send_message::<SubscribeAck>(
1635 "userDataStream.subscribe.listenToken",
1636 payload,
1637 WebsocketMessageSendOptions::new(),
1638 )
1639 .await
1640 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1641 let ack = match result {
1642 SendWebsocketMessageResult::Single(resp) => resp.data()?,
1643 SendWebsocketMessageResult::Multiple(responses) => responses
1645 .into_iter()
1646 .next()
1647 .ok_or_else(|| anyhow::anyhow!("empty subscribe ack"))?
1648 .data()?,
1649 };
1650 Ok(ack.subscription_id)
1651}
1652
1653fn cross_account_position_handler(
1657 position: OutboundAccountPosition,
1658 _subscription_id: Option<i64>,
1659 buf: &mut Vec<UnindexedAccountEvent>,
1660) {
1661 convert_margin_account_position(position, buf);
1662}
1663
1664#[cfg(test)]
1669fn convert_margin_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
1670 convert_margin_user_data_events_with(frame, buf, &mut cross_account_position_handler)
1671}
1672
1673fn convert_margin_user_data_events_with(
1688 frame: &str,
1689 buf: &mut Vec<UnindexedAccountEvent>,
1690 handle_position: &mut impl FnMut(
1691 OutboundAccountPosition,
1692 Option<i64>,
1693 &mut Vec<UnindexedAccountEvent>,
1694 ),
1695) -> bool {
1696 let (subscription_id, event_type, event_raw) = match parse_user_data_frame(frame) {
1697 UserDataFrame::Response => return false,
1699 UserDataFrame::Event {
1700 subscription_id,
1701 event_type,
1702 event,
1703 } => (subscription_id, event_type, event),
1704 UserDataFrame::Unrecognised => {
1705 static SEEN: AtomicU64 = AtomicU64::new(0);
1706 log_unrecognised_frame("BinanceMargin", &SEEN, frame);
1707 return false;
1708 }
1709 };
1710 match event_type {
1711 "executionReport" => {
1712 match serde_json::from_str::<ExecutionReport>(event_raw) {
1715 Ok(report) => convert_execution_report(&report, ExchangeId::BinanceMargin, buf),
1716 Err(e) => {
1717 warn!(error = %e, "BinanceMargin: undeserializable executionReport, dropping")
1718 }
1719 }
1720 false
1721 }
1722 "outboundAccountPosition" => {
1723 match serde_json::from_str::<OutboundAccountPosition>(event_raw) {
1724 Ok(position) => handle_position(position, subscription_id, buf),
1726 Err(e) => {
1727 warn!(error = %e, "BinanceMargin: undeserializable outboundAccountPosition, dropping")
1728 }
1729 }
1730 false
1731 }
1732 "balanceUpdate" => {
1733 false
1737 }
1738 "userLiabilityChange" => {
1739 match serde_json::from_str::<UserLiabilityChange>(event_raw) {
1746 Ok(c) => {
1747 if tracing::enabled!(tracing::Level::INFO) {
1748 info!(
1749 asset = c.a.as_deref().unwrap_or("?"),
1750 kind = c.t.as_deref().unwrap_or("?"),
1751 principal = c.p.as_deref().unwrap_or("?"),
1752 interest = c.i.as_deref().unwrap_or("?"),
1753 "BinanceMargin userLiabilityChange (observable; not applied to balance state)"
1754 );
1755 }
1756 }
1757 Err(e) => warn!(
1758 error = %e,
1759 "BinanceMargin: undeserializable userLiabilityChange, dropping"
1760 ),
1761 }
1762 false
1763 }
1764 "marginLevelStatusChange" => {
1765 if tracing::enabled!(tracing::Level::WARN) {
1770 match serde_json::from_str::<MarginLevelStatusChange>(event_raw) {
1771 Ok(c) => warn!(
1772 margin_level = c.l.as_deref().unwrap_or("?"),
1773 status = c.s.as_deref().unwrap_or("?"),
1774 "BinanceMargin marginLevelStatusChange (liquidation risk; observable, no policy)"
1775 ),
1776 Err(e) => warn!(
1777 error = %e,
1778 "BinanceMargin: undeserializable marginLevelStatusChange, dropping"
1779 ),
1780 }
1781 }
1782 false
1783 }
1784 "eventStreamTerminated" => {
1785 warn!("BinanceMargin user data stream terminated by exchange, signalling reconnect");
1786 true
1787 }
1788 other => {
1789 trace!(
1790 event_type = other,
1791 "BinanceMargin ignoring unhandled user data event"
1792 );
1793 false
1794 }
1795 }
1796}
1797
1798fn convert_margin_account_position(
1803 position: OutboundAccountPosition,
1804 buf: &mut Vec<UnindexedAccountEvent>,
1805) {
1806 let time_exchange = position
1807 .u
1808 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1809 .unwrap_or_else(Utc::now);
1810
1811 for b in position.b_uppercase.unwrap_or_default() {
1812 let asset = match b.a {
1813 Some(a) => AssetNameExchange::new(a),
1814 None => {
1815 warn!("BinanceMargin account position entry missing asset name");
1816 continue;
1817 }
1818 };
1819 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1820 Some(v) => v,
1821 None => {
1822 warn!(%asset, "BinanceMargin account position missing/unparseable 'free' field");
1823 continue;
1824 }
1825 };
1826 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1827 Some(v) => v,
1828 None => {
1829 warn!(%asset, "BinanceMargin account position missing/unparseable 'locked' field");
1830 continue;
1831 }
1832 };
1833 let update =
1834 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
1835 buf.push(UnindexedAccountEvent::new(
1836 ExchangeId::BinanceMargin,
1837 AccountEventKind::BalanceStreamUpdate(
1838 rustrade_integration::collection::snapshot::Snapshot::new(update),
1839 ),
1840 ));
1841 }
1842}
1843
1844fn route_isolated_account_position(
1855 position: OutboundAccountPosition,
1856 subscription_id: Option<i64>,
1857 sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
1858 base_quote: &HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>,
1859 buf: &mut Vec<UnindexedAccountEvent>,
1860) {
1861 let Some(sub_id) = subscription_id else {
1862 warn!(
1863 "BinanceMargin isolated: outboundAccountPosition without subscriptionId — dropping \
1864 (per-instrument balance routing requires it)"
1865 );
1866 return;
1867 };
1868 let instrument = {
1869 let map = sub_map.lock().unwrap_or_else(|p| p.into_inner());
1872 match map.get(&sub_id) {
1873 Some(inst) => inst.clone(),
1874 None => {
1875 warn!(
1876 subscription_id = sub_id,
1877 "BinanceMargin isolated: outboundAccountPosition for unmapped subscriptionId — dropping"
1878 );
1879 return;
1880 }
1881 }
1882 };
1883 let Some((base_asset, quote_asset)) = base_quote.get(&instrument) else {
1884 warn!(
1885 instrument = %instrument.name(),
1886 "BinanceMargin isolated: no base/quote known for instrument — dropping balance frame"
1887 );
1888 return;
1889 };
1890
1891 let time_exchange = position
1892 .u
1893 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1894 .unwrap_or_else(Utc::now);
1895
1896 let mut base_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
1897 let mut quote_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
1898 for b in position.b_uppercase.unwrap_or_default() {
1899 let Some(asset) = b.a.as_deref() else {
1900 warn!(instrument = %instrument.name(), "BinanceMargin isolated account position entry missing asset name");
1901 continue;
1902 };
1903 let side = if asset == base_asset.name().as_str() {
1905 &mut base_update
1906 } else if asset == quote_asset.name().as_str() {
1907 &mut quote_update
1908 } else {
1909 continue;
1910 };
1911 let (Some(free), Some(locked)) = (
1912 b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()),
1913 b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()),
1914 ) else {
1915 warn!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position missing/unparseable free/locked");
1916 continue;
1917 };
1918 *side = Some(AssetBalanceUpdate::new(
1919 AssetNameExchange::new(asset),
1920 BalanceUpdate::new(free, locked),
1921 time_exchange,
1922 ));
1923 }
1924
1925 let (Some(base), Some(quote)) = (base_update, quote_update) else {
1929 warn!(
1930 instrument = %instrument.name(),
1931 "BinanceMargin isolated: outboundAccountPosition missing base or quote side — dropping (no partial InstrumentBalanceUpdate)"
1932 );
1933 return;
1934 };
1935 buf.push(UnindexedAccountEvent::new(
1936 ExchangeId::BinanceMargin,
1937 AccountEventKind::InstrumentBalanceUpdate(InstrumentBalanceUpdate::new(
1938 instrument, base, quote,
1939 )),
1940 ));
1941}
1942
1943fn register_user_data_listener(
1955 ws: &Arc<WsApiBase>,
1956 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1957 dedup: SharedDedupCache,
1958 known: SharedKnownLiveOrders,
1959 heartbeat_flag: Arc<AtomicBool>,
1960 signal_tx: oneshot::Sender<()>,
1961 mut handle_position: impl FnMut(
1962 OutboundAccountPosition,
1963 Option<i64>,
1964 &mut Vec<UnindexedAccountEvent>,
1965 ) + Send
1966 + 'static,
1967) -> Subscription {
1968 let mut signal_tx_opt = Some(signal_tx);
1969 let mut event_tx = Some(tx);
1970 let mut event_buf = Vec::with_capacity(32);
1974
1975 ws.common.events.subscribe(move |event| {
1979 let Some(ref sender) = event_tx else { return };
1980 match event {
1981 WebsocketEvent::Message(json_str) => {
1982 heartbeat_flag.store(true, Ordering::Release);
1983 let terminated = convert_margin_user_data_events_with(
1986 &json_str,
1987 &mut event_buf,
1988 &mut handle_position,
1989 );
1990 for ev in event_buf.drain(..) {
1991 if let Some(key) = dedup_key_from_event(&ev)
1992 && is_duplicate(&dedup, key)
1993 {
1994 trace!("BinanceMargin dedup: skipping duplicate event");
1995 continue;
1996 }
1997 let held = KnownLiveOrders::observes(&ev.kind).then(|| {
2001 let mut held = known.lock();
2002 held.observe(&ev);
2003 held
2004 });
2005 if sender.send(ev).is_err() {
2006 drop(held);
2007 warn!("BinanceMargin account_stream receiver dropped, suppressing sends");
2008 event_tx.take();
2009 if let Some(s) = signal_tx_opt.take() {
2010 let _ = s.send(());
2011 }
2012 return;
2013 }
2014 }
2015 if terminated {
2016 event_tx.take();
2017 if let Some(s) = signal_tx_opt.take() {
2018 let _ = s.send(());
2019 }
2020 }
2021 }
2022 WebsocketEvent::Ping | WebsocketEvent::Pong => {
2023 heartbeat_flag.store(true, Ordering::Release);
2024 }
2025 WebsocketEvent::Error(e) => {
2026 warn!(%e, "BinanceMargin WebSocket error, will attempt reconnect");
2027 event_tx.take();
2028 if let Some(s) = signal_tx_opt.take() {
2029 let _ = s.send(());
2030 }
2031 }
2032 WebsocketEvent::Close(code, reason) => {
2033 warn!(code, %reason, "BinanceMargin WebSocket closed");
2034 event_tx.take();
2035 if let Some(s) = signal_tx_opt.take() {
2036 let _ = s.send(());
2037 }
2038 }
2039 _ => {
2040 trace!("BinanceMargin ignoring unhandled WebsocketEvent variant");
2041 }
2042 }
2043 })
2044}
2045
2046async fn recover_margin_fills(
2066 rest: &Arc<RestApi>,
2067 rate_limiter: &Arc<RateLimitTracker>,
2068 unrecovered: &mut UnrecoveredFills,
2069 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2070 dedup: &SharedDedupCache,
2071 known: &SharedKnownLiveOrders,
2072 is_isolated: bool,
2073) {
2074 use futures::StreamExt as _;
2075
2076 let due = unrecovered.due(tokio::time::Instant::now());
2077 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
2078 return;
2079 };
2080 info!(
2081 gaps = due.len(),
2082 oldest = %gap_time(oldest),
2083 is_isolated,
2084 "BinanceMargin recovering fills missed while disconnected"
2085 );
2086
2087 let mut settled = vec![false; due.len()];
2090 let started = AtomicUsize::new(0);
2091 let recovery = async {
2092 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
2093 let mut recovered = 0u32;
2094 let mut duplicates = 0u32;
2095
2096 let mut stream =
2097 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
2098 started.store(index + 1, Ordering::Relaxed);
2099 let rest = rest.clone();
2100 let rl = rate_limiter.clone();
2101 async move {
2102 let span = MyTradesFrom::Span {
2103 start: gap.start_ms,
2104 end: gap.end_ms,
2105 };
2106 let raw = match paginate_margin_my_trades(
2107 &rest,
2108 &rl,
2109 &inst,
2110 span,
2111 is_isolated,
2112 RequestKind::Essential,
2113 )
2114 .await
2115 {
2116 Ok(pages) => pages,
2117 Err(e) => return (index, Err(e)),
2118 };
2119 let totals = recovered_order_totals(
2123 ExchangeId::BinanceMargin,
2124 &inst,
2125 &raw,
2126 order_executions_deadline,
2127 |order_id| {
2128 paginate_margin_my_trades(
2129 &rest,
2130 &rl,
2131 &inst,
2132 MyTradesFrom::Order(order_id),
2133 is_isolated,
2134 RequestKind::Essential,
2135 )
2136 },
2137 )
2138 .await;
2139 let trades = raw
2140 .iter()
2141 .filter_map(|t| {
2142 let mut trade = convert_margin_trade(t, &inst)?;
2143 trade.order_filled_quantity =
2144 t.id.and_then(|id| totals.get(&id).copied());
2145 Some(trade)
2146 })
2147 .collect::<Vec<_>>();
2148 (index, Ok(trades))
2149 }
2150 }))
2151 .buffer_unordered(8);
2152 while let Some((index, result)) = stream.next().await {
2153 let (inst, gap) = &due[index];
2154 settled[index] = true;
2155 let trades = match result {
2156 Ok(trades) => trades,
2157 Err(e) => {
2158 gap_failed(
2159 unrecovered,
2160 tx,
2161 ExchangeId::BinanceMargin,
2162 inst,
2163 gap,
2164 FillRecoveryFailure::Request(e.to_string()),
2165 );
2166 continue;
2167 }
2168 };
2169 for trade in trades {
2170 let event = UnindexedAccountEvent::new(
2171 ExchangeId::BinanceMargin,
2172 AccountEventKind::Trade(trade),
2173 );
2174 if let Some(key) = dedup_key_from_event(&event)
2175 && is_duplicate(dedup, key)
2176 {
2177 duplicates += 1;
2178 continue;
2179 }
2180 let sent = {
2183 let mut known = known.lock();
2184 known.observe(&event);
2185 tx.send(event)
2186 };
2187 if sent.is_err() {
2188 debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2189 return;
2190 }
2191 recovered += 1;
2192 }
2193 unrecovered.recovered(inst, gap);
2194 }
2195 info!(
2196 recovered,
2197 duplicates, is_isolated, "BinanceMargin fill recovery complete"
2198 );
2199 };
2200 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2204 .await
2205 .is_err()
2206 {
2207 let started = started.load(Ordering::Relaxed);
2208 for ((inst, gap), _) in due[..started]
2209 .iter()
2210 .zip(&settled)
2211 .filter(|(_, settled)| !**settled)
2212 {
2213 gap_failed(
2214 unrecovered,
2215 tx,
2216 ExchangeId::BinanceMargin,
2217 inst,
2218 gap,
2219 FillRecoveryFailure::TimedOut {
2220 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2221 },
2222 );
2223 }
2224 }
2225}
2226
2227fn open_reconnect_recovery(
2232 unrecovered: &mut UnrecoveredFills,
2233 unchecked: &mut UncheckedOrders,
2234 known: &SharedKnownLiveOrders,
2235 instruments: &[InstrumentNameExchange],
2236 disconnected: DateTime<Utc>,
2237) {
2238 unrecovered.open(instruments, disconnected, Utc::now());
2239 let held = known.lock().instruments_among(instruments);
2240 unchecked.open(held);
2241}
2242
2243#[allow(
2250 clippy::cognitive_complexity,
2251 reason = "inherent reconnect-loop complexity (token + connect + subscribe + callback + recovery \
2252 + monitor + cleanup + backoff); mirrors spot's `connection_manager`, not worth splitting further"
2253)]
2254async fn margin_connection_manager(
2255 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2256 dedup: SharedDedupCache,
2257 ws_config: ConfigurationWebsocketApi,
2258 rest_config: Arc<ConfigurationRestApi>,
2259 rest: Arc<RestApi>,
2260 rate_limiter: Arc<RateLimitTracker>,
2261 known: SharedKnownLiveOrders,
2262 instruments: Vec<InstrumentNameExchange>,
2263 initial: Option<(Arc<WsApiBase>, UserListenToken)>,
2264) {
2265 enum DisconnectReason {
2266 Signal,
2267 HeartbeatTimeout,
2268 TokenRefresh,
2269 ConsumerDropped,
2270 }
2271
2272 let mut backoff = ExponentialBackoff::new();
2273 let mut disconnect_time: Option<DateTime<Utc>> = None;
2274 let mut unrecovered = UnrecoveredFills::default();
2276 let mut unchecked = UncheckedOrders::default();
2278 let (mut current_ws, mut current_token) = match initial {
2279 Some((ws, token)) => (Some(ws), Some(token)),
2280 None => (None, None),
2281 };
2282
2283 loop {
2284 let token = match current_token.take() {
2286 Some(t) => t,
2287 None => match acquire_user_listen_token(&rest_config, &rate_limiter, None).await {
2288 Ok(t) => t,
2289 Err(e) => {
2290 error!(%e, "BinanceMargin userListenToken acquisition failed");
2291 if !backoff.wait().await {
2292 error!("BinanceMargin max reconnect attempts exhausted");
2293 emit_stream_terminated(
2294 &tx,
2295 ExchangeId::BinanceMargin,
2296 StreamTerminationReason::ReconnectBudgetExhausted {
2297 attempts: backoff.attempts(),
2298 last_error: e.to_string(),
2299 },
2300 );
2301 break;
2302 }
2303 continue;
2304 }
2305 },
2306 };
2307
2308 let ws = match current_ws.take() {
2310 Some(ws) => ws,
2311 None => match connect_margin_ws(&ws_config).await {
2312 Ok(ws) => ws,
2313 Err(e) => {
2314 error!(%e, "BinanceMargin WS connect failed");
2315 if !backoff.wait().await {
2316 error!("BinanceMargin max reconnect attempts exhausted");
2317 emit_stream_terminated(
2318 &tx,
2319 ExchangeId::BinanceMargin,
2320 StreamTerminationReason::ReconnectBudgetExhausted {
2321 attempts: backoff.attempts(),
2322 last_error: e.to_string(),
2323 },
2324 );
2325 break;
2326 }
2327 continue;
2328 }
2329 },
2330 };
2331
2332 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2334 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2336 let subscription = register_user_data_listener(
2339 &ws,
2340 tx.clone(),
2341 dedup.clone(),
2342 known.clone(),
2343 heartbeat_flag.clone(),
2344 signal_tx,
2345 cross_account_position_handler,
2346 );
2347
2348 if let Err(e) = subscribe_listen_token(&ws, &token.token).await {
2350 warn!(%e, "BinanceMargin user-data subscribe failed, cleaning up and retrying");
2351 subscription.unsubscribe();
2352 if let Err(de) = ws.disconnect().await {
2353 warn!(%de, "BinanceMargin failed to disconnect after subscribe failure");
2354 }
2355 if !backoff.wait().await {
2357 error!("BinanceMargin max reconnect attempts exhausted");
2358 emit_stream_terminated(
2359 &tx,
2360 ExchangeId::BinanceMargin,
2361 StreamTerminationReason::ReconnectBudgetExhausted {
2362 attempts: backoff.attempts(),
2363 last_error: e.to_string(),
2364 },
2365 );
2366 break;
2367 }
2368 continue;
2369 }
2370 info!("BinanceMargin account_stream connected and subscribed");
2371 backoff.reset();
2375
2376 let token_deadline =
2379 tokio::time::Instant::now() + token_renew_after(token.expiration_time_ms);
2380
2381 if let Some(dt) = disconnect_time.take() {
2386 open_reconnect_recovery(&mut unrecovered, &mut unchecked, &known, &instruments, dt);
2387 }
2388 recover_margin_fills(
2391 &rest,
2392 &rate_limiter,
2393 &mut unrecovered,
2394 &tx,
2395 &dedup,
2396 &known,
2397 false,
2398 )
2399 .await;
2400 recover_margin_ended_orders(
2401 &rest,
2402 &rate_limiter,
2403 &known,
2404 &mut unchecked,
2405 &unrecovered,
2406 &tx,
2407 false,
2408 )
2409 .await;
2410
2411 let reason = {
2413 let mut signal_rx = signal_rx;
2414 let retry_gaps = async {
2420 loop {
2421 let next_check = unchecked.next_due(&unrecovered);
2422 match unrecovered.next_due().into_iter().chain(next_check).min() {
2423 Some(due) => tokio::time::sleep_until(due).await,
2424 None => std::future::pending::<()>().await,
2425 }
2426 recover_margin_fills(
2427 &rest,
2428 &rate_limiter,
2429 &mut unrecovered,
2430 &tx,
2431 &dedup,
2432 &known,
2433 false,
2434 )
2435 .await;
2436 recover_margin_ended_orders(
2437 &rest,
2438 &rate_limiter,
2439 &known,
2440 &mut unchecked,
2441 &unrecovered,
2442 &tx,
2443 false,
2444 )
2445 .await;
2446 }
2447 };
2448 tokio::pin!(retry_gaps);
2449 loop {
2450 tokio::select! {
2451 biased;
2454 _ = tx.closed() => {
2455 debug!("BinanceMargin account_stream consumer dropped, terminating");
2456 break DisconnectReason::ConsumerDropped;
2457 }
2458 _ = &mut signal_rx => {
2459 warn!("BinanceMargin WS disconnected, will attempt reconnect");
2460 break DisconnectReason::Signal;
2461 }
2462 () = tokio::time::sleep_until(token_deadline) => {
2463 info!("BinanceMargin userListenToken nearing expiry, renewing");
2464 break DisconnectReason::TokenRefresh;
2465 }
2466 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2467 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2468 continue;
2472 }
2473 warn!("BinanceMargin heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2474 break DisconnectReason::HeartbeatTimeout;
2475 }
2476 () = &mut retry_gaps => {}
2478 }
2479 }
2480 };
2481 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2482
2483 if should_reconnect {
2484 disconnect_time = Some(match reason {
2485 DisconnectReason::HeartbeatTimeout => {
2486 Utc::now()
2487 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2488 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2489 }
2490 DisconnectReason::TokenRefresh => {
2491 Utc::now()
2496 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2497 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2498 }
2499 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2500 });
2501 }
2502
2503 subscription.unsubscribe();
2505 if let Err(e) = ws.disconnect().await {
2506 warn!(%e, "BinanceMargin failed to disconnect WebSocket");
2507 }
2508
2509 if !should_reconnect || tx.is_closed() {
2510 debug!("BinanceMargin connection manager exiting");
2512 break;
2513 }
2514
2515 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2518 error!("BinanceMargin max reconnect attempts exhausted, stream terminating");
2519 let last_error = match reason {
2522 DisconnectReason::HeartbeatTimeout => {
2523 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2524 }
2525 _ => "WebSocket disconnected (server close/error)".to_string(),
2526 };
2527 emit_stream_terminated(
2528 &tx,
2529 ExchangeId::BinanceMargin,
2530 StreamTerminationReason::ReconnectBudgetExhausted {
2531 attempts: backoff.attempts(),
2532 last_error,
2533 },
2534 );
2535 break;
2536 }
2537 }
2538}
2539
2540fn build_base_quote_map(
2552 assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
2553) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
2554 let mut map = HashMap::with_capacity(assets.len());
2555 for entry in assets {
2556 let (Some(symbol), Some(base), Some(quote)) = (
2557 entry.symbol.as_deref(),
2558 entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
2559 entry
2560 .quote_asset
2561 .as_deref()
2562 .and_then(|q| q.asset.as_deref()),
2563 ) else {
2564 warn!(
2565 "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
2566 skipping base/quote map entry"
2567 );
2568 continue;
2569 };
2570 map.insert(
2571 InstrumentNameExchange::new(symbol),
2572 (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
2573 );
2574 }
2575 map
2576}
2577
2578async fn acquire_all_isolated_tokens(
2591 rest_config: &Arc<ConfigurationRestApi>,
2592 rate_limiter: &Arc<RateLimitTracker>,
2593 symbols: &[InstrumentNameExchange],
2594) -> Result<Vec<(InstrumentNameExchange, UserListenToken)>, UnindexedClientError> {
2595 use futures::{StreamExt as _, TryStreamExt as _};
2596
2597 futures::stream::iter(symbols.iter().cloned().map(|sym| {
2598 let rest_config = rest_config.clone();
2599 let rate_limiter = rate_limiter.clone();
2600 async move {
2601 let token = acquire_user_listen_token(&rest_config, &rate_limiter, Some(&sym)).await?;
2602 Ok::<_, UnindexedClientError>((sym, token))
2603 }
2604 }))
2605 .buffer_unordered(8)
2606 .try_collect()
2607 .await
2608}
2609
2610fn earliest_token_expiry_ms(tokens: &[(InstrumentNameExchange, UserListenToken)]) -> i64 {
2615 tokens
2616 .iter()
2617 .map(|(_, t)| t.expiration_time_ms)
2618 .min()
2619 .unwrap_or(i64::MAX)
2620}
2621
2622struct IsolatedLiveConn {
2625 ws: Arc<WsApiBase>,
2626 subscription: Subscription,
2627 signal_rx: oneshot::Receiver<()>,
2628 heartbeat_flag: Arc<AtomicBool>,
2629 earliest_expiry_ms: i64,
2632}
2633
2634async fn isolated_connect_and_subscribe(
2644 ws_config: &ConfigurationWebsocketApi,
2645 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2646 dedup: &SharedDedupCache,
2647 known: &SharedKnownLiveOrders,
2648 base_quote: &Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2649 tokens: &[(InstrumentNameExchange, UserListenToken)],
2650) -> anyhow::Result<IsolatedLiveConn> {
2651 let ws = connect_margin_ws(ws_config).await?;
2652 let sub_map = Arc::new(Mutex::new(
2658 HashMap::<i64, InstrumentNameExchange>::with_capacity(tokens.len()),
2659 ));
2660 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2661 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2663
2664 let handle_position = {
2665 let sub_map = sub_map.clone();
2666 let base_quote = base_quote.clone();
2667 move |position, subscription_id, buf: &mut Vec<UnindexedAccountEvent>| {
2668 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
2669 }
2670 };
2671 let subscription = register_user_data_listener(
2673 &ws,
2674 tx.clone(),
2675 dedup.clone(),
2676 known.clone(),
2677 heartbeat_flag.clone(),
2678 signal_tx,
2679 handle_position,
2680 );
2681
2682 let earliest_expiry_ms = earliest_token_expiry_ms(tokens);
2683 for (sym, token) in tokens {
2684 match subscribe_listen_token_capture(&ws, &token.token).await {
2685 Ok(Some(id)) => {
2686 sub_map
2687 .lock()
2688 .unwrap_or_else(|p| p.into_inner())
2689 .insert(id, sym.clone());
2690 }
2691 Ok(None) => warn!(
2692 symbol = %sym.name(),
2693 "BinanceMargin isolated: subscribe ack carried no subscriptionId — live per-pair \
2694 balances unavailable for this symbol (fills/orders unaffected; balances via snapshot)"
2695 ),
2696 Err(e) => {
2697 warn!(%e, symbol = %sym.name(), "BinanceMargin isolated subscribe failed");
2700 subscription.unsubscribe();
2701 if let Err(de) = ws.disconnect().await {
2702 warn!(%de, "BinanceMargin isolated: disconnect after subscribe failure failed");
2703 }
2704 return Err(e);
2705 }
2706 }
2707 }
2708
2709 Ok(IsolatedLiveConn {
2710 ws,
2711 subscription,
2712 signal_rx,
2713 heartbeat_flag,
2714 earliest_expiry_ms,
2715 })
2716}
2717
2718#[allow(
2727 clippy::cognitive_complexity,
2728 reason = "inherent reconnect-loop complexity (tokens + connect + subscribe + monitor + cleanup \
2729 + backoff); mirrors the cross manager, not worth splitting further"
2730)]
2731async fn isolated_connection_manager(
2732 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2733 dedup: SharedDedupCache,
2734 ws_config: ConfigurationWebsocketApi,
2735 rest_config: Arc<ConfigurationRestApi>,
2736 rest: Arc<RestApi>,
2737 rate_limiter: Arc<RateLimitTracker>,
2738 known: SharedKnownLiveOrders,
2739 symbols: Vec<InstrumentNameExchange>,
2740 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2741 initial: Option<IsolatedLiveConn>,
2742) {
2743 enum DisconnectReason {
2744 Signal,
2745 HeartbeatTimeout,
2746 TokenRefresh,
2747 ConsumerDropped,
2748 }
2749
2750 let mut backoff = ExponentialBackoff::new();
2751 let mut disconnect_time: Option<DateTime<Utc>> = None;
2752 let mut unrecovered = UnrecoveredFills::default();
2754 let mut unchecked = UncheckedOrders::default();
2756 let mut current = initial;
2757
2758 loop {
2759 let IsolatedLiveConn {
2761 ws,
2762 subscription,
2763 signal_rx,
2764 heartbeat_flag,
2765 earliest_expiry_ms,
2766 } = match current.take() {
2767 Some(live) => live,
2769 None => {
2770 let tokens = match acquire_all_isolated_tokens(
2773 &rest_config,
2774 &rate_limiter,
2775 &symbols,
2776 )
2777 .await
2778 {
2779 Ok(t) => t,
2780 Err(e) => {
2781 error!(%e, "BinanceMargin isolated userListenToken acquisition failed");
2782 if !backoff.wait().await {
2783 error!("BinanceMargin isolated max reconnect attempts exhausted");
2784 emit_stream_terminated(
2785 &tx,
2786 ExchangeId::BinanceMargin,
2787 StreamTerminationReason::ReconnectBudgetExhausted {
2788 attempts: backoff.attempts(),
2789 last_error: e.to_string(),
2790 },
2791 );
2792 break;
2793 }
2794 continue;
2795 }
2796 };
2797 match isolated_connect_and_subscribe(
2798 &ws_config,
2799 &tx,
2800 &dedup,
2801 &known,
2802 &base_quote,
2803 &tokens,
2804 )
2805 .await
2806 {
2807 Ok(live) => live,
2808 Err(e) => {
2809 error!(%e, "BinanceMargin isolated connect/subscribe failed");
2810 if !backoff.wait().await {
2811 error!("BinanceMargin isolated max reconnect attempts exhausted");
2812 emit_stream_terminated(
2813 &tx,
2814 ExchangeId::BinanceMargin,
2815 StreamTerminationReason::ReconnectBudgetExhausted {
2816 attempts: backoff.attempts(),
2817 last_error: e.to_string(),
2818 },
2819 );
2820 break;
2821 }
2822 continue;
2823 }
2824 }
2825 }
2826 };
2827
2828 info!(
2829 symbols = symbols.len(),
2830 "BinanceMargin isolated account_stream connected and subscribed"
2831 );
2832 backoff.reset();
2835
2836 let token_deadline = tokio::time::Instant::now() + token_renew_after(earliest_expiry_ms);
2839
2840 if let Some(dt) = disconnect_time.take() {
2843 open_reconnect_recovery(&mut unrecovered, &mut unchecked, &known, &symbols, dt);
2844 }
2845 recover_margin_fills(
2847 &rest,
2848 &rate_limiter,
2849 &mut unrecovered,
2850 &tx,
2851 &dedup,
2852 &known,
2853 true,
2854 )
2855 .await;
2856 recover_margin_ended_orders(
2857 &rest,
2858 &rate_limiter,
2859 &known,
2860 &mut unchecked,
2861 &unrecovered,
2862 &tx,
2863 true,
2864 )
2865 .await;
2866
2867 let reason = {
2870 let mut signal_rx = signal_rx;
2871 let retry_gaps = async {
2874 loop {
2875 let next_check = unchecked.next_due(&unrecovered);
2876 match unrecovered.next_due().into_iter().chain(next_check).min() {
2877 Some(due) => tokio::time::sleep_until(due).await,
2878 None => std::future::pending::<()>().await,
2879 }
2880 recover_margin_fills(
2881 &rest,
2882 &rate_limiter,
2883 &mut unrecovered,
2884 &tx,
2885 &dedup,
2886 &known,
2887 true,
2888 )
2889 .await;
2890 recover_margin_ended_orders(
2891 &rest,
2892 &rate_limiter,
2893 &known,
2894 &mut unchecked,
2895 &unrecovered,
2896 &tx,
2897 true,
2898 )
2899 .await;
2900 }
2901 };
2902 tokio::pin!(retry_gaps);
2903 loop {
2904 tokio::select! {
2905 biased;
2906 _ = tx.closed() => {
2907 debug!("BinanceMargin isolated consumer dropped, terminating");
2908 break DisconnectReason::ConsumerDropped;
2909 }
2910 _ = &mut signal_rx => {
2911 warn!("BinanceMargin isolated WS disconnected, will attempt reconnect");
2912 break DisconnectReason::Signal;
2913 }
2914 () = tokio::time::sleep_until(token_deadline) => {
2915 info!("BinanceMargin isolated userListenToken nearing expiry, renewing all");
2916 break DisconnectReason::TokenRefresh;
2917 }
2918 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2919 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2920 continue;
2921 }
2922 warn!("BinanceMargin isolated heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2923 break DisconnectReason::HeartbeatTimeout;
2924 }
2925 () = &mut retry_gaps => {}
2927 }
2928 }
2929 };
2930 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2931
2932 if should_reconnect {
2933 disconnect_time = Some(match reason {
2934 DisconnectReason::HeartbeatTimeout => {
2935 Utc::now()
2936 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2937 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2938 }
2939 DisconnectReason::TokenRefresh => {
2940 Utc::now()
2944 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2945 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2946 }
2947 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2948 });
2949 }
2950
2951 subscription.unsubscribe();
2953 if let Err(e) = ws.disconnect().await {
2954 warn!(%e, "BinanceMargin isolated failed to disconnect WebSocket");
2955 }
2956
2957 if !should_reconnect || tx.is_closed() {
2958 debug!("BinanceMargin isolated connection manager exiting");
2960 break;
2961 }
2962
2963 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2966 error!("BinanceMargin isolated max reconnect attempts exhausted, stream terminating");
2967 let last_error = match reason {
2970 DisconnectReason::HeartbeatTimeout => {
2971 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2972 }
2973 _ => "WebSocket disconnected (server close/error)".to_string(),
2974 };
2975 emit_stream_terminated(
2976 &tx,
2977 ExchangeId::BinanceMargin,
2978 StreamTerminationReason::ReconnectBudgetExhausted {
2979 attempts: backoff.attempts(),
2980 last_error,
2981 },
2982 );
2983 break;
2984 }
2985 }
2986}
2987
2988async fn fetch_margin_open_orders_for_instrument(
2995 rest: Arc<RestApi>,
2996 rate_limiter: Arc<RateLimitTracker>,
2997 instrument: InstrumentNameExchange,
2998 is_isolated: bool,
2999 kind: RequestKind,
3000) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
3001 let symbol_str = instrument.name().to_string();
3003 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
3004 let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
3005 let sym = symbol_str.clone();
3006 let isolated = isolated.clone();
3007 Box::pin(async move {
3008 let params = QueryMarginAccountsOpenOrdersParams::builder()
3009 .symbol(sym)
3010 .is_isolated(isolated)
3011 .build()?;
3012 rest.query_margin_accounts_open_orders(params).await
3013 })
3014 })
3015 .await
3016 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
3017
3018 let orders_data = response.data().await.map_err(response_decode_error)?;
3019
3020 let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceMargin, &instrument);
3021
3022 Ok((instrument, listing))
3023}
3024
3025async fn fetch_margin_all_open_orders(
3032 rest: Arc<RestApi>,
3033 rate_limiter: Arc<RateLimitTracker>,
3034 is_isolated: bool,
3035) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
3036 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
3037 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
3038 let isolated = isolated.clone();
3039 Box::pin(async move {
3040 let params = QueryMarginAccountsOpenOrdersParams::builder()
3041 .is_isolated(isolated)
3042 .build()?;
3043 rest.query_margin_accounts_open_orders(params).await
3044 })
3045 })
3046 .await
3047 .map_err(|e| classify_rest_query_error(&e, None))?;
3048
3049 let orders_data = response.data().await.map_err(response_decode_error)?;
3050
3051 let orders = orders_data
3052 .into_iter()
3053 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin))
3054 .collect();
3055
3056 Ok(orders)
3057}
3058
3059async fn fetch_margin_order_lookup(
3062 rest: Arc<RestApi>,
3063 rate_limiter: Arc<RateLimitTracker>,
3064 key: UnindexedOrderKey,
3065 is_isolated: bool,
3066 kind: RequestKind,
3067) -> Result<OrderLookup, UnindexedClientError> {
3068 let symbol = key.instrument.name().to_string();
3070 let cid = key.cid.0.to_string();
3071 let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
3072 let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
3073 let (symbol, cid, isolated) = (symbol.clone(), cid.clone(), isolated.clone());
3074 Box::pin(async move {
3075 let params = QueryMarginAccountsOrderParams::builder(symbol)
3076 .orig_client_order_id(cid)
3077 .is_isolated(isolated)
3078 .build()?;
3079 rest.query_margin_accounts_order(params).await
3080 })
3081 })
3082 .await
3083 {
3084 Ok(response) => response,
3085 Err(e) if is_unknown_order(&e) => {
3086 debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceMargin does not know this order");
3087 return Ok(OrderLookup::Unknown);
3088 }
3089 Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
3090 };
3091
3092 let row = response.data().await.map_err(response_decode_error)?;
3093
3094 Ok(convert_ended_order(&row, ExchangeId::BinanceMargin, &key)
3095 .map_or(OrderLookup::NotEnded, |order| {
3096 OrderLookup::Ended(Box::new(order))
3097 }))
3098}
3099
3100async fn listed_margin_open_cids(
3103 rest: Arc<RestApi>,
3104 rate_limiter: Arc<RateLimitTracker>,
3105 instruments: Vec<InstrumentNameExchange>,
3106 is_isolated: bool,
3107) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3108 let mut listed = FnvHashSet::default();
3109 for instrument in instruments {
3110 let (_, listing) = fetch_margin_open_orders_for_instrument(
3111 rest.clone(),
3112 rate_limiter.clone(),
3113 instrument,
3114 is_isolated,
3115 RequestKind::Essential,
3116 )
3117 .await?;
3118 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
3119 }
3120 Ok(listed)
3121}
3122
3123async fn recover_margin_ended_orders(
3126 rest: &Arc<RestApi>,
3127 rate_limiter: &Arc<RateLimitTracker>,
3128 known: &SharedKnownLiveOrders,
3129 unchecked: &mut UncheckedOrders,
3130 unrecovered: &UnrecoveredFills,
3131 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3132 is_isolated: bool,
3133) {
3134 recover_ended_orders(
3135 ExchangeId::BinanceMargin,
3136 known,
3137 unchecked,
3138 unrecovered,
3139 tx,
3140 OpenListing::PerInstrument,
3141 |instruments| {
3142 listed_margin_open_cids(rest.clone(), rate_limiter.clone(), instruments, is_isolated)
3143 },
3144 |key| {
3145 fetch_margin_order_lookup(
3146 rest.clone(),
3147 rate_limiter.clone(),
3148 key,
3149 is_isolated,
3150 RequestKind::Essential,
3151 )
3152 },
3153 )
3154 .await;
3155}
3156
3157const MARGIN_MY_TRADES_WINDOW_MS: i64 = 23 * 60 * 60 * 1000;
3163
3164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3167enum MarginTradesQuery {
3168 Window { start: i64, end: i64 },
3170 FromId(i64),
3172 Order(i64),
3174 OrderFromId { order_id: i64, from_id: i64 },
3176}
3177
3178impl MarginTradesQuery {
3179 fn first(from: MyTradesFrom, now_ms: i64) -> Option<Self> {
3183 match from {
3184 MyTradesFrom::Span { start, .. } => {
3185 (start <= now_ms).then(|| Self::window(start, now_ms))
3186 }
3187 MyTradesFrom::Order(order_id) => Some(Self::Order(order_id)),
3188 }
3189 }
3190
3191 fn window(start: i64, now_ms: i64) -> Self {
3193 Self::Window {
3194 start,
3195 end: start.saturating_add(MARGIN_MY_TRADES_WINDOW_MS).min(now_ms),
3196 }
3197 }
3198
3199 fn next(self, page_len: usize, last_id: Option<i64>, now_ms: i64) -> Option<Self> {
3202 let full = page_len >= BINANCE_MAX_TRADES;
3203 let after_last = || last_id?.checked_add(1);
3204 match self {
3205 Self::Window { end, .. } if page_len == 0 => {
3210 (end < now_ms).then(|| Self::window(end, now_ms))
3211 }
3212 Self::Window { end, .. } if end >= now_ms && !full => None,
3214 Self::Window { .. } => after_last().map(Self::FromId),
3217 Self::FromId(_) if full => after_last().map(Self::FromId),
3218 Self::Order(order_id) | Self::OrderFromId { order_id, .. } if full => {
3219 after_last().map(|from_id| Self::OrderFromId { order_id, from_id })
3220 }
3221 Self::FromId(_) | Self::Order(_) | Self::OrderFromId { .. } => None,
3222 }
3223 }
3224}
3225
3226async fn paginate_margin_my_trades(
3248 rest: &Arc<RestApi>,
3249 rate_limiter: &Arc<RateLimitTracker>,
3250 instrument: &InstrumentNameExchange,
3251 from: MyTradesFrom,
3252 is_isolated: bool,
3253 kind: RequestKind,
3254) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
3255 let symbol_str = instrument.name().to_string();
3257 let isolated = QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(is_isolated);
3258 #[allow(clippy::cast_possible_wrap)]
3263 let limit = BINANCE_MAX_TRADES as i64;
3264 let now_ms = match from {
3267 MyTradesFrom::Span { end, .. } => end.min(Utc::now().timestamp_millis()),
3268 MyTradesFrom::Order(_) => Utc::now().timestamp_millis(),
3269 };
3270 let mut all_pages = Vec::new();
3271 let mut requests = 0u32;
3272 let mut next = MarginTradesQuery::first(from, now_ms);
3273 while let Some(query) = next {
3274 requests += 1;
3275 let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
3276 let sym = symbol_str.clone();
3277 let isolated = isolated.clone();
3278 Box::pin(async move {
3279 let builder = QueryMarginAccountsTradeListParams::builder(sym)
3280 .is_isolated(isolated)
3281 .limit(limit);
3282 let params = match query {
3283 MarginTradesQuery::Window { start, end } => {
3284 builder.start_time(start).end_time(end)
3285 }
3286 MarginTradesQuery::FromId(id) => builder.from_id(id),
3287 MarginTradesQuery::Order(order_id) => builder.order_id(order_id),
3288 MarginTradesQuery::OrderFromId { order_id, from_id } => {
3289 builder.order_id(order_id).from_id(from_id)
3290 }
3291 }
3292 .build()?;
3293 rest.query_margin_accounts_trade_list(params).await
3294 })
3295 })
3296 .await
3297 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
3298
3299 let mut page = response.data().await.map_err(response_decode_error)?;
3300
3301 let page_len = page.len();
3302 let last_id = page.last().and_then(|t| t.id);
3303 let past_end = match from {
3305 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
3306 MyTradesFrom::Order(_) => false,
3307 };
3308 all_pages.extend(page);
3309 if past_end {
3310 break;
3311 }
3312
3313 if page_len > 0 && last_id.is_none() {
3314 warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, cannot page past it");
3315 break;
3316 }
3317 next = query.next(page_len, last_id, now_ms);
3318 }
3319 debug!(
3320 %instrument,
3321 requests,
3322 trades = all_pages.len(),
3323 "BinanceMargin paginate_my_trades: walk complete"
3324 );
3325 Ok(all_pages)
3326}
3327
3328fn convert_margin_balance_entry(
3338 b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
3339 now: DateTime<Utc>,
3340) -> Option<AssetBalance<AssetNameExchange>> {
3341 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
3342 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3343 Some(v) => v,
3344 None => {
3345 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
3346 return None;
3347 }
3348 };
3349 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3350 Some(v) => v,
3351 None => {
3352 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
3353 return None;
3354 }
3355 };
3356 let borrowed = b
3361 .borrowed
3362 .as_deref()
3363 .and_then(|s| Decimal::from_str(s).ok())
3364 .unwrap_or(Decimal::ZERO);
3365 let interest = b
3366 .interest
3367 .as_deref()
3368 .and_then(|s| Decimal::from_str(s).ok())
3369 .unwrap_or(Decimal::ZERO);
3370 Some(AssetBalance::new(
3371 asset_name,
3372 Balance::new_margin(free + locked, free, borrowed, interest),
3373 now,
3374 ))
3375}
3376
3377fn filter_and_convert_margin_balances(
3380 user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
3381 assets: &[AssetNameExchange],
3382) -> Vec<AssetBalance<AssetNameExchange>> {
3383 let now = Utc::now();
3384 if assets.is_empty() {
3386 return user_assets
3387 .into_iter()
3388 .filter_map(|b| convert_margin_balance_entry(b, now))
3389 .collect();
3390 }
3391 if assets.len() <= 16 {
3393 return user_assets
3394 .into_iter()
3395 .filter_map(|b| {
3396 let asset_name_str = b.asset.as_deref()?;
3397 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
3398 return None;
3399 }
3400 convert_margin_balance_entry(b, now)
3401 })
3402 .collect();
3403 }
3404 use std::collections::HashSet;
3405 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
3406 user_assets
3407 .into_iter()
3408 .filter_map(|b| {
3409 let asset_name_str = b.asset.as_deref()?;
3410 if !asset_set.contains(asset_name_str) {
3411 return None;
3412 }
3413 convert_margin_balance_entry(b, now)
3414 })
3415 .collect()
3416}
3417
3418fn active_order_snapshot(
3425 o: Order<ExchangeId, InstrumentNameExchange, Open>,
3426) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
3427{
3428 Order {
3429 key: o.key,
3430 side: o.side,
3431 price: o.price,
3432 quantity: o.quantity,
3433 kind: o.kind,
3434 time_in_force: o.time_in_force,
3435 state: OrderState::active(o.state),
3436 }
3437}
3438
3439fn convert_isolated_asset_balance(
3449 asset: Option<&str>,
3450 free: Option<&str>,
3451 locked: Option<&str>,
3452 borrowed: Option<&str>,
3453 interest: Option<&str>,
3454 now: DateTime<Utc>,
3455) -> Option<AssetBalance<AssetNameExchange>> {
3456 let asset_name = AssetNameExchange::new(asset?);
3457 let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
3458 Some(v) => v,
3459 None => {
3460 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
3461 return None;
3462 }
3463 };
3464 let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
3465 Some(v) => v,
3466 None => {
3467 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
3468 return None;
3469 }
3470 };
3471 let borrowed = borrowed
3472 .and_then(|s| Decimal::from_str(s).ok())
3473 .unwrap_or(Decimal::ZERO);
3474 let interest = interest
3475 .and_then(|s| Decimal::from_str(s).ok())
3476 .unwrap_or(Decimal::ZERO);
3477 Some(AssetBalance::new(
3478 asset_name,
3479 Balance::new_margin(free + locked, free, borrowed, interest),
3480 now,
3481 ))
3482}
3483
3484fn convert_isolated_margin_assets(
3492 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
3493) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
3494 let now = Utc::now();
3497 let mut map = HashMap::with_capacity(assets.len());
3498 for entry in assets {
3499 let Some(symbol) = entry.symbol.as_deref() else {
3500 warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
3501 continue;
3502 };
3503 let instrument = InstrumentNameExchange::new(symbol);
3504
3505 let Some(base_raw) = entry.base_asset.as_deref() else {
3506 warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
3507 continue;
3508 };
3509 let Some(quote_raw) = entry.quote_asset.as_deref() else {
3510 warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
3511 continue;
3512 };
3513
3514 let Some(base) = convert_isolated_asset_balance(
3515 base_raw.asset.as_deref(),
3516 base_raw.free.as_deref(),
3517 base_raw.locked.as_deref(),
3518 base_raw.borrowed.as_deref(),
3519 base_raw.interest.as_deref(),
3520 now,
3521 ) else {
3522 warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
3523 continue;
3524 };
3525 let Some(quote) = convert_isolated_asset_balance(
3526 quote_raw.asset.as_deref(),
3527 quote_raw.free.as_deref(),
3528 quote_raw.locked.as_deref(),
3529 quote_raw.borrowed.as_deref(),
3530 quote_raw.interest.as_deref(),
3531 now,
3532 ) else {
3533 warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
3534 continue;
3535 };
3536
3537 let risk = IsolatedMarginRisk {
3538 margin_level: entry
3539 .margin_level
3540 .as_deref()
3541 .and_then(|s| Decimal::from_str(s).ok()),
3542 margin_ratio: entry
3543 .margin_ratio
3544 .as_deref()
3545 .and_then(|s| Decimal::from_str(s).ok()),
3546 liquidation_price: entry
3547 .liquidate_price
3548 .as_deref()
3549 .and_then(|s| Decimal::from_str(s).ok()),
3550 };
3551
3552 map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
3553 }
3554 map
3555}
3556
3557fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
3560 symbols
3561 .chunks(5)
3562 .map(|chunk| {
3563 chunk
3564 .iter()
3565 .map(|s| s.name().as_str())
3566 .collect::<Vec<_>>()
3567 .join(",")
3568 })
3569 .collect()
3570}
3571
3572async fn fetch_isolated_margin_account_info(
3578 rest: Arc<RestApi>,
3579 rate_limiter: Arc<RateLimitTracker>,
3580 symbols: Vec<InstrumentNameExchange>,
3581) -> Result<Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>, UnindexedClientError> {
3582 use futures::{StreamExt as _, TryStreamExt as _};
3583
3584 let chunks = chunk_symbols(&symbols);
3585
3586 futures::stream::iter(chunks.into_iter().map(|symbols_param| {
3590 let rest = rest.clone();
3591 let rate_limiter = rate_limiter.clone();
3592 async move {
3593 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
3594 let symbols_param = symbols_param.clone();
3595 Box::pin(async move {
3596 let params = QueryIsolatedMarginAccountInfoParams::builder()
3597 .symbols(symbols_param)
3598 .build()?;
3599 rest.query_isolated_margin_account_info(params).await
3600 })
3601 })
3602 .await
3603 .map_err(|e| classify_rest_query_error(&e, None))?;
3604
3605 let info = response.data().await.map_err(response_decode_error)?;
3606 Ok::<_, UnindexedClientError>(info.assets.unwrap_or_default())
3607 }
3608 }))
3609 .buffer_unordered(8)
3610 .try_fold(
3611 Vec::with_capacity(symbols.len()),
3612 |mut acc, assets| async move {
3613 acc.extend(assets);
3614 Ok(acc)
3615 },
3616 )
3617 .await
3618}
3619
3620fn convert_margin_trade(
3627 t: &QueryMarginAccountsTradeListResponseInner,
3628 instrument: &InstrumentNameExchange,
3629) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3630 let trade_id_raw = match t.id {
3631 Some(id) => id,
3632 None => {
3633 warn!(%instrument, "BinanceMargin trade missing id");
3634 return None;
3635 }
3636 };
3637 let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
3638 let order_id = match t.order_id {
3639 Some(id) => OrderId(format_smolstr!("{id}")),
3640 None => {
3641 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
3642 return None;
3643 }
3644 };
3645 let side = match t.is_buyer {
3646 Some(true) => Side::Buy,
3647 Some(false) => Side::Sell,
3648 None => {
3649 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
3650 return None;
3651 }
3652 };
3653 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3654 Some(v) => v,
3655 None => {
3656 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
3657 return None;
3658 }
3659 };
3660 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3661 Some(v) => v,
3662 None => {
3663 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
3664 return None;
3665 }
3666 };
3667 let commission = t
3668 .commission
3669 .as_deref()
3670 .and_then(|s| Decimal::from_str(s).ok())
3671 .unwrap_or(Decimal::ZERO);
3672 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
3673 Some(ts) => ts,
3674 None => {
3675 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
3676 Utc::now()
3677 }
3678 };
3679
3680 let fee_asset = t
3684 .commission_asset
3685 .as_deref()
3686 .map(AssetNameExchange::from)
3687 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
3688
3689 Some(Trade::new(
3690 trade_id,
3691 order_id,
3692 instrument.clone(),
3693 StrategyId::unknown(), time_exchange,
3695 side,
3696 price,
3697 quantity,
3698 None,
3700 AssetFees::new(fee_asset, commission, None),
3701 ))
3702}
3703
3704#[derive(Debug)]
3710enum BuildOrderError {
3711 Unsupported,
3713 Build(String),
3715}
3716
3717trait IsolatedFlag {
3728 fn from_flag(is_isolated: bool) -> Self;
3730}
3731
3732macro_rules! impl_isolated_flag {
3737 ($($t:ty),* $(,)?) => {
3738 $(
3739 impl IsolatedFlag for $t {
3740 fn from_flag(is_isolated: bool) -> Self {
3741 if is_isolated { Self::True } else { Self::False }
3742 }
3743 }
3744 )*
3745 };
3746}
3747
3748impl_isolated_flag!(
3749 MarginAccountNewOrderIsIsolatedEnum,
3750 MarginAccountCancelOrderIsIsolatedEnum,
3751 QueryMarginAccountsOpenOrdersIsIsolatedEnum,
3752 QueryMarginAccountsOrderIsIsolatedEnum,
3753 QueryMarginAccountsTradeListIsIsolatedEnum,
3754);
3755
3756fn build_cancel_order_params(
3764 symbol: String,
3765 id: Option<&OrderId>,
3766 cid: &ClientOrderId,
3767 is_isolated: bool,
3768) -> Result<MarginAccountCancelOrderParams, String> {
3769 let mut builder = MarginAccountCancelOrderParams::builder(symbol).is_isolated(
3770 MarginAccountCancelOrderIsIsolatedEnum::from_flag(is_isolated),
3771 );
3772
3773 match id {
3774 Some(order_id) => match order_id.0.parse::<i64>() {
3775 Ok(id) => builder = builder.order_id(id),
3776 Err(_) => {
3777 error!(
3779 order_id = %order_id.0,
3780 "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
3781 );
3782 builder = builder.orig_client_order_id(cid.0.to_string());
3783 }
3784 },
3785 None => builder = builder.orig_client_order_id(cid.0.to_string()),
3786 }
3787
3788 builder.build().map_err(|e| e.to_string())
3789}
3790
3791#[allow(clippy::too_many_arguments)] fn build_new_order_params(
3800 symbol: String,
3801 side: Side,
3802 price: Option<Decimal>,
3803 quantity: Decimal,
3804 kind: OrderKind,
3805 time_in_force: TimeInForce,
3806 new_client_order_id: String,
3807 side_effect: MarginSideEffect,
3808 is_isolated: bool,
3809) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3810 let binance_side = match side {
3811 Side::Buy => MarginAccountNewOrderSideEnum::Buy,
3812 Side::Sell => MarginAccountNewOrderSideEnum::Sell,
3813 };
3814
3815 let (binance_type, binance_tif) =
3816 convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
3817
3818 let mut builder = MarginAccountNewOrderParams::builder(symbol, binance_side, binance_type)
3820 .quantity(quantity)
3821 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::from_flag(is_isolated))
3822 .side_effect_type(side_effect.as_sdk_enum())
3823 .new_client_order_id(new_client_order_id)
3824 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
3825
3826 if side_effect == MarginSideEffect::AutoBorrowRepay {
3829 builder = builder.auto_repay_at_cancel(true);
3830 }
3831
3832 if let Some(tif) = binance_tif {
3833 builder = builder.time_in_force(tif);
3834 }
3835
3836 match kind {
3839 OrderKind::Limit => {
3840 builder = builder.price(price);
3841 }
3842 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
3843 builder = builder.stop_price(trigger_price);
3844 }
3845 OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
3846 builder = builder.price(price).stop_price(trigger_price);
3847 }
3848 _ => {}
3852 }
3853
3854 builder
3855 .build()
3856 .map_err(|e| BuildOrderError::Build(e.to_string()))
3857}
3858
3859fn convert_order_kind_tif_margin(
3869 kind: OrderKind,
3870 tif: TimeInForce,
3871) -> Option<(
3872 MarginAccountNewOrderTypeEnum,
3873 Option<MarginAccountNewOrderTimeInForceEnum>,
3874)> {
3875 if matches!(
3876 kind,
3877 OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. }
3878 ) {
3879 warn!(
3880 ?kind,
3881 "BinanceMargin does not support trailing-stop orders yet"
3882 );
3883 return None;
3884 }
3885
3886 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
3887 let margin_type = match binance_type {
3888 BinanceOrderType::Market => MarginAccountNewOrderTypeEnum::Market,
3889 BinanceOrderType::Limit => MarginAccountNewOrderTypeEnum::Limit,
3890 BinanceOrderType::LimitMaker => MarginAccountNewOrderTypeEnum::LimitMaker,
3891 BinanceOrderType::StopLoss => MarginAccountNewOrderTypeEnum::StopLoss,
3892 BinanceOrderType::StopLossLimit => MarginAccountNewOrderTypeEnum::StopLossLimit,
3893 BinanceOrderType::TakeProfit => MarginAccountNewOrderTypeEnum::TakeProfit,
3894 BinanceOrderType::TakeProfitLimit => MarginAccountNewOrderTypeEnum::TakeProfitLimit,
3895 };
3896 let margin_tif = binance_tif.map(|t| match t {
3897 BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
3898 BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
3899 BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
3900 });
3901 Some((margin_type, margin_tif))
3902}
3903
3904#[cfg(test)]
3905#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3907 use super::*;
3908 use crate::client::binance::shared::convert_open_order;
3909 use crate::order::state::ActiveOrderState;
3910 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOpenOrdersResponseInner;
3911
3912 #[test]
3913 fn margin_side_effect_default_is_auto_borrow_repay() {
3914 assert_eq!(
3915 MarginSideEffect::default(),
3916 MarginSideEffect::AutoBorrowRepay
3917 );
3918 }
3919
3920 #[test]
3921 fn margin_side_effect_wire_strings() {
3922 assert_eq!(
3923 MarginSideEffect::AutoBorrowRepay.as_binance_str(),
3924 "AUTO_BORROW_REPAY"
3925 );
3926 assert_eq!(
3927 MarginSideEffect::NoBorrow.as_binance_str(),
3928 "NO_SIDE_EFFECT"
3929 );
3930 }
3931
3932 #[test]
3933 fn margin_side_effect_serde_round_trip() {
3934 assert_eq!(
3936 serde_json::to_string(&MarginSideEffect::AutoBorrowRepay).unwrap(),
3937 r#""auto_borrow_repay""#
3938 );
3939 assert_eq!(
3940 serde_json::to_string(&MarginSideEffect::NoBorrow).unwrap(),
3941 r#""no_borrow""#
3942 );
3943 assert_eq!(
3944 serde_json::from_str::<MarginSideEffect>(r#""auto_borrow_repay""#).unwrap(),
3945 MarginSideEffect::AutoBorrowRepay
3946 );
3947 assert_eq!(
3948 serde_json::from_str::<MarginSideEffect>(r#""no_borrow""#).unwrap(),
3949 MarginSideEffect::NoBorrow
3950 );
3951 }
3952
3953 #[test]
3954 fn config_debug_redacts_secrets() {
3955 let config = BinanceMarginConfig::new(
3956 "my_api_key".to_string(),
3957 "my_secret_key".to_string(),
3958 false,
3959 false,
3960 Vec::new(),
3961 MarginSideEffect::default(),
3962 );
3963 let debug = format!("{config:?}");
3964 assert!(!debug.contains("my_api_key"));
3965 assert!(!debug.contains("my_secret_key"));
3966 assert!(debug.contains("***"));
3967 }
3968
3969 #[test]
3970 fn cross_margin_uses_common_case_defaults() {
3971 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
3972 assert!(!config.testnet);
3973 assert!(!config.is_isolated);
3974 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3975 assert_eq!(config.api_key(), "k");
3976 }
3977
3978 #[test]
3979 fn config_deserializes_with_defaults() {
3980 let config: BinanceMarginConfig =
3982 serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
3983 .expect("deserialize");
3984 assert!(!config.is_isolated);
3985 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3986 }
3987
3988 #[test]
3989 fn config_deserializes_explicit_side_effect() {
3990 let config: BinanceMarginConfig = serde_json::from_str(
3991 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"side_effect":"no_borrow"}"#,
3992 )
3993 .expect("deserialize");
3994 assert!(config.is_isolated);
3995 assert_eq!(config.side_effect, MarginSideEffect::NoBorrow);
3996 }
3997
3998 #[test]
3999 fn isolated_ctor_sets_symbols_and_flag() {
4000 let symbols = vec![
4001 InstrumentNameExchange::new("BTCUSDT"),
4002 InstrumentNameExchange::new("ETHUSDT"),
4003 ];
4004 let config =
4005 BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
4006 assert!(config.is_isolated);
4007 assert_eq!(config.isolated_symbols, symbols);
4008 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
4009 }
4010
4011 #[test]
4012 fn isolated_config_with_symbols_constructs() {
4013 let config = BinanceMarginConfig::isolated(
4015 "k".to_string(),
4016 "s".to_string(),
4017 vec![InstrumentNameExchange::new("BTCUSDT")],
4018 );
4019 let _client = BinanceMargin::new(config); }
4021
4022 #[test]
4023 #[should_panic(expected = "non-empty isolated_symbols")]
4024 fn isolated_empty_symbols_panics_at_new() {
4025 let config = BinanceMarginConfig::new(
4027 "k".to_string(),
4028 "s".to_string(),
4029 false,
4030 true,
4031 Vec::new(),
4032 MarginSideEffect::default(),
4033 );
4034 let _ = BinanceMargin::new(config);
4035 }
4036
4037 #[test]
4038 #[should_panic(expected = "non-empty isolated_symbols")]
4039 fn isolated_empty_symbols_panics_via_deserialize_path() {
4040 let config: BinanceMarginConfig = serde_json::from_str(
4043 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
4044 )
4045 .expect("deserialize");
4046 assert!(config.isolated_symbols.is_empty());
4047 let _ = BinanceMargin::new(config);
4048 }
4049
4050 #[test]
4051 fn isolated_flag_maps_mode() {
4052 assert_eq!(
4055 MarginAccountNewOrderIsIsolatedEnum::from_flag(true).as_str(),
4056 "TRUE"
4057 );
4058 assert_eq!(
4059 MarginAccountNewOrderIsIsolatedEnum::from_flag(false).as_str(),
4060 "FALSE"
4061 );
4062 assert_eq!(
4063 MarginAccountCancelOrderIsIsolatedEnum::from_flag(true).as_str(),
4064 "TRUE"
4065 );
4066 assert_eq!(
4067 MarginAccountCancelOrderIsIsolatedEnum::from_flag(false).as_str(),
4068 "FALSE"
4069 );
4070 assert_eq!(
4071 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(true).as_str(),
4072 "TRUE"
4073 );
4074 assert_eq!(
4075 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(false).as_str(),
4076 "FALSE"
4077 );
4078 assert_eq!(
4079 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(true).as_str(),
4080 "TRUE"
4081 );
4082 assert_eq!(
4083 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(false).as_str(),
4084 "FALSE"
4085 );
4086 }
4087
4088 #[test]
4093 fn cancel_params_cross_with_parseable_order_id() {
4094 let p = build_cancel_order_params(
4095 "BTCUSDT".to_string(),
4096 Some(&OrderId::new("12345")),
4097 &ClientOrderId::new("cid-1"),
4098 false,
4099 )
4100 .expect("build");
4101 assert_eq!(p.symbol, "BTCUSDT");
4102 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("FALSE"));
4103 assert_eq!(p.order_id, Some(12345));
4104 assert_eq!(p.orig_client_order_id, None);
4105 }
4106
4107 #[test]
4108 fn cancel_params_isolated_is_config_driven() {
4109 let p = build_cancel_order_params(
4110 "BTCUSDT".to_string(),
4111 Some(&OrderId::new("12345")),
4112 &ClientOrderId::new("cid-1"),
4113 true,
4114 )
4115 .expect("build");
4116 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("TRUE"));
4117 assert_eq!(p.order_id, Some(12345));
4118 }
4119
4120 #[test]
4121 fn cancel_params_non_i64_order_id_falls_back_to_cid() {
4122 let p = build_cancel_order_params(
4123 "BTCUSDT".to_string(),
4124 Some(&OrderId::new("not-an-i64")),
4125 &ClientOrderId::new("cid-1"),
4126 false,
4127 )
4128 .expect("build");
4129 assert_eq!(p.order_id, None);
4130 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
4131 }
4132
4133 #[test]
4134 fn cancel_params_absent_order_id_uses_cid() {
4135 let p = build_cancel_order_params(
4136 "BTCUSDT".to_string(),
4137 None,
4138 &ClientOrderId::new("cid-1"),
4139 false,
4140 )
4141 .expect("build");
4142 assert_eq!(p.order_id, None);
4143 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
4144 }
4145
4146 use crate::order::TrailingOffsetType;
4151
4152 fn params(
4154 side: Side,
4155 price: Option<Decimal>,
4156 kind: OrderKind,
4157 tif: TimeInForce,
4158 side_effect: MarginSideEffect,
4159 ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
4160 build_new_order_params(
4161 "BTCUSDT".to_string(),
4162 side,
4163 price,
4164 Decimal::from(2),
4165 kind,
4166 tif,
4167 "cid-1".to_string(),
4168 side_effect,
4169 false, )
4171 }
4172
4173 fn gtc() -> TimeInForce {
4174 TimeInForce::GoodUntilCancelled { post_only: false }
4175 }
4176
4177 #[test]
4178 fn new_order_limit_maps_core_fields() {
4179 let p = params(
4180 Side::Buy,
4181 Some(Decimal::from(50_000)),
4182 OrderKind::Limit,
4183 gtc(),
4184 MarginSideEffect::AutoBorrowRepay,
4185 )
4186 .expect("build");
4187
4188 assert_eq!(p.symbol, "BTCUSDT");
4189 assert_eq!(p.side.as_str(), "BUY");
4190 assert_eq!(p.r#type.as_str(), "LIMIT");
4191 assert_eq!(p.quantity, Some(Decimal::from(2)));
4192 assert_eq!(p.price, Some(Decimal::from(50_000)));
4193 assert_eq!(p.stop_price, None);
4194 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
4195 assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
4196 assert_eq!(
4198 p.new_order_resp_type.as_ref().map(|r| r.as_str()),
4199 Some("FULL")
4200 );
4201 }
4202
4203 #[test]
4204 fn new_order_is_isolated_is_config_driven() {
4205 let cross = build_new_order_params(
4207 "BTCUSDT".to_string(),
4208 Side::Sell,
4209 Some(Decimal::from(10)),
4210 Decimal::from(2),
4211 OrderKind::Limit,
4212 gtc(),
4213 "cid-1".to_string(),
4214 MarginSideEffect::AutoBorrowRepay,
4215 false,
4216 )
4217 .expect("build");
4218 assert_eq!(
4219 cross.is_isolated.as_ref().map(|i| i.as_str()),
4220 Some("FALSE")
4221 );
4222 assert_eq!(cross.side.as_str(), "SELL");
4223
4224 let isolated = build_new_order_params(
4226 "BTCUSDT".to_string(),
4227 Side::Sell,
4228 Some(Decimal::from(10)),
4229 Decimal::from(2),
4230 OrderKind::Limit,
4231 gtc(),
4232 "cid-1".to_string(),
4233 MarginSideEffect::AutoBorrowRepay,
4234 true,
4235 )
4236 .expect("build");
4237 assert_eq!(
4238 isolated.is_isolated.as_ref().map(|i| i.as_str()),
4239 Some("TRUE")
4240 );
4241 }
4242
4243 #[test]
4244 fn side_effect_and_auto_repay_gating() {
4245 let auto = params(
4247 Side::Buy,
4248 Some(Decimal::from(1)),
4249 OrderKind::Limit,
4250 gtc(),
4251 MarginSideEffect::AutoBorrowRepay,
4252 )
4253 .expect("build");
4254 assert_eq!(
4255 auto.side_effect_type.as_ref().map(|s| s.as_str()),
4256 Some("AUTO_BORROW_REPAY")
4257 );
4258 assert_eq!(auto.auto_repay_at_cancel, Some(true));
4259
4260 let no_borrow = params(
4262 Side::Buy,
4263 Some(Decimal::from(1)),
4264 OrderKind::Limit,
4265 gtc(),
4266 MarginSideEffect::NoBorrow,
4267 )
4268 .expect("build");
4269 assert_eq!(
4270 no_borrow.side_effect_type.as_ref().map(|s| s.as_str()),
4271 Some("NO_SIDE_EFFECT")
4272 );
4273 assert_eq!(no_borrow.auto_repay_at_cancel, None);
4274 }
4275
4276 #[test]
4277 fn new_order_market_has_no_price_or_tif() {
4278 let p = params(
4279 Side::Buy,
4280 None,
4281 OrderKind::Market,
4282 gtc(),
4283 MarginSideEffect::AutoBorrowRepay,
4284 )
4285 .expect("build");
4286 assert_eq!(p.r#type.as_str(), "MARKET");
4287 assert_eq!(p.price, None);
4288 assert_eq!(p.stop_price, None);
4289 assert!(p.time_in_force.is_none());
4290 }
4291
4292 #[test]
4293 fn post_only_limit_maps_to_limit_maker() {
4294 let p = params(
4295 Side::Buy,
4296 Some(Decimal::from(10)),
4297 OrderKind::Limit,
4298 TimeInForce::GoodUntilCancelled { post_only: true },
4299 MarginSideEffect::AutoBorrowRepay,
4300 )
4301 .expect("build");
4302 assert_eq!(p.r#type.as_str(), "LIMIT_MAKER");
4303 assert!(p.time_in_force.is_none());
4305 }
4306
4307 #[test]
4308 fn conditional_kinds_set_stop_price() {
4309 let trigger = Decimal::from(48_000);
4310
4311 let stop = params(
4312 Side::Sell,
4313 None,
4314 OrderKind::Stop {
4315 trigger_price: trigger,
4316 },
4317 gtc(),
4318 MarginSideEffect::AutoBorrowRepay,
4319 )
4320 .expect("build");
4321 assert_eq!(stop.r#type.as_str(), "STOP_LOSS");
4322 assert_eq!(stop.stop_price, Some(trigger));
4323 assert_eq!(stop.price, None);
4324
4325 let stop_limit = params(
4326 Side::Sell,
4327 Some(Decimal::from(47_900)),
4328 OrderKind::StopLimit {
4329 trigger_price: trigger,
4330 },
4331 gtc(),
4332 MarginSideEffect::AutoBorrowRepay,
4333 )
4334 .expect("build");
4335 assert_eq!(stop_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
4336 assert_eq!(stop_limit.stop_price, Some(trigger));
4337 assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
4338 assert_eq!(
4339 stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
4340 Some("GTC")
4341 );
4342
4343 let take_profit = params(
4344 Side::Sell,
4345 None,
4346 OrderKind::TakeProfit {
4347 trigger_price: trigger,
4348 },
4349 gtc(),
4350 MarginSideEffect::AutoBorrowRepay,
4351 )
4352 .expect("build");
4353 assert_eq!(take_profit.r#type.as_str(), "TAKE_PROFIT");
4354 assert_eq!(take_profit.stop_price, Some(trigger));
4355 }
4356
4357 #[test]
4358 fn trailing_kinds_are_rejected() {
4359 let trailing_stop = params(
4360 Side::Sell,
4361 None,
4362 OrderKind::TrailingStop {
4363 offset: Decimal::from(100),
4364 offset_type: TrailingOffsetType::BasisPoints,
4365 },
4366 gtc(),
4367 MarginSideEffect::AutoBorrowRepay,
4368 );
4369 assert!(matches!(trailing_stop, Err(BuildOrderError::Unsupported)));
4370
4371 let trailing_stop_limit = params(
4372 Side::Sell,
4373 Some(Decimal::from(100)),
4374 OrderKind::TrailingStopLimit {
4375 offset: Decimal::from(100),
4376 offset_type: TrailingOffsetType::BasisPoints,
4377 stop_price: Decimal::from(95),
4378 limit_offset: Decimal::from(10),
4379 },
4380 gtc(),
4381 MarginSideEffect::AutoBorrowRepay,
4382 );
4383 assert!(matches!(
4384 trailing_stop_limit,
4385 Err(BuildOrderError::Unsupported)
4386 ));
4387 }
4388
4389 #[test]
4390 fn tif_variants_map_to_margin_enum() {
4391 for (tif, expected) in [
4392 (TimeInForce::ImmediateOrCancel, "IOC"),
4393 (TimeInForce::FillOrKill, "FOK"),
4394 (gtc(), "GTC"),
4395 ] {
4396 let p = params(
4397 Side::Buy,
4398 Some(Decimal::from(1)),
4399 OrderKind::Limit,
4400 tif,
4401 MarginSideEffect::AutoBorrowRepay,
4402 )
4403 .expect("build");
4404 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
4405 }
4406 }
4407
4408 fn user_asset(
4413 asset: &str,
4414 free: &str,
4415 locked: &str,
4416 borrowed: &str,
4417 interest: &str,
4418 ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
4419 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4420 a.asset = Some(asset.to_string());
4421 a.free = Some(free.to_string());
4422 a.locked = Some(locked.to_string());
4423 a.borrowed = Some(borrowed.to_string());
4424 a.interest = Some(interest.to_string());
4425 a
4426 }
4427
4428 #[test]
4429 fn margin_balance_entry_maps_debt() {
4430 let ab =
4432 convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
4433 .expect("convert");
4434 assert_eq!(ab.asset.name().as_str(), "USDT");
4435 assert_eq!(ab.balance.total, Decimal::from(12));
4436 assert_eq!(ab.balance.free, Decimal::from(10));
4437 assert!(
4438 ab.balance.margin.is_some(),
4439 "REST snapshot must populate margin"
4440 );
4441 assert_eq!(ab.balance.net_asset(), Decimal::from(9));
4443 }
4444
4445 #[test]
4446 fn margin_balance_short_is_negative_net() {
4447 let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
4449 .expect("convert");
4450 assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
4451 }
4452
4453 #[test]
4454 fn margin_balance_missing_debt_defaults_to_zero() {
4455 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4458 a.asset = Some("ETH".to_string());
4459 a.free = Some("4".to_string());
4460 a.locked = Some("0".to_string());
4461 let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
4462 assert!(ab.balance.margin.is_some());
4463 assert_eq!(ab.balance.net_asset(), Decimal::from(4));
4464 }
4465
4466 #[test]
4467 fn margin_balance_missing_free_is_dropped() {
4468 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4470 a.asset = Some("USDT".to_string());
4471 a.locked = Some("0".to_string());
4472 assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
4473 }
4474
4475 #[test]
4476 fn margin_balance_filtering() {
4477 let assets = vec![
4478 user_asset("USDT", "10", "0", "0", "0"),
4479 user_asset("BTC", "1", "0", "0", "0"),
4480 user_asset("ETH", "5", "0", "0", "0"),
4481 ];
4482 assert_eq!(
4484 filter_and_convert_margin_balances(assets.clone(), &[]).len(),
4485 3
4486 );
4487 let filtered = filter_and_convert_margin_balances(
4489 assets,
4490 &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
4491 );
4492 assert_eq!(filtered.len(), 2);
4493 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
4494 }
4495
4496 #[test]
4497 fn margin_balance_filtering_large_slice_uses_hashset_branch() {
4498 let assets = vec![
4500 user_asset("BTC", "1", "0", "0", "0"),
4501 user_asset("ETH", "5", "0", "0", "0"),
4502 user_asset("DOGE", "9", "0", "0", "0"), ];
4504 let mut requested: Vec<AssetNameExchange> = (0..17)
4506 .map(|i| AssetNameExchange::new(format!("A{i:02}")))
4507 .collect();
4508 requested.push(AssetNameExchange::new("BTC"));
4509 requested.push(AssetNameExchange::new("ETH"));
4510 assert!(
4511 requested.len() > 16,
4512 "must exceed the linear-scan threshold"
4513 );
4514
4515 let filtered = filter_and_convert_margin_balances(assets, &requested);
4516 assert_eq!(filtered.len(), 2);
4517 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
4518 }
4519
4520 use binance_sdk::margin_trading::rest_api::{
4525 QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
4526 QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
4527 };
4528
4529 fn d(s: &str) -> Decimal {
4530 Decimal::from_str(s).unwrap()
4531 }
4532
4533 fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
4534 let mut b = IsoBase::new();
4535 b.asset = Some(asset.to_string());
4536 b.free = Some(free.to_string());
4537 b.locked = Some(locked.to_string());
4538 b.borrowed = Some(borrowed.to_string());
4539 b.interest = Some(interest.to_string());
4540 b
4541 }
4542
4543 fn iso_quote(
4544 asset: &str,
4545 free: &str,
4546 locked: &str,
4547 borrowed: &str,
4548 interest: &str,
4549 ) -> IsoQuote {
4550 let mut q = IsoQuote::new();
4551 q.asset = Some(asset.to_string());
4552 q.free = Some(free.to_string());
4553 q.locked = Some(locked.to_string());
4554 q.borrowed = Some(borrowed.to_string());
4555 q.interest = Some(interest.to_string());
4556 q
4557 }
4558
4559 fn iso_entry(
4560 symbol: &str,
4561 base: IsoBase,
4562 quote: IsoQuote,
4563 margin_level: Option<&str>,
4564 margin_ratio: Option<&str>,
4565 liquidate_price: Option<&str>,
4566 ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
4567 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4568 e.symbol = Some(symbol.to_string());
4569 e.base_asset = Some(Box::new(base));
4570 e.quote_asset = Some(Box::new(quote));
4571 e.margin_level = margin_level.map(str::to_string);
4572 e.margin_ratio = margin_ratio.map(str::to_string);
4573 e.liquidate_price = liquidate_price.map(str::to_string);
4574 e
4575 }
4576
4577 fn isolated_client(symbols: &[&str]) -> BinanceMargin {
4578 let config = BinanceMarginConfig::isolated(
4579 "k".to_string(),
4580 "s".to_string(),
4581 symbols
4582 .iter()
4583 .map(|s| InstrumentNameExchange::new(*s))
4584 .collect(),
4585 );
4586 BinanceMargin::new(config)
4587 }
4588
4589 #[test]
4590 fn isolated_assets_map_base_quote_and_risk() {
4591 let entry = iso_entry(
4594 "BTCUSDT",
4595 iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
4596 iso_quote("USDT", "1000", "50", "300", "1.5"),
4597 Some("3.5"),
4598 Some("0.12"),
4599 Some("48000"),
4600 );
4601 let map = convert_isolated_margin_assets(vec![entry]);
4602 let state = map
4603 .get(&InstrumentNameExchange::new("BTCUSDT"))
4604 .expect("entry present");
4605
4606 assert_eq!(state.base.asset.name().as_str(), "BTC");
4607 assert_eq!(state.base.balance.total, d("0.6"));
4608 assert_eq!(state.base.balance.free, d("0.5"));
4609 assert_eq!(state.base.balance.net_asset(), d("0.4"));
4610
4611 assert_eq!(state.quote.asset.name().as_str(), "USDT");
4612 assert_eq!(state.quote.balance.total, d("1050"));
4613 assert_eq!(state.quote.balance.net_asset(), d("750"));
4614
4615 assert_eq!(state.risk.margin_level, Some(d("3.5")));
4616 assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
4617 assert_eq!(state.risk.liquidation_price, Some(d("48000")));
4618 }
4619
4620 #[test]
4621 fn isolated_base_short_is_negative_net() {
4622 let entry = iso_entry(
4624 "BTCUSDT",
4625 iso_base("BTC", "0", "0", "1.5", "0.001"),
4626 iso_quote("USDT", "100", "0", "0", "0"),
4627 None,
4628 None,
4629 None,
4630 );
4631 let map = convert_isolated_margin_assets(vec![entry]);
4632 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4633 assert_eq!(state.base.balance.net_asset(), d("-1.5"));
4634 }
4635
4636 #[test]
4637 fn isolated_missing_debt_defaults_zero_and_risk_optional() {
4638 let mut base = IsoBase::new();
4641 base.asset = Some("BTC".to_string());
4642 base.free = Some("0.5".to_string());
4643 base.locked = Some("0".to_string());
4644 let mut quote = IsoQuote::new();
4645 quote.asset = Some("USDT".to_string());
4646 quote.free = Some("100".to_string());
4647 quote.locked = Some("0".to_string());
4648
4649 let map = convert_isolated_margin_assets(vec![iso_entry(
4650 "BTCUSDT", base, quote, None, None, None,
4651 )]);
4652 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4653 assert!(state.base.balance.margin.is_some());
4654 assert_eq!(state.base.balance.net_asset(), d("0.5"));
4655 assert_eq!(state.risk.margin_level, None);
4656 assert_eq!(state.risk.liquidation_price, None);
4657 }
4658
4659 #[test]
4660 fn isolated_unparseable_risk_is_none_but_entry_kept() {
4661 let entry = iso_entry(
4662 "BTCUSDT",
4663 iso_base("BTC", "1", "0", "0", "0"),
4664 iso_quote("USDT", "1", "0", "0", "0"),
4665 Some("not_a_number"),
4666 None,
4667 None,
4668 );
4669 let map = convert_isolated_margin_assets(vec![entry]);
4670 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4671 assert_eq!(state.risk.margin_level, None);
4672 }
4673
4674 #[test]
4675 fn isolated_missing_base_free_drops_entry() {
4676 let mut base = IsoBase::new();
4678 base.asset = Some("BTC".to_string());
4679 base.locked = Some("0".to_string()); let entry = iso_entry(
4681 "BTCUSDT",
4682 base,
4683 iso_quote("USDT", "100", "0", "0", "0"),
4684 None,
4685 None,
4686 None,
4687 );
4688 assert!(convert_isolated_margin_assets(vec![entry]).is_empty());
4689 }
4690
4691 #[test]
4692 fn isolated_missing_symbol_drops_entry() {
4693 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4694 e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
4695 e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
4696 assert!(convert_isolated_margin_assets(vec![e]).is_empty());
4698 }
4699
4700 #[test]
4701 fn chunk_symbols_batches_by_five() {
4702 let syms: Vec<InstrumentNameExchange> = (0..12)
4703 .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
4704 .collect();
4705 let chunks = chunk_symbols(&syms);
4706 assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
4707 assert_eq!(chunks[0].split(',').count(), 5);
4708 assert_eq!(chunks[1].split(',').count(), 5);
4709 assert_eq!(chunks[2].split(',').count(), 2);
4710
4711 let three: Vec<_> = (0..3)
4713 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4714 .collect();
4715 assert_eq!(chunk_symbols(&three).len(), 1);
4716
4717 let five: Vec<_> = (0..5)
4719 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4720 .collect();
4721 let c = chunk_symbols(&five);
4722 assert_eq!(c.len(), 1);
4723 assert_eq!(c[0].split(',').count(), 5);
4724
4725 assert!(chunk_symbols(&[]).is_empty());
4727 }
4728
4729 #[test]
4730 fn effective_isolated_set_empty_returns_all_configured() {
4731 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4732 assert_eq!(
4733 client.effective_isolated_set(&[]),
4734 vec![
4735 InstrumentNameExchange::new("BTCUSDT"),
4736 InstrumentNameExchange::new("ETHUSDT"),
4737 ]
4738 );
4739 }
4740
4741 #[test]
4742 fn effective_isolated_set_intersects_requested() {
4743 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4744 assert_eq!(
4745 client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
4746 vec![InstrumentNameExchange::new("ETHUSDT")]
4747 );
4748 }
4749
4750 #[test]
4751 fn effective_isolated_set_skips_out_of_set() {
4752 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4753 assert_eq!(
4755 client.effective_isolated_set(&[
4756 InstrumentNameExchange::new("BTCUSDT"),
4757 InstrumentNameExchange::new("DOGEUSDT"),
4758 ]),
4759 vec![InstrumentNameExchange::new("BTCUSDT")]
4760 );
4761 }
4762
4763 #[tokio::test]
4764 async fn fetch_balances_isolated_returns_empty() {
4765 let client = isolated_client(&["BTCUSDT"]);
4768 assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
4769 }
4770
4771 fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
4772 let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
4773 o.order_id = order_id;
4774 o.client_order_id = Some("cid-9".to_string());
4775 o.side = Some(side.to_string());
4776 o.price = Some("50000".to_string());
4777 o.orig_qty = Some("2".to_string());
4778 o.executed_qty = Some("0.5".to_string());
4779 o.r#type = Some("LIMIT".to_string());
4780 o.time_in_force = Some("GTC".to_string());
4781 o.time = Some(1_700_000_000_000);
4782 o.status = Some("PARTIALLY_FILLED".to_string());
4783 o
4784 }
4785
4786 #[test]
4787 fn margin_open_order_converts() {
4788 let inst = InstrumentNameExchange::new("BTCUSDT");
4789 let order = convert_open_order(
4790 &open_order(Some(42), "BUY"),
4791 ExchangeId::BinanceMargin,
4792 &inst,
4793 )
4794 .expect("convert");
4795 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4796 assert_eq!(
4797 order.state.id.assigned().map(|id| id.0.as_str()),
4798 Some("42")
4799 );
4800 assert_eq!(order.key.cid.0.as_str(), "cid-9");
4801 assert_eq!(order.side, Side::Buy);
4802 assert_eq!(order.price, Some(Decimal::from(50_000)));
4803 assert_eq!(order.quantity, Decimal::from(2));
4804 assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
4805 assert_eq!(order.kind, OrderKind::Limit);
4806 }
4807
4808 #[test]
4811 fn margin_open_order_that_is_not_live_is_dropped() {
4812 let inst = InstrumentNameExchange::new("BTCUSDT");
4813 let mut o = open_order(Some(42), "BUY");
4814 o.status = Some("CANCELED".to_string());
4815 assert!(convert_open_order(&o, ExchangeId::BinanceMargin, &inst).is_none());
4816 }
4817
4818 #[test]
4819 fn margin_open_order_missing_order_id_is_dropped() {
4820 let inst = InstrumentNameExchange::new("BTCUSDT");
4821 assert!(
4822 convert_open_order(&open_order(None, "BUY"), ExchangeId::BinanceMargin, &inst)
4823 .is_none()
4824 );
4825 }
4826
4827 #[test]
4833 fn margin_open_order_prefers_update_time_over_creation_time() {
4834 let inst = InstrumentNameExchange::new("BTCUSDT");
4835
4836 let mut updated = open_order(Some(42), "BUY");
4837 updated.update_time = Some(1_700_000_005_000);
4838 let order =
4839 convert_open_order(&updated, ExchangeId::BinanceMargin, &inst).expect("convert");
4840 assert_eq!(
4841 order.state.time_exchange,
4842 Utc.timestamp_millis_opt(1_700_000_005_000)
4843 .single()
4844 .unwrap(),
4845 "update_time must win over time"
4846 );
4847 }
4848
4849 #[test]
4852 fn margin_open_order_falls_back_to_creation_time_when_update_time_absent() {
4853 let inst = InstrumentNameExchange::new("BTCUSDT");
4854
4855 let base = open_order(Some(42), "BUY");
4856 assert!(base.update_time.is_none(), "fixture must omit update_time");
4857 let order = convert_open_order(&base, ExchangeId::BinanceMargin, &inst).expect("convert");
4858 assert_eq!(
4859 order.state.time_exchange,
4860 Utc.timestamp_millis_opt(1_700_000_000_000)
4861 .single()
4862 .unwrap(),
4863 "absent update_time falls back to time"
4864 );
4865 }
4866
4867 #[test]
4868 fn margin_open_order_owned_symbol_recovers_instrument() {
4869 let mut o = open_order(Some(42), "SELL");
4871 o.symbol = Some("ETHUSDT".to_string());
4872 let order =
4873 convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).expect("convert");
4874 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4875 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4876 assert_eq!(order.side, Side::Sell);
4877 }
4878
4879 #[test]
4880 fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
4881 let o = open_order(Some(42), "BUY");
4883 assert!(o.symbol.is_none());
4884 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).is_none());
4885 }
4886
4887 fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
4888 let mut t = QueryMarginAccountsTradeListResponseInner::new();
4889 t.id = id;
4890 t.order_id = Some(7);
4891 t.is_buyer = is_buyer;
4892 t.price = Some("48000".to_string());
4893 t.qty = Some("0.25".to_string());
4894 t.commission = Some("0.001".to_string());
4895 t.commission_asset = Some("BNB".to_string());
4896 t.time = Some(1_700_000_000_000);
4897 t
4898 }
4899
4900 #[test]
4901 fn margin_trade_converts() {
4902 let inst = InstrumentNameExchange::new("BTCUSDT");
4903 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
4904 assert_eq!(tr.id.0.as_str(), "11");
4905 assert_eq!(tr.order_id.0.as_str(), "7");
4906 assert_eq!(tr.side, Side::Sell);
4907 assert_eq!(tr.price, Decimal::from(48_000));
4908 assert_eq!(tr.quantity, Decimal::new(25, 2));
4909 assert_eq!(tr.fees.asset.name().as_str(), "BNB");
4911 assert_eq!(tr.fees.fees, Decimal::new(1, 3));
4912 }
4913
4914 #[test]
4915 fn margin_trade_missing_fields_are_dropped() {
4916 let inst = InstrumentNameExchange::new("BTCUSDT");
4917 assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
4919 assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
4920 }
4921
4922 #[test]
4925 fn token_renew_after_applies_margin_and_floor() {
4926 let now_ms = Utc::now().timestamp_millis();
4927 let far = token_renew_after(now_ms + 86_400_000).as_secs();
4929 assert!(
4930 far > 80_000 && far <= 86_400,
4931 "expected ~24h-minus-margin, got {far}"
4932 );
4933 assert_eq!(
4935 token_renew_after(now_ms - 10_000).as_secs(),
4936 TOKEN_MIN_LIFETIME_SECS
4937 );
4938 assert_eq!(
4940 token_renew_after(now_ms + 60_000).as_secs(),
4941 TOKEN_MIN_LIFETIME_SECS
4942 );
4943 }
4944
4945 fn push(event: serde_json::Value) -> String {
4950 serde_json::json!({ "subscriptionId": 1, "event": event }).to_string()
4951 }
4952
4953 #[test]
4954 fn margin_ws_rpc_ack_yields_no_events() {
4955 let frame = serde_json::json!({ "id": "abc", "status": 200, "result": {} }).to_string();
4957 let mut buf = Vec::new();
4958 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4959 assert!(buf.is_empty());
4960 }
4961
4962 #[test]
4965 fn margin_ws_unrecognised_frames_yield_no_events() {
4966 for frame in [
4967 r#"{"e":"eventStreamTerminated","E":1}"#,
4968 r#"{"subscriptionId":1,"event":{"E":1}}"#,
4969 ] {
4970 let mut buf = Vec::new();
4971 assert!(!convert_margin_user_data_events(frame, &mut buf), "{frame}");
4972 assert!(buf.is_empty(), "{frame}");
4973 }
4974 }
4975
4976 #[test]
4984 fn margin_ws_execution_report_trade_maps_to_trade_and_order_snapshot() {
4985 let frame = push(serde_json::json!({
4986 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4987 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "cid-1",
4988 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
4989 "p": "48000", "q": "2", "f": "GTC",
4990 "n": "0.001", "N": "BNB", "T": 1_700_000_000_000_i64,
4991 }));
4992 let mut buf = Vec::new();
4993 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4994 assert_eq!(
4995 buf.len(),
4996 2,
4997 "a fill must produce the execution and the order snapshot, got: {buf:?}"
4998 );
4999 assert_eq!(buf[0].exchange, ExchangeId::BinanceMargin);
5000 match &buf[0].kind {
5001 AccountEventKind::Trade(t) => {
5002 assert_eq!(t.instrument.name().as_str(), "BTCUSDT");
5003 assert_eq!(t.side, Side::Buy);
5004 assert_eq!(t.price, Decimal::from(48_000));
5005 assert_eq!(t.quantity, Decimal::new(5, 1));
5006 assert_eq!(t.fees.asset.name().as_str(), "BNB"); assert_eq!(t.fees.fees, Decimal::new(1, 3));
5008 }
5009 other => panic!("expected Trade, got {other:?}"),
5010 }
5011 assert_eq!(buf[1].exchange, ExchangeId::BinanceMargin);
5012 match &buf[1].kind {
5013 AccountEventKind::OrderSnapshot(snap) => {
5014 assert_eq!(
5015 snap.0.quantity,
5016 Decimal::from(2),
5017 "the order's total quantity"
5018 );
5019 match &snap.0.state {
5020 OrderState::Active(ActiveOrderState::Open(open)) => {
5021 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("12345"));
5022 assert_eq!(
5023 open.filled_quantity,
5024 Decimal::new(5, 1),
5025 "the snapshot must carry the order's cumulative filled quantity (z)"
5026 );
5027 assert_eq!(
5028 open.time_exchange.timestamp_millis(),
5029 1_700_000_000_000,
5030 "stamped with the execution's transaction time (T), which is what \
5031 gives the engine's staleness gate an ordering key"
5032 );
5033 }
5034 other => panic!("expected an Open order, got {other:?}"),
5035 }
5036 }
5037 other => panic!("expected OrderSnapshot, got {other:?}"),
5038 }
5039 }
5040
5041 #[test]
5045 fn margin_trade_for_an_order_no_longer_working_emits_no_snapshot() {
5046 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
5047 let frame = push(serde_json::json!({
5048 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5049 "x": "TRADE", "X": status, "i": 12_345_i64, "c": "cid-1",
5050 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
5051 "p": "48000", "q": "2", "f": "GTC", "T": 1_700_000_000_000_i64,
5052 }));
5053 let mut buf = Vec::new();
5054 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5055 assert_eq!(
5056 buf.len(),
5057 1,
5058 "status {status} must produce the execution alone, got: {buf:?}"
5059 );
5060 assert!(
5061 matches!(buf[0].kind, AccountEventKind::Trade(_)),
5062 "the execution must still be reported for status {status}"
5063 );
5064 }
5065 }
5066
5067 #[test]
5068 fn margin_ws_new_report_maps_to_active_order_snapshot() {
5069 let frame = push(serde_json::json!({
5070 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "LIMIT",
5071 "x": "NEW", "X": "NEW", "i": 7_i64, "c": "cid-2",
5072 "p": "48000", "q": "1", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
5073 }));
5074 let mut buf = Vec::new();
5075 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5076 assert_eq!(buf.len(), 1);
5077 match &buf[0].kind {
5078 AccountEventKind::OrderSnapshot(snap) => {
5079 assert_eq!(snap.0.side, Side::Sell);
5080 assert_eq!(snap.0.price, Some(Decimal::from(48_000)));
5081 assert_eq!(snap.0.quantity, Decimal::from(1));
5082 assert!(
5083 matches!(snap.0.state, OrderState::Active(_)),
5084 "NEW should be an active (resting) order"
5085 );
5086 }
5087 other => panic!("expected OrderSnapshot, got {other:?}"),
5088 }
5089 }
5090
5091 #[test]
5092 fn margin_ws_canceled_report_maps_to_order_cancelled() {
5093 let frame = push(serde_json::json!({
5094 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5095 "x": "CANCELED", "X": "CANCELED", "i": 8_i64, "c": "cid-3",
5096 "z": "0", "T": 1_700_000_000_000_i64,
5097 }));
5098 let mut buf = Vec::new();
5099 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5100 assert_eq!(buf.len(), 1);
5101 match &buf[0].kind {
5102 AccountEventKind::OrderCancelled(resp) => assert!(resp.state.is_ok()),
5103 other => panic!("expected OrderCancelled, got {other:?}"),
5104 }
5105 }
5106
5107 #[test]
5108 fn margin_ws_execution_report_missing_symbol_is_dropped() {
5109 let frame = push(serde_json::json!({
5111 "e": "executionReport", "S": "BUY", "x": "TRADE", "i": 1_i64, "t": 1_i64,
5112 "l": "1", "L": "100", "T": 1_700_000_000_000_i64,
5113 }));
5114 let mut buf = Vec::new();
5115 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5116 assert!(buf.is_empty());
5117 }
5118
5119 #[test]
5120 fn margin_ws_outbound_account_position_maps_to_balance_stream_updates() {
5121 let frame = push(serde_json::json!({
5122 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5123 "B": [
5124 { "a": "USDT", "f": "100.0", "l": "5.0" },
5125 { "a": "BTC", "f": "0.5", "l": "0" },
5126 ],
5127 }));
5128 let mut buf = Vec::new();
5129 assert!(!convert_margin_user_data_events(&frame, &mut buf));
5130 assert_eq!(buf.len(), 2);
5131 for ev in &buf {
5133 assert!(matches!(ev.kind, AccountEventKind::BalanceStreamUpdate(_)));
5134 }
5135 }
5136
5137 #[test]
5138 fn margin_ws_stream_terminated_signals_reconnect() {
5139 let frame = push(serde_json::json!({ "e": "eventStreamTerminated" }));
5140 let mut buf = Vec::new();
5141 assert!(
5142 convert_margin_user_data_events(&frame, &mut buf),
5143 "eventStreamTerminated must signal reconnect"
5144 );
5145 assert!(buf.is_empty());
5146 }
5147
5148 #[test]
5149 fn margin_ws_margin_specific_events_are_observable_only() {
5150 let mut buf = Vec::new();
5153 let liability = push(serde_json::json!({
5154 "e": "userLiabilityChange", "a": "USDT", "t": "BORROW", "p": "100", "i": "0.01",
5155 }));
5156 assert!(!convert_margin_user_data_events(&liability, &mut buf));
5157 let level = push(serde_json::json!({
5158 "e": "marginLevelStatusChange", "l": "1.5", "s": "MARGIN_LEVEL_2",
5159 }));
5160 assert!(!convert_margin_user_data_events(&level, &mut buf));
5161 assert!(buf.is_empty());
5162 }
5163
5164 #[test]
5167 fn margin_trade_event_dedup_by_key() {
5168 let frame = push(serde_json::json!({
5171 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5172 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 4_242_i64,
5173 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
5174 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
5175 }));
5176 let mut buf = Vec::new();
5177 convert_margin_user_data_events(&frame, &mut buf);
5178 let event = buf.first().expect("a Trade event");
5181 assert!(
5182 matches!(event.kind, AccountEventKind::Trade(_)),
5183 "the execution is the first of the two events a fill produces"
5184 );
5185
5186 let cache = new_dedup_cache();
5187 let first = dedup_key_from_event(event).expect("Trade events have a dedup key");
5189 assert!(!is_duplicate(&cache, first), "first sighting is fresh");
5190 let second = dedup_key_from_event(event).expect("Trade events have a dedup key");
5191 assert!(
5192 is_duplicate(&cache, second),
5193 "second sighting is a duplicate"
5194 );
5195 }
5196
5197 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
5205 let cache = new_dedup_cache();
5206 let mut delivered = Vec::new();
5207 let mut buf = Vec::new();
5208 for frame in frames {
5209 let _ = convert_margin_user_data_events(frame, &mut buf);
5210 for ev in buf.drain(..) {
5211 if let Some(key) = dedup_key_from_event(&ev)
5212 && is_duplicate(&cache, key)
5213 {
5214 continue;
5215 }
5216 delivered.push(ev);
5217 }
5218 }
5219 delivered
5220 }
5221
5222 fn ws_new(cumulative_filled: &str) -> String {
5223 push(serde_json::json!({
5224 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5225 "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "client-1",
5226 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
5227 "T": 1_700_000_000_000_i64,
5228 }))
5229 }
5230
5231 fn ws_partial_fill(cumulative_filled: &str) -> String {
5232 push(serde_json::json!({
5233 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5234 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "client-1",
5235 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
5236 "l": "1", "L": "100", "t": 555_i64, "n": "0.1", "N": "USDT",
5237 "T": 1_700_000_001_000_i64,
5238 }))
5239 }
5240
5241 fn open_filled_quantities(events: &[UnindexedAccountEvent]) -> Vec<Decimal> {
5242 events
5243 .iter()
5244 .filter_map(|ev| match &ev.kind {
5245 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
5246 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
5247 _ => None,
5248 },
5249 _ => None,
5250 })
5251 .collect()
5252 }
5253
5254 #[test]
5262 fn margin_ws_fill_snapshot_survives_the_dedup_gate() {
5263 let delivered = drive_ws_pipeline(&[&ws_new("0"), &ws_partial_fill("1")]);
5264
5265 assert_eq!(
5266 delivered.len(),
5267 3,
5268 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
5269 );
5270 assert_eq!(
5271 open_filled_quantities(&delivered),
5272 vec![Decimal::ZERO, Decimal::ONE],
5273 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
5274 );
5275 }
5276
5277 #[test]
5280 fn margin_ws_replayed_frame_is_still_deduplicated() {
5281 let delivered = drive_ws_pipeline(&[
5282 &ws_new("0"),
5283 &ws_partial_fill("1"),
5284 &ws_new("0"),
5285 &ws_partial_fill("1"),
5286 ]);
5287
5288 assert_eq!(
5289 delivered.len(),
5290 3,
5291 "re-delivering both frames must add nothing, got: {delivered:?}"
5292 );
5293 }
5294
5295 #[test]
5301 fn margin_ws_an_ack_and_its_fill_reporting_one_state_deliver_that_state_once() {
5302 let delivered = drive_ws_pipeline(&[&ws_new("1"), &ws_partial_fill("1")]);
5303
5304 assert_eq!(
5305 delivered.len(),
5306 2,
5307 "the ack snapshot and the execution, with the fill's duplicate snapshot collapsed, \
5308 got: {delivered:?}"
5309 );
5310 assert!(
5311 delivered
5312 .iter()
5313 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
5314 "the execution must reach the consumer regardless"
5315 );
5316 assert_eq!(
5317 open_filled_quantities(&delivered),
5318 vec![Decimal::ONE],
5319 "one snapshot, carrying the state both reports agree on"
5320 );
5321 }
5322
5323 fn push_with_sub(subscription_id: i64, event: serde_json::Value) -> String {
5328 serde_json::json!({ "subscriptionId": subscription_id, "event": event }).to_string()
5329 }
5330
5331 fn isolated_handler(
5334 sub_map: Arc<Mutex<HashMap<i64, InstrumentNameExchange>>>,
5335 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
5336 ) -> impl FnMut(OutboundAccountPosition, Option<i64>, &mut Vec<UnindexedAccountEvent>) {
5337 move |position, subscription_id, buf| {
5338 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
5339 }
5340 }
5341
5342 fn btcusdt() -> InstrumentNameExchange {
5343 InstrumentNameExchange::new("BTCUSDT")
5344 }
5345
5346 fn token(expiration_time_ms: i64) -> UserListenToken {
5347 UserListenToken {
5348 token: "t".to_string(),
5349 expiration_time_ms,
5350 }
5351 }
5352
5353 #[test]
5354 fn listen_token_query_cross_vs_isolated() {
5355 assert!(build_listen_token_query(None).is_empty());
5357
5358 let q = build_listen_token_query(Some(&btcusdt()));
5360 assert_eq!(q.get("isIsolated").and_then(|v| v.as_str()), Some("TRUE"));
5361 assert_eq!(q.get("symbol").and_then(|v| v.as_str()), Some("BTCUSDT"));
5362 assert_eq!(q.len(), 2);
5363 }
5364
5365 #[test]
5366 fn earliest_token_expiry_picks_min() {
5367 let tokens = vec![
5368 (btcusdt(), token(3_000)),
5369 (InstrumentNameExchange::new("ETHUSDT"), token(1_000)),
5370 (InstrumentNameExchange::new("BNBUSDT"), token(2_000)),
5371 ];
5372 assert_eq!(earliest_token_expiry_ms(&tokens), 1_000);
5373 assert_eq!(earliest_token_expiry_ms(&[]), i64::MAX);
5375 }
5376
5377 #[test]
5378 fn base_quote_map_extracts_base_and_quote() {
5379 let entries = vec![
5380 iso_entry(
5381 "BTCUSDT",
5382 iso_base("BTC", "0", "0", "0", "0"),
5383 iso_quote("USDT", "0", "0", "0", "0"),
5384 None,
5385 None,
5386 None,
5387 ),
5388 iso_entry(
5389 "ETHBTC",
5390 iso_base("ETH", "0", "0", "0", "0"),
5391 iso_quote("BTC", "0", "0", "0", "0"),
5392 None,
5393 None,
5394 None,
5395 ),
5396 ];
5397 let map = build_base_quote_map(&entries);
5398 assert_eq!(
5399 map.get(&btcusdt())
5400 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5401 Some(("BTC", "USDT"))
5402 );
5403 assert_eq!(
5406 map.get(&InstrumentNameExchange::new("ETHBTC"))
5407 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5408 Some(("ETH", "BTC"))
5409 );
5410 }
5411
5412 #[test]
5413 fn isolated_outbound_position_routes_to_instrument_balance_update() {
5414 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5415 let base_quote = Arc::new(HashMap::from([(
5416 btcusdt(),
5417 (
5418 AssetNameExchange::new("BTC"),
5419 AssetNameExchange::new("USDT"),
5420 ),
5421 )]));
5422 let mut handler = isolated_handler(sub_map, base_quote);
5423
5424 let frame = push_with_sub(
5426 7,
5427 serde_json::json!({
5428 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5429 "B": [
5430 { "a": "USDT", "f": "1000.0", "l": "50.0" },
5431 { "a": "BTC", "f": "0.5", "l": "0.1" },
5432 ],
5433 }),
5434 );
5435 let mut buf = Vec::new();
5436 assert!(!convert_margin_user_data_events_with(
5437 &frame,
5438 &mut buf,
5439 &mut handler
5440 ));
5441 assert_eq!(buf.len(), 1, "one InstrumentBalanceUpdate for the pair");
5442 match &buf[0].kind {
5443 AccountEventKind::InstrumentBalanceUpdate(ibu) => {
5444 assert_eq!(ibu.instrument.name().as_str(), "BTCUSDT");
5445 assert_eq!(ibu.base.asset.name().as_str(), "BTC");
5446 assert_eq!(ibu.base.update.free, Decimal::new(5, 1));
5447 assert_eq!(ibu.base.update.locked, Decimal::new(1, 1));
5448 assert_eq!(ibu.quote.asset.name().as_str(), "USDT");
5449 assert_eq!(ibu.quote.update.free, Decimal::from(1000));
5450 assert_eq!(ibu.quote.update.locked, Decimal::from(50));
5451 }
5452 other => panic!("expected InstrumentBalanceUpdate, got {other:?}"),
5453 }
5454 assert!(!matches!(
5456 buf[0].kind,
5457 AccountEventKind::BalanceStreamUpdate(_)
5458 ));
5459 }
5460
5461 #[test]
5462 fn isolated_outbound_position_unknown_subscription_dropped() {
5463 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5465 let base_quote = Arc::new(HashMap::from([(
5466 btcusdt(),
5467 (
5468 AssetNameExchange::new("BTC"),
5469 AssetNameExchange::new("USDT"),
5470 ),
5471 )]));
5472 let mut handler = isolated_handler(sub_map, base_quote);
5473
5474 let frame = push_with_sub(
5475 99,
5476 serde_json::json!({
5477 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5478 "B": [{ "a": "BTC", "f": "1", "l": "0" }, { "a": "USDT", "f": "1", "l": "0" }],
5479 }),
5480 );
5481 let mut buf = Vec::new();
5482 assert!(!convert_margin_user_data_events_with(
5483 &frame,
5484 &mut buf,
5485 &mut handler
5486 ));
5487 assert!(buf.is_empty(), "unmapped subscriptionId frame is dropped");
5488 }
5489
5490 #[test]
5491 fn isolated_outbound_position_missing_side_dropped() {
5492 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5495 let base_quote = Arc::new(HashMap::from([(
5496 btcusdt(),
5497 (
5498 AssetNameExchange::new("BTC"),
5499 AssetNameExchange::new("USDT"),
5500 ),
5501 )]));
5502 let mut handler = isolated_handler(sub_map, base_quote);
5503
5504 let frame = push_with_sub(
5505 7,
5506 serde_json::json!({
5507 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5508 "B": [{ "a": "BTC", "f": "0.5", "l": "0" }],
5509 }),
5510 );
5511 let mut buf = Vec::new();
5512 assert!(!convert_margin_user_data_events_with(
5513 &frame,
5514 &mut buf,
5515 &mut handler
5516 ));
5517 assert!(buf.is_empty(), "missing quote side → frame dropped");
5518 }
5519
5520 #[test]
5521 fn isolated_execution_report_routes_by_inner_symbol_independent_of_map() {
5522 let sub_map = Arc::new(Mutex::new(HashMap::<i64, InstrumentNameExchange>::new()));
5526 let base_quote = Arc::new(HashMap::new());
5527 let mut handler = isolated_handler(sub_map, base_quote);
5528
5529 let frame = push_with_sub(
5530 42,
5531 serde_json::json!({
5532 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5533 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 5_i64,
5534 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
5535 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
5536 }),
5537 );
5538 let mut buf = Vec::new();
5539 assert!(!convert_margin_user_data_events_with(
5540 &frame,
5541 &mut buf,
5542 &mut handler
5543 ));
5544 assert_eq!(buf.len(), 2, "the execution and its order snapshot");
5545 match &buf[0].kind {
5546 AccountEventKind::Trade(t) => assert_eq!(t.instrument.name().as_str(), "BTCUSDT"),
5547 other => panic!("expected Trade, got {other:?}"),
5548 }
5549 match &buf[1].kind {
5550 AccountEventKind::OrderSnapshot(snap) => {
5551 assert_eq!(snap.0.key.instrument.name().as_str(), "BTCUSDT");
5552 }
5553 other => panic!("expected OrderSnapshot, got {other:?}"),
5554 }
5555 }
5556
5557 struct MarginMyTradesVenue {
5568 trades: Vec<(i64, i64, i64)>,
5570 end_exclusive: bool,
5571 }
5572
5573 impl wiremock::Respond for MarginMyTradesVenue {
5574 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
5575 const DAY_MS: i64 = 24 * 60 * 60 * 1000;
5576 let query: std::collections::HashMap<String, String> =
5577 request.url.query_pairs().into_owned().collect();
5578 let param = |name: &str| query.get(name).map(|v| v.parse::<i64>().unwrap());
5579 let limit = usize::try_from(param("limit").unwrap_or(500)).unwrap();
5580 let order = param("orderId");
5581
5582 let mut selected: Vec<&(i64, i64, i64)> = self
5583 .trades
5584 .iter()
5585 .filter(|(_, _, order_id)| order.is_none_or(|order| *order_id == order))
5586 .collect();
5587 if let Some(from_id) = param("fromId") {
5588 selected.retain(|(id, _, _)| *id >= from_id);
5589 } else if order.is_none() {
5590 let start = param("startTime").expect("a query by time sends startTime");
5591 let end = match param("endTime") {
5592 Some(end) if end - start >= DAY_MS => {
5593 return wiremock::ResponseTemplate::new(400).set_body_json(
5594 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
5595 );
5596 }
5597 Some(end) if self.end_exclusive => end - 1,
5598 Some(end) => end,
5599 None => start + DAY_MS - 1,
5600 };
5601 selected.retain(|(_, time, _)| (start..=end).contains(time));
5602 }
5603
5604 let page: Vec<serde_json::Value> = selected
5605 .into_iter()
5606 .take(limit)
5607 .map(|(id, time, order_id)| {
5608 serde_json::json!({
5609 "id": id, "symbol": "BTCUSDT", "time": time, "orderId": order_id,
5610 "price": "50000", "qty": "0.01", "commission": "0",
5611 "commissionAsset": "USDT", "isBuyer": true, "isMaker": false,
5612 "isBestMatch": true, "isIsolated": true,
5613 })
5614 })
5615 .collect();
5616 wiremock::ResponseTemplate::new(200).set_body_json(page)
5617 }
5618 }
5619
5620 fn since(start: i64) -> MyTradesFrom {
5622 MyTradesFrom::Span {
5623 start,
5624 end: i64::MAX,
5625 }
5626 }
5627
5628 async fn read_margin_trades(
5631 venue: MarginMyTradesVenue,
5632 from: MyTradesFrom,
5633 ) -> (Vec<i64>, Vec<std::collections::HashMap<String, String>>) {
5634 let server = wiremock::MockServer::start().await;
5635 wiremock::Mock::given(wiremock::matchers::method("GET"))
5636 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5637 .respond_with(venue)
5638 .mount(&server)
5639 .await;
5640 let rest = Arc::new(MarginTradingRestApi::from_config(
5641 ConfigurationRestApi::builder()
5642 .api_key("key")
5643 .api_secret("secret")
5644 .base_path(server.uri())
5645 .build()
5646 .unwrap(),
5647 ));
5648
5649 let read = paginate_margin_my_trades(
5650 &rest,
5651 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5652 &InstrumentNameExchange::new("BTCUSDT"),
5653 from,
5654 true,
5655 RequestKind::Query,
5656 )
5657 .await
5658 .unwrap();
5659
5660 let requests = server
5661 .received_requests()
5662 .await
5663 .unwrap()
5664 .iter()
5665 .map(|request| request.url.query_pairs().into_owned().collect())
5666 .collect();
5667 (read.into_iter().map(|t| t.id.unwrap()).collect(), requests)
5668 }
5669
5670 fn venue(trades: Vec<(i64, i64)>) -> MarginMyTradesVenue {
5672 MarginMyTradesVenue {
5673 trades: trades
5674 .into_iter()
5675 .map(|(id, time)| (id, time, id))
5676 .collect(),
5677 end_exclusive: false,
5678 }
5679 }
5680
5681 const HOUR_MS: i64 = 60 * 60 * 1000;
5682
5683 #[tokio::test]
5686 async fn margin_trades_read_a_lone_fill_beyond_the_first_day() {
5687 let now = Utc::now().timestamp_millis();
5688 let (ids, _) = read_margin_trades(
5689 venue(vec![(7, now - 72 * HOUR_MS)]),
5690 since(now - 120 * HOUR_MS),
5691 )
5692 .await;
5693
5694 assert_eq!(ids, vec![7]);
5695 }
5696
5697 #[tokio::test]
5700 async fn margin_trades_read_fills_across_several_windows_once_each() {
5701 let now = Utc::now().timestamp_millis();
5702 let trades = vec![
5703 (1, now - 119 * HOUR_MS),
5704 (2, now - 72 * HOUR_MS),
5705 (3, now - HOUR_MS),
5706 ];
5707 let (ids, _) = read_margin_trades(venue(trades), since(now - 144 * HOUR_MS)).await;
5708
5709 assert_eq!(ids, vec![1, 2, 3]);
5710 }
5711
5712 #[tokio::test]
5715 async fn margin_trades_read_a_trade_on_a_window_boundary_once() {
5716 for end_exclusive in [false, true] {
5717 let now = Utc::now().timestamp_millis();
5718 let from = now - 48 * HOUR_MS;
5719 let venue = MarginMyTradesVenue {
5720 end_exclusive,
5721 ..venue(vec![(5, from + MARGIN_MY_TRADES_WINDOW_MS)])
5722 };
5723 let (ids, _) = read_margin_trades(venue, since(from)).await;
5724
5725 assert_eq!(ids, vec![5], "end_exclusive: {end_exclusive}");
5726 }
5727 }
5728
5729 #[tokio::test]
5731 async fn margin_trades_page_on_by_id_past_a_full_window() {
5732 let now = Utc::now().timestamp_millis();
5733 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5734 let trades = (1..=count)
5735 .map(|id| (id, now - 48 * HOUR_MS + id))
5736 .collect();
5737 let (ids, _) = read_margin_trades(venue(trades), since(now - 50 * HOUR_MS)).await;
5738
5739 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5740 }
5741
5742 #[tokio::test]
5745 async fn margin_trades_page_on_past_a_full_final_window() {
5746 let now = Utc::now().timestamp_millis();
5747 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5748 let trades = (1..=count).map(|id| (id, now - HOUR_MS + id)).collect();
5749 let (ids, requests) = read_margin_trades(venue(trades), since(now - 2 * HOUR_MS)).await;
5750
5751 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5752 assert_eq!(requests.len(), 2);
5753 }
5754
5755 #[tokio::test]
5758 async fn margin_trades_quiet_lookback_sends_one_request_per_window() {
5759 let now = Utc::now().timestamp_millis();
5760 let (ids, requests) =
5761 read_margin_trades(venue(Vec::new()), since(now - 72 * HOUR_MS)).await;
5762
5763 assert!(ids.is_empty());
5764 assert_eq!(requests.len(), 4);
5766 }
5767
5768 #[tokio::test]
5771 async fn margin_trades_read_a_short_gap_in_one_request() {
5772 let now = Utc::now().timestamp_millis();
5773 let (ids, requests) =
5774 read_margin_trades(venue(vec![(9, now - HOUR_MS)]), since(now - 2 * HOUR_MS)).await;
5775
5776 assert_eq!(ids, vec![9]);
5777 assert_eq!(requests.len(), 1);
5778 }
5779
5780 #[tokio::test]
5782 async fn margin_trades_from_the_future_send_no_request() {
5783 let now = Utc::now().timestamp_millis();
5784 let (ids, requests) = read_margin_trades(venue(Vec::new()), since(now + HOUR_MS)).await;
5785
5786 assert!(ids.is_empty());
5787 assert!(requests.is_empty());
5788 }
5789
5790 #[tokio::test]
5793 async fn margin_trades_by_order_page_by_id_without_time_bounds() {
5794 let now = Utc::now().timestamp_millis();
5795 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5796 let trades = (1..=2 * count)
5798 .map(|id| {
5799 (
5800 id,
5801 now - 48 * HOUR_MS + id,
5802 if id % 2 == 0 { 42 } else { 7 },
5803 )
5804 })
5805 .collect();
5806 let venue = MarginMyTradesVenue {
5807 trades,
5808 end_exclusive: false,
5809 };
5810 let (ids, requests) = read_margin_trades(venue, MyTradesFrom::Order(42)).await;
5811
5812 assert_eq!(ids, (1..=count).map(|n| 2 * n).collect::<Vec<_>>());
5813 assert_eq!(requests.len(), 2);
5814 assert!(
5815 requests.iter().all(
5816 |query| query.get("orderId").map(String::as_str) == Some("42")
5817 && !query.contains_key("startTime")
5818 && !query.contains_key("endTime")
5819 )
5820 );
5821 }
5822
5823 async fn margin_trades_error(response: wiremock::ResponseTemplate) -> UnindexedClientError {
5826 let server = wiremock::MockServer::start().await;
5827 wiremock::Mock::given(wiremock::matchers::method("GET"))
5828 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5829 .respond_with(response)
5830 .mount(&server)
5831 .await;
5832 let rest = Arc::new(MarginTradingRestApi::from_config(
5833 ConfigurationRestApi::builder()
5834 .api_key("key")
5835 .api_secret("secret")
5836 .base_path(server.uri())
5837 .build()
5838 .unwrap(),
5839 ));
5840
5841 paginate_margin_my_trades(
5842 &rest,
5843 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5844 &InstrumentNameExchange::new("BTCUSDT"),
5845 since(Utc::now().timestamp_millis() - HOUR_MS),
5846 true,
5847 RequestKind::Query,
5848 )
5849 .await
5850 .unwrap_err()
5851 }
5852
5853 #[tokio::test]
5856 async fn margin_trades_report_a_venue_rejection_as_a_non_transient_request_rejection() {
5857 let err = margin_trades_error(wiremock::ResponseTemplate::new(400).set_body_json(
5858 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
5859 ))
5860 .await;
5861
5862 let UnindexedClientError::Api(ApiError::RequestRejected(msg)) = &err else {
5863 panic!("expected RequestRejected, got {err:?}");
5864 };
5865 assert!(msg.contains("-1127"), "code missing from {msg:?}");
5866 assert!(!err.is_transient());
5867 }
5868
5869 #[tokio::test]
5871 async fn margin_trades_report_an_undecodable_response_as_internal() {
5872 let err = margin_trades_error(
5873 wiremock::ResponseTemplate::new(200)
5874 .set_body_json(serde_json::json!({"unexpected": 1})),
5875 )
5876 .await;
5877
5878 assert!(
5879 matches!(err, UnindexedClientError::Internal(_)),
5880 "got {err:?}"
5881 );
5882 assert!(!err.is_transient());
5883 }
5884
5885 #[tokio::test]
5888 async fn sapi_used_ip_weight_near_the_limit_pauses_queries() {
5889 for (used, pauses) in [("10799", false), ("10800", true), ("not a number", false)] {
5890 let server = wiremock::MockServer::start().await;
5891 wiremock::Mock::given(wiremock::matchers::method("GET"))
5892 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
5893 .respond_with(
5894 wiremock::ResponseTemplate::new(200)
5895 .insert_header("x-sapi-used-ip-weight-1m", used)
5896 .set_body_json(serde_json::json!([])),
5897 )
5898 .mount(&server)
5899 .await;
5900 let rest = Arc::new(MarginTradingRestApi::from_config(
5901 ConfigurationRestApi::builder()
5902 .api_key("key")
5903 .api_secret("secret")
5904 .base_path(server.uri())
5905 .build()
5906 .unwrap(),
5907 ));
5908 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5909
5910 fetch_margin_all_open_orders(rest, Arc::clone(&tracker), false)
5911 .await
5912 .unwrap();
5913
5914 let query_waits = tokio::time::timeout(
5915 Duration::from_millis(50),
5916 tracker.wait_if_blocked(RequestKind::Query),
5917 )
5918 .await
5919 .is_err();
5920 assert_eq!(query_waits, pauses, "used IP weight {used}");
5921 tokio::time::timeout(
5922 Duration::from_millis(50),
5923 tracker.wait_if_blocked(RequestKind::Order),
5924 )
5925 .await
5926 .expect("orders never wait on the pause");
5927 }
5928 }
5929
5930 #[tokio::test]
5932 async fn margin_fill_recovery_does_not_wait_for_the_weight_pause() {
5933 let server = wiremock::MockServer::start().await;
5934 wiremock::Mock::given(wiremock::matchers::method("GET"))
5935 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5936 .respond_with(
5938 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
5939 "symbol": "BTCUSDT",
5940 "id": 1,
5941 "orderId": 7,
5942 "price": "100",
5943 "qty": "1",
5944 "commission": "0",
5945 "commissionAsset": "USDT",
5946 "time": Utc::now().timestamp_millis(),
5947 "isBuyer": true,
5948 "isMaker": false,
5949 }])),
5950 )
5951 .expect(2)
5952 .mount(&server)
5953 .await;
5954 let rest = Arc::new(MarginTradingRestApi::from_config(
5955 ConfigurationRestApi::builder()
5956 .api_key("key")
5957 .api_secret("secret")
5958 .base_path(server.uri())
5959 .build()
5960 .unwrap(),
5961 ));
5962 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5963 tracker.throttle(Duration::from_secs(60));
5964 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5965 let mut unrecovered = UnrecoveredFills::default();
5966 unrecovered.open(
5967 &[InstrumentNameExchange::new("BTCUSDT")],
5968 Utc::now() - chrono::Duration::minutes(1),
5969 Utc::now(),
5970 );
5971
5972 tokio::time::timeout(
5974 Duration::from_millis(800),
5975 recover_margin_fills(
5976 &rest,
5977 &tracker,
5978 &mut unrecovered,
5979 &tx,
5980 &new_dedup_cache(),
5981 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
5982 false,
5983 ),
5984 )
5985 .await
5986 .expect("recovery does not wait for the pause");
5987 }
5988
5989 #[tokio::test]
5992 async fn margin_trades_read_over_a_span_stop_at_its_end() {
5993 let now = Utc::now().timestamp_millis();
5994 let trades = vec![
5995 (1, now - 50 * HOUR_MS),
5996 (2, now - 49 * HOUR_MS),
5997 (3, now - 10 * HOUR_MS),
5998 ];
5999 let (ids, _) = read_margin_trades(
6000 venue(trades),
6001 MyTradesFrom::Span {
6002 start: now - 60 * HOUR_MS,
6003 end: now - 20 * HOUR_MS,
6004 },
6005 )
6006 .await;
6007
6008 assert_eq!(ids, vec![1, 2]);
6009 }
6010
6011 #[tokio::test]
6014 async fn a_margin_gap_whose_every_read_fails_is_reported_when_given_up() {
6015 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6016 let reconnect = Utc::now() - chrono::Duration::minutes(5);
6017 let server = wiremock::MockServer::start().await;
6018 wiremock::Mock::given(wiremock::matchers::method("GET"))
6019 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6020 .respond_with(
6021 wiremock::ResponseTemplate::new(400)
6022 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
6023 )
6024 .mount(&server)
6025 .await;
6026 let rest = Arc::new(MarginTradingRestApi::from_config(
6027 ConfigurationRestApi::builder()
6028 .api_key("key")
6029 .api_secret("secret")
6030 .base_path(server.uri())
6031 .build()
6032 .expect("valid config"),
6033 ));
6034 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
6035 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6036 let dedup = new_dedup_cache();
6037 let btc = InstrumentNameExchange::new("BTCUSDT");
6038 let mut unrecovered = UnrecoveredFills::default();
6039 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
6040
6041 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
6042 recover_margin_fills(
6043 &rest,
6044 &tracker,
6045 &mut unrecovered,
6046 &tx,
6047 &dedup,
6048 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6049 false,
6050 )
6051 .await;
6052 unrecovered.make_due();
6053 }
6054
6055 assert!(unrecovered.is_empty(), "the gap is given up");
6056 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6057 let [event] = forwarded.as_slice() else {
6058 panic!("only the give-up is sent: {forwarded:?}");
6059 };
6060 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
6061 panic!("expected FillRecoveryGaveUp, got {event:?}");
6062 };
6063 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
6064 assert_eq!(
6065 gave_up.scope,
6066 crate::FillRecoveryScope::Instruments(vec![btc])
6067 );
6068 assert_eq!(
6069 gave_up.start.timestamp_millis(),
6070 disconnect.timestamp_millis()
6071 );
6072 assert_eq!(
6073 gave_up.attempts,
6074 crate::client::order_recovery::MAX_GAP_RETRIES + 1
6075 );
6076 assert!(
6077 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
6078 "the last read's error: {:?}",
6079 gave_up.reason
6080 );
6081 }
6082
6083 #[tokio::test]
6086 async fn a_failed_margin_recovery_keeps_its_closed_gap_for_the_retry() {
6087 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6088 let reconnect = Utc::now() - chrono::Duration::minutes(5);
6089 let server = wiremock::MockServer::start().await;
6090 wiremock::Mock::given(wiremock::matchers::method("GET"))
6091 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6092 .respond_with(
6093 wiremock::ResponseTemplate::new(400)
6094 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
6095 )
6096 .up_to_n_times(1)
6097 .with_priority(1)
6098 .mount(&server)
6099 .await;
6100 wiremock::Mock::given(wiremock::matchers::method("GET"))
6101 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6102 .respond_with(venue(vec![
6103 (
6104 1,
6105 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
6106 ),
6107 (
6108 2,
6109 (Utc::now() - chrono::Duration::minutes(1)).timestamp_millis(),
6110 ),
6111 ]))
6112 .mount(&server)
6113 .await;
6114 let rest = Arc::new(MarginTradingRestApi::from_config(
6115 ConfigurationRestApi::builder()
6116 .api_key("key")
6117 .api_secret("secret")
6118 .base_path(server.uri())
6119 .build()
6120 .unwrap(),
6121 ));
6122 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
6123 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6124 let dedup = new_dedup_cache();
6125 let mut unrecovered = UnrecoveredFills::default();
6126 unrecovered.open(
6127 &[InstrumentNameExchange::new("BTCUSDT")],
6128 disconnect,
6129 reconnect,
6130 );
6131
6132 recover_margin_fills(
6133 &rest,
6134 &tracker,
6135 &mut unrecovered,
6136 &tx,
6137 &dedup,
6138 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6139 false,
6140 )
6141 .await;
6142 assert!(!unrecovered.is_empty(), "the failed gap is kept");
6143 assert!(rx.try_recv().is_err(), "nothing forwarded");
6144
6145 unrecovered.make_due();
6146 recover_margin_fills(
6147 &rest,
6148 &tracker,
6149 &mut unrecovered,
6150 &tx,
6151 &dedup,
6152 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6153 false,
6154 )
6155 .await;
6156 assert!(unrecovered.is_empty(), "the retry recovered it");
6157 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6158 assert_eq!(
6159 forwarded.len(),
6160 1,
6161 "only the fill inside the gap: {forwarded:?}"
6162 );
6163 }
6164
6165 #[tokio::test]
6168 async fn a_timed_out_margin_recovery_charges_only_the_gaps_it_started() {
6169 let server = wiremock::MockServer::start().await;
6170 wiremock::Mock::given(wiremock::matchers::method("GET"))
6171 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6172 .respond_with(
6173 wiremock::ResponseTemplate::new(200)
6174 .set_body_json(serde_json::json!([]))
6175 .set_delay(Duration::from_secs(3_600)),
6176 )
6177 .mount(&server)
6178 .await;
6179 let rest = Arc::new(MarginTradingRestApi::from_config(
6180 ConfigurationRestApi::builder()
6181 .api_key("key")
6182 .api_secret("secret")
6183 .base_path(server.uri())
6184 .timeout(3_600_000_u64)
6185 .retries(0_u32)
6186 .build()
6187 .unwrap(),
6188 ));
6189 let instruments: Vec<_> = (0..9)
6190 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
6191 .collect();
6192 let mut unrecovered = UnrecoveredFills::default();
6193 unrecovered.open(
6194 &instruments,
6195 Utc::now() - chrono::Duration::minutes(10),
6196 Utc::now(),
6197 );
6198 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
6199 tokio::time::pause();
6201
6202 recover_margin_fills(
6203 &rest,
6204 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6205 &mut unrecovered,
6206 &tx,
6207 &new_dedup_cache(),
6208 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6209 false,
6210 )
6211 .await;
6212
6213 let now = tokio::time::Instant::now();
6214 assert_eq!(
6215 unrecovered.due(now).len(),
6216 1,
6217 "the gap never started stays due"
6218 );
6219 assert_eq!(
6220 unrecovered
6221 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
6222 .len(),
6223 9,
6224 "the eight started wait for their first retry"
6225 );
6226 }
6227
6228 fn margin_order_row(cid: &str, status: &str) -> serde_json::Value {
6234 serde_json::json!({
6235 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
6236 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
6237 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "isIsolated": false,
6238 "time": 1_700_000_000_000_i64, "updateTime": 1_700_000_060_000_i64,
6239 })
6240 }
6241
6242 fn margin_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
6243 OrderKey::new(
6244 ExchangeId::BinanceMargin,
6245 InstrumentNameExchange::new(instrument),
6246 StrategyId::new("strategy"),
6247 ClientOrderId::new(cid),
6248 )
6249 }
6250
6251 #[test]
6252 fn a_margin_order_row_says_how_the_order_ended() {
6253 use crate::order::state::InactiveOrderState;
6254 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOrderResponse;
6255 let key = margin_key("BTCUSDT", "a");
6256 let ended = |row: serde_json::Value| {
6257 let row: QueryMarginAccountsOrderResponse = serde_json::from_value(row).unwrap();
6258 convert_ended_order(&row, ExchangeId::BinanceMargin, &key).map(|order| order.state)
6259 };
6260
6261 let mut filled = margin_order_row("a", "FILLED");
6262 filled["executedQty"] = "2".into();
6263 filled["cummulativeQuoteQty"] = "210".into();
6264 let Some(InactiveOrderState::FullyFilled(filled)) = ended(filled) else {
6265 panic!("FILLED is fully filled");
6266 };
6267 assert_eq!(filled.avg_price, Some(Decimal::from(105)));
6268 assert!(matches!(
6269 ended(margin_order_row("a", "CANCELED")),
6270 Some(InactiveOrderState::Cancelled(cancelled)) if cancelled.filled_quantity == Some(Decimal::ONE)
6271 ));
6272 assert_eq!(ended(margin_order_row("a", "NEW")), None);
6273
6274 let mut unknown_quote = margin_order_row("a", "FILLED");
6276 unknown_quote["executedQty"] = "2".into();
6277 unknown_quote["cummulativeQuoteQty"] = "-1".into();
6278 let Some(InactiveOrderState::FullyFilled(filled)) = ended(unknown_quote) else {
6279 panic!("still fully filled");
6280 };
6281 assert_eq!(filled.avg_price, None, "no negative average price");
6282 }
6283
6284 fn margin_rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
6285 Arc::new(MarginTradingRestApi::from_config(
6286 ConfigurationRestApi::builder()
6287 .api_key("key")
6288 .api_secret("secret")
6289 .base_path(server.uri())
6290 .build()
6291 .unwrap(),
6292 ))
6293 }
6294
6295 fn margin_client_at(server: &wiremock::MockServer, is_isolated: bool) -> BinanceMargin {
6297 let config = if is_isolated {
6298 BinanceMarginConfig::isolated(
6299 "key".into(),
6300 "secret".into(),
6301 vec![InstrumentNameExchange::new("BTCUSDT")],
6302 )
6303 } else {
6304 BinanceMarginConfig::cross_margin("key".into(), "secret".into())
6305 };
6306 let mut client = <BinanceMargin as ExecutionClient>::new(config);
6307 client.rest = margin_rest_at(server);
6308 client
6309 }
6310
6311 async fn mount_margin_order(
6313 server: &wiremock::MockServer,
6314 cid: &str,
6315 response: wiremock::ResponseTemplate,
6316 ) {
6317 wiremock::Mock::given(wiremock::matchers::method("GET"))
6318 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6319 .and(wiremock::matchers::query_param("origClientOrderId", cid))
6320 .respond_with(response)
6321 .mount(server)
6322 .await;
6323 }
6324
6325 fn margin_venue_error(code: i64) -> wiremock::ResponseTemplate {
6326 wiremock::ResponseTemplate::new(400)
6327 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
6328 }
6329
6330 fn margin_row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
6331 wiremock::ResponseTemplate::new(200).set_body_json(margin_order_row(cid, status))
6332 }
6333
6334 async fn margin_requests(server: &wiremock::MockServer) -> Vec<(String, Option<String>)> {
6336 server
6337 .received_requests()
6338 .await
6339 .unwrap()
6340 .iter()
6341 .map(|request| {
6342 let isolated = request
6343 .url
6344 .query_pairs()
6345 .find(|(key, _)| key == "isIsolated")
6346 .map(|(_, value)| value.into_owned());
6347 (request.url.path().to_owned(), isolated)
6348 })
6349 .collect()
6350 }
6351
6352 #[tokio::test]
6353 async fn margin_fetch_ended_orders_asks_the_configured_account_and_omits_the_rest() {
6354 use crate::order::state::InactiveOrderState;
6355 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
6356 let server = wiremock::MockServer::start().await;
6357 mount_margin_order(&server, "filled", margin_row_response("filled", "FILLED")).await;
6358 mount_margin_order(&server, "live", margin_row_response("live", "NEW")).await;
6359 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
6360 mount_margin_order(&server, "no-symbol", margin_venue_error(-1121)).await;
6361 let client = margin_client_at(&server, is_isolated);
6362 let keys = ["filled", "live", "unknown"].map(|cid| margin_key("BTCUSDT", cid));
6363
6364 let ended = client
6365 .fetch_ended_orders(
6366 &[keys.to_vec(), vec![margin_key("NOSUCH", "no-symbol")]].concat(),
6367 )
6368 .await
6369 .unwrap();
6370
6371 let [order] = ended.as_slice() else {
6372 panic!("one order ended: {ended:?}");
6373 };
6374 assert_eq!(order.key, keys[0], "the strategy asked survives");
6375 assert!(matches!(order.state, InactiveOrderState::FullyFilled(_)));
6376 let requests = margin_requests(&server).await;
6377 assert_eq!(requests.len(), 4);
6378 for (path, isolated) in requests {
6379 assert_eq!(path, "/sapi/v1/margin/order");
6380 assert_eq!(
6381 isolated.as_deref(),
6382 Some(flag),
6383 "is_isolated = {is_isolated}"
6384 );
6385 }
6386 }
6387 }
6388
6389 #[tokio::test]
6390 async fn margin_fetch_ended_orders_fails_when_any_lookup_fails() {
6391 let server = wiremock::MockServer::start().await;
6392 mount_margin_order(
6393 &server,
6394 "cancelled",
6395 margin_row_response("cancelled", "CANCELED"),
6396 )
6397 .await;
6398 mount_margin_order(&server, "refused", margin_venue_error(-1100)).await;
6399 let client = margin_client_at(&server, false);
6400
6401 let result = client
6402 .fetch_ended_orders(&[
6403 margin_key("BTCUSDT", "cancelled"),
6404 margin_key("BTCUSDT", "refused"),
6405 ])
6406 .await;
6407
6408 assert!(
6409 matches!(
6410 result,
6411 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
6412 ),
6413 "{result:?}"
6414 );
6415 }
6416
6417 fn held_margin_orders() -> SharedKnownLiveOrders {
6419 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6420 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
6421 known.lock().live(
6422 &margin_key("BTCUSDT", cid),
6423 Decimal::TWO,
6424 &Open::new(
6425 VenueOrderId::Assigned(OrderId::new(n.to_string())),
6426 Utc::now(),
6427 Decimal::ZERO,
6428 ),
6429 );
6430 }
6431 known
6432 }
6433
6434 #[tokio::test]
6435 async fn a_margin_reconnect_reports_how_an_unlisted_order_ended_in_the_configured_account() {
6436 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
6437 let server = wiremock::MockServer::start().await;
6438 wiremock::Mock::given(wiremock::matchers::method("GET"))
6439 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6440 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
6441 .respond_with(
6442 wiremock::ResponseTemplate::new(200)
6443 .set_body_json(serde_json::json!([margin_order_row("live", "NEW")])),
6444 )
6445 .mount(&server)
6446 .await;
6447 mount_margin_order(&server, "gone", margin_row_response("gone", "CANCELED")).await;
6448 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
6449 let known = held_margin_orders();
6450 let mut unchecked = UncheckedOrders::default();
6451 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
6452 let (tx, mut rx) = mpsc::unbounded_channel();
6453
6454 recover_margin_ended_orders(
6455 &margin_rest_at(&server),
6456 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6457 &known,
6458 &mut unchecked,
6459 &UnrecoveredFills::default(),
6460 &tx,
6461 is_isolated,
6462 )
6463 .await;
6464
6465 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6466 let [event] = sent.as_slice() else {
6467 panic!("one order ended: {sent:?}");
6468 };
6469 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
6470 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
6471 panic!("an order snapshot: {event:?}");
6472 };
6473 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
6474 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
6475 assert!(matches!(
6476 snapshot.0.state,
6477 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
6478 ));
6479 let mut requests = margin_requests(&server).await;
6480 requests.sort();
6481 let expected = |path: &str| (path.to_owned(), Some(flag.to_owned()));
6482 assert_eq!(
6483 requests,
6484 [
6485 expected("/sapi/v1/margin/openOrders"),
6486 expected("/sapi/v1/margin/order"),
6487 expected("/sapi/v1/margin/order"),
6488 ],
6489 "one listing, and a lookup of each unlisted order only"
6490 );
6491 let known = known.lock();
6492 assert!(known.contains(&ClientOrderId::new("live")));
6493 assert!(!known.contains(&ClientOrderId::new("gone")));
6494 assert!(
6495 !known.contains(&ClientOrderId::new("unknown")),
6496 "asking again cannot help"
6497 );
6498 assert!(unchecked.is_empty());
6499 }
6500 }
6501
6502 fn margin_open_request<'a>(
6503 instrument: &'a InstrumentNameExchange,
6504 cid: &str,
6505 ) -> OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange> {
6506 OrderRequestOpen {
6507 key: OrderKey::new(
6508 ExchangeId::BinanceMargin,
6509 instrument,
6510 StrategyId::new("strategy"),
6511 ClientOrderId::new(cid),
6512 ),
6513 state: crate::order::request::RequestOpen {
6514 side: Side::Buy,
6515 price: Some(Decimal::from(100)),
6516 quantity: Decimal::TWO,
6517 kind: OrderKind::Limit,
6518 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
6519 position_id: None,
6520 reduce_only: false,
6521 market: None,
6522 },
6523 }
6524 }
6525
6526 #[tokio::test]
6527 async fn placing_listing_and_cancelling_margin_orders_feed_the_known_live_set() {
6528 let server = wiremock::MockServer::start().await;
6529 wiremock::Mock::given(wiremock::matchers::method("POST"))
6530 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6531 .respond_with(
6532 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6533 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "placed",
6534 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
6535 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
6536 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6537 })),
6538 )
6539 .mount(&server)
6540 .await;
6541 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6542 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6543 .respond_with(
6544 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6545 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
6546 "clientOrderId": "cancel", "price": "100", "origQty": "2", "executedQty": "0",
6547 "cummulativeQuoteQty": "0", "status": "CANCELED", "timeInForce": "GTC",
6548 "type": "LIMIT", "side": "BUY",
6549 })),
6550 )
6551 .mount(&server)
6552 .await;
6553 wiremock::Mock::given(wiremock::matchers::method("GET"))
6554 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
6555 .respond_with(
6556 wiremock::ResponseTemplate::new(200)
6557 .set_body_json(serde_json::json!([margin_order_row("listed", "NEW")])),
6558 )
6559 .mount(&server)
6560 .await;
6561 let client = margin_client_at(&server, false);
6562 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6563
6564 let placed = client
6565 .open_order(margin_open_request(&btcusdt, "placed"))
6566 .await;
6567 assert!(
6568 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
6569 "{placed:?}"
6570 );
6571 assert!(
6572 client
6573 .known_live
6574 .lock()
6575 .contains(&ClientOrderId::new("placed"))
6576 );
6577
6578 client.fetch_open_orders(&[]).await.unwrap();
6579 assert!(
6580 client
6581 .known_live
6582 .lock()
6583 .contains(&ClientOrderId::new("listed"))
6584 );
6585
6586 let cancelled = client
6587 .cancel_order(OrderRequestCancel {
6588 key: OrderKey::new(
6589 ExchangeId::BinanceMargin,
6590 &btcusdt,
6591 StrategyId::new("strategy"),
6592 ClientOrderId::new("placed"),
6593 ),
6594 state: crate::order::request::RequestCancel {
6595 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
6596 },
6597 })
6598 .await;
6599 assert!(cancelled.state.is_ok(), "{cancelled:?}");
6600 assert!(
6601 !client
6602 .known_live
6603 .lock()
6604 .contains(&ClientOrderId::new("placed"))
6605 );
6606 }
6607
6608 #[tokio::test]
6610 async fn a_cancel_response_without_executed_qty_reports_the_fill_unknown() {
6611 let server = wiremock::MockServer::start().await;
6612 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
6613 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6614 .respond_with(
6615 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6616 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
6617 "clientOrderId": "cancel", "price": "100", "origQty": "2",
6618 "status": "CANCELED", "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
6619 })),
6620 )
6621 .mount(&server)
6622 .await;
6623 let client = margin_client_at(&server, false);
6624
6625 let cancelled = client
6626 .cancel_order(OrderRequestCancel {
6627 key: OrderKey::new(
6628 ExchangeId::BinanceMargin,
6629 &InstrumentNameExchange::new("BTCUSDT"),
6630 StrategyId::new("strategy"),
6631 ClientOrderId::new("placed"),
6632 ),
6633 state: crate::order::request::RequestCancel {
6634 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
6635 },
6636 })
6637 .await;
6638 let Ok(cancelled) = cancelled.state else {
6639 panic!("expected Ok, got {cancelled:?}");
6640 };
6641 assert_eq!(cancelled.filled_quantity, None);
6642 }
6643
6644 #[test]
6645 fn a_reconnect_opens_a_gap_on_each_stream_instrument_and_checks_those_with_orders_held() {
6646 let [btc, eth, xrp] = ["BTCUSDT", "ETHUSDT", "XRPUSDT"].map(InstrumentNameExchange::new);
6647 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6648 for (instrument, cid) in [(&btc, "btc"), (&xrp, "xrp")] {
6649 known.lock().live(
6650 &margin_key(instrument.name(), cid),
6651 Decimal::TWO,
6652 &Open::new(
6653 VenueOrderId::Assigned(OrderId::new(cid)),
6654 Utc::now(),
6655 Decimal::ZERO,
6656 ),
6657 );
6658 }
6659 let mut unrecovered = UnrecoveredFills::default();
6660 let mut unchecked = UncheckedOrders::default();
6661
6662 open_reconnect_recovery(
6663 &mut unrecovered,
6664 &mut unchecked,
6665 &known,
6666 &[btc.clone(), eth.clone()],
6667 Utc::now() - chrono::Duration::minutes(1),
6668 );
6669
6670 assert!(unrecovered.covers(&btc) && unrecovered.covers(ð));
6671 assert!(!unrecovered.covers(&xrp), "not the stream's");
6672 assert!(unchecked.contains(&btc));
6673 assert!(!unchecked.contains(ð), "no order held");
6674 assert!(!unchecked.contains(&xrp), "its fills are not recovered");
6675 }
6676
6677 #[tokio::test]
6680 async fn a_recovered_margin_fill_that_completes_a_held_order_ends_it() {
6681 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6682 let server = wiremock::MockServer::start().await;
6683 wiremock::Mock::given(wiremock::matchers::method("GET"))
6684 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6685 .respond_with(venue(vec![
6686 (
6687 1,
6688 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
6689 ),
6690 (
6691 2,
6692 (disconnect + chrono::Duration::minutes(2)).timestamp_millis(),
6693 ),
6694 ]))
6695 .mount(&server)
6696 .await;
6697 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
6700 for (cid, order_id, quantity) in [("one", "1", "0.01"), ("two", "2", "0.02")] {
6701 known.lock().live(
6702 &margin_key("BTCUSDT", cid),
6703 Decimal::from_str(quantity).unwrap(),
6704 &Open::new(
6705 VenueOrderId::Assigned(OrderId::new(order_id)),
6706 Utc::now(),
6707 Decimal::ZERO,
6708 ),
6709 );
6710 }
6711 let mut unrecovered = UnrecoveredFills::default();
6712 unrecovered.open(
6713 &[InstrumentNameExchange::new("BTCUSDT")],
6714 disconnect,
6715 Utc::now(),
6716 );
6717 let (tx, mut rx) = mpsc::unbounded_channel();
6718
6719 recover_margin_fills(
6720 &margin_rest_at(&server),
6721 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6722 &mut unrecovered,
6723 &tx,
6724 &new_dedup_cache(),
6725 &known,
6726 false,
6727 )
6728 .await;
6729
6730 assert_eq!(
6731 std::iter::from_fn(|| rx.try_recv().ok()).count(),
6732 2,
6733 "both fills"
6734 );
6735 let known = known.lock();
6736 assert!(!known.contains(&ClientOrderId::new("one")), "completed");
6737 assert!(known.contains(&ClientOrderId::new("two")), "part-filled");
6738 }
6739
6740 #[tokio::test]
6743 async fn a_margin_order_that_expired_on_placement_is_not_reported_or_held_open() {
6744 use rust_decimal_macros::dec;
6745
6746 for (cid, filled, quote) in [("unfilled", "0", "0"), ("partly", "1", "100")] {
6747 let server = wiremock::MockServer::start().await;
6748 wiremock::Mock::given(wiremock::matchers::method("POST"))
6749 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6750 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6751 serde_json::json!({
6752 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
6753 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
6754 "executedQty": filled, "cummulativeQuoteQty": quote,
6755 "status": "EXPIRED", "timeInForce": "IOC", "type": "LIMIT", "side": "BUY",
6756 }),
6757 ))
6758 .mount(&server)
6759 .await;
6760 let client = margin_client_at(&server, false);
6761 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
6762
6763 let placed = client.open_order(margin_open_request(&btcusdt, cid)).await;
6764
6765 let OrderState::Inactive(crate::order::state::InactiveOrderState::Expired(expired)) =
6766 &placed.state
6767 else {
6768 panic!("{cid}: expired, not open: {placed:?}");
6769 };
6770 assert_eq!(expired.filled_quantity, Decimal::from_str(filled).ok());
6771 let late = Open::new(
6772 VenueOrderId::Assigned(OrderId::new("7")),
6773 Utc::now(),
6774 Decimal::ZERO,
6775 );
6776 let mut known = client.known_live.lock();
6777 known.live(&placed.key, dec!(2), &late);
6778 assert!(
6779 !known.contains(&ClientOrderId::new(cid)),
6780 "{cid}: not held, even after a late live report"
6781 );
6782 }
6783 }
6784}