Skip to main content

binance/margin/http/
client.rs

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