Skip to main content

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:4` (schema ID:version)
33
34use std::{collections::HashMap, fmt::Debug, num::NonZeroU32, sync::Arc};
35
36use chrono::{DateTime, Utc};
37use dashmap::DashMap;
38use nautilus_common::cache::InstrumentLookupError;
39use nautilus_core::{
40    consts::NAUTILUS_USER_AGENT, datetime::SECONDS_IN_DAY, hex, nanos::UnixNanos, time::AtomicTime,
41};
42use nautilus_model::{
43    data::{Bar, BarType, TradeTick},
44    enums::{AggregationSource, BarAggregation, OrderSide, OrderType, TimeInForce},
45    events::AccountState,
46    identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
47    instruments::{Instrument, any::InstrumentAny},
48    reports::{FillReport, OrderStatusReport},
49    types::{Price, Quantity},
50};
51use nautilus_network::{
52    http::{HttpClient, HttpResponse, Method, USER_AGENT},
53    ratelimiter::quota::Quota,
54};
55use serde::Serialize;
56use ustr::Ustr;
57
58use super::{
59    error::{BinanceSpotHttpError, BinanceSpotHttpResult},
60    models::{
61        AvgPrice, BatchCancelResult, BatchOrderResult, BinanceAccountInfo, BinanceAccountTrade,
62        BinanceCancelOrderResponse, BinanceDepth, BinanceKlines, BinanceNewOrderResponse,
63        BinanceOrderResponse, BinanceTrades, BookTicker, ListenKeyResponse,
64        NewOcoOrderListResponse, Ticker24hr, TickerPrice, TradeFee,
65    },
66    parse,
67    query::{
68        AccountInfoParams, AccountTradesParams, AllOrdersParams, AvgPriceParams, BatchCancelItem,
69        BatchOrderItem, CancelOpenOrdersParams, CancelOrderParams, CancelReplaceOrderParams,
70        DepthParams, KlinesParams, ListenKeyParams, NewOcoOrderListParams, NewOrderParams,
71        OpenOrdersParams, QueryOrderParams, TickerParams, TradeFeeParams, TradesParams,
72    },
73};
74use crate::{
75    common::{
76        consts::{
77            BINANCE_API_KEY_HEADER, BINANCE_NAUTILUS_SPOT_BROKER_ID, BINANCE_NO_SUCH_ORDER_CODE,
78            BINANCE_SPOT_RATE_LIMITS, BinanceRateLimitQuota,
79        },
80        credential::SigningCredential,
81        encoder::{decode_broker_id, encode_broker_id},
82        enums::{
83            BinanceEnvironment, BinanceProductType, BinanceRateLimitInterval, BinanceRateLimitType,
84            BinanceSide, BinanceTimeInForce,
85        },
86        models::BinanceErrorResponse,
87        parse::{
88            get_currency, parse_fill_report_sbe, parse_klines_to_bars,
89            parse_new_order_response_sbe, parse_order_status_report_sbe, parse_spot_instrument_sbe,
90            parse_spot_trades_sbe,
91        },
92        urls::get_http_base_url,
93    },
94    spot::{
95        enums::{
96            BinanceCancelReplaceMode, BinanceOrderResponseType, BinanceSpotOrderType,
97            order_type_to_binance_spot, time_in_force_to_binance_spot,
98        },
99        sbe::spot::{
100            ReadBuf, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION,
101            error_response_codec::{self, ErrorResponseDecoder},
102            message_header_codec::MessageHeaderDecoder,
103        },
104    },
105};
106
107/// SBE schema header value for Spot API.
108pub const SBE_SCHEMA_HEADER: &str = "3:4";
109
110use crate::common::consts::BINANCE_SPOT_API_PATH as SPOT_API_PATH;
111
112/// Global rate limit key.
113const BINANCE_GLOBAL_RATE_KEY: &str = "binance:spot:global";
114
115/// Orders rate limit key prefix.
116const BINANCE_ORDERS_RATE_KEY: &str = "binance:spot:orders";
117
118struct RateLimitConfig {
119    default_quota: Option<Quota>,
120    keyed_quotas: Vec<(String, Quota)>,
121    order_keys: Vec<String>,
122}
123
124/// Low-level HTTP client for Binance Spot REST API with SBE encoding.
125///
126/// Handles:
127/// - Base URL resolution by environment.
128/// - Optional HMAC SHA256 signing for private endpoints.
129/// - Rate limiting using Spot API quotas.
130/// - SBE decoding to Binance-specific response types.
131///
132/// Methods are named to match Binance API endpoints and return
133/// venue-specific types (decoded from SBE).
134#[derive(Debug, Clone)]
135pub struct BinanceRawSpotHttpClient {
136    client: HttpClient,
137    base_url: String,
138    credential: Option<SigningCredential>,
139    recv_window: Option<u64>,
140    order_rate_keys: Vec<String>,
141}
142
143impl BinanceRawSpotHttpClient {
144    /// Creates a new Binance Spot raw HTTP client.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if the underlying [`HttpClient`] fails to build.
149    pub fn new(
150        environment: BinanceEnvironment,
151        api_key: Option<String>,
152        api_secret: Option<String>,
153        base_url_override: Option<String>,
154        recv_window: Option<u64>,
155        timeout_secs: Option<u64>,
156        proxy_url: Option<String>,
157    ) -> BinanceSpotHttpResult<Self> {
158        let RateLimitConfig {
159            default_quota,
160            keyed_quotas,
161            order_keys,
162        } = Self::rate_limit_config();
163
164        let credential = match (api_key, api_secret) {
165            (Some(key), Some(secret)) => Some(SigningCredential::new(key, secret)),
166            (None, None) => None,
167            _ => return Err(BinanceSpotHttpError::MissingCredentials),
168        };
169
170        let base_url = base_url_override.unwrap_or_else(|| {
171            get_http_base_url(BinanceProductType::Spot, environment).to_string()
172        });
173
174        let headers = Self::default_headers(&credential);
175
176        let client = HttpClient::new(
177            headers,
178            vec![BINANCE_API_KEY_HEADER.to_string()],
179            keyed_quotas,
180            default_quota,
181            timeout_secs,
182            proxy_url,
183        )?;
184
185        Ok(Self {
186            client,
187            base_url,
188            credential,
189            recv_window,
190            order_rate_keys: order_keys,
191        })
192    }
193
194    /// Returns the SBE schema ID.
195    #[must_use]
196    pub const fn schema_id() -> u16 {
197        SBE_SCHEMA_ID
198    }
199
200    /// Returns the SBE schema version.
201    #[must_use]
202    pub const fn schema_version() -> u16 {
203        SBE_SCHEMA_VERSION
204    }
205
206    /// Performs a GET request and returns raw response bytes.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the request fails.
211    pub async fn get<P>(&self, path: &str, params: Option<&P>) -> BinanceSpotHttpResult<Vec<u8>>
212    where
213        P: Serialize + ?Sized,
214    {
215        self.request(Method::GET, path, params, false, false).await
216    }
217
218    /// Performs a signed GET request and returns raw response bytes.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if credentials are missing or the request fails.
223    pub async fn get_signed<P>(
224        &self,
225        path: &str,
226        params: Option<&P>,
227    ) -> BinanceSpotHttpResult<Vec<u8>>
228    where
229        P: Serialize + ?Sized,
230    {
231        self.request(Method::GET, path, params, true, false).await
232    }
233
234    /// Performs a signed POST request and returns raw response bytes.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if credentials are missing or the request fails.
239    pub async fn post_signed<P>(
240        &self,
241        path: &str,
242        params: Option<&P>,
243    ) -> BinanceSpotHttpResult<Vec<u8>>
244    where
245        P: Serialize + ?Sized,
246    {
247        self.request(Method::POST, path, params, true, true).await
248    }
249
250    /// Performs a signed POST request and requests a JSON response.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if credentials are missing or the request fails.
255    pub async fn post_signed_json<P>(
256        &self,
257        path: &str,
258        params: Option<&P>,
259    ) -> BinanceSpotHttpResult<Vec<u8>>
260    where
261        P: Serialize + ?Sized,
262    {
263        self.request_with_extra_headers(
264            Method::POST,
265            path,
266            params,
267            true,
268            true,
269            Some(HashMap::from([(
270                "Accept".to_string(),
271                "application/json".to_string(),
272            )])),
273        )
274        .await
275    }
276
277    /// Performs a signed DELETE request and returns raw response bytes.
278    ///
279    /// # Errors
280    ///
281    /// Returns an error if credentials are missing or the request fails.
282    pub async fn delete_signed<P>(
283        &self,
284        path: &str,
285        params: Option<&P>,
286    ) -> BinanceSpotHttpResult<Vec<u8>>
287    where
288        P: Serialize + ?Sized,
289    {
290        self.request(Method::DELETE, path, params, true, true).await
291    }
292
293    async fn request<P>(
294        &self,
295        method: Method,
296        path: &str,
297        params: Option<&P>,
298        signed: bool,
299        use_order_quota: bool,
300    ) -> BinanceSpotHttpResult<Vec<u8>>
301    where
302        P: Serialize + ?Sized,
303    {
304        self.request_with_extra_headers(method, path, params, signed, use_order_quota, None)
305            .await
306    }
307
308    async fn request_with_extra_headers<P>(
309        &self,
310        method: Method,
311        path: &str,
312        params: Option<&P>,
313        signed: bool,
314        use_order_quota: bool,
315        extra_headers: Option<HashMap<String, String>>,
316    ) -> BinanceSpotHttpResult<Vec<u8>>
317    where
318        P: Serialize + ?Sized,
319    {
320        let mut query = params
321            .map(serde_urlencoded::to_string)
322            .transpose()
323            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?
324            .unwrap_or_default();
325
326        let mut headers = extra_headers.unwrap_or_default();
327
328        if signed {
329            let cred = self
330                .credential
331                .as_ref()
332                .ok_or(BinanceSpotHttpError::MissingCredentials)?;
333
334            if !query.is_empty() {
335                query.push('&');
336            }
337
338            let timestamp = Utc::now().timestamp_millis();
339            query.push_str(&format!("timestamp={timestamp}"));
340
341            if let Some(recv_window) = self.recv_window {
342                query.push_str(&format!("&recvWindow={recv_window}"));
343            }
344
345            let signature = Self::percent_encode(&cred.sign(&query));
346            query.push_str(&format!("&signature={signature}"));
347            headers.insert(
348                BINANCE_API_KEY_HEADER.to_string(),
349                cred.api_key().to_string(),
350            );
351        }
352
353        let url = self.build_url(path, &query);
354        let keys = self.rate_limit_keys(use_order_quota);
355
356        let response = self
357            .client
358            .request(
359                method,
360                url,
361                None::<&HashMap<String, Vec<String>>>,
362                Some(headers),
363                None,
364                None,
365                Some(keys),
366            )
367            .await?;
368
369        if !response.status.is_success() {
370            return self.parse_error_response(&response);
371        }
372
373        Ok(response.body.to_vec())
374    }
375
376    fn build_url(&self, path: &str, query: &str) -> String {
377        let normalized_path = if path.starts_with('/') {
378            path.to_string()
379        } else {
380            format!("/{path}")
381        };
382
383        let mut url = format!("{}{}{}", self.base_url, SPOT_API_PATH, normalized_path);
384
385        if !query.is_empty() {
386            url.push('?');
387            url.push_str(query);
388        }
389        url
390    }
391
392    fn rate_limit_keys(&self, use_orders: bool) -> Vec<String> {
393        if use_orders {
394            let mut keys = Vec::with_capacity(1 + self.order_rate_keys.len());
395            keys.push(BINANCE_GLOBAL_RATE_KEY.to_string());
396            keys.extend(self.order_rate_keys.iter().cloned());
397            keys
398        } else {
399            vec![BINANCE_GLOBAL_RATE_KEY.to_string()]
400        }
401    }
402
403    fn parse_error_response<T>(&self, response: &HttpResponse) -> BinanceSpotHttpResult<T> {
404        let status = response.status.as_u16();
405        let body = &response.body;
406
407        // Binance may return JSON errors even when SBE was requested
408        if let Ok(body_str) = std::str::from_utf8(body)
409            && let Ok(err) = serde_json::from_str::<BinanceErrorResponse>(body_str)
410        {
411            return Err(BinanceSpotHttpError::BinanceError {
412                code: err.code,
413                message: err.msg,
414            });
415        }
416
417        // Try to decode SBE error response
418        if let Some((code, message)) = Self::try_decode_sbe_error(body) {
419            return Err(BinanceSpotHttpError::BinanceError {
420                code: code.into(),
421                message,
422            });
423        }
424
425        Err(BinanceSpotHttpError::UnexpectedStatus {
426            status,
427            body: hex::encode(body),
428        })
429    }
430
431    /// Attempts to decode an SBE error response.
432    ///
433    /// Returns Some((code, message)) if successfully decoded, None otherwise.
434    fn try_decode_sbe_error(body: &[u8]) -> Option<(i16, String)> {
435        const HEADER_LEN: usize = 8;
436        if body.len() < HEADER_LEN + error_response_codec::SBE_BLOCK_LENGTH as usize {
437            return None;
438        }
439
440        let buf = ReadBuf::new(body);
441
442        // Decode message header
443        let header = MessageHeaderDecoder::default().wrap(buf, 0);
444        if header.template_id() != error_response_codec::SBE_TEMPLATE_ID {
445            return None;
446        }
447
448        // Decode error response
449        let mut decoder = ErrorResponseDecoder::default().header(header, 0);
450        let code = decoder.code();
451
452        // Decode the message string (VAR_DATA with 2-byte length prefix)
453        let msg_coords = decoder.msg_decoder();
454        let msg_bytes = decoder.msg_slice(msg_coords);
455        let message = String::from_utf8_lossy(msg_bytes).into_owned();
456
457        Some((code, message))
458    }
459
460    fn default_headers(credential: &Option<SigningCredential>) -> HashMap<String, String> {
461        let mut headers = HashMap::new();
462        headers.insert(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string());
463        headers.insert("Accept".to_string(), "application/sbe".to_string());
464        headers.insert("X-MBX-SBE".to_string(), SBE_SCHEMA_HEADER.to_string());
465
466        if let Some(cred) = credential {
467            headers.insert(
468                BINANCE_API_KEY_HEADER.to_string(),
469                cred.api_key().to_string(),
470            );
471        }
472        headers
473    }
474
475    fn rate_limit_config() -> RateLimitConfig {
476        let quotas = BINANCE_SPOT_RATE_LIMITS;
477        let mut keyed = Vec::new();
478        let mut order_keys = Vec::new();
479        let mut default = None;
480
481        for quota in quotas {
482            if let Some(q) = Self::quota_from(quota) {
483                match quota.rate_limit_type {
484                    BinanceRateLimitType::RequestWeight if default.is_none() => {
485                        default = Some(q);
486                    }
487                    BinanceRateLimitType::Orders => {
488                        let key = format!("{}:{:?}", BINANCE_ORDERS_RATE_KEY, quota.interval);
489                        order_keys.push(key.clone());
490                        keyed.push((key, q));
491                    }
492                    _ => {}
493                }
494            }
495        }
496
497        let default_quota = default.unwrap_or_else(|| {
498            Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant")
499        });
500
501        keyed.push((BINANCE_GLOBAL_RATE_KEY.to_string(), default_quota));
502
503        RateLimitConfig {
504            default_quota: Some(default_quota),
505            keyed_quotas: keyed,
506            order_keys,
507        }
508    }
509
510    fn quota_from(quota: &BinanceRateLimitQuota) -> Option<Quota> {
511        let burst = NonZeroU32::new(quota.limit)?;
512        match quota.interval {
513            BinanceRateLimitInterval::Second => Quota::per_second(burst),
514            BinanceRateLimitInterval::Minute => Some(Quota::per_minute(burst)),
515            BinanceRateLimitInterval::Day => {
516                Quota::with_period(std::time::Duration::from_secs(SECONDS_IN_DAY))
517                    .map(|q| q.allow_burst(burst))
518            }
519            BinanceRateLimitInterval::Unknown => None,
520        }
521    }
522
523    /// Tests connectivity to the API.
524    ///
525    /// # Errors
526    ///
527    /// Returns an error if the request fails or SBE decoding fails.
528    pub async fn ping(&self) -> BinanceSpotHttpResult<()> {
529        let bytes = self.get("ping", None::<&()>).await?;
530        parse::decode_ping(&bytes)?;
531        Ok(())
532    }
533
534    /// Returns the server time in **microseconds** since epoch.
535    ///
536    /// Note: SBE provides microsecond precision vs JSON's milliseconds.
537    ///
538    /// # Errors
539    ///
540    /// Returns an error if the request fails or SBE decoding fails.
541    pub async fn server_time(&self) -> BinanceSpotHttpResult<i64> {
542        let bytes = self.get("time", None::<&()>).await?;
543        let timestamp = parse::decode_server_time(&bytes)?;
544        Ok(timestamp)
545    }
546
547    /// Returns exchange information including trading symbols.
548    ///
549    /// # Errors
550    ///
551    /// Returns an error if the request fails or SBE decoding fails.
552    pub async fn exchange_info(
553        &self,
554    ) -> BinanceSpotHttpResult<super::models::BinanceExchangeInfoSbe> {
555        let bytes = self.get("exchangeInfo", None::<&()>).await?;
556        let info = parse::decode_exchange_info(&bytes)?;
557        Ok(info)
558    }
559
560    /// Returns order book depth for a symbol.
561    ///
562    /// # Errors
563    ///
564    /// Returns an error if the request fails or SBE decoding fails.
565    pub async fn depth(&self, params: &DepthParams) -> BinanceSpotHttpResult<BinanceDepth> {
566        let bytes = self.get("depth", Some(params)).await?;
567        let depth = parse::decode_depth(&bytes)?;
568        Ok(depth)
569    }
570
571    /// Returns recent trades for a symbol.
572    ///
573    /// # Errors
574    ///
575    /// Returns an error if the request fails or SBE decoding fails.
576    pub async fn trades(
577        &self,
578        symbol: &str,
579        limit: Option<u32>,
580    ) -> BinanceSpotHttpResult<BinanceTrades> {
581        let params = TradesParams {
582            symbol: symbol.to_string(),
583            limit,
584        };
585        let bytes = self.get("trades", Some(&params)).await?;
586        let trades = parse::decode_trades(&bytes)?;
587        Ok(trades)
588    }
589
590    /// Returns kline (candlestick) data for a symbol.
591    ///
592    /// # Errors
593    ///
594    /// Returns an error if the request fails or SBE decoding fails.
595    pub async fn klines(
596        &self,
597        symbol: &str,
598        interval: &str,
599        start_time: Option<i64>,
600        end_time: Option<i64>,
601        limit: Option<u32>,
602    ) -> BinanceSpotHttpResult<BinanceKlines> {
603        let params = KlinesParams {
604            symbol: symbol.to_string(),
605            interval: interval.to_string(),
606            start_time,
607            end_time,
608            time_zone: None,
609            limit,
610        };
611        let bytes = self.get("klines", Some(&params)).await?;
612        let klines = parse::decode_klines(&bytes)?;
613        Ok(klines)
614    }
615
616    /// Performs a public GET request that returns JSON.
617    async fn get_json<P>(&self, path: &str, params: Option<&P>) -> BinanceSpotHttpResult<Vec<u8>>
618    where
619        P: Serialize + ?Sized,
620    {
621        let query = params
622            .map(serde_urlencoded::to_string)
623            .transpose()
624            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?
625            .unwrap_or_default();
626
627        let url = self.build_url(path, &query);
628        let keys = vec![BINANCE_GLOBAL_RATE_KEY.to_string()];
629
630        let response = self
631            .client
632            .request(
633                Method::GET,
634                url,
635                None::<&HashMap<String, Vec<String>>>,
636                None,
637                None,
638                None,
639                Some(keys),
640            )
641            .await?;
642
643        if !response.status.is_success() {
644            return self.parse_error_response(&response);
645        }
646
647        Ok(response.body.to_vec())
648    }
649
650    /// Returns 24-hour ticker price change statistics.
651    ///
652    /// If `symbol` is None, returns statistics for all symbols.
653    ///
654    /// # Errors
655    ///
656    /// Returns an error if the request fails.
657    pub async fn ticker_24hr(
658        &self,
659        symbol: Option<&str>,
660    ) -> BinanceSpotHttpResult<Vec<Ticker24hr>> {
661        let params = symbol.map(TickerParams::for_symbol);
662        let bytes = self.get_json("ticker/24hr", params.as_ref()).await?;
663
664        // Single symbol returns object, multiple returns array
665        if symbol.is_some() {
666            let ticker: Ticker24hr = serde_json::from_slice(&bytes)
667                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
668            Ok(vec![ticker])
669        } else {
670            let tickers: Vec<Ticker24hr> = serde_json::from_slice(&bytes)
671                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
672            Ok(tickers)
673        }
674    }
675
676    /// Returns latest price for a symbol or all symbols.
677    ///
678    /// If `symbol` is None, returns prices for all symbols.
679    ///
680    /// # Errors
681    ///
682    /// Returns an error if the request fails.
683    pub async fn ticker_price(
684        &self,
685        symbol: Option<&str>,
686    ) -> BinanceSpotHttpResult<Vec<TickerPrice>> {
687        let params = symbol.map(TickerParams::for_symbol);
688        let bytes = self.get_json("ticker/price", params.as_ref()).await?;
689
690        // Single symbol returns object, multiple returns array
691        if symbol.is_some() {
692            let ticker: TickerPrice = serde_json::from_slice(&bytes)
693                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
694            Ok(vec![ticker])
695        } else {
696            let tickers: Vec<TickerPrice> = serde_json::from_slice(&bytes)
697                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
698            Ok(tickers)
699        }
700    }
701
702    /// Returns best bid/ask price for a symbol or all symbols.
703    ///
704    /// If `symbol` is None, returns book ticker for all symbols.
705    ///
706    /// # Errors
707    ///
708    /// Returns an error if the request fails.
709    pub async fn ticker_book(
710        &self,
711        symbol: Option<&str>,
712    ) -> BinanceSpotHttpResult<Vec<BookTicker>> {
713        let params = symbol.map(TickerParams::for_symbol);
714        let bytes = self.get_json("ticker/bookTicker", params.as_ref()).await?;
715
716        // Single symbol returns object, multiple returns array
717        if symbol.is_some() {
718            let ticker: BookTicker = serde_json::from_slice(&bytes)
719                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
720            Ok(vec![ticker])
721        } else {
722            let tickers: Vec<BookTicker> = serde_json::from_slice(&bytes)
723                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
724            Ok(tickers)
725        }
726    }
727
728    /// Returns current average price for a symbol.
729    ///
730    /// # Errors
731    ///
732    /// Returns an error if the request fails.
733    pub async fn avg_price(&self, symbol: &str) -> BinanceSpotHttpResult<AvgPrice> {
734        let params = AvgPriceParams::new(symbol);
735        let bytes = self.get_json("avgPrice", Some(&params)).await?;
736
737        let avg_price: AvgPrice = serde_json::from_slice(&bytes)
738            .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
739        Ok(avg_price)
740    }
741
742    /// Returns trading fee rates for symbols.
743    ///
744    /// If `symbol` is None, returns fee rates for all symbols.
745    /// Uses SAPI endpoint (requires authentication).
746    ///
747    /// # Errors
748    ///
749    /// Returns an error if credentials are missing or the request fails.
750    pub async fn get_trade_fee(
751        &self,
752        symbol: Option<&str>,
753    ) -> BinanceSpotHttpResult<Vec<TradeFee>> {
754        let params = symbol.map(TradeFeeParams::for_symbol);
755        let bytes = self
756            .get_signed_sapi("asset/tradeFee", params.as_ref())
757            .await?;
758
759        let fees: Vec<TradeFee> = serde_json::from_slice(&bytes)
760            .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
761        Ok(fees)
762    }
763
764    /// Performs a signed GET request to SAPI endpoints (returns JSON).
765    async fn get_signed_sapi<P>(
766        &self,
767        path: &str,
768        params: Option<&P>,
769    ) -> BinanceSpotHttpResult<Vec<u8>>
770    where
771        P: Serialize + ?Sized,
772    {
773        let cred = self
774            .credential
775            .as_ref()
776            .ok_or(BinanceSpotHttpError::MissingCredentials)?;
777
778        let mut query = params
779            .map(serde_urlencoded::to_string)
780            .transpose()
781            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?
782            .unwrap_or_default();
783
784        if !query.is_empty() {
785            query.push('&');
786        }
787
788        let timestamp = Utc::now().timestamp_millis();
789        query.push_str(&format!("timestamp={timestamp}"));
790
791        if let Some(recv_window) = self.recv_window {
792            query.push_str(&format!("&recvWindow={recv_window}"));
793        }
794
795        let signature = Self::percent_encode(&cred.sign(&query));
796        query.push_str(&format!("&signature={signature}"));
797
798        // Build SAPI URL (different from regular API path)
799        let normalized_path = if path.starts_with('/') {
800            path.to_string()
801        } else {
802            format!("/{path}")
803        };
804
805        let mut url = format!("{}/sapi/v1{}", self.base_url, normalized_path);
806
807        if !query.is_empty() {
808            url.push('?');
809            url.push_str(&query);
810        }
811
812        let mut headers = HashMap::new();
813        headers.insert(
814            BINANCE_API_KEY_HEADER.to_string(),
815            cred.api_key().to_string(),
816        );
817
818        let keys = vec![BINANCE_GLOBAL_RATE_KEY.to_string()];
819
820        let response = self
821            .client
822            .request(
823                Method::GET,
824                url,
825                None::<&HashMap<String, Vec<String>>>,
826                Some(headers),
827                None,
828                None,
829                Some(keys),
830            )
831            .await?;
832
833        if !response.status.is_success() {
834            return self.parse_error_response(&response);
835        }
836
837        Ok(response.body.to_vec())
838    }
839
840    /// Percent-encodes a string for use in URL query parameters.
841    fn percent_encode(input: &str) -> String {
842        let mut result = String::with_capacity(input.len() * 3);
843        for byte in input.bytes() {
844            match byte {
845                // Unreserved characters (RFC 3986)
846                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
847                    result.push(byte as char);
848                }
849                _ => {
850                    result.push('%');
851                    result.push_str(&format!("{byte:02X}"));
852                }
853            }
854        }
855        result
856    }
857
858    /// Submits multiple orders in a single request (up to 5 orders).
859    ///
860    /// Each order in the batch is processed independently. The response contains
861    /// the result for each order, which can be either a success or an error.
862    ///
863    /// # Errors
864    ///
865    /// Returns an error if credentials are missing, the request fails, or
866    /// JSON parsing fails. Individual order failures are returned in the
867    /// response array as `BatchOrderResult::Error`.
868    pub async fn batch_submit_orders(
869        &self,
870        orders: &[BatchOrderItem],
871    ) -> BinanceSpotHttpResult<Vec<BatchOrderResult>> {
872        if orders.is_empty() {
873            return Ok(Vec::new());
874        }
875
876        if orders.len() > 5 {
877            return Err(BinanceSpotHttpError::ValidationError(
878                "Batch order limit is 5 orders maximum".to_string(),
879            ));
880        }
881
882        let batch_json = serde_json::to_string(orders)
883            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?;
884
885        let bytes = self
886            .batch_request(Method::POST, "batchOrders", &batch_json)
887            .await?;
888
889        let results: Vec<BatchOrderResult> = serde_json::from_slice(&bytes)
890            .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
891
892        Ok(results)
893    }
894
895    /// Cancels multiple orders in a single request (up to 5 orders).
896    ///
897    /// Each cancel in the batch is processed independently. The response contains
898    /// the result for each cancel, which can be either a success or an error.
899    ///
900    /// # Errors
901    ///
902    /// Returns an error if credentials are missing, the request fails, or
903    /// JSON parsing fails. Individual cancel failures are returned in the
904    /// response array as `BatchCancelResult::Error`.
905    pub async fn batch_cancel_orders(
906        &self,
907        cancels: &[BatchCancelItem],
908    ) -> BinanceSpotHttpResult<Vec<BatchCancelResult>> {
909        if cancels.is_empty() {
910            return Ok(Vec::new());
911        }
912
913        if cancels.len() > 5 {
914            return Err(BinanceSpotHttpError::ValidationError(
915                "Batch cancel limit is 5 orders maximum".to_string(),
916            ));
917        }
918
919        let batch_json = serde_json::to_string(cancels)
920            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?;
921
922        let bytes = self
923            .batch_request(Method::DELETE, "batchOrders", &batch_json)
924            .await?;
925
926        let results: Vec<BatchCancelResult> = serde_json::from_slice(&bytes)
927            .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
928
929        Ok(results)
930    }
931
932    /// Performs a signed batch request with the batchOrders parameter.
933    async fn batch_request(
934        &self,
935        method: Method,
936        path: &str,
937        batch_json: &str,
938    ) -> BinanceSpotHttpResult<Vec<u8>> {
939        let cred = self
940            .credential
941            .as_ref()
942            .ok_or(BinanceSpotHttpError::MissingCredentials)?;
943
944        let encoded_batch = Self::percent_encode(batch_json);
945        let timestamp = Utc::now().timestamp_millis();
946        let mut query = format!("batchOrders={encoded_batch}&timestamp={timestamp}");
947
948        if let Some(recv_window) = self.recv_window {
949            query.push_str(&format!("&recvWindow={recv_window}"));
950        }
951
952        let signature = Self::percent_encode(&cred.sign(&query));
953        query.push_str(&format!("&signature={signature}"));
954
955        let url = self.build_url(path, &query);
956
957        let mut headers = HashMap::new();
958        headers.insert(
959            BINANCE_API_KEY_HEADER.to_string(),
960            cred.api_key().to_string(),
961        );
962
963        let keys = self.rate_limit_keys(true);
964
965        let response = self
966            .client
967            .request(
968                method,
969                url,
970                None::<&HashMap<String, Vec<String>>>,
971                Some(headers),
972                None,
973                None,
974                Some(keys),
975            )
976            .await?;
977
978        if !response.status.is_success() {
979            return self.parse_error_response(&response);
980        }
981
982        Ok(response.body.to_vec())
983    }
984
985    /// Returns account information including balances.
986    ///
987    /// # Errors
988    ///
989    /// Returns an error if the request fails or SBE decoding fails.
990    pub async fn account(
991        &self,
992        params: &AccountInfoParams,
993    ) -> BinanceSpotHttpResult<BinanceAccountInfo> {
994        let bytes = self.get_signed("account", Some(params)).await?;
995        let response = parse::decode_account(&bytes)?;
996        Ok(response)
997    }
998
999    /// Returns account trade history for a symbol.
1000    ///
1001    /// # Errors
1002    ///
1003    /// Returns an error if the request fails or SBE decoding fails.
1004    pub async fn account_trades(
1005        &self,
1006        symbol: &str,
1007        order_id: Option<i64>,
1008        start_time: Option<i64>,
1009        end_time: Option<i64>,
1010        limit: Option<u32>,
1011    ) -> BinanceSpotHttpResult<Vec<BinanceAccountTrade>> {
1012        let params = AccountTradesParams {
1013            symbol: symbol.to_string(),
1014            order_id,
1015            start_time,
1016            end_time,
1017            from_id: None,
1018            limit,
1019        };
1020        let bytes = self.get_signed("myTrades", Some(&params)).await?;
1021        let response = parse::decode_account_trades(&bytes)?;
1022        Ok(response)
1023    }
1024
1025    /// Queries an order's status.
1026    ///
1027    /// Either `order_id` or `client_order_id` must be provided.
1028    ///
1029    /// # Errors
1030    ///
1031    /// Returns an error if the request fails or SBE decoding fails.
1032    pub async fn query_order(
1033        &self,
1034        symbol: &str,
1035        order_id: Option<i64>,
1036        client_order_id: Option<&str>,
1037    ) -> BinanceSpotHttpResult<BinanceOrderResponse> {
1038        let params = QueryOrderParams {
1039            symbol: symbol.to_string(),
1040            order_id,
1041            orig_client_order_id: client_order_id.map(|s| s.to_string()),
1042        };
1043        let bytes = self.get_signed("order", Some(&params)).await?;
1044        let response = parse::decode_order(&bytes)?;
1045        Ok(response)
1046    }
1047
1048    /// Returns all open orders for a symbol or all symbols.
1049    ///
1050    /// # Errors
1051    ///
1052    /// Returns an error if the request fails or SBE decoding fails.
1053    pub async fn open_orders(
1054        &self,
1055        symbol: Option<&str>,
1056    ) -> BinanceSpotHttpResult<Vec<BinanceOrderResponse>> {
1057        let params = OpenOrdersParams {
1058            symbol: symbol.map(|s| s.to_string()),
1059        };
1060        let bytes = self.get_signed("openOrders", Some(&params)).await?;
1061        let response = parse::decode_orders(&bytes)?;
1062        Ok(response)
1063    }
1064
1065    /// Returns all orders (including closed) for a symbol.
1066    ///
1067    /// # Errors
1068    ///
1069    /// Returns an error if the request fails or SBE decoding fails.
1070    pub async fn all_orders(
1071        &self,
1072        symbol: &str,
1073        start_time: Option<i64>,
1074        end_time: Option<i64>,
1075        limit: Option<u32>,
1076    ) -> BinanceSpotHttpResult<Vec<BinanceOrderResponse>> {
1077        let params = AllOrdersParams {
1078            symbol: symbol.to_string(),
1079            order_id: None,
1080            start_time,
1081            end_time,
1082            limit,
1083        };
1084        let bytes = self.get_signed("allOrders", Some(&params)).await?;
1085        let response = parse::decode_orders(&bytes)?;
1086        Ok(response)
1087    }
1088
1089    /// Performs a signed POST request for order operations.
1090    async fn post_order<P>(&self, path: &str, params: Option<&P>) -> BinanceSpotHttpResult<Vec<u8>>
1091    where
1092        P: Serialize + ?Sized,
1093    {
1094        self.post_signed(path, params).await
1095    }
1096
1097    /// Performs a signed DELETE request for cancel operations.
1098    async fn delete_order<P>(
1099        &self,
1100        path: &str,
1101        params: Option<&P>,
1102    ) -> BinanceSpotHttpResult<Vec<u8>>
1103    where
1104        P: Serialize + ?Sized,
1105    {
1106        self.delete_signed(path, params).await
1107    }
1108
1109    /// Creates a new order.
1110    ///
1111    /// # Errors
1112    ///
1113    /// Returns an error if the request fails or SBE decoding fails.
1114    #[expect(clippy::too_many_arguments)]
1115    pub async fn new_order(
1116        &self,
1117        symbol: &str,
1118        side: BinanceSide,
1119        order_type: BinanceSpotOrderType,
1120        time_in_force: Option<BinanceTimeInForce>,
1121        quantity: Option<&str>,
1122        price: Option<&str>,
1123        client_order_id: Option<&str>,
1124        stop_price: Option<&str>,
1125    ) -> BinanceSpotHttpResult<BinanceNewOrderResponse> {
1126        let params = NewOrderParams {
1127            symbol: symbol.to_string(),
1128            side,
1129            order_type,
1130            time_in_force,
1131            quantity: quantity.map(|s| s.to_string()),
1132            quote_order_qty: None,
1133            price: price.map(|s| s.to_string()),
1134            new_client_order_id: client_order_id.map(|s| s.to_string()),
1135            stop_price: stop_price.map(|s| s.to_string()),
1136            trailing_delta: None,
1137            iceberg_qty: None,
1138            new_order_resp_type: Some(BinanceOrderResponseType::Full),
1139            self_trade_prevention_mode: None,
1140            strategy_id: None,
1141            strategy_type: None,
1142        };
1143        let bytes = self.post_order("order", Some(&params)).await?;
1144        let response = parse::decode_new_order_full(&bytes)?;
1145        Ok(response)
1146    }
1147
1148    /// Creates a new order with full parameter support.
1149    ///
1150    /// Extends [`new_order`](Self::new_order) with `quote_order_qty` (for market
1151    /// orders denominated in quote currency) and `iceberg_qty` (display
1152    /// quantity for iceberg orders).
1153    ///
1154    /// # Errors
1155    ///
1156    /// Returns an error if the request fails or SBE decoding fails.
1157    #[expect(clippy::too_many_arguments)]
1158    pub async fn new_order_full(
1159        &self,
1160        symbol: &str,
1161        side: BinanceSide,
1162        order_type: BinanceSpotOrderType,
1163        time_in_force: Option<BinanceTimeInForce>,
1164        quantity: Option<&str>,
1165        quote_order_qty: Option<&str>,
1166        price: Option<&str>,
1167        client_order_id: Option<&str>,
1168        stop_price: Option<&str>,
1169        iceberg_qty: Option<&str>,
1170    ) -> BinanceSpotHttpResult<BinanceNewOrderResponse> {
1171        let params = NewOrderParams {
1172            symbol: symbol.to_string(),
1173            side,
1174            order_type,
1175            time_in_force,
1176            quantity: quantity.map(|s| s.to_string()),
1177            quote_order_qty: quote_order_qty.map(|s| s.to_string()),
1178            price: price.map(|s| s.to_string()),
1179            new_client_order_id: client_order_id.map(|s| s.to_string()),
1180            stop_price: stop_price.map(|s| s.to_string()),
1181            trailing_delta: None,
1182            iceberg_qty: iceberg_qty.map(|s| s.to_string()),
1183            new_order_resp_type: Some(BinanceOrderResponseType::Full),
1184            self_trade_prevention_mode: None,
1185            strategy_id: None,
1186            strategy_type: None,
1187        };
1188        let bytes = self.post_order("order", Some(&params)).await?;
1189        let response = parse::decode_new_order_full(&bytes)?;
1190        Ok(response)
1191    }
1192
1193    /// Creates a new OCO order list.
1194    ///
1195    /// # Errors
1196    ///
1197    /// Returns an error if the request fails or JSON decoding fails.
1198    pub async fn new_oco_order_list(
1199        &self,
1200        params: &NewOcoOrderListParams,
1201    ) -> BinanceSpotHttpResult<NewOcoOrderListResponse> {
1202        let bytes = self.post_signed_json("orderList/oco", Some(params)).await?;
1203        serde_json::from_slice(&bytes).map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))
1204    }
1205
1206    /// Cancels an existing order and places a new order atomically.
1207    ///
1208    /// # Errors
1209    ///
1210    /// Returns an error if the request fails or SBE decoding fails.
1211    #[expect(clippy::too_many_arguments)]
1212    pub async fn cancel_replace_order(
1213        &self,
1214        symbol: &str,
1215        side: BinanceSide,
1216        order_type: BinanceSpotOrderType,
1217        time_in_force: Option<BinanceTimeInForce>,
1218        quantity: Option<&str>,
1219        price: Option<&str>,
1220        cancel_order_id: Option<i64>,
1221        cancel_client_order_id: Option<&str>,
1222        new_client_order_id: Option<&str>,
1223    ) -> BinanceSpotHttpResult<BinanceNewOrderResponse> {
1224        let params = CancelReplaceOrderParams {
1225            symbol: symbol.to_string(),
1226            side,
1227            order_type,
1228            cancel_replace_mode: BinanceCancelReplaceMode::StopOnFailure,
1229            time_in_force,
1230            quantity: quantity.map(|s| s.to_string()),
1231            quote_order_qty: None,
1232            price: price.map(|s| s.to_string()),
1233            cancel_order_id,
1234            cancel_orig_client_order_id: cancel_client_order_id.map(|s| s.to_string()),
1235            new_client_order_id: new_client_order_id.map(|s| s.to_string()),
1236            stop_price: None,
1237            trailing_delta: None,
1238            iceberg_qty: None,
1239            new_order_resp_type: Some(BinanceOrderResponseType::Full),
1240            self_trade_prevention_mode: None,
1241        };
1242        let bytes = self
1243            .post_order("order/cancelReplace", Some(&params))
1244            .await?;
1245        let response = parse::decode_new_order_full(&bytes)?;
1246        Ok(response)
1247    }
1248
1249    /// Cancels an existing order.
1250    ///
1251    /// Either `order_id` or `client_order_id` must be provided.
1252    ///
1253    /// # Errors
1254    ///
1255    /// Returns an error if the request fails or SBE decoding fails.
1256    pub async fn cancel_order(
1257        &self,
1258        symbol: &str,
1259        order_id: Option<i64>,
1260        client_order_id: Option<&str>,
1261    ) -> BinanceSpotHttpResult<BinanceCancelOrderResponse> {
1262        let params = match (order_id, client_order_id) {
1263            (Some(id), _) => CancelOrderParams::by_order_id(symbol, id),
1264            (None, Some(id)) => CancelOrderParams::by_client_order_id(symbol, id.to_string()),
1265            (None, None) => {
1266                return Err(BinanceSpotHttpError::ValidationError(
1267                    "Either order_id or client_order_id must be provided".to_string(),
1268                ));
1269            }
1270        };
1271        let bytes = self.delete_order("order", Some(&params)).await?;
1272        let response = parse::decode_cancel_order(&bytes)?;
1273        Ok(response)
1274    }
1275
1276    /// Cancels all open orders for a symbol.
1277    ///
1278    /// # Errors
1279    ///
1280    /// Returns an error if the request fails or SBE decoding fails.
1281    pub async fn cancel_open_orders(
1282        &self,
1283        symbol: &str,
1284    ) -> BinanceSpotHttpResult<Vec<BinanceCancelOrderResponse>> {
1285        let params = CancelOpenOrdersParams::new(symbol.to_string());
1286        let bytes = self.delete_order("openOrders", Some(&params)).await?;
1287        let response = parse::decode_cancel_open_orders(&bytes)?;
1288        Ok(response)
1289    }
1290
1291    /// Performs an API-key authenticated request (no signature) that returns JSON.
1292    async fn request_with_api_key<P>(
1293        &self,
1294        method: Method,
1295        path: &str,
1296        params: Option<&P>,
1297    ) -> BinanceSpotHttpResult<Vec<u8>>
1298    where
1299        P: Serialize + ?Sized,
1300    {
1301        let cred = self
1302            .credential
1303            .as_ref()
1304            .ok_or(BinanceSpotHttpError::MissingCredentials)?;
1305
1306        let query = params
1307            .map(serde_urlencoded::to_string)
1308            .transpose()
1309            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?
1310            .unwrap_or_default();
1311
1312        let url = self.build_url(path, &query);
1313
1314        let mut headers = HashMap::new();
1315        headers.insert(
1316            BINANCE_API_KEY_HEADER.to_string(),
1317            cred.api_key().to_string(),
1318        );
1319
1320        let keys = vec![BINANCE_GLOBAL_RATE_KEY.to_string()];
1321
1322        let response = self
1323            .client
1324            .request(
1325                method,
1326                url,
1327                None::<&HashMap<String, Vec<String>>>,
1328                Some(headers),
1329                None,
1330                None,
1331                Some(keys),
1332            )
1333            .await?;
1334
1335        if !response.status.is_success() {
1336            return self.parse_error_response(&response);
1337        }
1338
1339        Ok(response.body.to_vec())
1340    }
1341
1342    /// Creates a new listen key for the user data stream.
1343    ///
1344    /// Listen keys are valid for 60 minutes. Use `extend_listen_key` to keep
1345    /// the stream alive.
1346    ///
1347    /// # Errors
1348    ///
1349    /// Returns an error if credentials are missing or the request fails.
1350    pub async fn create_listen_key(&self) -> BinanceSpotHttpResult<ListenKeyResponse> {
1351        let bytes = self
1352            .request_with_api_key(Method::POST, "userDataStream", None::<&()>)
1353            .await?;
1354
1355        let response: ListenKeyResponse = serde_json::from_slice(&bytes)
1356            .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1357
1358        Ok(response)
1359    }
1360
1361    /// Extends the validity of a listen key by 60 minutes.
1362    ///
1363    /// Should be called periodically to keep the user data stream alive.
1364    ///
1365    /// # Errors
1366    ///
1367    /// Returns an error if credentials are missing or the request fails.
1368    pub async fn extend_listen_key(&self, listen_key: &str) -> BinanceSpotHttpResult<()> {
1369        let params = ListenKeyParams::new(listen_key);
1370        self.request_with_api_key(Method::PUT, "userDataStream", Some(&params))
1371            .await?;
1372        Ok(())
1373    }
1374
1375    /// Closes a listen key, terminating the user data stream.
1376    ///
1377    /// # Errors
1378    ///
1379    /// Returns an error if credentials are missing or the request fails.
1380    pub async fn close_listen_key(&self, listen_key: &str) -> BinanceSpotHttpResult<()> {
1381        let params = ListenKeyParams::new(listen_key);
1382        self.request_with_api_key(Method::DELETE, "userDataStream", Some(&params))
1383            .await?;
1384        Ok(())
1385    }
1386}
1387
1388/// High-level HTTP client for Binance Spot API.
1389///
1390/// Wraps [`BinanceRawSpotHttpClient`] and provides domain-level methods:
1391/// - Simple types (ping, server_time): Pass through from raw client.
1392/// - Complex types (instruments, orders): Transform to Nautilus domain types.
1393pub struct BinanceSpotHttpClient {
1394    inner: Arc<BinanceRawSpotHttpClient>,
1395    clock: &'static AtomicTime,
1396    instruments_cache: Arc<DashMap<Ustr, InstrumentAny>>,
1397}
1398
1399impl Clone for BinanceSpotHttpClient {
1400    fn clone(&self) -> Self {
1401        Self {
1402            inner: self.inner.clone(),
1403            clock: self.clock,
1404            instruments_cache: self.instruments_cache.clone(),
1405        }
1406    }
1407}
1408
1409impl Debug for BinanceSpotHttpClient {
1410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1411        f.debug_struct(stringify!(BinanceSpotHttpClient))
1412            .field("inner", &self.inner)
1413            .field("instruments_cached", &self.instruments_cache.len())
1414            .finish()
1415    }
1416}
1417
1418impl BinanceSpotHttpClient {
1419    /// Creates a new Binance Spot HTTP client.
1420    ///
1421    /// # Errors
1422    ///
1423    /// Returns an error if the underlying HTTP client cannot be created.
1424    #[expect(clippy::too_many_arguments)]
1425    pub fn new(
1426        environment: BinanceEnvironment,
1427        clock: &'static AtomicTime,
1428        api_key: Option<String>,
1429        api_secret: Option<String>,
1430        base_url_override: Option<String>,
1431        recv_window: Option<u64>,
1432        timeout_secs: Option<u64>,
1433        proxy_url: Option<String>,
1434    ) -> BinanceSpotHttpResult<Self> {
1435        let inner = BinanceRawSpotHttpClient::new(
1436            environment,
1437            api_key,
1438            api_secret,
1439            base_url_override,
1440            recv_window,
1441            timeout_secs,
1442            proxy_url,
1443        )?;
1444
1445        Ok(Self {
1446            inner: Arc::new(inner),
1447            clock,
1448            instruments_cache: Arc::new(DashMap::new()),
1449        })
1450    }
1451
1452    /// Returns a reference to the inner raw client.
1453    #[must_use]
1454    pub fn inner(&self) -> &BinanceRawSpotHttpClient {
1455        &self.inner
1456    }
1457
1458    /// Returns the SBE schema ID.
1459    #[must_use]
1460    pub const fn schema_id() -> u16 {
1461        SBE_SCHEMA_ID
1462    }
1463
1464    /// Returns the SBE schema version.
1465    #[must_use]
1466    pub const fn schema_version() -> u16 {
1467        SBE_SCHEMA_VERSION
1468    }
1469
1470    /// Generates a timestamp for initialization.
1471    fn generate_ts_init(&self) -> UnixNanos {
1472        self.clock.get_time_ns()
1473    }
1474
1475    fn command_validation_error(message: impl Into<String>) -> anyhow::Error {
1476        anyhow::anyhow!(BinanceSpotHttpError::ValidationError(message.into()))
1477    }
1478
1479    fn response_parse_error(message: impl Into<String>) -> anyhow::Error {
1480        anyhow::anyhow!(BinanceSpotHttpError::ResponseParseError(message.into()))
1481    }
1482
1483    /// Retrieves an instrument from the cache.
1484    fn instrument_from_cache(&self, symbol: Ustr) -> anyhow::Result<InstrumentAny> {
1485        self.instruments_cache
1486            .get(&symbol)
1487            .map(|entry| entry.value().clone())
1488            .ok_or_else(|| anyhow::anyhow!("Instrument {symbol} not in cache"))
1489    }
1490
1491    /// Caches multiple instruments.
1492    pub fn cache_instruments(&self, instruments: Vec<InstrumentAny>) {
1493        for inst in instruments {
1494            self.instruments_cache
1495                .insert(inst.raw_symbol().inner(), inst);
1496        }
1497    }
1498
1499    /// Caches a single instrument.
1500    pub fn cache_instrument(&self, instrument: InstrumentAny) {
1501        self.instruments_cache
1502            .insert(instrument.raw_symbol().inner(), instrument);
1503    }
1504
1505    /// Gets an instrument from the cache by symbol.
1506    #[must_use]
1507    pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1508        self.instruments_cache
1509            .get(symbol)
1510            .map(|entry| entry.value().clone())
1511    }
1512
1513    /// Tests connectivity to the API.
1514    ///
1515    /// # Errors
1516    ///
1517    /// Returns an error if the request fails or SBE decoding fails.
1518    pub async fn ping(&self) -> BinanceSpotHttpResult<()> {
1519        self.inner.ping().await
1520    }
1521
1522    /// Returns the server time in **microseconds** since epoch.
1523    ///
1524    /// Note: SBE provides microsecond precision vs JSON's milliseconds.
1525    ///
1526    /// # Errors
1527    ///
1528    /// Returns an error if the request fails or SBE decoding fails.
1529    pub async fn server_time(&self) -> BinanceSpotHttpResult<i64> {
1530        self.inner.server_time().await
1531    }
1532
1533    /// Returns exchange information including trading symbols.
1534    ///
1535    /// # Errors
1536    ///
1537    /// Returns an error if the request fails or SBE decoding fails.
1538    pub async fn exchange_info(
1539        &self,
1540    ) -> BinanceSpotHttpResult<super::models::BinanceExchangeInfoSbe> {
1541        self.inner.exchange_info().await
1542    }
1543
1544    /// Requests Nautilus instruments for all trading symbols.
1545    ///
1546    /// Fetches exchange info via SBE and parses each symbol into a CurrencyPair.
1547    /// Non-trading symbols are skipped with a debug log.
1548    ///
1549    /// # Errors
1550    ///
1551    /// Returns an error if the request fails or SBE decoding fails.
1552    pub async fn request_instruments(&self) -> BinanceSpotHttpResult<Vec<InstrumentAny>> {
1553        let info = self.exchange_info().await?;
1554        let ts_init = self.generate_ts_init();
1555
1556        let mut instruments = Vec::with_capacity(info.symbols.len());
1557        for symbol in &info.symbols {
1558            match parse_spot_instrument_sbe(symbol, ts_init, ts_init) {
1559                Ok(instrument) => instruments.push(instrument),
1560                Err(e) => {
1561                    log::debug!(
1562                        "Skipping symbol during instrument parsing: symbol={}, error={e}",
1563                        symbol.symbol
1564                    );
1565                }
1566            }
1567        }
1568
1569        // Cache instruments for use by other domain methods
1570        self.cache_instruments(instruments.clone());
1571
1572        log::debug!("Loaded spot instruments: count={}", instruments.len());
1573        Ok(instruments)
1574    }
1575
1576    /// Requests recent trades for an instrument.
1577    ///
1578    /// # Errors
1579    ///
1580    /// Returns an error if the request fails, the instrument is not cached,
1581    /// or trade parsing fails.
1582    pub async fn request_trades(
1583        &self,
1584        instrument_id: InstrumentId,
1585        limit: Option<u32>,
1586    ) -> anyhow::Result<Vec<TradeTick>> {
1587        let symbol = instrument_id.symbol.inner();
1588        let instrument = self.instrument_from_cache_by_id(instrument_id)?;
1589        let ts_init = self.generate_ts_init();
1590
1591        let trades = self
1592            .inner
1593            .trades(symbol.as_str(), limit)
1594            .await
1595            .map_err(|e| anyhow::anyhow!(e))?;
1596
1597        parse_spot_trades_sbe(&trades, &instrument, ts_init)
1598    }
1599
1600    /// Requests bar (kline/candlestick) data.
1601    ///
1602    /// # Errors
1603    ///
1604    /// Returns an error if the bar type is not supported, instrument is not cached,
1605    /// or the request fails.
1606    pub async fn request_bars(
1607        &self,
1608        bar_type: BarType,
1609        start: Option<DateTime<Utc>>,
1610        end: Option<DateTime<Utc>>,
1611        limit: Option<u32>,
1612    ) -> anyhow::Result<Vec<Bar>> {
1613        anyhow::ensure!(
1614            bar_type.aggregation_source() == AggregationSource::External,
1615            "Only EXTERNAL aggregation is supported"
1616        );
1617
1618        let spec = bar_type.spec();
1619        let step = spec.step.get();
1620        let interval = match spec.aggregation {
1621            BarAggregation::Second => {
1622                anyhow::bail!("Binance Spot does not support second-level kline intervals")
1623            }
1624            BarAggregation::Minute => format!("{step}m"),
1625            BarAggregation::Hour => format!("{step}h"),
1626            BarAggregation::Day => format!("{step}d"),
1627            BarAggregation::Week => format!("{step}w"),
1628            BarAggregation::Month => format!("{step}M"),
1629            a => anyhow::bail!("Binance does not support {a:?} aggregation"),
1630        };
1631
1632        let instrument_id = bar_type.instrument_id();
1633        let symbol = instrument_id.symbol;
1634        let instrument = self.instrument_from_cache_by_id(instrument_id)?;
1635        let ts_init = self.generate_ts_init();
1636
1637        let klines = self
1638            .inner
1639            .klines(
1640                symbol.as_str(),
1641                &interval,
1642                start.map(|dt| dt.timestamp_millis()),
1643                end.map(|dt| dt.timestamp_millis()),
1644                limit,
1645            )
1646            .await
1647            .map_err(|e| anyhow::anyhow!(e))?;
1648
1649        parse_klines_to_bars(&klines, bar_type, &instrument, ts_init)
1650    }
1651
1652    fn instrument_from_cache_by_id(
1653        &self,
1654        instrument_id: InstrumentId,
1655    ) -> anyhow::Result<InstrumentAny> {
1656        self.instruments_cache
1657            .get(&instrument_id.symbol.inner())
1658            .map(|entry| entry.value().clone())
1659            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id).into())
1660    }
1661
1662    /// Requests the account state with Nautilus types.
1663    ///
1664    /// # Errors
1665    ///
1666    /// Returns an error if the request fails or SBE decoding fails.
1667    pub async fn request_account_state(
1668        &self,
1669        account_id: AccountId,
1670    ) -> anyhow::Result<AccountState> {
1671        let ts_init = self.clock.get_time_ns();
1672        let params = AccountInfoParams::default();
1673        let account_info = self.inner.account(&params).await?;
1674        Ok(account_info.to_account_state(account_id, ts_init))
1675    }
1676
1677    /// Requests the status of a specific order.
1678    ///
1679    /// Either `venue_order_id` or `client_order_id` must be provided.
1680    ///
1681    /// # Errors
1682    ///
1683    /// Returns an error if neither identifier is provided, the request fails for any
1684    /// reason other than a missing order, instrument is not cached, or parsing fails.
1685    pub async fn request_order_status_report(
1686        &self,
1687        account_id: AccountId,
1688        instrument_id: InstrumentId,
1689        venue_order_id: Option<VenueOrderId>,
1690        client_order_id: Option<ClientOrderId>,
1691    ) -> anyhow::Result<Option<OrderStatusReport>> {
1692        anyhow::ensure!(
1693            venue_order_id.is_some() || client_order_id.is_some(),
1694            "Either venue_order_id or client_order_id must be provided"
1695        );
1696
1697        let symbol = instrument_id.symbol.inner();
1698        let instrument = self.instrument_from_cache(symbol)?;
1699        let ts_init = self.generate_ts_init();
1700
1701        let order_id = venue_order_id
1702            .map(|id| id.inner().parse::<i64>())
1703            .transpose()
1704            .map_err(|_| anyhow::anyhow!("Invalid venue order ID"))?;
1705
1706        let client_id_str =
1707            client_order_id.map(|id| encode_broker_id(&id, BINANCE_NAUTILUS_SPOT_BROKER_ID));
1708
1709        let order = match self
1710            .inner
1711            .query_order(symbol.as_str(), order_id, client_id_str.as_deref())
1712            .await
1713        {
1714            Ok(order) => order,
1715            Err(e) if Self::is_no_such_order_error(&e) => {
1716                log::debug!("Binance Spot order not found: instrument_id={instrument_id}");
1717                return Ok(None);
1718            }
1719            Err(e) => anyhow::bail!(e),
1720        };
1721
1722        parse_order_status_report_sbe(
1723            &order,
1724            account_id,
1725            &instrument,
1726            BINANCE_NAUTILUS_SPOT_BROKER_ID,
1727            ts_init,
1728        )
1729        .map(Some)
1730    }
1731
1732    const fn is_no_such_order_error(error: &BinanceSpotHttpError) -> bool {
1733        matches!(
1734            error,
1735            BinanceSpotHttpError::BinanceError { code, .. } if *code == BINANCE_NO_SUCH_ORDER_CODE
1736        )
1737    }
1738
1739    /// Requests order status reports.
1740    ///
1741    /// When `open_only` is true, returns only open orders (instrument_id optional).
1742    /// When `open_only` is false, returns order history (instrument_id required).
1743    ///
1744    /// # Errors
1745    ///
1746    /// Returns an error if the request fails, any order's instrument is not cached,
1747    /// or parsing fails.
1748    pub async fn request_order_status_reports(
1749        &self,
1750        account_id: AccountId,
1751        instrument_id: Option<InstrumentId>,
1752        start: Option<DateTime<Utc>>,
1753        end: Option<DateTime<Utc>>,
1754        open_only: bool,
1755        limit: Option<u32>,
1756    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1757        let ts_init = self.generate_ts_init();
1758        let symbol = instrument_id.map(|id| id.symbol.to_string());
1759
1760        let orders = if open_only {
1761            self.inner
1762                .open_orders(symbol.as_deref())
1763                .await
1764                .map_err(|e| anyhow::anyhow!(e))?
1765        } else {
1766            let symbol = symbol
1767                .ok_or_else(|| anyhow::anyhow!("instrument_id is required when open_only=false"))?;
1768            self.inner
1769                .all_orders(
1770                    &symbol,
1771                    start.map(|dt| dt.timestamp_millis()),
1772                    end.map(|dt| dt.timestamp_millis()),
1773                    limit,
1774                )
1775                .await
1776                .map_err(|e| anyhow::anyhow!(e))?
1777        };
1778
1779        orders
1780            .iter()
1781            .map(|order| {
1782                let symbol = Ustr::from(&order.symbol);
1783                let instrument = self.instrument_from_cache(symbol)?;
1784                parse_order_status_report_sbe(
1785                    order,
1786                    account_id,
1787                    &instrument,
1788                    BINANCE_NAUTILUS_SPOT_BROKER_ID,
1789                    ts_init,
1790                )
1791            })
1792            .collect()
1793    }
1794
1795    /// Requests fill reports (trade history) for an instrument.
1796    ///
1797    /// # Errors
1798    ///
1799    /// Returns an error if the request fails, any trade's instrument is not cached,
1800    /// or parsing fails.
1801    pub async fn request_fill_reports(
1802        &self,
1803        account_id: AccountId,
1804        instrument_id: InstrumentId,
1805        venue_order_id: Option<VenueOrderId>,
1806        start: Option<DateTime<Utc>>,
1807        end: Option<DateTime<Utc>>,
1808        limit: Option<u32>,
1809    ) -> anyhow::Result<Vec<FillReport>> {
1810        let ts_init = self.generate_ts_init();
1811        let symbol = instrument_id.symbol.inner();
1812
1813        let order_id = venue_order_id
1814            .map(|id| id.inner().parse::<i64>())
1815            .transpose()
1816            .map_err(|_| anyhow::anyhow!("Invalid venue order ID"))?;
1817
1818        let trades = self
1819            .inner
1820            .account_trades(
1821                symbol.as_str(),
1822                order_id,
1823                start.map(|dt| dt.timestamp_millis()),
1824                end.map(|dt| dt.timestamp_millis()),
1825                limit,
1826            )
1827            .await
1828            .map_err(|e| anyhow::anyhow!(e))?;
1829
1830        trades
1831            .iter()
1832            .map(|trade| {
1833                let symbol = Ustr::from(&trade.symbol);
1834                let instrument = self.instrument_from_cache(symbol)?;
1835                let commission_currency = get_currency(&trade.commission_asset);
1836                parse_fill_report_sbe(trade, account_id, &instrument, commission_currency, ts_init)
1837            })
1838            .collect()
1839    }
1840
1841    /// Submits a new order to the venue.
1842    ///
1843    /// Converts Nautilus domain types to Binance-specific parameters
1844    /// and returns an `OrderStatusReport`.
1845    ///
1846    /// # Errors
1847    ///
1848    /// Returns an error if:
1849    /// - The instrument is not cached.
1850    /// - The order type or time-in-force is unsupported.
1851    /// - Stop orders are submitted without a trigger price.
1852    /// - The request fails or SBE decoding fails.
1853    #[expect(clippy::too_many_arguments)]
1854    pub async fn submit_order(
1855        &self,
1856        account_id: AccountId,
1857        instrument_id: InstrumentId,
1858        client_order_id: ClientOrderId,
1859        order_side: OrderSide,
1860        order_type: OrderType,
1861        quantity: Quantity,
1862        time_in_force: TimeInForce,
1863        price: Option<Price>,
1864        trigger_price: Option<Price>,
1865        post_only: bool,
1866        quote_quantity: bool,
1867        display_qty: Option<Quantity>,
1868    ) -> anyhow::Result<OrderStatusReport> {
1869        let symbol = instrument_id.symbol.inner();
1870        let instrument = self
1871            .instrument_from_cache(symbol)
1872            .map_err(|e| Self::command_validation_error(e.to_string()))?;
1873        let ts_init = self.generate_ts_init();
1874
1875        let binance_side = BinanceSide::try_from(order_side)
1876            .map_err(|e| Self::command_validation_error(e.to_string()))?;
1877        let binance_order_type = order_type_to_binance_spot(order_type, post_only)
1878            .map_err(|e| Self::command_validation_error(e.to_string()))?;
1879
1880        // Validate trigger price for conditional orders
1881        let requires_trigger = matches!(
1882            order_type,
1883            OrderType::StopMarket
1884                | OrderType::StopLimit
1885                | OrderType::MarketIfTouched
1886                | OrderType::LimitIfTouched
1887        );
1888
1889        if requires_trigger && trigger_price.is_none() {
1890            return Err(Self::command_validation_error(
1891                "Conditional orders require a trigger price",
1892            ));
1893        }
1894
1895        // Validate price for order types that require it
1896        let requires_price = matches!(
1897            binance_order_type,
1898            BinanceSpotOrderType::Limit
1899                | BinanceSpotOrderType::StopLossLimit
1900                | BinanceSpotOrderType::TakeProfitLimit
1901                | BinanceSpotOrderType::LimitMaker
1902        );
1903
1904        if requires_price && price.is_none() {
1905            return Err(Self::command_validation_error(format!(
1906                "{binance_order_type:?} orders require a price"
1907            )));
1908        }
1909
1910        // Only send TIF for order types that support it
1911        let supports_tif = matches!(
1912            binance_order_type,
1913            BinanceSpotOrderType::Limit
1914                | BinanceSpotOrderType::StopLossLimit
1915                | BinanceSpotOrderType::TakeProfitLimit
1916        );
1917        let binance_tif = if supports_tif {
1918            Some(
1919                time_in_force_to_binance_spot(time_in_force)
1920                    .map_err(|e| Self::command_validation_error(e.to_string()))?,
1921            )
1922        } else {
1923            None
1924        };
1925
1926        let qty_str = quantity.to_string();
1927        let price_str = price.map(|p| p.to_string());
1928        let stop_price_str = trigger_price.map(|p| p.to_string());
1929        let iceberg_qty_str = display_qty.map(|q| q.to_string());
1930        let client_id_str = encode_broker_id(&client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID);
1931
1932        if quote_quantity && binance_order_type != BinanceSpotOrderType::Market {
1933            return Err(Self::command_validation_error(
1934                "quoteOrderQty is only supported for MARKET orders",
1935            ));
1936        }
1937
1938        let (base_qty, quote_qty) = if quote_quantity {
1939            (None, Some(qty_str.as_str()))
1940        } else {
1941            (Some(qty_str.as_str()), None)
1942        };
1943
1944        let response = self
1945            .inner
1946            .new_order_full(
1947                symbol.as_str(),
1948                binance_side,
1949                binance_order_type,
1950                binance_tif,
1951                base_qty,
1952                quote_qty,
1953                price_str.as_deref(),
1954                Some(&client_id_str),
1955                stop_price_str.as_deref(),
1956                iceberg_qty_str.as_deref(),
1957            )
1958            .await?;
1959
1960        parse_new_order_response_sbe(
1961            &response,
1962            account_id,
1963            &instrument,
1964            BINANCE_NAUTILUS_SPOT_BROKER_ID,
1965            ts_init,
1966        )
1967        .map_err(|e| Self::response_parse_error(e.to_string()))
1968    }
1969
1970    /// Submits multiple orders in a single batch request.
1971    ///
1972    /// Binance limits batch submit to 5 orders maximum.
1973    ///
1974    /// # Errors
1975    ///
1976    /// Returns an error if the request fails or JSON parsing fails.
1977    pub async fn submit_order_list(
1978        &self,
1979        orders: &[BatchOrderItem],
1980    ) -> BinanceSpotHttpResult<Vec<BatchOrderResult>> {
1981        self.inner.batch_submit_orders(orders).await
1982    }
1983
1984    /// Submits a Spot OCO order list.
1985    ///
1986    /// # Errors
1987    ///
1988    /// Returns an error if the request fails or JSON parsing fails.
1989    pub async fn submit_oco_order_list(
1990        &self,
1991        params: &NewOcoOrderListParams,
1992    ) -> BinanceSpotHttpResult<NewOcoOrderListResponse> {
1993        self.inner.new_oco_order_list(params).await
1994    }
1995
1996    /// Modifies an existing order (cancel and replace atomically).
1997    ///
1998    /// # Errors
1999    ///
2000    /// Returns an error if:
2001    /// - The instrument is not cached.
2002    /// - The order type or time-in-force is unsupported.
2003    /// - The request fails or SBE decoding fails.
2004    #[expect(clippy::too_many_arguments)]
2005    pub async fn modify_order(
2006        &self,
2007        account_id: AccountId,
2008        instrument_id: InstrumentId,
2009        venue_order_id: VenueOrderId,
2010        client_order_id: ClientOrderId,
2011        order_side: OrderSide,
2012        order_type: OrderType,
2013        quantity: Quantity,
2014        time_in_force: TimeInForce,
2015        price: Option<Price>,
2016    ) -> anyhow::Result<OrderStatusReport> {
2017        let symbol = instrument_id.symbol.inner();
2018        let instrument = self
2019            .instrument_from_cache(symbol)
2020            .map_err(|e| Self::command_validation_error(e.to_string()))?;
2021        let ts_init = self.generate_ts_init();
2022
2023        let binance_side = BinanceSide::try_from(order_side)
2024            .map_err(|e| Self::command_validation_error(e.to_string()))?;
2025        let binance_order_type = order_type_to_binance_spot(order_type, false)
2026            .map_err(|e| Self::command_validation_error(e.to_string()))?;
2027        let binance_tif = time_in_force_to_binance_spot(time_in_force)
2028            .map_err(|e| Self::command_validation_error(e.to_string()))?;
2029
2030        let cancel_order_id: i64 = venue_order_id.inner().parse().map_err(|_| {
2031            Self::command_validation_error(format!("Invalid venue order ID: {venue_order_id}"))
2032        })?;
2033
2034        let qty_str = quantity.to_string();
2035        let price_str = price.map(|p| p.to_string());
2036        let client_id_str = encode_broker_id(&client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID);
2037
2038        let response = self
2039            .inner
2040            .cancel_replace_order(
2041                symbol.as_str(),
2042                binance_side,
2043                binance_order_type,
2044                Some(binance_tif),
2045                Some(&qty_str),
2046                price_str.as_deref(),
2047                Some(cancel_order_id),
2048                None,
2049                Some(&client_id_str),
2050            )
2051            .await
2052            .map_err(|e| anyhow::anyhow!(e))?;
2053
2054        parse_new_order_response_sbe(
2055            &response,
2056            account_id,
2057            &instrument,
2058            BINANCE_NAUTILUS_SPOT_BROKER_ID,
2059            ts_init,
2060        )
2061        .map_err(|e| Self::response_parse_error(e.to_string()))
2062    }
2063
2064    /// Cancels an existing order on the venue.
2065    ///
2066    /// Either `venue_order_id` or `client_order_id` must be provided.
2067    ///
2068    /// # Errors
2069    ///
2070    /// Returns an error if the request fails or SBE decoding fails.
2071    pub async fn cancel_order(
2072        &self,
2073        instrument_id: InstrumentId,
2074        venue_order_id: Option<VenueOrderId>,
2075        client_order_id: Option<ClientOrderId>,
2076    ) -> anyhow::Result<VenueOrderId> {
2077        let symbol = instrument_id.symbol.inner();
2078
2079        let order_id = match venue_order_id {
2080            Some(venue_order_id) => match venue_order_id.inner().parse::<i64>() {
2081                Ok(order_id) => Some(order_id),
2082                Err(e) if client_order_id.is_some() => {
2083                    log::warn!(
2084                        "Unable to parse venue_order_id {venue_order_id} for cancel, canceling by client_order_id: {e}"
2085                    );
2086                    None
2087                }
2088                Err(e) => {
2089                    return Err(Self::command_validation_error(format!(
2090                        "Invalid venue order ID: {e}"
2091                    )));
2092                }
2093            },
2094            None => None,
2095        };
2096
2097        let client_id_str =
2098            client_order_id.map(|id| encode_broker_id(&id, BINANCE_NAUTILUS_SPOT_BROKER_ID));
2099
2100        let response = self
2101            .inner
2102            .cancel_order(symbol.as_str(), order_id, client_id_str.as_deref())
2103            .await
2104            .map_err(|e| anyhow::anyhow!(e))?;
2105
2106        Ok(VenueOrderId::new(response.order_id.to_string()))
2107    }
2108
2109    /// Cancels multiple orders in a single batch request.
2110    ///
2111    /// Binance limits batch cancel to 5 orders maximum.
2112    ///
2113    /// # Errors
2114    ///
2115    /// Returns an error if the request fails or JSON parsing fails.
2116    pub async fn batch_cancel_orders(
2117        &self,
2118        cancels: &[BatchCancelItem],
2119    ) -> BinanceSpotHttpResult<Vec<BatchCancelResult>> {
2120        self.inner.batch_cancel_orders(cancels).await
2121    }
2122
2123    /// Cancels all open orders for a symbol.
2124    ///
2125    /// Returns the venue order IDs of all canceled orders.
2126    ///
2127    /// # Errors
2128    ///
2129    /// Returns an error if the request fails or SBE decoding fails.
2130    pub async fn cancel_all_orders(
2131        &self,
2132        instrument_id: InstrumentId,
2133    ) -> anyhow::Result<Vec<(VenueOrderId, ClientOrderId)>> {
2134        let symbol = instrument_id.symbol.inner();
2135
2136        let responses = self
2137            .inner
2138            .cancel_open_orders(symbol.as_str())
2139            .await
2140            .map_err(|e| anyhow::anyhow!(e))?;
2141
2142        Ok(responses
2143            .into_iter()
2144            .map(|r| {
2145                (
2146                    VenueOrderId::new(r.order_id.to_string()),
2147                    ClientOrderId::new(decode_broker_id(
2148                        &r.orig_client_order_id,
2149                        BINANCE_NAUTILUS_SPOT_BROKER_ID,
2150                    )),
2151                )
2152            })
2153            .collect())
2154    }
2155}
2156
2157#[cfg(test)]
2158mod tests {
2159    use rstest::rstest;
2160
2161    use super::*;
2162
2163    #[rstest]
2164    fn test_schema_constants() {
2165        assert_eq!(BinanceRawSpotHttpClient::schema_id(), 3);
2166        assert_eq!(BinanceRawSpotHttpClient::schema_version(), 4);
2167        assert_eq!(BinanceSpotHttpClient::schema_id(), 3);
2168        assert_eq!(BinanceSpotHttpClient::schema_version(), 4);
2169    }
2170
2171    #[rstest]
2172    fn test_sbe_schema_header() {
2173        assert_eq!(SBE_SCHEMA_HEADER, "3:4");
2174    }
2175
2176    #[rstest]
2177    fn test_default_headers_include_sbe() {
2178        let headers = BinanceRawSpotHttpClient::default_headers(&None);
2179
2180        assert_eq!(headers.get("Accept"), Some(&"application/sbe".to_string()));
2181        assert_eq!(headers.get("X-MBX-SBE"), Some(&"3:4".to_string()));
2182    }
2183
2184    #[rstest]
2185    fn test_rate_limit_config() {
2186        let config = BinanceRawSpotHttpClient::rate_limit_config();
2187
2188        assert!(config.default_quota.is_some());
2189        // Spot has 2 ORDERS quotas (SECOND and DAY)
2190        assert_eq!(config.order_keys.len(), 2);
2191    }
2192
2193    #[rstest]
2194    fn test_quota_from_unknown_interval_returns_none() {
2195        let quota = BinanceRateLimitQuota {
2196            rate_limit_type: BinanceRateLimitType::Orders,
2197            interval: BinanceRateLimitInterval::Unknown,
2198            interval_num: 1,
2199            limit: 10,
2200        };
2201
2202        assert!(BinanceRawSpotHttpClient::quota_from(&quota).is_none());
2203    }
2204}