nautilus_binance/spot/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 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:2` (schema ID:version)
33
34use std::{collections::HashMap, fmt::Debug, num::NonZeroU32, sync::Arc};
35
36use chrono::Utc;
37use dashmap::DashMap;
38use nautilus_core::{consts::NAUTILUS_USER_AGENT, nanos::UnixNanos};
39use nautilus_model::{
40    data::TradeTick,
41    enums::{OrderSide, OrderType, TimeInForce},
42    identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
43    instruments::{Instrument, any::InstrumentAny},
44    reports::{FillReport, OrderStatusReport},
45    types::{Price, Quantity},
46};
47use nautilus_network::{
48    http::{HttpClient, HttpResponse, Method},
49    ratelimiter::quota::Quota,
50};
51use serde::Serialize;
52use ustr::Ustr;
53
54use super::{
55    error::{BinanceSpotHttpError, BinanceSpotHttpResult},
56    models::{
57        BinanceAccountInfo, BinanceAccountTrade, BinanceCancelOrderResponse, BinanceDepth,
58        BinanceNewOrderResponse, BinanceOrderResponse, BinanceTrades,
59    },
60    parse,
61    query::{
62        AccountInfoParams, AccountTradesParams, AllOrdersParams, CancelOpenOrdersParams,
63        CancelOrderParams, CancelReplaceOrderParams, DepthParams, NewOrderParams, OpenOrdersParams,
64        QueryOrderParams, TradesParams,
65    },
66};
67use crate::{
68    common::{
69        consts::BINANCE_SPOT_RATE_LIMITS,
70        credential::Credential,
71        enums::{BinanceEnvironment, BinanceProductType, BinanceSide, BinanceTimeInForce},
72        models::BinanceErrorResponse,
73        sbe::spot::{SBE_SCHEMA_ID, SBE_SCHEMA_VERSION},
74        urls::get_http_base_url,
75    },
76    spot::enums::BinanceSpotOrderType,
77};
78
79/// SBE schema header value for Spot API.
80pub const SBE_SCHEMA_HEADER: &str = "3:2";
81
82/// Binance Spot API path.
83const SPOT_API_PATH: &str = "/api/v3";
84
85/// Global rate limit key.
86const BINANCE_GLOBAL_RATE_KEY: &str = "binance:spot:global";
87
88/// Orders rate limit key prefix.
89const BINANCE_ORDERS_RATE_KEY: &str = "binance:spot:orders";
90
91/// Low-level HTTP client for Binance Spot REST API with SBE encoding.
92///
93/// Handles:
94/// - Base URL resolution by environment.
95/// - Optional HMAC SHA256 signing for private endpoints.
96/// - Rate limiting using Spot API quotas.
97/// - SBE decoding to Binance-specific response types.
98///
99/// Methods are named to match Binance API endpoints and return
100/// venue-specific types (decoded from SBE).
101#[derive(Debug, Clone)]
102pub struct BinanceRawSpotHttpClient {
103    client: HttpClient,
104    base_url: String,
105    credential: Option<Credential>,
106    recv_window: Option<u64>,
107    order_rate_keys: Vec<String>,
108}
109
110impl BinanceRawSpotHttpClient {
111    /// Creates a new Binance Spot raw HTTP client.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the underlying [`HttpClient`] fails to build.
116    pub fn new(
117        environment: BinanceEnvironment,
118        api_key: Option<String>,
119        api_secret: Option<String>,
120        base_url_override: Option<String>,
121        recv_window: Option<u64>,
122        timeout_secs: Option<u64>,
123        proxy_url: Option<String>,
124    ) -> BinanceSpotHttpResult<Self> {
125        let RateLimitConfig {
126            default_quota,
127            keyed_quotas,
128            order_keys,
129        } = Self::rate_limit_config();
130
131        let credential = match (api_key, api_secret) {
132            (Some(key), Some(secret)) => Some(Credential::new(key, secret)),
133            (None, None) => None,
134            _ => return Err(BinanceSpotHttpError::MissingCredentials),
135        };
136
137        let base_url = base_url_override.unwrap_or_else(|| {
138            get_http_base_url(BinanceProductType::Spot, environment).to_string()
139        });
140
141        let headers = Self::default_headers(&credential);
142
143        let client = HttpClient::new(
144            headers,
145            vec!["X-MBX-APIKEY".to_string()],
146            keyed_quotas,
147            default_quota,
148            timeout_secs,
149            proxy_url,
150        )?;
151
152        Ok(Self {
153            client,
154            base_url,
155            credential,
156            recv_window,
157            order_rate_keys: order_keys,
158        })
159    }
160
161    /// Returns the SBE schema ID.
162    #[must_use]
163    pub const fn schema_id() -> u16 {
164        SBE_SCHEMA_ID
165    }
166
167    /// Returns the SBE schema version.
168    #[must_use]
169    pub const fn schema_version() -> u16 {
170        SBE_SCHEMA_VERSION
171    }
172
173    /// Performs a GET request and returns raw response bytes.
174    pub async fn get<P>(&self, path: &str, params: Option<&P>) -> BinanceSpotHttpResult<Vec<u8>>
175    where
176        P: Serialize + ?Sized,
177    {
178        self.request(Method::GET, path, params, false, false).await
179    }
180
181    /// Performs a signed GET request and returns raw response bytes.
182    pub async fn get_signed<P>(
183        &self,
184        path: &str,
185        params: Option<&P>,
186    ) -> BinanceSpotHttpResult<Vec<u8>>
187    where
188        P: Serialize + ?Sized,
189    {
190        self.request(Method::GET, path, params, true, false).await
191    }
192
193    /// Performs a signed POST request for order operations.
194    pub async fn post_order<P>(
195        &self,
196        path: &str,
197        params: Option<&P>,
198    ) -> BinanceSpotHttpResult<Vec<u8>>
199    where
200        P: Serialize + ?Sized,
201    {
202        self.request(Method::POST, path, params, true, true).await
203    }
204
205    /// Performs a signed DELETE request for cancel operations.
206    pub async fn delete_order<P>(
207        &self,
208        path: &str,
209        params: Option<&P>,
210    ) -> BinanceSpotHttpResult<Vec<u8>>
211    where
212        P: Serialize + ?Sized,
213    {
214        self.request(Method::DELETE, path, params, true, true).await
215    }
216
217    /// Tests connectivity to the API.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if the request fails or SBE decoding fails.
222    pub async fn ping(&self) -> BinanceSpotHttpResult<()> {
223        let bytes = self.get("ping", None::<&()>).await?;
224        parse::decode_ping(&bytes)?;
225        Ok(())
226    }
227
228    /// Returns the server time in **microseconds** since epoch.
229    ///
230    /// Note: SBE provides microsecond precision vs JSON's milliseconds.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if the request fails or SBE decoding fails.
235    pub async fn server_time(&self) -> BinanceSpotHttpResult<i64> {
236        let bytes = self.get("time", None::<&()>).await?;
237        let timestamp = parse::decode_server_time(&bytes)?;
238        Ok(timestamp)
239    }
240
241    /// Returns exchange information including trading symbols.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if the request fails or SBE decoding fails.
246    pub async fn exchange_info(
247        &self,
248    ) -> BinanceSpotHttpResult<super::models::BinanceExchangeInfoSbe> {
249        let bytes = self.get("exchangeInfo", None::<&()>).await?;
250        let info = parse::decode_exchange_info(&bytes)?;
251        Ok(info)
252    }
253
254    /// Returns order book depth for a symbol.
255    ///
256    /// # Errors
257    ///
258    /// Returns an error if the request fails or SBE decoding fails.
259    pub async fn depth(&self, params: &DepthParams) -> BinanceSpotHttpResult<BinanceDepth> {
260        let bytes = self.get("depth", Some(params)).await?;
261        let depth = parse::decode_depth(&bytes)?;
262        Ok(depth)
263    }
264
265    /// Returns recent trades for a symbol.
266    ///
267    /// # Errors
268    ///
269    /// Returns an error if the request fails or SBE decoding fails.
270    pub async fn trades(&self, params: &TradesParams) -> BinanceSpotHttpResult<BinanceTrades> {
271        let bytes = self.get("trades", Some(params)).await?;
272        let trades = parse::decode_trades(&bytes)?;
273        Ok(trades)
274    }
275
276    /// Creates a new order.
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if the request fails or SBE decoding fails.
281    pub async fn new_order(
282        &self,
283        params: &NewOrderParams,
284    ) -> BinanceSpotHttpResult<BinanceNewOrderResponse> {
285        let bytes = self.post_order("order", Some(params)).await?;
286        let response = parse::decode_new_order_full(&bytes)?;
287        Ok(response)
288    }
289
290    /// Cancels an existing order.
291    ///
292    /// # Errors
293    ///
294    /// Returns an error if the request fails or SBE decoding fails.
295    pub async fn cancel_order(
296        &self,
297        params: &CancelOrderParams,
298    ) -> BinanceSpotHttpResult<BinanceCancelOrderResponse> {
299        let bytes = self.delete_order("order", Some(params)).await?;
300        let response = parse::decode_cancel_order(&bytes)?;
301        Ok(response)
302    }
303
304    /// Cancels all open orders for a symbol.
305    ///
306    /// # Errors
307    ///
308    /// Returns an error if the request fails or SBE decoding fails.
309    pub async fn cancel_open_orders(
310        &self,
311        params: &CancelOpenOrdersParams,
312    ) -> BinanceSpotHttpResult<Vec<BinanceCancelOrderResponse>> {
313        let bytes = self.delete_order("openOrders", Some(params)).await?;
314        let response = parse::decode_cancel_open_orders(&bytes)?;
315        Ok(response)
316    }
317
318    /// Cancels an existing order and places a new order atomically.
319    ///
320    /// # Errors
321    ///
322    /// Returns an error if the request fails or SBE decoding fails.
323    pub async fn cancel_replace_order(
324        &self,
325        params: &CancelReplaceOrderParams,
326    ) -> BinanceSpotHttpResult<BinanceNewOrderResponse> {
327        let bytes = self.post_order("order/cancelReplace", Some(params)).await?;
328        let response = parse::decode_new_order_full(&bytes)?;
329        Ok(response)
330    }
331
332    /// Queries an order's status.
333    ///
334    /// # Errors
335    ///
336    /// Returns an error if the request fails or SBE decoding fails.
337    pub async fn query_order(
338        &self,
339        params: &QueryOrderParams,
340    ) -> BinanceSpotHttpResult<BinanceOrderResponse> {
341        let bytes = self.get_signed("order", Some(params)).await?;
342        let response = parse::decode_order(&bytes)?;
343        Ok(response)
344    }
345
346    /// Returns all open orders for a symbol or all symbols.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if the request fails or SBE decoding fails.
351    pub async fn open_orders(
352        &self,
353        params: &OpenOrdersParams,
354    ) -> BinanceSpotHttpResult<Vec<BinanceOrderResponse>> {
355        let bytes = self.get_signed("openOrders", Some(params)).await?;
356        let response = parse::decode_orders(&bytes)?;
357        Ok(response)
358    }
359
360    /// Returns all orders (including closed) for a symbol.
361    ///
362    /// # Errors
363    ///
364    /// Returns an error if the request fails or SBE decoding fails.
365    pub async fn all_orders(
366        &self,
367        params: &AllOrdersParams,
368    ) -> BinanceSpotHttpResult<Vec<BinanceOrderResponse>> {
369        let bytes = self.get_signed("allOrders", Some(params)).await?;
370        let response = parse::decode_orders(&bytes)?;
371        Ok(response)
372    }
373
374    /// Returns account information including balances.
375    ///
376    /// # Errors
377    ///
378    /// Returns an error if the request fails or SBE decoding fails.
379    pub async fn account(
380        &self,
381        params: &AccountInfoParams,
382    ) -> BinanceSpotHttpResult<BinanceAccountInfo> {
383        let bytes = self.get_signed("account", Some(params)).await?;
384        let response = parse::decode_account(&bytes)?;
385        Ok(response)
386    }
387
388    /// Returns account trade history for a symbol.
389    ///
390    /// # Errors
391    ///
392    /// Returns an error if the request fails or SBE decoding fails.
393    pub async fn account_trades(
394        &self,
395        params: &AccountTradesParams,
396    ) -> BinanceSpotHttpResult<Vec<BinanceAccountTrade>> {
397        let bytes = self.get_signed("myTrades", Some(params)).await?;
398        let response = parse::decode_account_trades(&bytes)?;
399        Ok(response)
400    }
401
402    async fn request<P>(
403        &self,
404        method: Method,
405        path: &str,
406        params: Option<&P>,
407        signed: bool,
408        use_order_quota: bool,
409    ) -> BinanceSpotHttpResult<Vec<u8>>
410    where
411        P: Serialize + ?Sized,
412    {
413        let mut query = params
414            .map(serde_urlencoded::to_string)
415            .transpose()
416            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?
417            .unwrap_or_default();
418
419        let mut headers = HashMap::new();
420        if signed {
421            let cred = self
422                .credential
423                .as_ref()
424                .ok_or(BinanceSpotHttpError::MissingCredentials)?;
425
426            if !query.is_empty() {
427                query.push('&');
428            }
429
430            let timestamp = Utc::now().timestamp_millis();
431            query.push_str(&format!("timestamp={timestamp}"));
432
433            if let Some(recv_window) = self.recv_window {
434                query.push_str(&format!("&recvWindow={recv_window}"));
435            }
436
437            let signature = cred.sign(&query);
438            query.push_str(&format!("&signature={signature}"));
439            headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
440        }
441
442        let url = self.build_url(path, &query);
443        let keys = self.rate_limit_keys(use_order_quota);
444
445        let response = self
446            .client
447            .request(
448                method,
449                url,
450                None::<&HashMap<String, Vec<String>>>,
451                Some(headers),
452                None,
453                None,
454                Some(keys),
455            )
456            .await?;
457
458        if !response.status.is_success() {
459            return self.parse_error_response(response);
460        }
461
462        Ok(response.body.to_vec())
463    }
464
465    fn build_url(&self, path: &str, query: &str) -> String {
466        let normalized_path = if path.starts_with('/') {
467            path.to_string()
468        } else {
469            format!("/{path}")
470        };
471
472        let mut url = format!("{}{}{}", self.base_url, SPOT_API_PATH, normalized_path);
473        if !query.is_empty() {
474            url.push('?');
475            url.push_str(query);
476        }
477        url
478    }
479
480    fn rate_limit_keys(&self, use_orders: bool) -> Vec<String> {
481        if use_orders {
482            let mut keys = Vec::with_capacity(1 + self.order_rate_keys.len());
483            keys.push(BINANCE_GLOBAL_RATE_KEY.to_string());
484            keys.extend(self.order_rate_keys.iter().cloned());
485            keys
486        } else {
487            vec![BINANCE_GLOBAL_RATE_KEY.to_string()]
488        }
489    }
490
491    fn parse_error_response<T>(&self, response: HttpResponse) -> BinanceSpotHttpResult<T> {
492        let status = response.status.as_u16();
493        let body_hex = hex::encode(&response.body);
494
495        // Binance may return JSON errors even when SBE was requested
496        if let Ok(body_str) = std::str::from_utf8(&response.body)
497            && let Ok(err) = serde_json::from_str::<BinanceErrorResponse>(body_str)
498        {
499            return Err(BinanceSpotHttpError::BinanceError {
500                code: err.code,
501                message: err.msg,
502            });
503        }
504
505        Err(BinanceSpotHttpError::UnexpectedStatus {
506            status,
507            body: body_hex,
508        })
509    }
510
511    fn default_headers(credential: &Option<Credential>) -> HashMap<String, String> {
512        let mut headers = HashMap::new();
513        headers.insert("User-Agent".to_string(), NAUTILUS_USER_AGENT.to_string());
514        headers.insert("Accept".to_string(), "application/sbe".to_string());
515        headers.insert("X-MBX-SBE".to_string(), SBE_SCHEMA_HEADER.to_string());
516        if let Some(cred) = credential {
517            headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
518        }
519        headers
520    }
521
522    fn rate_limit_config() -> RateLimitConfig {
523        let quotas = BINANCE_SPOT_RATE_LIMITS;
524        let mut keyed = Vec::new();
525        let mut order_keys = Vec::new();
526        let mut default = None;
527
528        for quota in quotas {
529            if let Some(q) = Self::quota_from(quota) {
530                if quota.rate_limit_type == "REQUEST_WEIGHT" && default.is_none() {
531                    default = Some(q);
532                } else if quota.rate_limit_type == "ORDERS" {
533                    let key = format!("{}:{}", BINANCE_ORDERS_RATE_KEY, quota.interval);
534                    order_keys.push(key.clone());
535                    keyed.push((key, q));
536                }
537            }
538        }
539
540        let default_quota =
541            default.unwrap_or_else(|| Quota::per_second(NonZeroU32::new(10).unwrap()));
542
543        keyed.push((BINANCE_GLOBAL_RATE_KEY.to_string(), default_quota));
544
545        RateLimitConfig {
546            default_quota: Some(default_quota),
547            keyed_quotas: keyed,
548            order_keys,
549        }
550    }
551
552    fn quota_from(quota: &crate::common::consts::BinanceRateLimitQuota) -> Option<Quota> {
553        let burst = NonZeroU32::new(quota.limit)?;
554        match quota.interval {
555            "SECOND" => Some(Quota::per_second(burst)),
556            "MINUTE" => Some(Quota::per_minute(burst)),
557            "DAY" => Quota::with_period(std::time::Duration::from_secs(86_400))
558                .map(|q| q.allow_burst(burst)),
559            _ => None,
560        }
561    }
562}
563
564struct RateLimitConfig {
565    default_quota: Option<Quota>,
566    keyed_quotas: Vec<(String, Quota)>,
567    order_keys: Vec<String>,
568}
569
570/// High-level HTTP client for Binance Spot API.
571///
572/// Wraps [`BinanceRawSpotHttpClient`] and provides domain-level methods:
573/// - Simple types (ping, server_time): Pass through from raw client.
574/// - Complex types (instruments, orders): Transform to Nautilus domain types.
575#[cfg_attr(
576    feature = "python",
577    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.binance")
578)]
579pub struct BinanceSpotHttpClient {
580    inner: Arc<BinanceRawSpotHttpClient>,
581    instruments_cache: Arc<DashMap<Ustr, InstrumentAny>>,
582}
583
584impl Clone for BinanceSpotHttpClient {
585    fn clone(&self) -> Self {
586        Self {
587            inner: self.inner.clone(),
588            instruments_cache: self.instruments_cache.clone(),
589        }
590    }
591}
592
593impl Debug for BinanceSpotHttpClient {
594    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
595        f.debug_struct("BinanceSpotHttpClient")
596            .field("inner", &self.inner)
597            .field("instruments_cached", &self.instruments_cache.len())
598            .finish()
599    }
600}
601
602impl BinanceSpotHttpClient {
603    /// Creates a new Binance Spot HTTP client.
604    ///
605    /// # Errors
606    ///
607    /// Returns an error if the underlying HTTP client cannot be created.
608    pub fn new(
609        environment: BinanceEnvironment,
610        api_key: Option<String>,
611        api_secret: Option<String>,
612        base_url_override: Option<String>,
613        recv_window: Option<u64>,
614        timeout_secs: Option<u64>,
615        proxy_url: Option<String>,
616    ) -> BinanceSpotHttpResult<Self> {
617        let inner = BinanceRawSpotHttpClient::new(
618            environment,
619            api_key,
620            api_secret,
621            base_url_override,
622            recv_window,
623            timeout_secs,
624            proxy_url,
625        )?;
626
627        Ok(Self {
628            inner: Arc::new(inner),
629            instruments_cache: Arc::new(DashMap::new()),
630        })
631    }
632
633    /// Returns a reference to the inner raw client.
634    #[must_use]
635    pub fn inner(&self) -> &BinanceRawSpotHttpClient {
636        &self.inner
637    }
638
639    /// Returns the SBE schema ID.
640    #[must_use]
641    pub const fn schema_id() -> u16 {
642        SBE_SCHEMA_ID
643    }
644
645    /// Returns the SBE schema version.
646    #[must_use]
647    pub const fn schema_version() -> u16 {
648        SBE_SCHEMA_VERSION
649    }
650
651    /// Generates a timestamp for initialization.
652    fn generate_ts_init(&self) -> UnixNanos {
653        UnixNanos::from(chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64)
654    }
655
656    /// Retrieves an instrument from the cache.
657    fn instrument_from_cache(&self, symbol: Ustr) -> anyhow::Result<InstrumentAny> {
658        self.instruments_cache
659            .get(&symbol)
660            .map(|entry| entry.value().clone())
661            .ok_or_else(|| anyhow::anyhow!("Instrument {symbol} not in cache"))
662    }
663
664    /// Caches multiple instruments.
665    pub fn cache_instruments(&self, instruments: Vec<InstrumentAny>) {
666        for inst in instruments {
667            self.instruments_cache
668                .insert(inst.raw_symbol().inner(), inst);
669        }
670    }
671
672    /// Caches a single instrument.
673    pub fn cache_instrument(&self, instrument: InstrumentAny) {
674        self.instruments_cache
675            .insert(instrument.raw_symbol().inner(), instrument);
676    }
677
678    /// Gets an instrument from the cache by symbol.
679    #[must_use]
680    pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
681        self.instruments_cache
682            .get(symbol)
683            .map(|entry| entry.value().clone())
684    }
685
686    /// Tests connectivity to the API.
687    ///
688    /// # Errors
689    ///
690    /// Returns an error if the request fails or SBE decoding fails.
691    pub async fn ping(&self) -> BinanceSpotHttpResult<()> {
692        self.inner.ping().await
693    }
694
695    /// Returns the server time in **microseconds** since epoch.
696    ///
697    /// Note: SBE provides microsecond precision vs JSON's milliseconds.
698    ///
699    /// # Errors
700    ///
701    /// Returns an error if the request fails or SBE decoding fails.
702    pub async fn server_time(&self) -> BinanceSpotHttpResult<i64> {
703        self.inner.server_time().await
704    }
705
706    /// Returns exchange information including trading symbols.
707    ///
708    /// # Errors
709    ///
710    /// Returns an error if the request fails or SBE decoding fails.
711    pub async fn exchange_info(
712        &self,
713    ) -> BinanceSpotHttpResult<super::models::BinanceExchangeInfoSbe> {
714        self.inner.exchange_info().await
715    }
716
717    /// Requests Nautilus instruments for all trading symbols.
718    ///
719    /// Fetches exchange info via SBE and parses each symbol into a CurrencyPair.
720    /// Non-trading symbols are skipped with a debug log.
721    ///
722    /// # Errors
723    ///
724    /// Returns an error if the request fails or SBE decoding fails.
725    pub async fn request_instruments(&self) -> BinanceSpotHttpResult<Vec<InstrumentAny>> {
726        let info = self.exchange_info().await?;
727        let ts_init = self.generate_ts_init();
728
729        let mut instruments = Vec::with_capacity(info.symbols.len());
730        for symbol in &info.symbols {
731            match crate::common::parse::parse_spot_instrument_sbe(symbol, ts_init, ts_init) {
732                Ok(instrument) => instruments.push(instrument),
733                Err(e) => {
734                    tracing::debug!(
735                        symbol = %symbol.symbol,
736                        error = %e,
737                        "Skipping symbol during instrument parsing"
738                    );
739                }
740            }
741        }
742
743        // Cache instruments for use by other domain methods
744        self.cache_instruments(instruments.clone());
745
746        tracing::info!(count = instruments.len(), "Loaded spot instruments");
747        Ok(instruments)
748    }
749
750    /// Requests recent trades for an instrument.
751    ///
752    /// # Errors
753    ///
754    /// Returns an error if the request fails, the instrument is not cached,
755    /// or trade parsing fails.
756    pub async fn request_trades(
757        &self,
758        instrument_id: InstrumentId,
759        limit: Option<u32>,
760    ) -> anyhow::Result<Vec<TradeTick>> {
761        let symbol = instrument_id.symbol.inner();
762        let instrument = self.instrument_from_cache(symbol)?;
763        let ts_init = self.generate_ts_init();
764
765        let params = TradesParams {
766            symbol: symbol.to_string(),
767            limit,
768        };
769
770        let trades = self
771            .inner
772            .trades(&params)
773            .await
774            .map_err(|e| anyhow::anyhow!(e))?;
775
776        crate::common::parse::parse_spot_trades_sbe(&trades, &instrument, ts_init)
777    }
778
779    /// Submits a new order to the venue.
780    ///
781    /// Converts Nautilus domain types to Binance-specific parameters
782    /// and returns an `OrderStatusReport`.
783    ///
784    /// # Errors
785    ///
786    /// Returns an error if:
787    /// - The instrument is not cached.
788    /// - The order type or time-in-force is unsupported.
789    /// - Stop orders are submitted without a trigger price.
790    /// - The request fails or SBE decoding fails.
791    #[allow(clippy::too_many_arguments)]
792    pub async fn submit_order(
793        &self,
794        account_id: AccountId,
795        instrument_id: InstrumentId,
796        client_order_id: ClientOrderId,
797        order_side: OrderSide,
798        order_type: OrderType,
799        quantity: Quantity,
800        time_in_force: TimeInForce,
801        price: Option<Price>,
802        trigger_price: Option<Price>,
803        post_only: bool,
804    ) -> anyhow::Result<OrderStatusReport> {
805        let symbol = instrument_id.symbol.inner();
806        let instrument = self.instrument_from_cache(symbol)?;
807        let ts_init = self.generate_ts_init();
808
809        let binance_side = match order_side {
810            OrderSide::Buy => BinanceSide::Buy,
811            OrderSide::Sell => BinanceSide::Sell,
812            _ => anyhow::bail!("Invalid order side: {order_side:?}"),
813        };
814
815        let is_stop_order = matches!(order_type, OrderType::StopMarket | OrderType::StopLimit);
816        if is_stop_order && trigger_price.is_none() {
817            anyhow::bail!("Stop orders require a trigger price");
818        }
819
820        let binance_order_type = match (order_type, post_only) {
821            (OrderType::Market, _) => BinanceSpotOrderType::Market,
822            (OrderType::Limit, true) => BinanceSpotOrderType::LimitMaker,
823            (OrderType::Limit, false) => BinanceSpotOrderType::Limit,
824            (OrderType::StopMarket, _) => BinanceSpotOrderType::StopLoss,
825            (OrderType::StopLimit, _) => BinanceSpotOrderType::StopLossLimit,
826            _ => anyhow::bail!("Unsupported order type: {order_type:?}"),
827        };
828
829        let binance_tif = match time_in_force {
830            TimeInForce::Gtc => BinanceTimeInForce::Gtc,
831            TimeInForce::Ioc => BinanceTimeInForce::Ioc,
832            TimeInForce::Fok => BinanceTimeInForce::Fok,
833            TimeInForce::Gtd => BinanceTimeInForce::Gtd,
834            _ => anyhow::bail!("Unsupported time in force: {time_in_force:?}"),
835        };
836
837        let params = NewOrderParams {
838            symbol: symbol.to_string(),
839            side: binance_side,
840            order_type: binance_order_type,
841            time_in_force: Some(binance_tif),
842            quantity: Some(quantity.to_string()),
843            quote_order_qty: None,
844            price: price.map(|p| p.to_string()),
845            new_client_order_id: Some(client_order_id.to_string()),
846            stop_price: trigger_price.map(|p| p.to_string()),
847            trailing_delta: None,
848            iceberg_qty: None,
849            new_order_resp_type: None,
850            self_trade_prevention_mode: None,
851        };
852
853        let response = self
854            .inner
855            .new_order(&params)
856            .await
857            .map_err(|e| anyhow::anyhow!(e))?;
858
859        crate::common::parse::parse_new_order_response_sbe(
860            &response,
861            account_id,
862            &instrument,
863            ts_init,
864        )
865    }
866
867    /// Cancels an existing order on the venue by venue order ID.
868    ///
869    /// # Errors
870    ///
871    /// Returns an error if the request fails or SBE decoding fails.
872    pub async fn cancel_order(
873        &self,
874        instrument_id: InstrumentId,
875        venue_order_id: VenueOrderId,
876    ) -> anyhow::Result<VenueOrderId> {
877        let symbol = instrument_id.symbol.inner().to_string();
878
879        let order_id: i64 = venue_order_id
880            .inner()
881            .parse()
882            .map_err(|_| anyhow::anyhow!("Invalid venue order ID: {venue_order_id}"))?;
883
884        let params = CancelOrderParams::by_order_id(&symbol, order_id);
885
886        let response = self
887            .inner
888            .cancel_order(&params)
889            .await
890            .map_err(|e| anyhow::anyhow!(e))?;
891
892        Ok(VenueOrderId::new(response.order_id.to_string()))
893    }
894
895    /// Cancels an existing order on the venue by client order ID.
896    ///
897    /// # Errors
898    ///
899    /// Returns an error if the request fails or SBE decoding fails.
900    pub async fn cancel_order_by_client_id(
901        &self,
902        instrument_id: InstrumentId,
903        client_order_id: ClientOrderId,
904    ) -> anyhow::Result<VenueOrderId> {
905        let symbol = instrument_id.symbol.inner().to_string();
906        let params = CancelOrderParams::by_client_order_id(&symbol, client_order_id.to_string());
907
908        let response = self
909            .inner
910            .cancel_order(&params)
911            .await
912            .map_err(|e| anyhow::anyhow!(e))?;
913
914        Ok(VenueOrderId::new(response.order_id.to_string()))
915    }
916
917    /// Cancels all open orders for a symbol.
918    ///
919    /// Returns the venue order IDs of all canceled orders.
920    ///
921    /// # Errors
922    ///
923    /// Returns an error if the request fails or SBE decoding fails.
924    pub async fn cancel_all_orders(
925        &self,
926        instrument_id: InstrumentId,
927    ) -> anyhow::Result<Vec<VenueOrderId>> {
928        let symbol = instrument_id.symbol.inner().to_string();
929        let params = CancelOpenOrdersParams::new(symbol);
930
931        let responses = self
932            .inner
933            .cancel_open_orders(&params)
934            .await
935            .map_err(|e| anyhow::anyhow!(e))?;
936
937        Ok(responses
938            .into_iter()
939            .map(|r| VenueOrderId::new(r.order_id.to_string()))
940            .collect())
941    }
942
943    /// Modifies an existing order (cancel and replace atomically).
944    ///
945    /// # Errors
946    ///
947    /// Returns an error if:
948    /// - The instrument is not cached.
949    /// - The order type or time-in-force is unsupported.
950    /// - The request fails or SBE decoding fails.
951    #[allow(clippy::too_many_arguments)]
952    pub async fn modify_order(
953        &self,
954        account_id: AccountId,
955        instrument_id: InstrumentId,
956        venue_order_id: VenueOrderId,
957        client_order_id: ClientOrderId,
958        order_side: OrderSide,
959        order_type: OrderType,
960        quantity: Quantity,
961        time_in_force: TimeInForce,
962        price: Option<Price>,
963    ) -> anyhow::Result<OrderStatusReport> {
964        let symbol = instrument_id.symbol.inner();
965        let instrument = self.instrument_from_cache(symbol)?;
966        let ts_init = self.generate_ts_init();
967
968        let binance_side = match order_side {
969            OrderSide::Buy => BinanceSide::Buy,
970            OrderSide::Sell => BinanceSide::Sell,
971            _ => anyhow::bail!("Invalid order side: {order_side:?}"),
972        };
973
974        let binance_order_type = match order_type {
975            OrderType::Market => BinanceSpotOrderType::Market,
976            OrderType::Limit => BinanceSpotOrderType::Limit,
977            _ => anyhow::bail!("Unsupported order type for modify: {order_type:?}"),
978        };
979
980        let binance_tif = match time_in_force {
981            TimeInForce::Gtc => BinanceTimeInForce::Gtc,
982            TimeInForce::Ioc => BinanceTimeInForce::Ioc,
983            TimeInForce::Fok => BinanceTimeInForce::Fok,
984            TimeInForce::Gtd => BinanceTimeInForce::Gtd,
985            _ => anyhow::bail!("Unsupported time in force: {time_in_force:?}"),
986        };
987
988        let cancel_order_id: i64 = venue_order_id
989            .inner()
990            .parse()
991            .map_err(|_| anyhow::anyhow!("Invalid venue order ID: {venue_order_id}"))?;
992
993        let params = CancelReplaceOrderParams {
994            symbol: symbol.to_string(),
995            side: binance_side,
996            order_type: binance_order_type,
997            cancel_replace_mode: crate::spot::enums::BinanceCancelReplaceMode::StopOnFailure,
998            time_in_force: Some(binance_tif),
999            quantity: Some(quantity.to_string()),
1000            quote_order_qty: None,
1001            price: price.map(|p| p.to_string()),
1002            cancel_order_id: Some(cancel_order_id),
1003            cancel_orig_client_order_id: None,
1004            new_client_order_id: Some(client_order_id.to_string()),
1005            stop_price: None,
1006            trailing_delta: None,
1007            iceberg_qty: None,
1008            new_order_resp_type: None,
1009            self_trade_prevention_mode: None,
1010        };
1011
1012        let response = self
1013            .inner
1014            .cancel_replace_order(&params)
1015            .await
1016            .map_err(|e| anyhow::anyhow!(e))?;
1017
1018        crate::common::parse::parse_new_order_response_sbe(
1019            &response,
1020            account_id,
1021            &instrument,
1022            ts_init,
1023        )
1024    }
1025
1026    /// Requests the status of a specific order.
1027    ///
1028    /// # Errors
1029    ///
1030    /// Returns an error if the request fails, instrument is not cached,
1031    /// or parsing fails.
1032    pub async fn request_order_status(
1033        &self,
1034        account_id: AccountId,
1035        instrument_id: InstrumentId,
1036        params: &QueryOrderParams,
1037    ) -> anyhow::Result<OrderStatusReport> {
1038        let symbol = instrument_id.symbol.inner();
1039        let instrument = self.instrument_from_cache(symbol)?;
1040        let ts_init = self.generate_ts_init();
1041
1042        let order = self
1043            .inner
1044            .query_order(params)
1045            .await
1046            .map_err(|e| anyhow::anyhow!(e))?;
1047
1048        crate::common::parse::parse_order_status_report_sbe(
1049            &order,
1050            account_id,
1051            &instrument,
1052            ts_init,
1053        )
1054    }
1055
1056    /// Requests all open orders for a symbol or all symbols.
1057    ///
1058    /// # Errors
1059    ///
1060    /// Returns an error if the request fails, any order's instrument is not cached,
1061    /// or parsing fails.
1062    pub async fn request_open_orders(
1063        &self,
1064        account_id: AccountId,
1065        params: &OpenOrdersParams,
1066    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1067        let ts_init = self.generate_ts_init();
1068
1069        let orders = self
1070            .inner
1071            .open_orders(params)
1072            .await
1073            .map_err(|e| anyhow::anyhow!(e))?;
1074
1075        orders
1076            .iter()
1077            .map(|order| {
1078                let symbol = Ustr::from(&order.symbol);
1079                let instrument = self.instrument_from_cache(symbol)?;
1080                crate::common::parse::parse_order_status_report_sbe(
1081                    order,
1082                    account_id,
1083                    &instrument,
1084                    ts_init,
1085                )
1086            })
1087            .collect()
1088    }
1089
1090    /// Requests order history (including closed orders) for a symbol.
1091    ///
1092    /// # Errors
1093    ///
1094    /// Returns an error if the request fails, any order's instrument is not cached,
1095    /// or parsing fails.
1096    pub async fn request_order_history(
1097        &self,
1098        account_id: AccountId,
1099        params: &AllOrdersParams,
1100    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1101        let ts_init = self.generate_ts_init();
1102
1103        let orders = self
1104            .inner
1105            .all_orders(params)
1106            .await
1107            .map_err(|e| anyhow::anyhow!(e))?;
1108
1109        orders
1110            .iter()
1111            .map(|order| {
1112                let symbol = Ustr::from(&order.symbol);
1113                let instrument = self.instrument_from_cache(symbol)?;
1114                crate::common::parse::parse_order_status_report_sbe(
1115                    order,
1116                    account_id,
1117                    &instrument,
1118                    ts_init,
1119                )
1120            })
1121            .collect()
1122    }
1123
1124    /// Requests account state including balances.
1125    ///
1126    /// # Errors
1127    ///
1128    /// Returns an error if the request fails or SBE decoding fails.
1129    pub async fn request_account_state(
1130        &self,
1131        params: &AccountInfoParams,
1132    ) -> BinanceSpotHttpResult<BinanceAccountInfo> {
1133        self.inner.account(params).await
1134    }
1135
1136    /// Requests fill reports (trade history) for a symbol.
1137    ///
1138    /// # Errors
1139    ///
1140    /// Returns an error if the request fails, any trade's instrument is not cached,
1141    /// or parsing fails.
1142    pub async fn request_fill_reports(
1143        &self,
1144        account_id: AccountId,
1145        params: &AccountTradesParams,
1146    ) -> anyhow::Result<Vec<FillReport>> {
1147        let ts_init = self.generate_ts_init();
1148
1149        let trades = self
1150            .inner
1151            .account_trades(params)
1152            .await
1153            .map_err(|e| anyhow::anyhow!(e))?;
1154
1155        trades
1156            .iter()
1157            .map(|trade| {
1158                let symbol = Ustr::from(&trade.symbol);
1159                let instrument = self.instrument_from_cache(symbol)?;
1160                let commission_currency =
1161                    crate::common::parse::get_currency(&trade.commission_asset);
1162                crate::common::parse::parse_fill_report_sbe(
1163                    trade,
1164                    account_id,
1165                    &instrument,
1166                    commission_currency,
1167                    ts_init,
1168                )
1169            })
1170            .collect()
1171    }
1172}
1173
1174#[cfg(test)]
1175mod tests {
1176    use rstest::rstest;
1177
1178    use super::*;
1179
1180    #[rstest]
1181    fn test_schema_constants() {
1182        assert_eq!(BinanceRawSpotHttpClient::schema_id(), 3);
1183        assert_eq!(BinanceRawSpotHttpClient::schema_version(), 2);
1184        assert_eq!(BinanceSpotHttpClient::schema_id(), 3);
1185        assert_eq!(BinanceSpotHttpClient::schema_version(), 2);
1186    }
1187
1188    #[rstest]
1189    fn test_sbe_schema_header() {
1190        assert_eq!(SBE_SCHEMA_HEADER, "3:2");
1191    }
1192
1193    #[rstest]
1194    fn test_default_headers_include_sbe() {
1195        let headers = BinanceRawSpotHttpClient::default_headers(&None);
1196
1197        assert_eq!(headers.get("Accept"), Some(&"application/sbe".to_string()));
1198        assert_eq!(headers.get("X-MBX-SBE"), Some(&"3:2".to_string()));
1199    }
1200
1201    #[rstest]
1202    fn test_rate_limit_config() {
1203        let config = BinanceRawSpotHttpClient::rate_limit_config();
1204
1205        assert!(config.default_quota.is_some());
1206        // Spot has 2 ORDERS quotas (SECOND and DAY)
1207        assert_eq!(config.order_keys.len(), 2);
1208    }
1209}