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