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