nautilus_binance/futures/http/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Binance Futures HTTP client for USD-M and COIN-M markets.
17
18use std::{collections::HashMap, num::NonZeroU32, sync::Arc, time::Duration};
19
20use chrono::Utc;
21use dashmap::DashMap;
22use nautilus_core::{consts::NAUTILUS_USER_AGENT, nanos::UnixNanos};
23use nautilus_model::instruments::any::InstrumentAny;
24use nautilus_network::{
25    http::{HttpClient, HttpResponse, Method},
26    ratelimiter::quota::Quota,
27};
28use serde::{Deserialize, Serialize, de::DeserializeOwned};
29use ustr::Ustr;
30
31use super::{
32    error::{BinanceFuturesHttpError, BinanceFuturesHttpResult},
33    models::{
34        BinanceBookTicker, BinanceFuturesCoinExchangeInfo, BinanceFuturesCoinSymbol,
35        BinanceFuturesMarkPrice, BinanceFuturesTicker24hr, BinanceFuturesUsdExchangeInfo,
36        BinanceFuturesUsdSymbol, BinanceOrderBook, BinancePriceTicker, BinanceServerTime,
37    },
38    query::{BinanceBookTickerParams, BinanceDepthParams, BinanceTicker24hrParams},
39};
40use crate::common::{
41    consts::{
42        BINANCE_DAPI_PATH, BINANCE_DAPI_RATE_LIMITS, BINANCE_FAPI_PATH, BINANCE_FAPI_RATE_LIMITS,
43        BinanceRateLimitQuota,
44    },
45    credential::Credential,
46    enums::{
47        BinanceEnvironment, BinanceProductType, BinanceRateLimitInterval, BinanceRateLimitType,
48    },
49    models::BinanceErrorResponse,
50    parse::{parse_coinm_instrument, parse_usdm_instrument},
51    urls::get_http_base_url,
52};
53
54const BINANCE_GLOBAL_RATE_KEY: &str = "binance:global";
55const BINANCE_ORDERS_RATE_KEY: &str = "binance:orders";
56
57/// Raw HTTP client for Binance Futures REST API.
58#[derive(Debug, Clone)]
59pub struct BinanceRawFuturesHttpClient {
60    client: HttpClient,
61    base_url: String,
62    api_path: &'static str,
63    credential: Option<Credential>,
64    recv_window: Option<u64>,
65    order_rate_keys: Vec<String>,
66}
67
68impl BinanceRawFuturesHttpClient {
69    /// Creates a new Binance raw futures HTTP client.
70    #[allow(clippy::too_many_arguments)]
71    pub fn new(
72        product_type: BinanceProductType,
73        environment: BinanceEnvironment,
74        api_key: Option<String>,
75        api_secret: Option<String>,
76        base_url_override: Option<String>,
77        recv_window: Option<u64>,
78        timeout_secs: Option<u64>,
79        proxy_url: Option<String>,
80    ) -> BinanceFuturesHttpResult<Self> {
81        let RateLimitConfig {
82            default_quota,
83            keyed_quotas,
84            order_keys,
85        } = Self::rate_limit_config(product_type);
86
87        let credential = match (api_key, api_secret) {
88            (Some(key), Some(secret)) => Some(Credential::new(key, secret)),
89            (None, None) => None,
90            _ => return Err(BinanceFuturesHttpError::MissingCredentials),
91        };
92
93        let base_url = base_url_override
94            .unwrap_or_else(|| get_http_base_url(product_type, environment).to_string());
95
96        let api_path = Self::resolve_api_path(product_type);
97        let headers = Self::default_headers(&credential);
98
99        let client = HttpClient::new(
100            headers,
101            vec!["X-MBX-APIKEY".to_string()],
102            keyed_quotas,
103            default_quota,
104            timeout_secs,
105            proxy_url,
106        )?;
107
108        Ok(Self {
109            client,
110            base_url,
111            api_path,
112            credential,
113            recv_window,
114            order_rate_keys: order_keys,
115        })
116    }
117
118    /// Performs a GET request and deserializes the response body.
119    pub async fn get<P, T>(
120        &self,
121        path: &str,
122        params: Option<&P>,
123        signed: bool,
124        use_order_quota: bool,
125    ) -> BinanceFuturesHttpResult<T>
126    where
127        P: Serialize + ?Sized,
128        T: DeserializeOwned,
129    {
130        self.request(Method::GET, path, params, signed, use_order_quota, None)
131            .await
132    }
133
134    /// Performs a POST request with optional body and signed query.
135    pub async fn post<P, T>(
136        &self,
137        path: &str,
138        params: Option<&P>,
139        body: Option<Vec<u8>>,
140        signed: bool,
141        use_order_quota: bool,
142    ) -> BinanceFuturesHttpResult<T>
143    where
144        P: Serialize + ?Sized,
145        T: DeserializeOwned,
146    {
147        self.request(Method::POST, path, params, signed, use_order_quota, body)
148            .await
149    }
150
151    /// Performs a PUT request with signed query.
152    pub async fn request_put<P, T>(
153        &self,
154        path: &str,
155        params: Option<&P>,
156        signed: bool,
157        use_order_quota: bool,
158    ) -> BinanceFuturesHttpResult<T>
159    where
160        P: Serialize + ?Sized,
161        T: DeserializeOwned,
162    {
163        self.request(Method::PUT, path, params, signed, use_order_quota, None)
164            .await
165    }
166
167    /// Performs a DELETE request with signed query.
168    pub async fn request_delete<P, T>(
169        &self,
170        path: &str,
171        params: Option<&P>,
172        signed: bool,
173        use_order_quota: bool,
174    ) -> BinanceFuturesHttpResult<T>
175    where
176        P: Serialize + ?Sized,
177        T: DeserializeOwned,
178    {
179        self.request(Method::DELETE, path, params, signed, use_order_quota, None)
180            .await
181    }
182
183    async fn request<P, T>(
184        &self,
185        method: Method,
186        path: &str,
187        params: Option<&P>,
188        signed: bool,
189        use_order_quota: bool,
190        body: Option<Vec<u8>>,
191    ) -> BinanceFuturesHttpResult<T>
192    where
193        P: Serialize + ?Sized,
194        T: DeserializeOwned,
195    {
196        let mut query = params
197            .map(serde_urlencoded::to_string)
198            .transpose()
199            .map_err(|e| BinanceFuturesHttpError::ValidationError(e.to_string()))?
200            .unwrap_or_default();
201
202        let mut headers = HashMap::new();
203        if signed {
204            let cred = self
205                .credential
206                .as_ref()
207                .ok_or(BinanceFuturesHttpError::MissingCredentials)?;
208
209            if !query.is_empty() {
210                query.push('&');
211            }
212
213            let timestamp = Utc::now().timestamp_millis();
214            query.push_str(&format!("timestamp={timestamp}"));
215
216            if let Some(recv_window) = self.recv_window {
217                query.push_str(&format!("&recvWindow={recv_window}"));
218            }
219
220            let signature = cred.sign(&query);
221            query.push_str(&format!("&signature={signature}"));
222            headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
223        }
224
225        let url = self.build_url(path, &query);
226        let keys = self.rate_limit_keys(use_order_quota);
227
228        let response = self
229            .client
230            .request(
231                method,
232                url,
233                None::<&HashMap<String, Vec<String>>>,
234                Some(headers),
235                body,
236                None,
237                Some(keys),
238            )
239            .await?;
240
241        if !response.status.is_success() {
242            return self.parse_error_response(response);
243        }
244
245        serde_json::from_slice::<T>(&response.body)
246            .map_err(|e| BinanceFuturesHttpError::JsonError(e.to_string()))
247    }
248
249    fn build_url(&self, path: &str, query: &str) -> String {
250        let normalized = if path.starts_with('/') {
251            path.to_string()
252        } else {
253            format!("/{path}")
254        };
255        let mut url = format!("{}{}{}", self.base_url, self.api_path, normalized);
256        if !query.is_empty() {
257            url.push('?');
258            url.push_str(query);
259        }
260        url
261    }
262
263    fn rate_limit_keys(&self, use_orders: bool) -> Vec<String> {
264        if use_orders {
265            let mut keys = Vec::with_capacity(1 + self.order_rate_keys.len());
266            keys.push(BINANCE_GLOBAL_RATE_KEY.to_string());
267            keys.extend(self.order_rate_keys.iter().cloned());
268            keys
269        } else {
270            vec![BINANCE_GLOBAL_RATE_KEY.to_string()]
271        }
272    }
273
274    fn parse_error_response<T>(&self, response: HttpResponse) -> BinanceFuturesHttpResult<T> {
275        let status = response.status.as_u16();
276        let body = String::from_utf8_lossy(&response.body).to_string();
277
278        if let Ok(err) = serde_json::from_str::<BinanceErrorResponse>(&body) {
279            return Err(BinanceFuturesHttpError::BinanceError {
280                code: err.code,
281                message: err.msg,
282            });
283        }
284
285        Err(BinanceFuturesHttpError::UnexpectedStatus { status, body })
286    }
287
288    fn default_headers(credential: &Option<Credential>) -> HashMap<String, String> {
289        let mut headers = HashMap::new();
290        headers.insert("User-Agent".to_string(), NAUTILUS_USER_AGENT.to_string());
291        if let Some(cred) = credential {
292            headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
293        }
294        headers
295    }
296
297    fn resolve_api_path(product_type: BinanceProductType) -> &'static str {
298        match product_type {
299            BinanceProductType::UsdM => BINANCE_FAPI_PATH,
300            BinanceProductType::CoinM => BINANCE_DAPI_PATH,
301            _ => BINANCE_FAPI_PATH, // Default to USD-M
302        }
303    }
304
305    fn rate_limit_config(product_type: BinanceProductType) -> RateLimitConfig {
306        let quotas = match product_type {
307            BinanceProductType::UsdM => BINANCE_FAPI_RATE_LIMITS,
308            BinanceProductType::CoinM => BINANCE_DAPI_RATE_LIMITS,
309            _ => BINANCE_FAPI_RATE_LIMITS,
310        };
311
312        let mut keyed = Vec::new();
313        let mut order_keys = Vec::new();
314        let mut default = None;
315
316        for quota in quotas {
317            if let Some(q) = Self::quota_from(quota) {
318                match quota.rate_limit_type {
319                    BinanceRateLimitType::RequestWeight if default.is_none() => {
320                        default = Some(q);
321                    }
322                    BinanceRateLimitType::Orders => {
323                        let key = format!("{}:{:?}", BINANCE_ORDERS_RATE_KEY, quota.interval);
324                        order_keys.push(key.clone());
325                        keyed.push((key, q));
326                    }
327                    _ => {}
328                }
329            }
330        }
331
332        let default_quota =
333            default.unwrap_or_else(|| Quota::per_second(NonZeroU32::new(10).unwrap()));
334
335        keyed.push((BINANCE_GLOBAL_RATE_KEY.to_string(), default_quota));
336
337        RateLimitConfig {
338            default_quota: Some(default_quota),
339            keyed_quotas: keyed,
340            order_keys,
341        }
342    }
343
344    fn quota_from(quota: &BinanceRateLimitQuota) -> Option<Quota> {
345        let burst = NonZeroU32::new(quota.limit)?;
346        match quota.interval {
347            BinanceRateLimitInterval::Second => Some(Quota::per_second(burst)),
348            BinanceRateLimitInterval::Minute => Some(Quota::per_minute(burst)),
349            BinanceRateLimitInterval::Day => {
350                Quota::with_period(Duration::from_secs(86_400)).map(|q| q.allow_burst(burst))
351            }
352        }
353    }
354}
355
356struct RateLimitConfig {
357    default_quota: Option<Quota>,
358    keyed_quotas: Vec<(String, Quota)>,
359    order_keys: Vec<String>,
360}
361
362/// In-memory cache entry for Binance Futures instruments.
363#[derive(Clone, Debug)]
364pub enum BinanceFuturesInstrument {
365    /// USD-M futures symbol.
366    UsdM(BinanceFuturesUsdSymbol),
367    /// COIN-M futures symbol.
368    CoinM(BinanceFuturesCoinSymbol),
369}
370
371/// Query parameters for mark price endpoints.
372#[derive(Debug, Clone, Serialize)]
373#[serde(rename_all = "camelCase")]
374pub struct MarkPriceParams {
375    /// Trading symbol (optional - if omitted, returns all symbols).
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub symbol: Option<String>,
378}
379
380/// Response wrapper for mark price endpoint.
381#[derive(Debug, Deserialize)]
382#[serde(untagged)]
383enum MarkPriceResponse {
384    Single(BinanceFuturesMarkPrice),
385    Multiple(Vec<BinanceFuturesMarkPrice>),
386}
387
388impl From<MarkPriceResponse> for Vec<BinanceFuturesMarkPrice> {
389    fn from(response: MarkPriceResponse) -> Self {
390        match response {
391            MarkPriceResponse::Single(price) => vec![price],
392            MarkPriceResponse::Multiple(prices) => prices,
393        }
394    }
395}
396
397/// Query parameters for funding rate history.
398#[derive(Debug, Clone, Serialize)]
399#[serde(rename_all = "camelCase")]
400pub struct FundingRateParams {
401    /// Trading symbol.
402    pub symbol: String,
403    /// Start time in milliseconds (optional).
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub start_time: Option<i64>,
406    /// End time in milliseconds (optional).
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub end_time: Option<i64>,
409    /// Limit results (default 100, max 1000).
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub limit: Option<u32>,
412}
413
414/// Query parameters for open interest endpoints.
415#[derive(Debug, Clone, Serialize)]
416#[serde(rename_all = "camelCase")]
417pub struct OpenInterestParams {
418    /// Trading symbol.
419    pub symbol: String,
420}
421
422/// Open interest response.
423#[derive(Debug, Clone, Deserialize)]
424#[serde(rename_all = "camelCase")]
425pub struct BinanceOpenInterest {
426    /// Trading symbol.
427    pub symbol: String,
428    /// Total open interest.
429    pub open_interest: String,
430    /// Response timestamp.
431    pub time: i64,
432}
433
434/// Funding rate history entry.
435#[derive(Debug, Clone, Deserialize)]
436#[serde(rename_all = "camelCase")]
437pub struct BinanceFundingRate {
438    /// Trading symbol.
439    pub symbol: String,
440    /// Funding rate value.
441    pub funding_rate: String,
442    /// Funding time in milliseconds.
443    pub funding_time: i64,
444    /// Mark price at funding time.
445    #[serde(default)]
446    pub mark_price: Option<String>,
447}
448
449/// Listen key response from user data stream endpoints.
450#[derive(Debug, Clone, Deserialize)]
451#[serde(rename_all = "camelCase")]
452pub struct ListenKeyResponse {
453    /// The listen key for WebSocket user data stream.
454    pub listen_key: String,
455}
456
457/// Listen key request parameters.
458#[derive(Debug, Clone, Serialize)]
459#[serde(rename_all = "camelCase")]
460struct ListenKeyParams {
461    listen_key: String,
462}
463
464/// Binance Futures HTTP client for USD-M and COIN-M perpetuals.
465#[derive(Debug, Clone)]
466#[cfg_attr(
467    feature = "python",
468    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.binance")
469)]
470pub struct BinanceFuturesHttpClient {
471    raw: BinanceRawFuturesHttpClient,
472    product_type: BinanceProductType,
473    instruments: Arc<DashMap<Ustr, BinanceFuturesInstrument>>,
474}
475
476impl BinanceFuturesHttpClient {
477    /// Creates a new [`BinanceFuturesHttpClient`] instance.
478    #[allow(clippy::too_many_arguments)]
479    pub fn new(
480        product_type: BinanceProductType,
481        environment: BinanceEnvironment,
482        api_key: Option<String>,
483        api_secret: Option<String>,
484        base_url_override: Option<String>,
485        recv_window: Option<u64>,
486        timeout_secs: Option<u64>,
487        proxy_url: Option<String>,
488    ) -> BinanceFuturesHttpResult<Self> {
489        match product_type {
490            BinanceProductType::UsdM | BinanceProductType::CoinM => {}
491            _ => {
492                return Err(BinanceFuturesHttpError::ValidationError(format!(
493                    "BinanceFuturesHttpClient requires UsdM or CoinM product type, got {product_type:?}"
494                )));
495            }
496        }
497
498        let raw = BinanceRawFuturesHttpClient::new(
499            product_type,
500            environment,
501            api_key,
502            api_secret,
503            base_url_override,
504            recv_window,
505            timeout_secs,
506            proxy_url,
507        )?;
508
509        Ok(Self {
510            raw,
511            product_type,
512            instruments: Arc::new(DashMap::new()),
513        })
514    }
515
516    /// Returns the product type (UsdM or CoinM).
517    #[must_use]
518    pub const fn product_type(&self) -> BinanceProductType {
519        self.product_type
520    }
521
522    /// Returns a reference to the underlying raw HTTP client.
523    #[must_use]
524    pub const fn raw(&self) -> &BinanceRawFuturesHttpClient {
525        &self.raw
526    }
527
528    /// Returns a reference to the instruments cache.
529    #[must_use]
530    pub fn instruments_cache(&self) -> &DashMap<Ustr, BinanceFuturesInstrument> {
531        &self.instruments
532    }
533
534    /// Returns server time.
535    pub async fn server_time(&self) -> BinanceFuturesHttpResult<BinanceServerTime> {
536        self.raw
537            .get::<_, BinanceServerTime>("time", None::<&()>, false, false)
538            .await
539    }
540
541    /// Fetches exchange information and populates the instrument cache.
542    pub async fn exchange_info(&self) -> BinanceFuturesHttpResult<()> {
543        match self.product_type {
544            BinanceProductType::UsdM => {
545                let info: BinanceFuturesUsdExchangeInfo = self
546                    .raw
547                    .get("exchangeInfo", None::<&()>, false, false)
548                    .await?;
549                for symbol in info.symbols {
550                    self.instruments
551                        .insert(symbol.symbol, BinanceFuturesInstrument::UsdM(symbol));
552                }
553            }
554            BinanceProductType::CoinM => {
555                let info: BinanceFuturesCoinExchangeInfo = self
556                    .raw
557                    .get("exchangeInfo", None::<&()>, false, false)
558                    .await?;
559                for symbol in info.symbols {
560                    self.instruments
561                        .insert(symbol.symbol, BinanceFuturesInstrument::CoinM(symbol));
562                }
563            }
564            _ => {
565                return Err(BinanceFuturesHttpError::ValidationError(
566                    "Invalid product type for futures".to_string(),
567                ));
568            }
569        }
570
571        Ok(())
572    }
573
574    /// Fetches exchange information and returns parsed Nautilus instruments.
575    pub async fn request_instruments(&self) -> BinanceFuturesHttpResult<Vec<InstrumentAny>> {
576        let ts_init = UnixNanos::default();
577
578        let instruments = match self.product_type {
579            BinanceProductType::UsdM => {
580                let info: BinanceFuturesUsdExchangeInfo = self
581                    .raw
582                    .get("exchangeInfo", None::<&()>, false, false)
583                    .await?;
584
585                let mut instruments = Vec::with_capacity(info.symbols.len());
586                for symbol in &info.symbols {
587                    match parse_usdm_instrument(symbol, ts_init, ts_init) {
588                        Ok(instrument) => instruments.push(instrument),
589                        Err(e) => {
590                            log::debug!(
591                                "Skipping symbol during instrument parsing: symbol={}, error={e}",
592                                symbol.symbol
593                            );
594                        }
595                    }
596                }
597
598                log::info!(
599                    "Loaded USD-M perpetual instruments: count={}",
600                    instruments.len()
601                );
602                instruments
603            }
604            BinanceProductType::CoinM => {
605                let info: BinanceFuturesCoinExchangeInfo = self
606                    .raw
607                    .get("exchangeInfo", None::<&()>, false, false)
608                    .await?;
609
610                let mut instruments = Vec::with_capacity(info.symbols.len());
611                for symbol in &info.symbols {
612                    match parse_coinm_instrument(symbol, ts_init, ts_init) {
613                        Ok(instrument) => instruments.push(instrument),
614                        Err(e) => {
615                            log::debug!(
616                                "Skipping symbol during instrument parsing: symbol={}, error={e}",
617                                symbol.symbol
618                            );
619                        }
620                    }
621                }
622
623                log::info!(
624                    "Loaded COIN-M perpetual instruments: count={}",
625                    instruments.len()
626                );
627                instruments
628            }
629            _ => {
630                return Err(BinanceFuturesHttpError::ValidationError(
631                    "Invalid product type for futures".to_string(),
632                ));
633            }
634        };
635
636        Ok(instruments)
637    }
638
639    /// Fetches 24hr ticker statistics.
640    pub async fn ticker_24h(
641        &self,
642        params: &BinanceTicker24hrParams,
643    ) -> BinanceFuturesHttpResult<Vec<BinanceFuturesTicker24hr>> {
644        self.raw
645            .get("ticker/24hr", Some(params), false, false)
646            .await
647    }
648
649    /// Fetches best bid/ask prices.
650    pub async fn book_ticker(
651        &self,
652        params: &BinanceBookTickerParams,
653    ) -> BinanceFuturesHttpResult<Vec<BinanceBookTicker>> {
654        self.raw
655            .get("ticker/bookTicker", Some(params), false, false)
656            .await
657    }
658
659    /// Fetches price ticker.
660    pub async fn price_ticker(
661        &self,
662        symbol: Option<&str>,
663    ) -> BinanceFuturesHttpResult<Vec<BinancePriceTicker>> {
664        #[derive(Serialize)]
665        struct Params<'a> {
666            #[serde(skip_serializing_if = "Option::is_none")]
667            symbol: Option<&'a str>,
668        }
669        self.raw
670            .get("ticker/price", Some(&Params { symbol }), false, false)
671            .await
672    }
673
674    /// Fetches order book depth.
675    pub async fn depth(
676        &self,
677        params: &BinanceDepthParams,
678    ) -> BinanceFuturesHttpResult<BinanceOrderBook> {
679        self.raw.get("depth", Some(params), false, false).await
680    }
681
682    /// Fetches mark price and funding rate.
683    pub async fn mark_price(
684        &self,
685        params: &MarkPriceParams,
686    ) -> BinanceFuturesHttpResult<Vec<BinanceFuturesMarkPrice>> {
687        let response: MarkPriceResponse = self
688            .raw
689            .get("premiumIndex", Some(params), false, false)
690            .await?;
691        Ok(response.into())
692    }
693
694    /// Fetches funding rate history.
695    pub async fn funding_rate(
696        &self,
697        params: &FundingRateParams,
698    ) -> BinanceFuturesHttpResult<Vec<BinanceFundingRate>> {
699        self.raw
700            .get("fundingRate", Some(params), false, false)
701            .await
702    }
703
704    /// Fetches current open interest for a symbol.
705    pub async fn open_interest(
706        &self,
707        params: &OpenInterestParams,
708    ) -> BinanceFuturesHttpResult<BinanceOpenInterest> {
709        self.raw
710            .get("openInterest", Some(params), false, false)
711            .await
712    }
713
714    /// Creates a listen key for user data stream.
715    pub async fn create_listen_key(&self) -> BinanceFuturesHttpResult<ListenKeyResponse> {
716        self.raw
717            .post::<(), ListenKeyResponse>("listenKey", None, None, true, false)
718            .await
719    }
720
721    /// Keeps alive an existing listen key.
722    pub async fn keepalive_listen_key(&self, listen_key: &str) -> BinanceFuturesHttpResult<()> {
723        let params = ListenKeyParams {
724            listen_key: listen_key.to_string(),
725        };
726        let _: serde_json::Value = self
727            .raw
728            .request_put("listenKey", Some(&params), true, false)
729            .await?;
730        Ok(())
731    }
732
733    /// Closes an existing listen key.
734    pub async fn close_listen_key(&self, listen_key: &str) -> BinanceFuturesHttpResult<()> {
735        let params = ListenKeyParams {
736            listen_key: listen_key.to_string(),
737        };
738        let _: serde_json::Value = self
739            .raw
740            .request_delete("listenKey", Some(&params), true, false)
741            .await?;
742        Ok(())
743    }
744}
745
746#[cfg(test)]
747mod tests {
748    use nautilus_network::http::{HttpStatus, StatusCode};
749    use rstest::rstest;
750    use tokio_util::bytes::Bytes;
751
752    use super::*;
753
754    #[rstest]
755    fn test_rate_limit_config_usdm_has_request_weight_and_orders() {
756        let config = BinanceRawFuturesHttpClient::rate_limit_config(BinanceProductType::UsdM);
757
758        assert!(config.default_quota.is_some());
759        assert_eq!(config.order_keys.len(), 2);
760        assert!(config.order_keys.iter().any(|k| k.contains("Second")));
761        assert!(config.order_keys.iter().any(|k| k.contains("Minute")));
762    }
763
764    #[rstest]
765    fn test_rate_limit_config_coinm_has_request_weight_and_orders() {
766        let config = BinanceRawFuturesHttpClient::rate_limit_config(BinanceProductType::CoinM);
767
768        assert!(config.default_quota.is_some());
769        assert_eq!(config.order_keys.len(), 2);
770    }
771
772    #[rstest]
773    fn test_create_client_rejects_spot_product_type() {
774        let result = BinanceFuturesHttpClient::new(
775            BinanceProductType::Spot,
776            BinanceEnvironment::Mainnet,
777            None,
778            None,
779            None,
780            None,
781            None,
782            None,
783        );
784
785        assert!(result.is_err());
786    }
787
788    fn create_test_raw_client() -> BinanceRawFuturesHttpClient {
789        BinanceRawFuturesHttpClient::new(
790            BinanceProductType::UsdM,
791            BinanceEnvironment::Mainnet,
792            None,
793            None,
794            None,
795            None,
796            None,
797            None,
798        )
799        .expect("Failed to create test client")
800    }
801
802    #[rstest]
803    fn test_parse_error_response_binance_error() {
804        let client = create_test_raw_client();
805        let response = HttpResponse {
806            status: HttpStatus::new(StatusCode::BAD_REQUEST),
807            headers: HashMap::new(),
808            body: Bytes::from(r#"{"code":-1121,"msg":"Invalid symbol."}"#),
809        };
810
811        let result: BinanceFuturesHttpResult<()> = client.parse_error_response(response);
812
813        match result {
814            Err(BinanceFuturesHttpError::BinanceError { code, message }) => {
815                assert_eq!(code, -1121);
816                assert_eq!(message, "Invalid symbol.");
817            }
818            other => panic!("Expected BinanceError, got {other:?}"),
819        }
820    }
821}