nautilus_binance/http/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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 HTTP client implementation.
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;
23use nautilus_network::{
24    http::{HttpClient, HttpResponse, Method},
25    ratelimiter::quota::Quota,
26};
27use serde::{Serialize, de::DeserializeOwned};
28use ustr::Ustr;
29
30use super::error::{BinanceHttpError, BinanceHttpResult};
31use crate::{
32    common::{
33        consts::{
34            BINANCE_DAPI_PATH, BINANCE_DAPI_RATE_LIMITS, BINANCE_EAPI_PATH,
35            BINANCE_EAPI_RATE_LIMITS, BINANCE_FAPI_PATH, BINANCE_FAPI_RATE_LIMITS,
36            BINANCE_SPOT_API_PATH, BINANCE_SPOT_RATE_LIMITS,
37        },
38        credential::Credential,
39        enums::{BinanceEnvironment, BinanceProductType},
40        models::BinanceErrorResponse,
41        urls::get_http_base_url,
42    },
43    http::{
44        models::{
45            BinanceBookTicker, BinanceFuturesCoinExchangeInfo, BinanceFuturesCoinSymbol,
46            BinanceFuturesTicker24hr, BinanceFuturesUsdExchangeInfo, BinanceFuturesUsdSymbol,
47            BinanceOrderBook, BinancePriceTicker, BinanceServerTime, BinanceSpotExchangeInfo,
48            BinanceSpotSymbol, BinanceSpotTicker24hr,
49        },
50        query::{
51            BinanceBookTickerParams, BinanceDepthParams, BinancePriceTickerParams,
52            BinanceSpotExchangeInfoParams, BinanceTicker24hrParams,
53        },
54    },
55};
56
57const BINANCE_GLOBAL_RATE_KEY: &str = "binance:global";
58const BINANCE_ORDERS_RATE_KEY: &str = "binance:orders";
59
60/// Lightweight raw HTTP client for Binance REST API access.
61///
62/// Handles:
63/// - Base URL and API path resolution by product type/environment.
64/// - Optional HMAC SHA256 signing for private endpoints.
65/// - Rate limiting using documented quotas per product type.
66/// - Basic error deserialization for Binance error payloads.
67#[derive(Debug, Clone)]
68pub struct BinanceRawHttpClient {
69    client: HttpClient,
70    base_url: String,
71    api_path: &'static str,
72    credential: Option<Credential>,
73    recv_window: Option<u64>,
74    order_rate_keys: Vec<String>,
75}
76
77impl BinanceRawHttpClient {
78    /// Creates a new Binance raw HTTP client.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the underlying [`HttpClient`] fails to build.
83    #[allow(clippy::too_many_arguments)]
84    pub fn new(
85        product_type: BinanceProductType,
86        environment: BinanceEnvironment,
87        api_key: Option<String>,
88        api_secret: Option<String>,
89        base_url_override: Option<String>,
90        recv_window: Option<u64>,
91        timeout_secs: Option<u64>,
92        proxy_url: Option<String>,
93    ) -> BinanceHttpResult<Self> {
94        let RateLimitConfig {
95            default_quota,
96            keyed_quotas,
97            order_keys,
98        } = Self::rate_limit_config(product_type);
99
100        let credential = match (api_key, api_secret) {
101            (Some(key), Some(secret)) => Some(Credential::new(key, secret)),
102            (None, None) => None,
103            _ => return Err(BinanceHttpError::MissingCredentials),
104        };
105
106        let base_url = base_url_override
107            .unwrap_or_else(|| get_http_base_url(product_type, environment).to_string());
108
109        let api_path = Self::resolve_api_path(product_type);
110        let headers = Self::default_headers(&credential);
111
112        let client = HttpClient::new(
113            headers,
114            vec!["X-MBX-APIKEY".to_string()],
115            keyed_quotas,
116            default_quota,
117            timeout_secs,
118            proxy_url,
119        )?;
120
121        Ok(Self {
122            client,
123            base_url,
124            api_path,
125            credential,
126            recv_window,
127            order_rate_keys: order_keys,
128        })
129    }
130
131    /// Performs a GET request and deserializes the response body.
132    ///
133    /// When `signed` is true, `timestamp`/`recvWindow` are appended and the signature is added.
134    pub async fn get<P, T>(
135        &self,
136        path: &str,
137        params: Option<&P>,
138        signed: bool,
139        use_order_quota: bool,
140    ) -> BinanceHttpResult<T>
141    where
142        P: Serialize + ?Sized,
143        T: DeserializeOwned,
144    {
145        self.request(Method::GET, path, params, signed, use_order_quota, None)
146            .await
147    }
148
149    /// Performs a POST request with optional body and signed query.
150    pub async fn post<P, T>(
151        &self,
152        path: &str,
153        params: Option<&P>,
154        body: Option<Vec<u8>>,
155        signed: bool,
156        use_order_quota: bool,
157    ) -> BinanceHttpResult<T>
158    where
159        P: Serialize + ?Sized,
160        T: DeserializeOwned,
161    {
162        self.request(Method::POST, path, params, signed, use_order_quota, body)
163            .await
164    }
165
166    async fn request<P, T>(
167        &self,
168        method: Method,
169        path: &str,
170        params: Option<&P>,
171        signed: bool,
172        use_order_quota: bool,
173        body: Option<Vec<u8>>,
174    ) -> BinanceHttpResult<T>
175    where
176        P: Serialize + ?Sized,
177        T: DeserializeOwned,
178    {
179        let mut query = params
180            .map(serde_urlencoded::to_string)
181            .transpose()
182            .map_err(|e| BinanceHttpError::ValidationError(e.to_string()))?
183            .unwrap_or_default();
184
185        let mut headers = HashMap::new();
186        if signed {
187            let cred = self
188                .credential
189                .as_ref()
190                .ok_or(BinanceHttpError::MissingCredentials)?;
191
192            if !query.is_empty() {
193                query.push('&');
194            }
195
196            let timestamp = Utc::now().timestamp_millis();
197            query.push_str(&format!("timestamp={timestamp}"));
198
199            if let Some(recv_window) = self.recv_window {
200                query.push_str(&format!("&recvWindow={recv_window}"));
201            }
202
203            let signature = cred.sign(&query);
204            query.push_str(&format!("&signature={signature}"));
205            headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
206        }
207
208        let url = self.build_url(path, &query);
209        let keys = self.rate_limit_keys(use_order_quota);
210
211        let response = self
212            .client
213            .request(
214                method,
215                url,
216                None::<&HashMap<String, Vec<String>>>,
217                Some(headers),
218                body,
219                None,
220                Some(keys),
221            )
222            .await?;
223
224        if !response.status.is_success() {
225            return self.parse_error_response(response);
226        }
227
228        serde_json::from_slice::<T>(&response.body)
229            .map_err(|e| BinanceHttpError::JsonError(e.to_string()))
230    }
231
232    #[cfg(not(test))]
233    fn build_url(&self, path: &str, query: &str) -> String {
234        Self::build_url_impl(&self.base_url, self.api_path, path, query)
235    }
236
237    #[cfg(test)]
238    pub(crate) fn build_url(&self, path: &str, query: &str) -> String {
239        Self::build_url_impl(&self.base_url, self.api_path, path, query)
240    }
241
242    fn build_url_impl(base_url: &str, api_path: &str, path: &str, query: &str) -> String {
243        let mut url = format!("{}{}{}", base_url, api_path, Self::normalize_path(path));
244        if !query.is_empty() {
245            url.push('?');
246            url.push_str(query);
247        }
248        url
249    }
250
251    pub(crate) fn normalize_path(path: &str) -> String {
252        if path.starts_with('/') {
253            path.to_string()
254        } else {
255            format!("/{path}")
256        }
257    }
258
259    #[cfg(not(test))]
260    fn rate_limit_keys(&self, use_orders: bool) -> Vec<String> {
261        Self::rate_limit_keys_impl(&self.order_rate_keys, use_orders)
262    }
263
264    #[cfg(test)]
265    pub(crate) fn rate_limit_keys(&self, use_orders: bool) -> Vec<String> {
266        Self::rate_limit_keys_impl(&self.order_rate_keys, use_orders)
267    }
268
269    fn rate_limit_keys_impl(order_rate_keys: &[String], use_orders: bool) -> Vec<String> {
270        if use_orders {
271            let mut keys = Vec::with_capacity(1 + order_rate_keys.len());
272            keys.push(BINANCE_GLOBAL_RATE_KEY.to_string());
273            keys.extend(order_rate_keys.iter().cloned());
274            keys
275        } else {
276            vec![BINANCE_GLOBAL_RATE_KEY.to_string()]
277        }
278    }
279
280    pub(crate) fn parse_error_response<T>(&self, response: HttpResponse) -> BinanceHttpResult<T> {
281        let status = response.status.as_u16();
282        let body = String::from_utf8_lossy(&response.body).to_string();
283
284        if let Ok(err) = serde_json::from_str::<BinanceErrorResponse>(&body) {
285            return Err(BinanceHttpError::BinanceError {
286                code: err.code,
287                message: err.msg,
288            });
289        }
290
291        Err(BinanceHttpError::UnexpectedStatus { status, body })
292    }
293
294    fn default_headers(credential: &Option<Credential>) -> HashMap<String, String> {
295        let mut headers = HashMap::new();
296        headers.insert("User-Agent".to_string(), NAUTILUS_USER_AGENT.to_string());
297        if let Some(cred) = credential {
298            headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
299        }
300        headers
301    }
302
303    fn resolve_api_path(product_type: BinanceProductType) -> &'static str {
304        match product_type {
305            BinanceProductType::Spot | BinanceProductType::Margin => BINANCE_SPOT_API_PATH,
306            BinanceProductType::UsdM => BINANCE_FAPI_PATH,
307            BinanceProductType::CoinM => BINANCE_DAPI_PATH,
308            BinanceProductType::Options => BINANCE_EAPI_PATH,
309        }
310    }
311
312    pub(crate) fn rate_limit_config(product_type: BinanceProductType) -> RateLimitConfig {
313        let quotas = match product_type {
314            BinanceProductType::Spot | BinanceProductType::Margin => BINANCE_SPOT_RATE_LIMITS,
315            BinanceProductType::UsdM => BINANCE_FAPI_RATE_LIMITS,
316            BinanceProductType::CoinM => BINANCE_DAPI_RATE_LIMITS,
317            BinanceProductType::Options => BINANCE_EAPI_RATE_LIMITS,
318        };
319
320        let mut keyed = Vec::new();
321        let mut order_keys = Vec::new();
322        let mut default = None;
323
324        for quota in quotas {
325            if let Some(q) = Self::quota_from(quota) {
326                if quota.rate_limit_type == "REQUEST_WEIGHT" && default.is_none() {
327                    default = Some(q);
328                } else if quota.rate_limit_type == "ORDERS" {
329                    let key = format!("{}:{}", BINANCE_ORDERS_RATE_KEY, quota.interval);
330                    order_keys.push(key.clone());
331                    keyed.push((key, q));
332                }
333            }
334        }
335
336        let default_quota =
337            default.unwrap_or_else(|| Quota::per_second(NonZeroU32::new(10).unwrap()));
338
339        keyed.push((BINANCE_GLOBAL_RATE_KEY.to_string(), default_quota));
340
341        RateLimitConfig {
342            default_quota: Some(default_quota),
343            keyed_quotas: keyed,
344            order_keys,
345        }
346    }
347
348    fn quota_from(quota: &crate::common::consts::BinanceRateLimitQuota) -> Option<Quota> {
349        let burst = NonZeroU32::new(quota.limit)?;
350        match quota.interval {
351            "SECOND" => Some(Quota::per_second(burst)),
352            "MINUTE" => Some(Quota::per_minute(burst)),
353            "DAY" => Quota::with_period(Duration::from_secs(86_400)).map(|q| q.allow_burst(burst)),
354            _ => None,
355        }
356    }
357}
358pub(crate) struct RateLimitConfig {
359    pub(crate) default_quota: Option<Quota>,
360    pub(crate) keyed_quotas: Vec<(String, Quota)>,
361    pub(crate) order_keys: Vec<String>,
362}
363
364/// In-memory cache entry for Binance instruments.
365#[derive(Clone, Debug)]
366#[allow(dead_code)]
367pub enum BinanceInstrument {
368    Spot(BinanceSpotSymbol),
369    UsdM(BinanceFuturesUsdSymbol),
370    CoinM(BinanceFuturesCoinSymbol),
371}
372
373/// Unified 24h ticker response across spot and futures products.
374#[derive(Clone, Debug)]
375pub enum BinanceTicker24hrEither {
376    Spot(Vec<BinanceSpotTicker24hr>),
377    Futures(Vec<BinanceFuturesTicker24hr>),
378}
379
380/// Higher-level HTTP client providing typed endpoints and instrument caching.
381#[derive(Debug, Clone)]
382pub struct BinanceHttpClient {
383    raw: BinanceRawHttpClient,
384    product_type: BinanceProductType,
385    instruments: Arc<DashMap<Ustr, BinanceInstrument>>,
386}
387
388impl BinanceHttpClient {
389    /// Creates a new [`BinanceHttpClient`] wrapping the raw client with an instrument cache.
390    ///
391    /// # Errors
392    ///
393    /// Returns an error if the underlying HTTP client cannot be created or credentials are invalid.
394    #[allow(clippy::too_many_arguments)]
395    pub fn new(
396        product_type: BinanceProductType,
397        environment: BinanceEnvironment,
398        api_key: Option<String>,
399        api_secret: Option<String>,
400        base_url_override: Option<String>,
401        recv_window: Option<u64>,
402        timeout_secs: Option<u64>,
403        proxy_url: Option<String>,
404    ) -> BinanceHttpResult<Self> {
405        let raw = BinanceRawHttpClient::new(
406            product_type,
407            environment,
408            api_key,
409            api_secret,
410            base_url_override,
411            recv_window,
412            timeout_secs,
413            proxy_url,
414        )?;
415
416        Ok(Self {
417            raw,
418            product_type,
419            instruments: Arc::new(DashMap::new()),
420        })
421    }
422
423    /// Returns a reference to the underlying raw client.
424    #[must_use]
425    pub const fn raw(&self) -> &BinanceRawHttpClient {
426        &self.raw
427    }
428
429    /// Returns server time for the configured product type.
430    pub async fn server_time(&self) -> BinanceHttpResult<BinanceServerTime> {
431        self.raw
432            .get::<_, BinanceServerTime>("time", None::<&()>, false, false)
433            .await
434    }
435
436    /// Fetches exchange information and populates the instrument cache.
437    pub async fn exchange_info(&self) -> BinanceHttpResult<()> {
438        match self.product_type {
439            BinanceProductType::Spot | BinanceProductType::Margin => {
440                let info: BinanceSpotExchangeInfo = self
441                    .raw
442                    .get(
443                        "exchangeInfo",
444                        None::<&BinanceSpotExchangeInfoParams>,
445                        false,
446                        false,
447                    )
448                    .await?;
449                for symbol in info.symbols {
450                    self.instruments
451                        .insert(symbol.symbol, BinanceInstrument::Spot(symbol));
452                }
453            }
454            BinanceProductType::UsdM => {
455                let info: BinanceFuturesUsdExchangeInfo = self
456                    .raw
457                    .get("exchangeInfo", None::<&()>, false, false)
458                    .await?;
459                for symbol in info.symbols {
460                    self.instruments
461                        .insert(symbol.symbol, BinanceInstrument::UsdM(symbol));
462                }
463            }
464            BinanceProductType::CoinM => {
465                let info: BinanceFuturesCoinExchangeInfo = self
466                    .raw
467                    .get("exchangeInfo", None::<&()>, false, false)
468                    .await?;
469                for symbol in info.symbols {
470                    self.instruments
471                        .insert(symbol.symbol, BinanceInstrument::CoinM(symbol));
472                }
473            }
474            BinanceProductType::Options => {
475                // Options exchange info follows similar pattern; keep placeholder for future coverage.
476                return Err(BinanceHttpError::ValidationError(
477                    "Options exchangeInfo not yet implemented".to_string(),
478                ));
479            }
480        }
481
482        Ok(())
483    }
484
485    /// Retrieves an instrument from cache, optionally fetching exchange info first.
486    pub async fn get_instrument(
487        &self,
488        symbol: &str,
489    ) -> BinanceHttpResult<Option<BinanceInstrument>> {
490        let key = Ustr::from(symbol);
491
492        if let Some(entry) = self.instruments.get(&key) {
493            return Ok(Some(entry.value().clone()));
494        }
495
496        // Lazy load the cache.
497        self.exchange_info().await?;
498        Ok(self.instruments.get(&key).map(|e| e.value().clone()))
499    }
500
501    /// 24h ticker endpoint.
502    pub async fn ticker_24h(
503        &self,
504        params: &BinanceTicker24hrParams,
505    ) -> BinanceHttpResult<BinanceTicker24hrEither> {
506        match self.product_type {
507            BinanceProductType::Spot | BinanceProductType::Margin => {
508                let data: Vec<BinanceSpotTicker24hr> = self
509                    .raw
510                    .get("ticker/24hr", Some(params), false, false)
511                    .await?;
512                Ok(BinanceTicker24hrEither::Spot(data))
513            }
514            _ => {
515                let data: Vec<BinanceFuturesTicker24hr> = self
516                    .raw
517                    .get("ticker/24hr", Some(params), false, false)
518                    .await?;
519                Ok(BinanceTicker24hrEither::Futures(data))
520            }
521        }
522    }
523
524    /// Book ticker endpoint.
525    pub async fn book_ticker(
526        &self,
527        params: &BinanceBookTickerParams,
528    ) -> BinanceHttpResult<Vec<BinanceBookTicker>> {
529        self.raw
530            .get("ticker/bookTicker", Some(params), false, false)
531            .await
532    }
533
534    /// Price ticker endpoint.
535    pub async fn price_ticker(
536        &self,
537        params: &BinancePriceTickerParams,
538    ) -> BinanceHttpResult<Vec<BinancePriceTicker>> {
539        self.raw
540            .get("ticker/price", Some(params), false, false)
541            .await
542    }
543
544    /// Order book depth endpoint.
545    pub async fn depth(&self, params: &BinanceDepthParams) -> BinanceHttpResult<BinanceOrderBook> {
546        self.raw.get("depth", Some(params), false, false).await
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use nautilus_network::http::{HttpStatus, StatusCode};
553    use rstest::rstest;
554    use tokio_util::bytes::Bytes;
555
556    use super::*;
557
558    // ------------------------------------------------------------------------------------------------
559    // URL builder tests
560    // ------------------------------------------------------------------------------------------------
561
562    #[rstest]
563    #[case("time", "/time")]
564    #[case("/time", "/time")]
565    #[case("ticker/24hr", "/ticker/24hr")]
566    #[case("/ticker/24hr", "/ticker/24hr")]
567    fn test_normalize_path(#[case] input: &str, #[case] expected: &str) {
568        assert_eq!(BinanceRawHttpClient::normalize_path(input), expected);
569    }
570
571    #[rstest]
572    fn test_build_url_without_query() {
573        let client = create_test_client(None);
574        let url = client.build_url("time", "");
575
576        assert_eq!(url, "https://api.binance.com/api/v3/time");
577    }
578
579    #[rstest]
580    fn test_build_url_with_query() {
581        let client = create_test_client(None);
582        let url = client.build_url("depth", "symbol=BTCUSDT&limit=100");
583
584        assert_eq!(
585            url,
586            "https://api.binance.com/api/v3/depth?symbol=BTCUSDT&limit=100"
587        );
588    }
589
590    #[rstest]
591    fn test_build_url_path_with_leading_slash() {
592        let client = create_test_client(None);
593        let url = client.build_url("/exchangeInfo", "");
594
595        assert_eq!(url, "https://api.binance.com/api/v3/exchangeInfo");
596    }
597
598    // ------------------------------------------------------------------------------------------------
599    // Error parsing tests
600    // ------------------------------------------------------------------------------------------------
601
602    #[rstest]
603    fn test_parse_error_response_binance_error() {
604        let client = create_test_client(None);
605        let response = HttpResponse {
606            status: HttpStatus::new(StatusCode::BAD_REQUEST),
607            headers: HashMap::new(),
608            body: Bytes::from(r#"{"code":-1121,"msg":"Invalid symbol."}"#),
609        };
610
611        let result: BinanceHttpResult<()> = client.parse_error_response(response);
612
613        match result {
614            Err(BinanceHttpError::BinanceError { code, message }) => {
615                assert_eq!(code, -1121);
616                assert_eq!(message, "Invalid symbol.");
617            }
618            other => panic!("Expected BinanceError, got {other:?}"),
619        }
620    }
621
622    #[rstest]
623    fn test_parse_error_response_unexpected_status_non_json() {
624        let client = create_test_client(None);
625        let response = HttpResponse {
626            status: HttpStatus::new(StatusCode::INTERNAL_SERVER_ERROR),
627            headers: HashMap::new(),
628            body: Bytes::from("Internal Server Error"),
629        };
630
631        let result: BinanceHttpResult<()> = client.parse_error_response(response);
632
633        match result {
634            Err(BinanceHttpError::UnexpectedStatus { status, body }) => {
635                assert_eq!(status, 500);
636                assert_eq!(body, "Internal Server Error");
637            }
638            other => panic!("Expected UnexpectedStatus, got {other:?}"),
639        }
640    }
641
642    #[rstest]
643    fn test_parse_error_response_malformed_json() {
644        let client = create_test_client(None);
645        let response = HttpResponse {
646            status: HttpStatus::new(StatusCode::BAD_REQUEST),
647            headers: HashMap::new(),
648            body: Bytes::from(r#"{"error": "not binance format"}"#),
649        };
650
651        let result: BinanceHttpResult<()> = client.parse_error_response(response);
652
653        match result {
654            Err(BinanceHttpError::UnexpectedStatus { status, body }) => {
655                assert_eq!(status, 400);
656                assert!(body.contains("not binance format"));
657            }
658            other => panic!("Expected UnexpectedStatus, got {other:?}"),
659        }
660    }
661
662    // ------------------------------------------------------------------------------------------------
663    // Rate limit wiring tests
664    // ------------------------------------------------------------------------------------------------
665
666    #[rstest]
667    fn test_rate_limit_config_spot_has_request_weight_and_orders() {
668        let config = BinanceRawHttpClient::rate_limit_config(BinanceProductType::Spot);
669
670        assert!(config.default_quota.is_some());
671        // Spot has 2 ORDERS quotas (SECOND and DAY)
672        assert_eq!(config.order_keys.len(), 2);
673        assert!(config.order_keys.iter().any(|k| k.contains("SECOND")));
674        assert!(config.order_keys.iter().any(|k| k.contains("DAY")));
675    }
676
677    #[rstest]
678    fn test_rate_limit_config_usdm_has_request_weight_and_orders() {
679        let config = BinanceRawHttpClient::rate_limit_config(BinanceProductType::UsdM);
680
681        assert!(config.default_quota.is_some());
682        // USD-M has 2 ORDERS quotas (SECOND and MINUTE)
683        assert_eq!(config.order_keys.len(), 2);
684        assert!(config.order_keys.iter().any(|k| k.contains("SECOND")));
685        assert!(config.order_keys.iter().any(|k| k.contains("MINUTE")));
686    }
687
688    #[rstest]
689    fn test_rate_limit_config_coinm_has_request_weight_and_orders() {
690        let config = BinanceRawHttpClient::rate_limit_config(BinanceProductType::CoinM);
691
692        assert!(config.default_quota.is_some());
693        // COIN-M has 2 ORDERS quotas (SECOND and MINUTE)
694        assert_eq!(config.order_keys.len(), 2);
695    }
696
697    #[rstest]
698    fn test_rate_limit_config_options_has_request_weight_and_orders() {
699        let config = BinanceRawHttpClient::rate_limit_config(BinanceProductType::Options);
700
701        assert!(config.default_quota.is_some());
702        // Options has 2 ORDERS quotas (SECOND and MINUTE)
703        assert_eq!(config.order_keys.len(), 2);
704    }
705
706    #[rstest]
707    fn test_rate_limit_keys_without_orders() {
708        let client = create_test_client(None);
709        let keys = client.rate_limit_keys(false);
710
711        assert_eq!(keys.len(), 1);
712        assert_eq!(keys[0], BINANCE_GLOBAL_RATE_KEY);
713    }
714
715    #[rstest]
716    fn test_rate_limit_keys_with_orders() {
717        let client = create_test_client(None);
718        let keys = client.rate_limit_keys(true);
719
720        // Should have global key + order keys (2 for Spot)
721        assert!(keys.len() >= 2);
722        assert!(keys.contains(&BINANCE_GLOBAL_RATE_KEY.to_string()));
723        assert!(keys.iter().any(|k| k.starts_with(BINANCE_ORDERS_RATE_KEY)));
724    }
725
726    // ------------------------------------------------------------------------------------------------
727    // Test helpers
728    // ------------------------------------------------------------------------------------------------
729
730    fn create_test_client(recv_window: Option<u64>) -> BinanceRawHttpClient {
731        BinanceRawHttpClient::new(
732            BinanceProductType::Spot,
733            BinanceEnvironment::Mainnet,
734            None,
735            None,
736            None,
737            recv_window,
738            None,
739            None,
740        )
741        .expect("Failed to create test client")
742    }
743}