nautilus_binance/spot/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 Spot HTTP client with SBE encoding.
17//!
18//! This client communicates with Binance Spot REST API using SBE (Simple Binary
19//! Encoding) for all request/response payloads, providing microsecond timestamp
20//! precision and reduced latency compared to JSON.
21//!
22//! ## Architecture
23//!
24//! Two-layer client pattern:
25//! - [`BinanceRawSpotHttpClient`]: Low-level API methods returning raw bytes.
26//! - [`BinanceSpotHttpClient`]: High-level methods with SBE decoding.
27//!
28//! ## SBE Headers
29//!
30//! All requests include:
31//! - `Accept: application/sbe`
32//! - `X-MBX-SBE: 3:1` (schema ID:version)
33
34use std::{collections::HashMap, num::NonZeroU32, sync::Arc};
35
36use chrono::Utc;
37use nautilus_core::consts::NAUTILUS_USER_AGENT;
38use nautilus_network::{
39    http::{HttpClient, HttpResponse, Method},
40    ratelimiter::quota::Quota,
41};
42use serde::Serialize;
43
44use super::{
45    error::{BinanceSpotHttpError, BinanceSpotHttpResult},
46    models::{BinanceDepth, BinanceTrades},
47    parse,
48    query::{DepthParams, TradesParams},
49};
50use crate::common::{
51    consts::BINANCE_SPOT_RATE_LIMITS,
52    credential::Credential,
53    enums::{BinanceEnvironment, BinanceProductType},
54    models::BinanceErrorResponse,
55    sbe::spot::{SBE_SCHEMA_ID, SBE_SCHEMA_VERSION},
56    urls::get_http_base_url,
57};
58
59/// SBE schema header value for Spot API.
60pub const SBE_SCHEMA_HEADER: &str = "3:1";
61
62/// Binance Spot API path.
63const SPOT_API_PATH: &str = "/api/v3";
64
65/// Global rate limit key.
66const BINANCE_GLOBAL_RATE_KEY: &str = "binance:spot:global";
67
68/// Orders rate limit key prefix.
69const BINANCE_ORDERS_RATE_KEY: &str = "binance:spot:orders";
70
71/// Low-level HTTP client for Binance Spot REST API with SBE encoding.
72///
73/// Handles:
74/// - Base URL resolution by environment.
75/// - Optional HMAC SHA256 signing for private endpoints.
76/// - Rate limiting using Spot API quotas.
77/// - SBE decoding to Binance-specific response types.
78///
79/// Methods are named to match Binance API endpoints and return
80/// venue-specific types (decoded from SBE).
81#[derive(Debug, Clone)]
82pub struct BinanceRawSpotHttpClient {
83    client: HttpClient,
84    base_url: String,
85    credential: Option<Credential>,
86    recv_window: Option<u64>,
87    order_rate_keys: Vec<String>,
88}
89
90impl BinanceRawSpotHttpClient {
91    /// Creates a new Binance Spot raw HTTP client.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if the underlying [`HttpClient`] fails to build.
96    pub fn new(
97        environment: BinanceEnvironment,
98        api_key: Option<String>,
99        api_secret: Option<String>,
100        base_url_override: Option<String>,
101        recv_window: Option<u64>,
102        timeout_secs: Option<u64>,
103        proxy_url: Option<String>,
104    ) -> BinanceSpotHttpResult<Self> {
105        let RateLimitConfig {
106            default_quota,
107            keyed_quotas,
108            order_keys,
109        } = Self::rate_limit_config();
110
111        let credential = match (api_key, api_secret) {
112            (Some(key), Some(secret)) => Some(Credential::new(key, secret)),
113            (None, None) => None,
114            _ => return Err(BinanceSpotHttpError::MissingCredentials),
115        };
116
117        let base_url = base_url_override.unwrap_or_else(|| {
118            get_http_base_url(BinanceProductType::Spot, environment).to_string()
119        });
120
121        let headers = Self::default_headers(&credential);
122
123        let client = HttpClient::new(
124            headers,
125            vec!["X-MBX-APIKEY".to_string()],
126            keyed_quotas,
127            default_quota,
128            timeout_secs,
129            proxy_url,
130        )?;
131
132        Ok(Self {
133            client,
134            base_url,
135            credential,
136            recv_window,
137            order_rate_keys: order_keys,
138        })
139    }
140
141    /// Returns the SBE schema ID.
142    #[must_use]
143    pub const fn schema_id() -> u16 {
144        SBE_SCHEMA_ID
145    }
146
147    /// Returns the SBE schema version.
148    #[must_use]
149    pub const fn schema_version() -> u16 {
150        SBE_SCHEMA_VERSION
151    }
152
153    /// Performs a GET request and returns raw response bytes.
154    pub async fn get<P>(&self, path: &str, params: Option<&P>) -> BinanceSpotHttpResult<Vec<u8>>
155    where
156        P: Serialize + ?Sized,
157    {
158        self.request(Method::GET, path, params, false, false).await
159    }
160
161    /// Performs a signed GET request and returns raw response bytes.
162    pub async fn get_signed<P>(
163        &self,
164        path: &str,
165        params: Option<&P>,
166    ) -> BinanceSpotHttpResult<Vec<u8>>
167    where
168        P: Serialize + ?Sized,
169    {
170        self.request(Method::GET, path, params, true, false).await
171    }
172
173    /// Performs a signed POST request for order operations.
174    pub async fn post_order<P>(
175        &self,
176        path: &str,
177        params: Option<&P>,
178    ) -> BinanceSpotHttpResult<Vec<u8>>
179    where
180        P: Serialize + ?Sized,
181    {
182        self.request(Method::POST, path, params, true, true).await
183    }
184
185    /// Tests connectivity to the API.
186    ///
187    /// # Errors
188    ///
189    /// Returns an error if the request fails or SBE decoding fails.
190    pub async fn ping(&self) -> BinanceSpotHttpResult<()> {
191        let bytes = self.get("ping", None::<&()>).await?;
192        parse::decode_ping(&bytes)?;
193        Ok(())
194    }
195
196    /// Returns the server time in **microseconds** since epoch.
197    ///
198    /// Note: SBE provides microsecond precision vs JSON's milliseconds.
199    ///
200    /// # Errors
201    ///
202    /// Returns an error if the request fails or SBE decoding fails.
203    pub async fn server_time(&self) -> BinanceSpotHttpResult<i64> {
204        let bytes = self.get("time", None::<&()>).await?;
205        let timestamp = parse::decode_server_time(&bytes)?;
206        Ok(timestamp)
207    }
208
209    /// Returns order book depth for a symbol.
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if the request fails or SBE decoding fails.
214    pub async fn depth(&self, params: &DepthParams) -> BinanceSpotHttpResult<BinanceDepth> {
215        let bytes = self.get("depth", Some(params)).await?;
216        let depth = parse::decode_depth(&bytes)?;
217        Ok(depth)
218    }
219
220    /// Returns recent trades for a symbol.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if the request fails or SBE decoding fails.
225    pub async fn trades(&self, params: &TradesParams) -> BinanceSpotHttpResult<BinanceTrades> {
226        let bytes = self.get("trades", Some(params)).await?;
227        let trades = parse::decode_trades(&bytes)?;
228        Ok(trades)
229    }
230
231    async fn request<P>(
232        &self,
233        method: Method,
234        path: &str,
235        params: Option<&P>,
236        signed: bool,
237        use_order_quota: bool,
238    ) -> BinanceSpotHttpResult<Vec<u8>>
239    where
240        P: Serialize + ?Sized,
241    {
242        let mut query = params
243            .map(serde_urlencoded::to_string)
244            .transpose()
245            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?
246            .unwrap_or_default();
247
248        let mut headers = HashMap::new();
249        if signed {
250            let cred = self
251                .credential
252                .as_ref()
253                .ok_or(BinanceSpotHttpError::MissingCredentials)?;
254
255            if !query.is_empty() {
256                query.push('&');
257            }
258
259            let timestamp = Utc::now().timestamp_millis();
260            query.push_str(&format!("timestamp={timestamp}"));
261
262            if let Some(recv_window) = self.recv_window {
263                query.push_str(&format!("&recvWindow={recv_window}"));
264            }
265
266            let signature = cred.sign(&query);
267            query.push_str(&format!("&signature={signature}"));
268            headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
269        }
270
271        let url = self.build_url(path, &query);
272        let keys = self.rate_limit_keys(use_order_quota);
273
274        let response = self
275            .client
276            .request(
277                method,
278                url,
279                None::<&HashMap<String, Vec<String>>>,
280                Some(headers),
281                None,
282                None,
283                Some(keys),
284            )
285            .await?;
286
287        if !response.status.is_success() {
288            return self.parse_error_response(response);
289        }
290
291        Ok(response.body.to_vec())
292    }
293
294    fn build_url(&self, path: &str, query: &str) -> String {
295        let normalized_path = if path.starts_with('/') {
296            path.to_string()
297        } else {
298            format!("/{path}")
299        };
300
301        let mut url = format!("{}{}{}", self.base_url, SPOT_API_PATH, normalized_path);
302        if !query.is_empty() {
303            url.push('?');
304            url.push_str(query);
305        }
306        url
307    }
308
309    fn rate_limit_keys(&self, use_orders: bool) -> Vec<String> {
310        if use_orders {
311            let mut keys = Vec::with_capacity(1 + self.order_rate_keys.len());
312            keys.push(BINANCE_GLOBAL_RATE_KEY.to_string());
313            keys.extend(self.order_rate_keys.iter().cloned());
314            keys
315        } else {
316            vec![BINANCE_GLOBAL_RATE_KEY.to_string()]
317        }
318    }
319
320    fn parse_error_response<T>(&self, response: HttpResponse) -> BinanceSpotHttpResult<T> {
321        let status = response.status.as_u16();
322        let body_hex = hex::encode(&response.body);
323
324        // Binance may return JSON errors even when SBE was requested
325        if let Ok(body_str) = std::str::from_utf8(&response.body)
326            && let Ok(err) = serde_json::from_str::<BinanceErrorResponse>(body_str)
327        {
328            return Err(BinanceSpotHttpError::BinanceError {
329                code: err.code,
330                message: err.msg,
331            });
332        }
333
334        Err(BinanceSpotHttpError::UnexpectedStatus {
335            status,
336            body: body_hex,
337        })
338    }
339
340    fn default_headers(credential: &Option<Credential>) -> HashMap<String, String> {
341        let mut headers = HashMap::new();
342        headers.insert("User-Agent".to_string(), NAUTILUS_USER_AGENT.to_string());
343        headers.insert("Accept".to_string(), "application/sbe".to_string());
344        headers.insert("X-MBX-SBE".to_string(), SBE_SCHEMA_HEADER.to_string());
345        if let Some(cred) = credential {
346            headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
347        }
348        headers
349    }
350
351    fn rate_limit_config() -> RateLimitConfig {
352        let quotas = BINANCE_SPOT_RATE_LIMITS;
353        let mut keyed = Vec::new();
354        let mut order_keys = Vec::new();
355        let mut default = None;
356
357        for quota in quotas {
358            if let Some(q) = Self::quota_from(quota) {
359                if quota.rate_limit_type == "REQUEST_WEIGHT" && default.is_none() {
360                    default = Some(q);
361                } else if quota.rate_limit_type == "ORDERS" {
362                    let key = format!("{}:{}", BINANCE_ORDERS_RATE_KEY, quota.interval);
363                    order_keys.push(key.clone());
364                    keyed.push((key, q));
365                }
366            }
367        }
368
369        let default_quota =
370            default.unwrap_or_else(|| Quota::per_second(NonZeroU32::new(10).unwrap()));
371
372        keyed.push((BINANCE_GLOBAL_RATE_KEY.to_string(), default_quota));
373
374        RateLimitConfig {
375            default_quota: Some(default_quota),
376            keyed_quotas: keyed,
377            order_keys,
378        }
379    }
380
381    fn quota_from(quota: &crate::common::consts::BinanceRateLimitQuota) -> Option<Quota> {
382        let burst = NonZeroU32::new(quota.limit)?;
383        match quota.interval {
384            "SECOND" => Some(Quota::per_second(burst)),
385            "MINUTE" => Some(Quota::per_minute(burst)),
386            "DAY" => Quota::with_period(std::time::Duration::from_secs(86_400))
387                .map(|q| q.allow_burst(burst)),
388            _ => None,
389        }
390    }
391}
392
393struct RateLimitConfig {
394    default_quota: Option<Quota>,
395    keyed_quotas: Vec<(String, Quota)>,
396    order_keys: Vec<String>,
397}
398
399/// High-level HTTP client for Binance Spot API.
400///
401/// Wraps [`BinanceRawSpotHttpClient`] and provides domain-level methods:
402/// - Simple types (ping, server_time): Pass through from raw client.
403/// - Complex types (instruments, orders): Transform to Nautilus domain types.
404#[derive(Debug, Clone)]
405pub struct BinanceSpotHttpClient {
406    inner: Arc<BinanceRawSpotHttpClient>,
407}
408
409impl BinanceSpotHttpClient {
410    /// Creates a new Binance Spot HTTP client.
411    ///
412    /// # Errors
413    ///
414    /// Returns an error if the underlying HTTP client cannot be created.
415    pub fn new(
416        environment: BinanceEnvironment,
417        api_key: Option<String>,
418        api_secret: Option<String>,
419        base_url_override: Option<String>,
420        recv_window: Option<u64>,
421        timeout_secs: Option<u64>,
422        proxy_url: Option<String>,
423    ) -> BinanceSpotHttpResult<Self> {
424        let inner = BinanceRawSpotHttpClient::new(
425            environment,
426            api_key,
427            api_secret,
428            base_url_override,
429            recv_window,
430            timeout_secs,
431            proxy_url,
432        )?;
433
434        Ok(Self {
435            inner: Arc::new(inner),
436        })
437    }
438
439    /// Returns a reference to the inner raw client.
440    #[must_use]
441    pub fn inner(&self) -> &BinanceRawSpotHttpClient {
442        &self.inner
443    }
444
445    /// Returns the SBE schema ID.
446    #[must_use]
447    pub const fn schema_id() -> u16 {
448        SBE_SCHEMA_ID
449    }
450
451    /// Returns the SBE schema version.
452    #[must_use]
453    pub const fn schema_version() -> u16 {
454        SBE_SCHEMA_VERSION
455    }
456
457    /// Tests connectivity to the API.
458    ///
459    /// # Errors
460    ///
461    /// Returns an error if the request fails or SBE decoding fails.
462    pub async fn ping(&self) -> BinanceSpotHttpResult<()> {
463        self.inner.ping().await
464    }
465
466    /// Returns the server time in **microseconds** since epoch.
467    ///
468    /// Note: SBE provides microsecond precision vs JSON's milliseconds.
469    ///
470    /// # Errors
471    ///
472    /// Returns an error if the request fails or SBE decoding fails.
473    pub async fn server_time(&self) -> BinanceSpotHttpResult<i64> {
474        self.inner.server_time().await
475    }
476
477    /// Returns order book depth for a symbol.
478    ///
479    /// # Errors
480    ///
481    /// Returns an error if the request fails or SBE decoding fails.
482    pub async fn depth(&self, params: &DepthParams) -> BinanceSpotHttpResult<BinanceDepth> {
483        self.inner.depth(params).await
484    }
485
486    /// Returns recent trades for a symbol.
487    ///
488    /// # Errors
489    ///
490    /// Returns an error if the request fails or SBE decoding fails.
491    pub async fn trades(&self, params: &TradesParams) -> BinanceSpotHttpResult<BinanceTrades> {
492        self.inner.trades(params).await
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use rstest::rstest;
499
500    use super::*;
501
502    #[rstest]
503    fn test_schema_constants() {
504        assert_eq!(BinanceRawSpotHttpClient::schema_id(), 3);
505        assert_eq!(BinanceRawSpotHttpClient::schema_version(), 1);
506        assert_eq!(BinanceSpotHttpClient::schema_id(), 3);
507        assert_eq!(BinanceSpotHttpClient::schema_version(), 1);
508    }
509
510    #[rstest]
511    fn test_sbe_schema_header() {
512        assert_eq!(SBE_SCHEMA_HEADER, "3:1");
513    }
514
515    #[rstest]
516    fn test_default_headers_include_sbe() {
517        let headers = BinanceRawSpotHttpClient::default_headers(&None);
518
519        assert_eq!(headers.get("Accept"), Some(&"application/sbe".to_string()));
520        assert_eq!(headers.get("X-MBX-SBE"), Some(&"3:1".to_string()));
521    }
522
523    #[rstest]
524    fn test_rate_limit_config() {
525        let config = BinanceRawSpotHttpClient::rate_limit_config();
526
527        assert!(config.default_quota.is_some());
528        // Spot has 2 ORDERS quotas (SECOND and DAY)
529        assert_eq!(config.order_keys.len(), 2);
530    }
531}