Skip to main content

binance/margin/http/
client.rs

1use reqwest::{self, Method, RequestBuilder, header::HeaderMap};
2
3use crate::{
4    SensitiveString,
5    crypto::sign_query,
6    margin::{
7        ApiError, Error, HEADER_RETRY_AFTER, HEADER_X_MBX_APIKEY, Path,
8        http::{
9            EmptyResponse, GetAllMarginAssetsParams, GetMarginAccountParams,
10            GetMaxBorrowableParams, Headers, ListenKey, MarginAccount, MarginAsset, MaxBorrowable,
11            NewOrderRequest, NewOrderResponse, Order, PrivateConfig, QueryOrderParams, Response,
12        },
13    },
14    serde::{deserialize_json, serialize_query},
15    timestamp,
16};
17
18/// Client for the authenticated `/sapi/v1/margin/*` surface.
19///
20/// Margin has no public endpoints — for unauthenticated market data
21/// (klines, depth, tickers, exchange info) and connectivity (`/api/v3/ping`,
22/// `/api/v3/time`), use [`crate::spot::http::PublicClient`].
23pub struct PrivateClient {
24    base_url: String,
25    headers: HeaderMap,
26    api_secret: SensitiveString,
27}
28
29impl PrivateClient {
30    pub fn new(cfg: PrivateConfig) -> Self {
31        let headers = {
32            let mut headers = HeaderMap::new();
33            let api_key = cfg.api_key.expose().parse().unwrap();
34            headers.append(HEADER_X_MBX_APIKEY, api_key);
35            if let Some(extra) = cfg.headers {
36                headers.extend(extra);
37            }
38            headers
39        };
40        Self {
41            base_url: cfg.base_url,
42            headers,
43            api_secret: cfg.api_secret,
44        }
45    }
46}
47
48// Margin metadata
49impl PrivateClient {
50    /// Get all margin assets supported by the exchange.
51    pub async fn get_all_assets(
52        &self,
53        params: GetAllMarginAssetsParams,
54    ) -> Result<Response<Vec<MarginAsset>>, Error> {
55        let query = serialize_query(&params)?;
56        let query = sign_query(&self.api_secret, timestamp(), &query);
57        let url = format!("{}{}?{query}", self.base_url, Path::AllAssets);
58        let client = reqwest::Client::builder().build()?;
59        let request = client
60            .request(Method::GET, url)
61            .headers(self.headers.clone());
62        send(request).await
63    }
64}
65
66// Cross margin account
67impl PrivateClient {
68    /// Get the caller's cross-margin account snapshot (balances, level, etc.).
69    pub async fn margin_account(
70        &self,
71        params: GetMarginAccountParams,
72    ) -> Result<Response<MarginAccount>, Error> {
73        let query = serialize_query(&params)?;
74        let query = sign_query(&self.api_secret, timestamp(), &query);
75        let url = format!("{}{}?{query}", self.base_url, Path::Account);
76        let client = reqwest::Client::builder().build()?;
77        let request = client
78            .request(Method::GET, url)
79            .headers(self.headers.clone());
80        send(request).await
81    }
82}
83
84// Margin trading
85impl PrivateClient {
86    /// Place a new margin order.
87    ///
88    /// Set `is_isolated = IsIsolated::True` to route the order to the isolated
89    /// margin account for the symbol; otherwise the cross-margin account is used.
90    /// Combine with [`SideEffectType::MarginBuy`] / [`SideEffectType::AutoRepay`]
91    /// to opt into automatic borrowing or repayment when the order fills.
92    pub async fn new_order(
93        &self,
94        params: NewOrderRequest,
95    ) -> Result<Response<NewOrderResponse>, Error> {
96        let query = serialize_query(&params)?;
97        let query = sign_query(&self.api_secret, timestamp(), &query);
98        let url = format!("{}{}", self.base_url, Path::Order);
99        let client = reqwest::Client::builder().build()?;
100        let request = client
101            .request(Method::POST, url)
102            .headers(self.headers.clone())
103            .body(query);
104        send(request).await
105    }
106
107    /// Look up a single margin order by `order_id` or `orig_client_order_id`.
108    pub async fn query_order(&self, params: QueryOrderParams) -> Result<Response<Order>, Error> {
109        let query = serialize_query(&params)?;
110        let query = sign_query(&self.api_secret, timestamp(), &query);
111        let url = format!("{}{}?{query}", self.base_url, Path::Order);
112        let client = reqwest::Client::builder().build()?;
113        let request = client
114            .request(Method::GET, url)
115            .headers(self.headers.clone());
116        send(request).await
117    }
118}
119
120// Margin borrow / repay
121impl PrivateClient {
122    /// Query the maximum borrowable amount for an asset.
123    ///
124    /// Pass `isolated_symbol` to ask about the isolated account for a specific
125    /// symbol; without it the call reports the cross-margin limit.
126    pub async fn max_borrowable(
127        &self,
128        params: GetMaxBorrowableParams,
129    ) -> Result<Response<MaxBorrowable>, Error> {
130        let query = serialize_query(&params)?;
131        let query = sign_query(&self.api_secret, timestamp(), &query);
132        let url = format!("{}{}?{query}", self.base_url, Path::MaxBorrowable);
133        let client = reqwest::Client::builder().build()?;
134        let request = client
135            .request(Method::GET, url)
136            .headers(self.headers.clone());
137        send(request).await
138    }
139}
140
141// User data stream — cross margin.
142//
143// Unlike the trading endpoints, listenKey operations are authenticated by
144// API key alone (`X-MBX-APIKEY` header). They do NOT take `timestamp` /
145// `signature`, so these methods skip `sign_query` entirely.
146impl PrivateClient {
147    /// Create a new listenKey for the cross-margin user data stream.
148    ///
149    /// Returns a key that can be used to connect to
150    /// `wss://stream.binance.com:9443/ws/<listenKey>`. The key expires after
151    /// 60 minutes — extend via [`Self::keepalive_listen_key`] every 30 min.
152    pub async fn create_listen_key(&self) -> Result<Response<ListenKey>, Error> {
153        let url = format!("{}{}", self.base_url, Path::UserDataStream);
154        let client = reqwest::Client::builder().build()?;
155        let request = client
156            .request(Method::POST, url)
157            .headers(self.headers.clone());
158        send(request).await
159    }
160
161    /// Extend a cross-margin listenKey's lifetime by 60 minutes. Idempotent;
162    /// safe to call on a schedule (recommended every 30 min).
163    pub async fn keepalive_listen_key(
164        &self,
165        listen_key: &str,
166    ) -> Result<Response<EmptyResponse>, Error> {
167        let url = format!("{}{}", self.base_url, Path::UserDataStream);
168        let client = reqwest::Client::builder().build()?;
169        let request = client
170            .request(Method::PUT, url)
171            .headers(self.headers.clone())
172            .query(&[("listenKey", listen_key)]);
173        send(request).await
174    }
175
176    /// Close a cross-margin listenKey. The WebSocket connection associated
177    /// with the key will be dropped by the server.
178    pub async fn close_listen_key(
179        &self,
180        listen_key: &str,
181    ) -> Result<Response<EmptyResponse>, Error> {
182        let url = format!("{}{}", self.base_url, Path::UserDataStream);
183        let client = reqwest::Client::builder().build()?;
184        let request = client
185            .request(Method::DELETE, url)
186            .headers(self.headers.clone())
187            .query(&[("listenKey", listen_key)]);
188        send(request).await
189    }
190}
191
192// User data stream — isolated margin. Same lifecycle as cross-margin but
193// every call carries the isolated-account `symbol`.
194impl PrivateClient {
195    /// Create a new listenKey for an isolated-margin account's user data
196    /// stream. Each isolated account has its own key.
197    pub async fn create_isolated_listen_key(
198        &self,
199        symbol: &str,
200    ) -> Result<Response<ListenKey>, Error> {
201        let url = format!("{}{}", self.base_url, Path::UserDataStreamIsolated);
202        let client = reqwest::Client::builder().build()?;
203        let request = client
204            .request(Method::POST, url)
205            .headers(self.headers.clone())
206            .query(&[("symbol", symbol)]);
207        send(request).await
208    }
209
210    /// Extend an isolated-margin listenKey's lifetime by 60 minutes.
211    pub async fn keepalive_isolated_listen_key(
212        &self,
213        symbol: &str,
214        listen_key: &str,
215    ) -> Result<Response<EmptyResponse>, Error> {
216        let url = format!("{}{}", self.base_url, Path::UserDataStreamIsolated);
217        let client = reqwest::Client::builder().build()?;
218        let request = client
219            .request(Method::PUT, url)
220            .headers(self.headers.clone())
221            .query(&[("symbol", symbol), ("listenKey", listen_key)]);
222        send(request).await
223    }
224
225    /// Close an isolated-margin listenKey.
226    pub async fn close_isolated_listen_key(
227        &self,
228        symbol: &str,
229        listen_key: &str,
230    ) -> Result<Response<EmptyResponse>, Error> {
231        let url = format!("{}{}", self.base_url, Path::UserDataStreamIsolated);
232        let client = reqwest::Client::builder().build()?;
233        let request = client
234            .request(Method::DELETE, url)
235            .headers(self.headers.clone())
236            .query(&[("symbol", symbol), ("listenKey", listen_key)]);
237        send(request).await
238    }
239}
240
241async fn send<T>(request: RequestBuilder) -> Result<Response<T>, Error>
242where
243    T: serde::de::DeserializeOwned,
244{
245    let response = request.send().await?;
246    let status = response.status();
247    let headers = parse_headers(response.headers());
248    let json = response.text().await?;
249
250    if !status.is_success() {
251        #[cfg(debug_assertions)]
252        tracing::debug!(?status, ?json, "request failed");
253
254        let api_err = deserialize_json::<ApiError>(&json)?;
255        return Err(Error::Api(api_err));
256    }
257
258    let result = deserialize_json(&json)?;
259    Ok(Response { result, headers })
260}
261
262fn parse_headers(headers: &HeaderMap) -> Headers {
263    let retry_after = headers
264        .get(HEADER_RETRY_AFTER)
265        .and_then(|h| h.to_str().unwrap_or_default().parse().ok());
266    Headers { retry_after }
267}