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