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:5` (schema ID:version)
33
34use std::{collections::HashMap, fmt::Debug, num::NonZeroU32, sync::Arc};
35
36use ahash::AHashMap;
37use jiff::Timestamp;
38use nautilus_common::cache::InstrumentLookupError;
39use nautilus_core::{
40    collections::AtomicMap, datetime::SECONDS_IN_DAY, hex, nanos::UnixNanos, time::AtomicTime,
41};
42use nautilus_model::{
43    data::{Bar, BarType, BookOrder, TradeTick},
44    enums::{
45        AggregationSource, BarAggregation, BookType, MarketStatusAction, OrderSide, OrderType,
46        TimeInForce,
47    },
48    events::AccountState,
49    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
50    instruments::{Instrument, any::InstrumentAny},
51    orderbook::OrderBook,
52    reports::{FillReport, OrderStatusReport},
53    types::{Price, Quantity},
54};
55use nautilus_network::{
56    http::{
57        HttpClient, HttpRedirectPolicy, HttpResponse, Method, create_standard_nautilus_headers,
58    },
59    ratelimiter::quota::Quota,
60    retry::{RetryConfig, RetryError, RetryManager},
61};
62use rust_decimal::Decimal;
63use serde::{Deserialize, Serialize};
64use serde_json::Value;
65use ustr::Ustr;
66
67use super::{
68    error::{BinanceSpotHttpError, BinanceSpotHttpResult},
69    models::{
70        AvgPrice, BatchCancelResult, BatchOrderResult, BinanceAccountCommission,
71        BinanceAccountInfo, BinanceAccountRatesJson, BinanceAccountTrade, BinanceAggTrade,
72        BinanceAggTrades, BinanceBalance, BinanceCancelOpenOrdersResponse,
73        BinanceCancelOrderListResponse, BinanceCancelOrderResponse, BinanceDepth,
74        BinanceExchangeInfoJson, BinanceKline, BinanceKlines, BinanceNewOrderResponse,
75        BinanceOrderFill, BinanceOrderResponse, BinancePriceLevel, BinanceTrade, BinanceTrades,
76        BookTicker, ListenKeyResponse, NewOcoOrderListResponse, OrderListOrder, Ticker24hr,
77        TickerPrice, TradeFee,
78    },
79    parse,
80    query::{
81        AccountCommissionParams, AccountInfoParams, AccountTradesParams, AggTradesParams,
82        AllOrdersParams, AvgPriceParams, BatchCancelItem, BatchOrderItem, CancelOpenOrdersParams,
83        CancelOrderParams, CancelReplaceOrderParams, DepthParams, KlinesParams, ListenKeyParams,
84        NewOcoOrderListParams, NewOrderParams, OpenOrdersParams, QueryOrderParams, TickerParams,
85        TradeFeeParams, TradesParams,
86    },
87};
88use crate::{
89    common::{
90        consts::{
91            BINANCE_API_KEY_HEADER, BINANCE_NAUTILUS_SPOT_BROKER_ID, BINANCE_NO_SUCH_ORDER_CODE,
92            BINANCE_RETRY_AFTER_HEADER, BINANCE_SPOT_RATE_LIMITS, BINANCE_VENUE,
93            BinanceRateLimitQuota,
94        },
95        credential::SigningCredential,
96        encoder::{decode_client_order_id, encode_broker_id},
97        enums::{
98            BinanceEnvironment, BinanceOrderStatus, BinanceProductType, BinanceRateLimitInterval,
99            BinanceRateLimitType, BinanceSelfTradePreventionMode, BinanceSide, BinanceTimeInForce,
100        },
101        fees::BINANCE_SPOT_FEE_DEFAULT,
102        instruments::BinanceInstrumentSelector,
103        models::BinanceErrorResponse,
104        parse::{
105            get_currency, parse_fill_report_sbe, parse_klines_to_binance_bars,
106            parse_new_order_response_sbe, parse_order_status_report_sbe,
107            parse_spot_instrument_json_with_fees, parse_spot_instrument_sbe_with_fees,
108            parse_spot_trades_sbe, should_warn_on_instrument_parse_error,
109        },
110        symbol::format_instrument_id,
111        urls::get_http_base_url,
112    },
113    config::BinanceInstrumentProviderConfig,
114    spot::{
115        enums::{
116            BinanceCancelReplaceMode, BinanceOrderResponseType, BinanceSpotOrderType,
117            order_type_to_binance_spot, time_in_force_to_binance_spot,
118        },
119        sbe::{
120            generated::symbol_status::SymbolStatus,
121            spot::{
122                ReadBuf, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION,
123                contingency_type::ContingencyType as SbeContingencyType,
124                error_response_codec::{self, ErrorResponseDecoder},
125                list_order_status::ListOrderStatus as SbeListOrderStatus,
126                list_status_type::ListStatusType as SbeListStatusType,
127                message_header_codec::MessageHeaderDecoder,
128                order_side::OrderSide as SbeOrderSide,
129                order_status::OrderStatus as SbeOrderStatus,
130                order_type::OrderType as SbeOrderType,
131                self_trade_prevention_mode::SelfTradePreventionMode as SbeSelfTradePreventionMode,
132                time_in_force::TimeInForce as SbeTimeInForce,
133            },
134        },
135    },
136};
137
138/// SBE schema header value (`X-MBX-SBE`) sent on Spot API requests.
139///
140/// Requests the current `3:5` schema. The decoder accepts any version within schema ID `3`
141/// (see `parse::MessageHeader::validate`), so compatible responses continue to decode.
142pub const SBE_SCHEMA_HEADER: &str = "3:5";
143
144use crate::common::consts::{
145    BINANCE_SAPI_PATH as SAPI_PATH, BINANCE_SPOT_API_PATH as SPOT_API_PATH,
146};
147
148/// Global rate limit key.
149const BINANCE_GLOBAL_RATE_KEY: &str = "binance:spot:global";
150
151/// Orders rate limit key prefix.
152const BINANCE_ORDERS_RATE_KEY: &str = "binance:spot:orders";
153
154struct RateLimitConfig {
155    default_quota: Option<Quota>,
156    keyed_quotas: Vec<(String, Quota)>,
157    order_keys: Vec<String>,
158}
159
160#[derive(Debug, Deserialize)]
161#[serde(rename_all = "camelCase")]
162struct SpotAccountJson {
163    #[serde(default)]
164    maker_commission: i64,
165    #[serde(default)]
166    taker_commission: i64,
167    #[serde(default)]
168    buyer_commission: i64,
169    #[serde(default)]
170    seller_commission: i64,
171    #[serde(default)]
172    commission_rates: Option<SpotCommissionRatesJson>,
173    can_trade: bool,
174    can_withdraw: bool,
175    can_deposit: bool,
176    #[serde(default)]
177    require_self_trade_prevention: bool,
178    #[serde(default)]
179    prevent_sor: bool,
180    update_time: i64,
181    account_type: String,
182    balances: Vec<SpotBalanceJson>,
183}
184
185#[derive(Debug, Deserialize)]
186struct SpotCommissionRatesJson {
187    maker: String,
188    taker: String,
189    buyer: String,
190    seller: String,
191}
192
193#[derive(Debug, Deserialize)]
194struct SpotBalanceJson {
195    asset: String,
196    free: String,
197    locked: String,
198}
199
200#[derive(Debug, Deserialize)]
201#[serde(rename_all = "camelCase")]
202struct SpotOrderJson {
203    symbol: String,
204    order_id: i64,
205    #[serde(default)]
206    order_list_id: Option<i64>,
207    client_order_id: String,
208    #[serde(default)]
209    orig_client_order_id: String,
210    #[serde(default)]
211    transact_time: i64,
212    #[serde(default)]
213    price: String,
214    #[serde(default)]
215    orig_qty: String,
216    #[serde(default)]
217    executed_qty: String,
218    #[serde(default, rename = "cummulativeQuoteQty")]
219    cummulative_quote_qty: String,
220    status: BinanceOrderStatus,
221    time_in_force: BinanceTimeInForce,
222    #[serde(rename = "type")]
223    order_type: BinanceSpotOrderType,
224    side: BinanceSide,
225    #[serde(default)]
226    stop_price: String,
227    #[serde(default)]
228    iceberg_qty: String,
229    #[serde(default)]
230    time: i64,
231    #[serde(default)]
232    update_time: i64,
233    #[serde(default)]
234    is_working: bool,
235    #[serde(default)]
236    working_time: Option<i64>,
237    #[serde(default)]
238    orig_quote_order_qty: String,
239    #[serde(default)]
240    self_trade_prevention_mode: Option<BinanceSelfTradePreventionMode>,
241    #[serde(default)]
242    fills: Vec<SpotOrderFillJson>,
243}
244
245#[derive(Debug, Deserialize)]
246#[serde(rename_all = "camelCase")]
247struct SpotCancelOrderListJson {
248    order_list_id: i64,
249    contingency_type: String,
250    list_status_type: String,
251    list_order_status: String,
252    list_client_order_id: String,
253    transaction_time: i64,
254    symbol: String,
255    orders: Vec<OrderListOrder>,
256    order_reports: Vec<SpotOrderJson>,
257}
258
259#[derive(Debug, Deserialize)]
260#[serde(rename_all = "camelCase")]
261struct SpotCancelReplaceJson {
262    new_order_response: SpotOrderJson,
263}
264
265#[derive(Debug, Deserialize)]
266#[serde(rename_all = "camelCase")]
267struct SpotOrderFillJson {
268    price: String,
269    qty: String,
270    commission: String,
271    commission_asset: String,
272    #[serde(default)]
273    trade_id: Option<i64>,
274}
275
276#[derive(Debug, Deserialize)]
277#[serde(rename_all = "camelCase")]
278struct SpotAccountTradeJson {
279    symbol: String,
280    id: i64,
281    order_id: i64,
282    #[serde(default)]
283    order_list_id: Option<i64>,
284    price: String,
285    qty: String,
286    quote_qty: String,
287    commission: String,
288    commission_asset: String,
289    time: i64,
290    is_buyer: bool,
291    is_maker: bool,
292    is_best_match: bool,
293}
294
295#[derive(Debug, Deserialize)]
296#[serde(rename_all = "camelCase")]
297struct SpotDepthJson {
298    last_update_id: i64,
299    bids: Vec<[String; 2]>,
300    asks: Vec<[String; 2]>,
301}
302
303#[derive(Debug, Deserialize)]
304#[serde(rename_all = "camelCase")]
305struct SpotTradeJson {
306    id: i64,
307    price: String,
308    qty: String,
309    quote_qty: String,
310    time: i64,
311    is_buyer_maker: bool,
312    is_best_match: bool,
313}
314
315#[derive(Debug, Deserialize)]
316struct SpotAggTradeJson {
317    #[serde(rename = "a")]
318    id: i64,
319    #[serde(rename = "p")]
320    price: String,
321    #[serde(rename = "q")]
322    qty: String,
323    #[serde(rename = "f")]
324    first_trade_id: i64,
325    #[serde(rename = "l")]
326    last_trade_id: i64,
327    #[serde(rename = "T")]
328    time: i64,
329    #[serde(rename = "m")]
330    is_buyer_maker: bool,
331    #[serde(rename = "M")]
332    is_best_match: bool,
333}
334
335type SpotKlineJson = (
336    i64,
337    String,
338    String,
339    String,
340    String,
341    String,
342    i64,
343    String,
344    i64,
345    String,
346    String,
347    String,
348);
349
350/// Low-level HTTP client for Binance Spot REST API with SBE encoding.
351///
352/// Handles:
353/// - Base URL resolution by environment.
354/// - Optional HMAC SHA256 signing for private endpoints.
355/// - Rate limiting using Spot API quotas.
356/// - SBE decoding to Binance-specific response types.
357///
358/// Methods are named to match Binance API endpoints and return
359/// venue-specific types (decoded from SBE).
360#[derive(Debug, Clone)]
361pub struct BinanceRawSpotHttpClient {
362    retry_manager: Arc<RetryManager<BinanceSpotHttpError>>,
363    client: HttpClient,
364    base_url: String,
365    credential: Option<SigningCredential>,
366    recv_window: Option<u64>,
367    order_rate_keys: Vec<String>,
368    json_responses: bool,
369}
370
371impl BinanceRawSpotHttpClient {
372    /// Returns whether signed requests can be made.
373    #[must_use]
374    pub fn has_credentials(&self) -> bool {
375        self.credential.is_some()
376    }
377
378    /// Creates a new Binance Spot raw HTTP client.
379    ///
380    /// # Errors
381    ///
382    /// Returns an error if the underlying [`HttpClient`] fails to build.
383    pub fn new(
384        environment: BinanceEnvironment,
385        api_key: Option<String>,
386        api_secret: Option<String>,
387        base_url_override: Option<String>,
388        recv_window: Option<u64>,
389        timeout_secs: Option<u64>,
390        proxy_url: Option<String>,
391    ) -> BinanceSpotHttpResult<Self> {
392        Self::new_with_json_responses(
393            environment,
394            api_key,
395            api_secret,
396            base_url_override,
397            recv_window,
398            timeout_secs,
399            proxy_url,
400            false,
401        )
402    }
403
404    /// Creates a raw Spot client with JSON responses instead of Global SBE responses.
405    ///
406    /// # Errors
407    ///
408    /// Returns an error if the underlying [`HttpClient`] fails to build.
409    #[expect(clippy::too_many_arguments)]
410    pub fn new_with_json_responses(
411        environment: BinanceEnvironment,
412        api_key: Option<String>,
413        api_secret: Option<String>,
414        base_url_override: Option<String>,
415        recv_window: Option<u64>,
416        timeout_secs: Option<u64>,
417        proxy_url: Option<String>,
418        json_responses: bool,
419    ) -> BinanceSpotHttpResult<Self> {
420        let RateLimitConfig {
421            default_quota,
422            keyed_quotas,
423            order_keys,
424        } = Self::rate_limit_config();
425
426        let credential = match (api_key, api_secret) {
427            (Some(key), Some(secret)) => Some(SigningCredential::new(key, secret)),
428            (None, None) => None,
429            _ => return Err(BinanceSpotHttpError::MissingCredentials),
430        };
431
432        let base_url = base_url_override.unwrap_or_else(|| {
433            get_http_base_url(BinanceProductType::Spot, environment).to_string()
434        });
435
436        let headers = Self::default_headers(&credential, json_responses);
437
438        let client = HttpClient::builder()
439            .redirect_policy(HttpRedirectPolicy::Reject)
440            .headers(headers)
441            .header_keys(vec![
442                BINANCE_API_KEY_HEADER.to_string(),
443                BINANCE_RETRY_AFTER_HEADER.to_string(),
444            ])
445            .keyed_quotas(keyed_quotas)
446            .maybe_default_quota(default_quota)
447            .maybe_timeout_secs(timeout_secs)
448            .maybe_proxy_url(proxy_url)
449            .build()?;
450
451        Ok(Self {
452            retry_manager: Arc::new(RetryManager::new(crate::common::http::retry_config())),
453            client,
454            base_url,
455            credential,
456            recv_window,
457            order_rate_keys: order_keys,
458            json_responses,
459        })
460    }
461
462    /// Returns the SBE schema ID.
463    #[must_use]
464    pub const fn schema_id() -> u16 {
465        SBE_SCHEMA_ID
466    }
467
468    /// Returns the SBE schema version.
469    #[must_use]
470    pub const fn schema_version() -> u16 {
471        SBE_SCHEMA_VERSION
472    }
473
474    /// Performs a GET request and returns raw response bytes.
475    ///
476    /// # Errors
477    ///
478    /// Returns an error if the request fails.
479    pub async fn get<P>(&self, path: &str, params: Option<&P>) -> BinanceSpotHttpResult<Vec<u8>>
480    where
481        P: Serialize + ?Sized,
482    {
483        self.request(Method::GET, path, params, false, false).await
484    }
485
486    /// Performs a signed GET request and returns raw response bytes.
487    ///
488    /// # Errors
489    ///
490    /// Returns an error if credentials are missing or the request fails.
491    pub async fn get_signed<P>(
492        &self,
493        path: &str,
494        params: Option<&P>,
495    ) -> BinanceSpotHttpResult<Vec<u8>>
496    where
497        P: Serialize + ?Sized,
498    {
499        self.request(Method::GET, path, params, true, false).await
500    }
501
502    /// Performs a signed GET request and requests a JSON response.
503    ///
504    /// # Errors
505    ///
506    /// Returns an error if credentials are missing or the request fails.
507    pub async fn get_signed_json<P>(
508        &self,
509        path: &str,
510        params: Option<&P>,
511    ) -> BinanceSpotHttpResult<Vec<u8>>
512    where
513        P: Serialize + ?Sized,
514    {
515        self.request_with_extra_headers(
516            Method::GET,
517            path,
518            params,
519            true,
520            false,
521            Some(HashMap::from([(
522                "Accept".to_string(),
523                "application/json".to_string(),
524            )])),
525        )
526        .await
527    }
528
529    /// Performs a signed POST request and returns raw response bytes.
530    ///
531    /// # Errors
532    ///
533    /// Returns an error if credentials are missing or the request fails.
534    pub async fn post_signed<P>(
535        &self,
536        path: &str,
537        params: Option<&P>,
538    ) -> BinanceSpotHttpResult<Vec<u8>>
539    where
540        P: Serialize + ?Sized,
541    {
542        self.request(Method::POST, path, params, true, true).await
543    }
544
545    /// Performs a signed POST request and requests a JSON response.
546    ///
547    /// # Errors
548    ///
549    /// Returns an error if credentials are missing or the request fails.
550    pub async fn post_signed_json<P>(
551        &self,
552        path: &str,
553        params: Option<&P>,
554    ) -> BinanceSpotHttpResult<Vec<u8>>
555    where
556        P: Serialize + ?Sized,
557    {
558        self.request_with_extra_headers(
559            Method::POST,
560            path,
561            params,
562            true,
563            true,
564            Some(HashMap::from([(
565                "Accept".to_string(),
566                "application/json".to_string(),
567            )])),
568        )
569        .await
570    }
571
572    /// Performs a signed DELETE request and returns raw response bytes.
573    ///
574    /// # Errors
575    ///
576    /// Returns an error if credentials are missing or the request fails.
577    pub async fn delete_signed<P>(
578        &self,
579        path: &str,
580        params: Option<&P>,
581    ) -> BinanceSpotHttpResult<Vec<u8>>
582    where
583        P: Serialize + ?Sized,
584    {
585        self.request(Method::DELETE, path, params, true, true).await
586    }
587
588    /// Performs a signed DELETE request and requests a JSON response.
589    ///
590    /// # Errors
591    ///
592    /// Returns an error if credentials are missing or the request fails.
593    pub async fn delete_signed_json<P>(
594        &self,
595        path: &str,
596        params: Option<&P>,
597    ) -> BinanceSpotHttpResult<Vec<u8>>
598    where
599        P: Serialize + ?Sized,
600    {
601        self.request_with_extra_headers(
602            Method::DELETE,
603            path,
604            params,
605            true,
606            true,
607            Some(HashMap::from([(
608                "Accept".to_string(),
609                "application/json".to_string(),
610            )])),
611        )
612        .await
613    }
614
615    async fn request<P>(
616        &self,
617        method: Method,
618        path: &str,
619        params: Option<&P>,
620        signed: bool,
621        use_order_quota: bool,
622    ) -> BinanceSpotHttpResult<Vec<u8>>
623    where
624        P: Serialize + ?Sized,
625    {
626        self.request_with_extra_headers(method, path, params, signed, use_order_quota, None)
627            .await
628    }
629
630    async fn request_with_extra_headers<P>(
631        &self,
632        method: Method,
633        path: &str,
634        params: Option<&P>,
635        signed: bool,
636        use_order_quota: bool,
637        extra_headers: Option<HashMap<String, String>>,
638    ) -> BinanceSpotHttpResult<Vec<u8>>
639    where
640        P: Serialize + ?Sized,
641    {
642        let operation = || {
643            self.request_with_extra_headers_once(
644                method.clone(),
645                path,
646                params,
647                signed,
648                use_order_quota,
649                extra_headers.clone(),
650            )
651        };
652
653        if method != Method::GET {
654            return operation().await;
655        }
656        self.retry_manager
657            .invocation(
658                path,
659                operation,
660                BinanceSpotHttpError::is_retryable,
661                |e| match e {
662                    RetryError::Canceled => {
663                        BinanceSpotHttpError::Canceled("HTTP requests canceled".to_string())
664                    }
665                    RetryError::OperationTimeout { timeout_ms } => {
666                        BinanceSpotHttpError::Timeout(format!("Request exceeded {timeout_ms}ms"))
667                    }
668                    RetryError::InvalidConfiguration { message } => {
669                        BinanceSpotHttpError::ValidationError(message)
670                    }
671                    e @ RetryError::ElapsedBudgetExceeded { .. } => {
672                        BinanceSpotHttpError::RetryBudgetExceeded(e.to_string())
673                    }
674                },
675            )
676            .retry_delay(&BinanceSpotHttpError::retry_after)
677            .execute()
678            .await
679    }
680
681    async fn request_with_extra_headers_once<P>(
682        &self,
683        method: Method,
684        path: &str,
685        params: Option<&P>,
686        signed: bool,
687        use_order_quota: bool,
688        extra_headers: Option<HashMap<String, String>>,
689    ) -> BinanceSpotHttpResult<Vec<u8>>
690    where
691        P: Serialize + ?Sized,
692    {
693        let mut query = params
694            .map(serde_urlencoded::to_string)
695            .transpose()
696            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?
697            .unwrap_or_default();
698
699        let mut headers = extra_headers.unwrap_or_default();
700
701        if signed {
702            let cred = self
703                .credential
704                .as_ref()
705                .ok_or(BinanceSpotHttpError::MissingCredentials)?;
706
707            if !query.is_empty() {
708                query.push('&');
709            }
710
711            let timestamp = Timestamp::now().as_millisecond();
712            query.push_str(&format!("timestamp={timestamp}"));
713
714            if let Some(recv_window) = self.recv_window {
715                query.push_str(&format!("&recvWindow={recv_window}"));
716            }
717
718            let signature = Self::percent_encode(&cred.sign(&query));
719            query.push_str(&format!("&signature={signature}"));
720            headers.insert(
721                BINANCE_API_KEY_HEADER.to_string(),
722                cred.api_key().to_string(),
723            );
724        }
725
726        let url = self.build_url(path, &query);
727        let keys = self.rate_limit_keys(use_order_quota);
728
729        let response = self
730            .client
731            .request_with_url_redacted(
732                method,
733                url,
734                None::<&HashMap<String, Vec<String>>>,
735                Some(headers),
736                None,
737                None,
738                Some(keys),
739            )
740            .await?;
741
742        if !response.status.is_success() {
743            return self.parse_error_response(&response);
744        }
745
746        Ok(response.body.to_vec())
747    }
748
749    fn build_url(&self, path: &str, query: &str) -> String {
750        let normalized_path = if path.starts_with('/') {
751            path.to_string()
752        } else {
753            format!("/{path}")
754        };
755
756        let mut url = if normalized_path.starts_with(&format!("{SAPI_PATH}/")) {
757            format!("{}{normalized_path}", self.base_url)
758        } else {
759            format!("{}{}{}", self.base_url, SPOT_API_PATH, normalized_path)
760        };
761
762        if !query.is_empty() {
763            url.push('?');
764            url.push_str(query);
765        }
766        url
767    }
768
769    fn rate_limit_keys(&self, use_orders: bool) -> Vec<String> {
770        if use_orders {
771            let mut keys = Vec::with_capacity(1 + self.order_rate_keys.len());
772            keys.push(BINANCE_GLOBAL_RATE_KEY.to_string());
773            keys.extend(self.order_rate_keys.iter().cloned());
774            keys
775        } else {
776            vec![BINANCE_GLOBAL_RATE_KEY.to_string()]
777        }
778    }
779
780    fn parse_error_response<T>(&self, response: &HttpResponse) -> BinanceSpotHttpResult<T> {
781        let status = response.status.as_u16();
782        let body = &response.body;
783        let retry_after = crate::common::http::retry_after(&response.headers, Timestamp::now());
784
785        // Binance may return JSON errors even when SBE was requested
786        if let Ok(body_str) = std::str::from_utf8(body)
787            && let Ok(err) = serde_json::from_str::<BinanceErrorResponse>(body_str)
788        {
789            return Err(BinanceSpotHttpError::BinanceError {
790                code: err.code,
791                message: err.msg,
792                status,
793                retry_after,
794            });
795        }
796
797        // Try to decode SBE error response
798        if let Some((code, message)) = Self::try_decode_sbe_error(body) {
799            return Err(BinanceSpotHttpError::BinanceError {
800                code: code.into(),
801                message,
802                status,
803                retry_after,
804            });
805        }
806
807        Err(BinanceSpotHttpError::UnexpectedStatus {
808            status,
809            body: hex::encode(body),
810            retry_after,
811        })
812    }
813
814    /// Attempts to decode an SBE error response.
815    ///
816    /// Returns Some((code, message)) if successfully decoded, None otherwise.
817    fn try_decode_sbe_error(body: &[u8]) -> Option<(i16, String)> {
818        const HEADER_LEN: usize = 8;
819        if body.len() < HEADER_LEN + error_response_codec::SBE_BLOCK_LENGTH as usize {
820            return None;
821        }
822
823        let buf = ReadBuf::new(body);
824
825        // Decode message header
826        let header = MessageHeaderDecoder::default().wrap(buf, 0);
827        if header.template_id() != error_response_codec::SBE_TEMPLATE_ID {
828            return None;
829        }
830
831        // Decode error response
832        let mut decoder = ErrorResponseDecoder::default().header(header, 0);
833        let code = decoder.code();
834
835        // Decode the message string (VAR_DATA with 2-byte length prefix)
836        let msg_coords = decoder.msg_decoder();
837        let msg_bytes = decoder.msg_slice(msg_coords);
838        let message = String::from_utf8_lossy(msg_bytes).into_owned();
839
840        Some((code, message))
841    }
842
843    fn default_headers(
844        credential: &Option<SigningCredential>,
845        json_responses: bool,
846    ) -> HashMap<String, String> {
847        let mut headers: HashMap<String, String> =
848            create_standard_nautilus_headers().into_iter().collect();
849
850        if json_responses {
851            headers.insert("Accept".to_string(), "application/json".to_string());
852        } else {
853            headers.insert("Accept".to_string(), "application/sbe".to_string());
854            headers.insert("X-MBX-SBE".to_string(), SBE_SCHEMA_HEADER.to_string());
855        }
856
857        if let Some(cred) = credential {
858            headers.insert(
859                BINANCE_API_KEY_HEADER.to_string(),
860                cred.api_key().to_string(),
861            );
862        }
863        headers
864    }
865
866    fn rate_limit_config() -> RateLimitConfig {
867        let quotas = BINANCE_SPOT_RATE_LIMITS;
868        let mut keyed = Vec::new();
869        let mut order_keys = Vec::new();
870        let mut default = None;
871
872        for quota in quotas {
873            if let Some(q) = Self::quota_from(quota) {
874                match quota.rate_limit_type {
875                    BinanceRateLimitType::RequestWeight if default.is_none() => {
876                        default = Some(q);
877                    }
878                    BinanceRateLimitType::Orders => {
879                        let key = format!("{}:{:?}", BINANCE_ORDERS_RATE_KEY, quota.interval);
880                        order_keys.push(key.clone());
881                        keyed.push((key, q));
882                    }
883                    _ => {}
884                }
885            }
886        }
887
888        let default_quota = default.unwrap_or_else(|| {
889            Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant")
890        });
891
892        keyed.push((BINANCE_GLOBAL_RATE_KEY.to_string(), default_quota));
893
894        RateLimitConfig {
895            default_quota: Some(default_quota),
896            keyed_quotas: keyed,
897            order_keys,
898        }
899    }
900
901    fn quota_from(quota: &BinanceRateLimitQuota) -> Option<Quota> {
902        let burst = NonZeroU32::new(quota.limit)?;
903        match quota.interval {
904            BinanceRateLimitInterval::Second => Quota::per_second(burst),
905            BinanceRateLimitInterval::Minute => Some(Quota::per_minute(burst)),
906            BinanceRateLimitInterval::Day => {
907                Quota::with_period(std::time::Duration::from_secs(SECONDS_IN_DAY))
908                    .map(|q| q.allow_burst(burst))
909            }
910            BinanceRateLimitInterval::Unknown => None,
911        }
912    }
913
914    /// Tests connectivity to the API.
915    ///
916    /// # Errors
917    ///
918    /// Returns an error if the request fails or SBE decoding fails.
919    pub async fn ping(&self) -> BinanceSpotHttpResult<()> {
920        let bytes = self.get("ping", None::<&()>).await?;
921        parse::decode_ping(&bytes)?;
922        Ok(())
923    }
924
925    /// Returns the server time in **microseconds** since epoch.
926    ///
927    /// Note: SBE provides microsecond precision vs JSON's milliseconds.
928    ///
929    /// # Errors
930    ///
931    /// Returns an error if the request fails or SBE decoding fails.
932    pub async fn server_time(&self) -> BinanceSpotHttpResult<i64> {
933        if self.json_responses {
934            #[derive(Deserialize)]
935            #[serde(rename_all = "camelCase")]
936            struct ServerTimeJson {
937                server_time: i64,
938            }
939
940            let bytes = self.get_json("time", None::<&()>).await?;
941            let response: ServerTimeJson = serde_json::from_slice(&bytes)
942                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
943            return millis_to_micros(response.server_time);
944        }
945
946        let bytes = self.get("time", None::<&()>).await?;
947        let timestamp = parse::decode_server_time(&bytes)?;
948        Ok(timestamp)
949    }
950
951    /// Returns exchange information including trading symbols.
952    ///
953    /// # Errors
954    ///
955    /// Returns an error if the request fails or SBE decoding fails.
956    pub async fn exchange_info(
957        &self,
958    ) -> BinanceSpotHttpResult<super::models::BinanceExchangeInfoSbe> {
959        let bytes = self.get("exchangeInfo", None::<&()>).await?;
960        let info = parse::decode_exchange_info(&bytes)?;
961        Ok(info)
962    }
963
964    /// Returns JSON exchange information for endpoints that do not support SBE.
965    ///
966    /// # Errors
967    ///
968    /// Returns an error if the request or JSON decode fails.
969    pub async fn exchange_info_json(&self) -> BinanceSpotHttpResult<BinanceExchangeInfoJson> {
970        let bytes = self.get_json("exchangeInfo", None::<&()>).await?;
971        serde_json::from_slice(&bytes).map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))
972    }
973
974    /// Returns order book depth for a symbol.
975    ///
976    /// # Errors
977    ///
978    /// Returns an error if the request fails or SBE decoding fails.
979    pub async fn depth(&self, params: &DepthParams) -> BinanceSpotHttpResult<BinanceDepth> {
980        if self.json_responses {
981            let bytes = self.get_json("depth", Some(params)).await?;
982            let response: SpotDepthJson = serde_json::from_slice(&bytes)
983                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
984            spot_depth_from_json(response)
985        } else {
986            let bytes = self.get("depth", Some(params)).await?;
987            Ok(parse::decode_depth(&bytes)?)
988        }
989    }
990
991    /// Returns recent trades for a symbol.
992    ///
993    /// # Errors
994    ///
995    /// Returns an error if the request fails or SBE decoding fails.
996    pub async fn trades(
997        &self,
998        symbol: &str,
999        limit: Option<u32>,
1000    ) -> BinanceSpotHttpResult<BinanceTrades> {
1001        let params = TradesParams {
1002            symbol: symbol.to_string(),
1003            limit,
1004        };
1005
1006        if self.json_responses {
1007            let bytes = self.get_json("trades", Some(&params)).await?;
1008            let response: Vec<SpotTradeJson> = serde_json::from_slice(&bytes)
1009                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1010            spot_trades_from_json(response)
1011        } else {
1012            let bytes = self.get("trades", Some(&params)).await?;
1013            Ok(parse::decode_trades(&bytes)?)
1014        }
1015    }
1016
1017    /// Returns aggregate trades for a symbol.
1018    ///
1019    /// # Errors
1020    ///
1021    /// Returns an error if the bounds are invalid or the request cannot be decoded.
1022    pub async fn agg_trades(
1023        &self,
1024        params: &AggTradesParams,
1025    ) -> BinanceSpotHttpResult<BinanceAggTrades> {
1026        if params.limit.is_some_and(|limit| limit > 1000) {
1027            return Err(BinanceSpotHttpError::ValidationError(
1028                "aggregate trade limit must not exceed 1000".to_string(),
1029            ));
1030        }
1031
1032        if matches!((params.start_time, params.end_time), (Some(start), Some(end)) if start > end) {
1033            return Err(BinanceSpotHttpError::ValidationError(
1034                "aggregate trade startTime must not exceed endTime".to_string(),
1035            ));
1036        }
1037
1038        if self.json_responses {
1039            let bytes = self.get_json("aggTrades", Some(params)).await?;
1040            let response: Vec<SpotAggTradeJson> = serde_json::from_slice(&bytes)
1041                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1042            spot_agg_trades_from_json(response)
1043        } else {
1044            let bytes = self.get("aggTrades", Some(params)).await?;
1045            Ok(parse::decode_agg_trades(&bytes)?)
1046        }
1047    }
1048
1049    /// Returns kline (candlestick) data for a symbol.
1050    ///
1051    /// # Errors
1052    ///
1053    /// Returns an error if the request fails or SBE decoding fails.
1054    pub async fn klines(
1055        &self,
1056        symbol: &str,
1057        interval: &str,
1058        start_time: Option<i64>,
1059        end_time: Option<i64>,
1060        limit: Option<u32>,
1061    ) -> BinanceSpotHttpResult<BinanceKlines> {
1062        let params = KlinesParams {
1063            symbol: symbol.to_string(),
1064            interval: interval.to_string(),
1065            start_time,
1066            end_time,
1067            time_zone: None,
1068            limit,
1069        };
1070
1071        if self.json_responses {
1072            let bytes = self.get_json("klines", Some(&params)).await?;
1073            let response: Vec<SpotKlineJson> = serde_json::from_slice(&bytes)
1074                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1075            spot_klines_from_json(response)
1076        } else {
1077            let bytes = self.get("klines", Some(&params)).await?;
1078            Ok(parse::decode_klines(&bytes)?)
1079        }
1080    }
1081
1082    /// Performs a public GET request that returns JSON.
1083    async fn get_json<P>(&self, path: &str, params: Option<&P>) -> BinanceSpotHttpResult<Vec<u8>>
1084    where
1085        P: Serialize + ?Sized,
1086    {
1087        self.request_with_extra_headers(
1088            Method::GET,
1089            path,
1090            params,
1091            false,
1092            false,
1093            Some(HashMap::from([(
1094                "Accept".to_string(),
1095                "application/json".to_string(),
1096            )])),
1097        )
1098        .await
1099    }
1100
1101    /// Returns 24-hour ticker price change statistics.
1102    ///
1103    /// If `symbol` is None, returns statistics for all symbols.
1104    ///
1105    /// # Errors
1106    ///
1107    /// Returns an error if the request fails.
1108    pub async fn ticker_24hr(
1109        &self,
1110        symbol: Option<&str>,
1111    ) -> BinanceSpotHttpResult<Vec<Ticker24hr>> {
1112        let params = symbol.map(TickerParams::for_symbol);
1113        let bytes = self.get_json("ticker/24hr", params.as_ref()).await?;
1114
1115        // Single symbol returns object, multiple returns array
1116        if symbol.is_some() {
1117            let ticker: Ticker24hr = serde_json::from_slice(&bytes)
1118                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1119            Ok(vec![ticker])
1120        } else {
1121            let tickers: Vec<Ticker24hr> = serde_json::from_slice(&bytes)
1122                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1123            Ok(tickers)
1124        }
1125    }
1126
1127    /// Returns latest price for a symbol or all symbols.
1128    ///
1129    /// If `symbol` is None, returns prices for all symbols.
1130    ///
1131    /// # Errors
1132    ///
1133    /// Returns an error if the request fails.
1134    pub async fn ticker_price(
1135        &self,
1136        symbol: Option<&str>,
1137    ) -> BinanceSpotHttpResult<Vec<TickerPrice>> {
1138        let params = symbol.map(TickerParams::for_symbol);
1139        let bytes = self.get_json("ticker/price", params.as_ref()).await?;
1140
1141        // Single symbol returns object, multiple returns array
1142        if symbol.is_some() {
1143            let ticker: TickerPrice = serde_json::from_slice(&bytes)
1144                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1145            Ok(vec![ticker])
1146        } else {
1147            let tickers: Vec<TickerPrice> = serde_json::from_slice(&bytes)
1148                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1149            Ok(tickers)
1150        }
1151    }
1152
1153    /// Returns best bid/ask price for a symbol or all symbols.
1154    ///
1155    /// If `symbol` is None, returns book ticker for all symbols.
1156    ///
1157    /// # Errors
1158    ///
1159    /// Returns an error if the request fails.
1160    pub async fn ticker_book(
1161        &self,
1162        symbol: Option<&str>,
1163    ) -> BinanceSpotHttpResult<Vec<BookTicker>> {
1164        let params = symbol.map(TickerParams::for_symbol);
1165        let bytes = self.get_json("ticker/bookTicker", params.as_ref()).await?;
1166
1167        // Single symbol returns object, multiple returns array
1168        if symbol.is_some() {
1169            let ticker: BookTicker = serde_json::from_slice(&bytes)
1170                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1171            Ok(vec![ticker])
1172        } else {
1173            let tickers: Vec<BookTicker> = serde_json::from_slice(&bytes)
1174                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1175            Ok(tickers)
1176        }
1177    }
1178
1179    /// Returns current average price for a symbol.
1180    ///
1181    /// # Errors
1182    ///
1183    /// Returns an error if the request fails.
1184    pub async fn avg_price(&self, symbol: &str) -> BinanceSpotHttpResult<AvgPrice> {
1185        let params = AvgPriceParams::new(symbol);
1186        let bytes = self.get_json("avgPrice", Some(&params)).await?;
1187
1188        let avg_price: AvgPrice = serde_json::from_slice(&bytes)
1189            .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1190        Ok(avg_price)
1191    }
1192
1193    /// Returns trading fee rates for symbols.
1194    ///
1195    /// If `symbol` is None, returns fee rates for all symbols.
1196    /// Uses SAPI endpoint (requires authentication).
1197    ///
1198    /// # Errors
1199    ///
1200    /// Returns an error if credentials are missing or the request fails.
1201    pub async fn get_trade_fee(
1202        &self,
1203        symbol: Option<&str>,
1204    ) -> BinanceSpotHttpResult<Vec<TradeFee>> {
1205        let params = symbol.map(TradeFeeParams::for_symbol);
1206        let bytes = self
1207            .get_signed_sapi("asset/tradeFee", params.as_ref())
1208            .await?;
1209
1210        let fees: Vec<TradeFee> = serde_json::from_slice(&bytes)
1211            .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1212        Ok(fees)
1213    }
1214
1215    /// Performs a signed GET request to SAPI endpoints (returns JSON).
1216    async fn get_signed_sapi<P>(
1217        &self,
1218        path: &str,
1219        params: Option<&P>,
1220    ) -> BinanceSpotHttpResult<Vec<u8>>
1221    where
1222        P: Serialize + ?Sized,
1223    {
1224        let path = format!("{SAPI_PATH}/{}", path.trim_start_matches('/'));
1225        self.request(Method::GET, &path, params, true, false).await
1226    }
1227
1228    /// Percent-encodes a string for use in URL query parameters.
1229    fn percent_encode(input: &str) -> String {
1230        let mut result = String::with_capacity(input.len() * 3);
1231        for byte in input.bytes() {
1232            match byte {
1233                // Unreserved characters (RFC 3986)
1234                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1235                    result.push(byte as char);
1236                }
1237                _ => {
1238                    result.push('%');
1239                    result.push_str(&format!("{byte:02X}"));
1240                }
1241            }
1242        }
1243        result
1244    }
1245
1246    /// Submits multiple orders in a single request (up to 5 orders).
1247    ///
1248    /// Each order in the batch is processed independently. The response contains
1249    /// the result for each order, which can be either a success or an error.
1250    ///
1251    /// # Errors
1252    ///
1253    /// Returns an error if credentials are missing, the request fails, or
1254    /// JSON parsing fails. Individual order failures are returned in the
1255    /// response array as `BatchOrderResult::Error`.
1256    pub async fn batch_submit_orders(
1257        &self,
1258        orders: &[BatchOrderItem],
1259    ) -> BinanceSpotHttpResult<Vec<BatchOrderResult>> {
1260        if orders.is_empty() {
1261            return Ok(Vec::new());
1262        }
1263
1264        if orders.len() > 5 {
1265            return Err(BinanceSpotHttpError::ValidationError(
1266                "Batch order limit is 5 orders maximum".to_string(),
1267            ));
1268        }
1269
1270        let batch_json = serde_json::to_string(orders)
1271            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?;
1272
1273        let bytes = self
1274            .batch_request(Method::POST, "batchOrders", &batch_json)
1275            .await?;
1276
1277        let results: Vec<BatchOrderResult> = serde_json::from_slice(&bytes)
1278            .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1279
1280        Ok(results)
1281    }
1282
1283    /// Cancels multiple orders in a single request (up to 5 orders).
1284    ///
1285    /// Each cancel in the batch is processed independently. The response contains
1286    /// the result for each cancel, which can be either a success or an error.
1287    ///
1288    /// # Errors
1289    ///
1290    /// Returns an error if credentials are missing, the request fails, or
1291    /// JSON parsing fails. Individual cancel failures are returned in the
1292    /// response array as `BatchCancelResult::Error`.
1293    pub async fn batch_cancel_orders(
1294        &self,
1295        cancels: &[BatchCancelItem],
1296    ) -> BinanceSpotHttpResult<Vec<BatchCancelResult>> {
1297        if cancels.is_empty() {
1298            return Ok(Vec::new());
1299        }
1300
1301        if cancels.len() > 5 {
1302            return Err(BinanceSpotHttpError::ValidationError(
1303                "Batch cancel limit is 5 orders maximum".to_string(),
1304            ));
1305        }
1306
1307        let batch_json = serde_json::to_string(cancels)
1308            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?;
1309
1310        let bytes = self
1311            .batch_request(Method::DELETE, "batchOrders", &batch_json)
1312            .await?;
1313
1314        let results: Vec<BatchCancelResult> = serde_json::from_slice(&bytes)
1315            .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1316
1317        Ok(results)
1318    }
1319
1320    /// Performs a signed batch request with the batchOrders parameter.
1321    async fn batch_request(
1322        &self,
1323        method: Method,
1324        path: &str,
1325        batch_json: &str,
1326    ) -> BinanceSpotHttpResult<Vec<u8>> {
1327        let cred = self
1328            .credential
1329            .as_ref()
1330            .ok_or(BinanceSpotHttpError::MissingCredentials)?;
1331
1332        let encoded_batch = Self::percent_encode(batch_json);
1333        let timestamp = Timestamp::now().as_millisecond();
1334        let mut query = format!("batchOrders={encoded_batch}&timestamp={timestamp}");
1335
1336        if let Some(recv_window) = self.recv_window {
1337            query.push_str(&format!("&recvWindow={recv_window}"));
1338        }
1339
1340        let signature = Self::percent_encode(&cred.sign(&query));
1341        query.push_str(&format!("&signature={signature}"));
1342
1343        let url = self.build_url(path, &query);
1344
1345        let mut headers = HashMap::new();
1346        headers.insert(
1347            BINANCE_API_KEY_HEADER.to_string(),
1348            cred.api_key().to_string(),
1349        );
1350
1351        let keys = self.rate_limit_keys(true);
1352
1353        let response = self
1354            .client
1355            .request_with_url_redacted(
1356                method,
1357                url,
1358                None::<&HashMap<String, Vec<String>>>,
1359                Some(headers),
1360                None,
1361                None,
1362                Some(keys),
1363            )
1364            .await?;
1365
1366        if !response.status.is_success() {
1367            return self.parse_error_response(&response);
1368        }
1369
1370        Ok(response.body.to_vec())
1371    }
1372
1373    /// Returns account information including balances.
1374    ///
1375    /// # Errors
1376    ///
1377    /// Returns an error if the request fails or SBE decoding fails.
1378    pub async fn account(
1379        &self,
1380        params: &AccountInfoParams,
1381    ) -> BinanceSpotHttpResult<BinanceAccountInfo> {
1382        if self.json_responses {
1383            let bytes = self.get_signed_json("account", Some(params)).await?;
1384            let response: SpotAccountJson = serde_json::from_slice(&bytes)
1385                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1386            spot_account_from_json(response)
1387        } else {
1388            let bytes = self.get_signed("account", Some(params)).await?;
1389            Ok(parse::decode_account(&bytes)?)
1390        }
1391    }
1392
1393    /// Returns account-specific commission rates for one symbol.
1394    ///
1395    /// # Errors
1396    ///
1397    /// Returns an error if credentials are missing, the request fails, or JSON is malformed.
1398    pub async fn account_commission(
1399        &self,
1400        symbol: &str,
1401    ) -> BinanceSpotHttpResult<BinanceAccountCommission> {
1402        let params = AccountCommissionParams::new(symbol);
1403        let bytes = self
1404            .get_signed_json("account/commission", Some(&params))
1405            .await?;
1406        serde_json::from_slice(&bytes).map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))
1407    }
1408
1409    /// Returns the minimal JSON account commission view used by Binance US.
1410    ///
1411    /// # Errors
1412    ///
1413    /// Returns an error if credentials are missing, the request fails, or JSON is malformed.
1414    pub async fn account_rates_json(&self) -> BinanceSpotHttpResult<BinanceAccountRatesJson> {
1415        let params = AccountInfoParams::default();
1416        let bytes = self.get_signed_json("account", Some(&params)).await?;
1417        serde_json::from_slice(&bytes).map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))
1418    }
1419
1420    /// Returns account trade history for a symbol.
1421    ///
1422    /// # Errors
1423    ///
1424    /// Returns an error if the request fails or SBE decoding fails.
1425    pub async fn account_trades(
1426        &self,
1427        symbol: &str,
1428        order_id: Option<i64>,
1429        start_time: Option<i64>,
1430        end_time: Option<i64>,
1431        limit: Option<u32>,
1432    ) -> BinanceSpotHttpResult<Vec<BinanceAccountTrade>> {
1433        self.account_trades_with_cursor(symbol, order_id, start_time, end_time, None, limit)
1434            .await
1435    }
1436
1437    async fn account_trades_with_cursor(
1438        &self,
1439        symbol: &str,
1440        order_id: Option<i64>,
1441        start_time: Option<i64>,
1442        end_time: Option<i64>,
1443        from_id: Option<i64>,
1444        limit: Option<u32>,
1445    ) -> BinanceSpotHttpResult<Vec<BinanceAccountTrade>> {
1446        let params = AccountTradesParams {
1447            symbol: symbol.to_string(),
1448            order_id,
1449            start_time,
1450            end_time,
1451            from_id,
1452            limit,
1453        };
1454
1455        if self.json_responses {
1456            let bytes = self.get_signed_json("myTrades", Some(&params)).await?;
1457            let response: Vec<SpotAccountTradeJson> = serde_json::from_slice(&bytes)
1458                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1459            response
1460                .into_iter()
1461                .map(spot_account_trade_from_json)
1462                .collect()
1463        } else {
1464            let bytes = self.get_signed("myTrades", Some(&params)).await?;
1465            Ok(parse::decode_account_trades(&bytes)?)
1466        }
1467    }
1468
1469    /// Queries an order's status.
1470    ///
1471    /// Either `order_id` or `client_order_id` must be provided.
1472    ///
1473    /// # Errors
1474    ///
1475    /// Returns an error if the request fails or SBE decoding fails.
1476    pub async fn query_order(
1477        &self,
1478        symbol: &str,
1479        order_id: Option<i64>,
1480        client_order_id: Option<&str>,
1481    ) -> BinanceSpotHttpResult<BinanceOrderResponse> {
1482        let params = QueryOrderParams {
1483            symbol: symbol.to_string(),
1484            order_id,
1485            orig_client_order_id: client_order_id.map(|s| s.to_string()),
1486        };
1487
1488        if self.json_responses {
1489            let bytes = self.get_signed_json("order", Some(&params)).await?;
1490            let response: SpotOrderJson = serde_json::from_slice(&bytes)
1491                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1492            spot_order_from_json(response)
1493        } else {
1494            let bytes = self.get_signed("order", Some(&params)).await?;
1495            Ok(parse::decode_order(&bytes)?)
1496        }
1497    }
1498
1499    /// Returns all open orders for a symbol or all symbols.
1500    ///
1501    /// # Errors
1502    ///
1503    /// Returns an error if the request fails or SBE decoding fails.
1504    pub async fn open_orders(
1505        &self,
1506        symbol: Option<&str>,
1507    ) -> BinanceSpotHttpResult<Vec<BinanceOrderResponse>> {
1508        let params = OpenOrdersParams {
1509            symbol: symbol.map(|s| s.to_string()),
1510        };
1511
1512        if self.json_responses {
1513            let bytes = self.get_signed_json("openOrders", Some(&params)).await?;
1514            let response: Vec<SpotOrderJson> = serde_json::from_slice(&bytes)
1515                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1516            response.into_iter().map(spot_order_from_json).collect()
1517        } else {
1518            let bytes = self.get_signed("openOrders", Some(&params)).await?;
1519            Ok(parse::decode_orders(&bytes)?)
1520        }
1521    }
1522
1523    /// Returns all orders (including closed) for a symbol.
1524    ///
1525    /// # Errors
1526    ///
1527    /// Returns an error if the request fails or SBE decoding fails.
1528    pub async fn all_orders(
1529        &self,
1530        symbol: &str,
1531        start_time: Option<i64>,
1532        end_time: Option<i64>,
1533        limit: Option<u32>,
1534    ) -> BinanceSpotHttpResult<Vec<BinanceOrderResponse>> {
1535        let params = AllOrdersParams {
1536            symbol: symbol.to_string(),
1537            order_id: None,
1538            start_time,
1539            end_time,
1540            limit,
1541        };
1542
1543        if self.json_responses {
1544            let bytes = self.get_signed_json("allOrders", Some(&params)).await?;
1545            let response: Vec<SpotOrderJson> = serde_json::from_slice(&bytes)
1546                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1547            response.into_iter().map(spot_order_from_json).collect()
1548        } else {
1549            let bytes = self.get_signed("allOrders", Some(&params)).await?;
1550            Ok(parse::decode_orders(&bytes)?)
1551        }
1552    }
1553
1554    /// Performs a signed POST request for order operations.
1555    async fn post_order<P>(&self, path: &str, params: Option<&P>) -> BinanceSpotHttpResult<Vec<u8>>
1556    where
1557        P: Serialize + ?Sized,
1558    {
1559        if self.json_responses {
1560            self.post_signed_json(path, params).await
1561        } else {
1562            self.post_signed(path, params).await
1563        }
1564    }
1565
1566    /// Performs a signed DELETE request for cancel operations.
1567    async fn delete_order<P>(
1568        &self,
1569        path: &str,
1570        params: Option<&P>,
1571    ) -> BinanceSpotHttpResult<Vec<u8>>
1572    where
1573        P: Serialize + ?Sized,
1574    {
1575        if self.json_responses {
1576            self.delete_signed_json(path, params).await
1577        } else {
1578            self.delete_signed(path, params).await
1579        }
1580    }
1581
1582    /// Creates a new order.
1583    ///
1584    /// # Errors
1585    ///
1586    /// Returns an error if the request fails or SBE decoding fails.
1587    #[expect(clippy::too_many_arguments)]
1588    pub async fn new_order(
1589        &self,
1590        symbol: &str,
1591        side: BinanceSide,
1592        order_type: BinanceSpotOrderType,
1593        time_in_force: Option<BinanceTimeInForce>,
1594        quantity: Option<&str>,
1595        price: Option<&str>,
1596        client_order_id: Option<&str>,
1597        stop_price: Option<&str>,
1598    ) -> BinanceSpotHttpResult<BinanceNewOrderResponse> {
1599        let params = NewOrderParams {
1600            symbol: symbol.to_string(),
1601            side,
1602            order_type,
1603            time_in_force,
1604            quantity: quantity.map(|s| s.to_string()),
1605            quote_order_qty: None,
1606            price: price.map(|s| s.to_string()),
1607            new_client_order_id: client_order_id.map(|s| s.to_string()),
1608            stop_price: stop_price.map(|s| s.to_string()),
1609            trailing_delta: None,
1610            iceberg_qty: None,
1611            new_order_resp_type: Some(BinanceOrderResponseType::Full),
1612            self_trade_prevention_mode: None,
1613            strategy_id: None,
1614            strategy_type: None,
1615        };
1616        let bytes = self.post_order("order", Some(&params)).await?;
1617        self.decode_new_order_response(&bytes)
1618    }
1619
1620    /// Creates a new order with full parameter support.
1621    ///
1622    /// Extends [`new_order`](Self::new_order) with `quote_order_qty` (for market
1623    /// orders denominated in quote currency) and `iceberg_qty` (display
1624    /// quantity for iceberg orders).
1625    ///
1626    /// # Errors
1627    ///
1628    /// Returns an error if the request fails or SBE decoding fails.
1629    #[expect(clippy::too_many_arguments)]
1630    pub async fn new_order_full(
1631        &self,
1632        symbol: &str,
1633        side: BinanceSide,
1634        order_type: BinanceSpotOrderType,
1635        time_in_force: Option<BinanceTimeInForce>,
1636        quantity: Option<&str>,
1637        quote_order_qty: Option<&str>,
1638        price: Option<&str>,
1639        client_order_id: Option<&str>,
1640        stop_price: Option<&str>,
1641        iceberg_qty: Option<&str>,
1642    ) -> BinanceSpotHttpResult<BinanceNewOrderResponse> {
1643        let params = NewOrderParams {
1644            symbol: symbol.to_string(),
1645            side,
1646            order_type,
1647            time_in_force,
1648            quantity: quantity.map(|s| s.to_string()),
1649            quote_order_qty: quote_order_qty.map(|s| s.to_string()),
1650            price: price.map(|s| s.to_string()),
1651            new_client_order_id: client_order_id.map(|s| s.to_string()),
1652            stop_price: stop_price.map(|s| s.to_string()),
1653            trailing_delta: None,
1654            iceberg_qty: iceberg_qty.map(|s| s.to_string()),
1655            new_order_resp_type: Some(BinanceOrderResponseType::Full),
1656            self_trade_prevention_mode: None,
1657            strategy_id: None,
1658            strategy_type: None,
1659        };
1660        let bytes = self.post_order("order", Some(&params)).await?;
1661        self.decode_new_order_response(&bytes)
1662    }
1663
1664    /// Creates a new OCO order list.
1665    ///
1666    /// # Errors
1667    ///
1668    /// Returns an error if the request fails or JSON decoding fails.
1669    pub async fn new_oco_order_list(
1670        &self,
1671        params: &NewOcoOrderListParams,
1672    ) -> BinanceSpotHttpResult<NewOcoOrderListResponse> {
1673        let bytes = self.post_signed_json("orderList/oco", Some(params)).await?;
1674        serde_json::from_slice(&bytes).map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))
1675    }
1676
1677    /// Cancels an existing order and places a new order atomically.
1678    ///
1679    /// # Errors
1680    ///
1681    /// Returns an error if the request fails or SBE decoding fails.
1682    #[expect(clippy::too_many_arguments)]
1683    pub async fn cancel_replace_order(
1684        &self,
1685        symbol: &str,
1686        side: BinanceSide,
1687        order_type: BinanceSpotOrderType,
1688        time_in_force: Option<BinanceTimeInForce>,
1689        quantity: Option<&str>,
1690        price: Option<&str>,
1691        cancel_order_id: Option<i64>,
1692        cancel_client_order_id: Option<&str>,
1693        cancel_new_client_order_id: Option<&str>,
1694        new_client_order_id: Option<&str>,
1695    ) -> BinanceSpotHttpResult<BinanceNewOrderResponse> {
1696        let params = CancelReplaceOrderParams {
1697            symbol: symbol.to_string(),
1698            side,
1699            order_type,
1700            cancel_replace_mode: BinanceCancelReplaceMode::StopOnFailure,
1701            time_in_force,
1702            quantity: quantity.map(|s| s.to_string()),
1703            quote_order_qty: None,
1704            price: price.map(|s| s.to_string()),
1705            cancel_order_id,
1706            cancel_orig_client_order_id: cancel_client_order_id.map(|s| s.to_string()),
1707            cancel_new_client_order_id: cancel_new_client_order_id.map(|s| s.to_string()),
1708            new_client_order_id: new_client_order_id.map(|s| s.to_string()),
1709            stop_price: None,
1710            trailing_delta: None,
1711            iceberg_qty: None,
1712            new_order_resp_type: Some(BinanceOrderResponseType::Full),
1713            self_trade_prevention_mode: None,
1714        };
1715        let bytes = self
1716            .post_order("order/cancelReplace", Some(&params))
1717            .await?;
1718
1719        if self.json_responses {
1720            let response: SpotCancelReplaceJson = serde_json::from_slice(&bytes)
1721                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1722            spot_new_order_from_json(response.new_order_response)
1723        } else {
1724            Ok(parse::decode_cancel_replace(&bytes)?)
1725        }
1726    }
1727
1728    /// Cancels an existing order.
1729    ///
1730    /// Either `order_id` or `client_order_id` must be provided.
1731    ///
1732    /// # Errors
1733    ///
1734    /// Returns an error if the request fails or SBE decoding fails.
1735    pub async fn cancel_order(
1736        &self,
1737        symbol: &str,
1738        order_id: Option<i64>,
1739        client_order_id: Option<&str>,
1740    ) -> BinanceSpotHttpResult<BinanceCancelOrderResponse> {
1741        let params = match (order_id, client_order_id) {
1742            (Some(id), _) => CancelOrderParams::by_order_id(symbol, id),
1743            (None, Some(id)) => CancelOrderParams::by_client_order_id(symbol, id.to_string()),
1744            (None, None) => {
1745                return Err(BinanceSpotHttpError::ValidationError(
1746                    "Either order_id or client_order_id must be provided".to_string(),
1747                ));
1748            }
1749        };
1750        let bytes = self.delete_order("order", Some(&params)).await?;
1751        self.decode_cancel_order_response(&bytes)
1752    }
1753
1754    /// Cancels all open orders for a symbol.
1755    ///
1756    /// Returns one response per ordinary order and order-list child.
1757    ///
1758    /// # Errors
1759    ///
1760    /// Returns an error if the request fails or SBE decoding fails.
1761    pub async fn cancel_open_orders(
1762        &self,
1763        symbol: &str,
1764    ) -> BinanceSpotHttpResult<Vec<BinanceCancelOrderResponse>> {
1765        Ok(self
1766            .cancel_open_order_responses(symbol)
1767            .await?
1768            .into_iter()
1769            .flat_map(|response| match response {
1770                BinanceCancelOpenOrdersResponse::Order(response) => vec![response],
1771                BinanceCancelOpenOrdersResponse::OrderList(response) => response.order_reports,
1772            })
1773            .collect())
1774    }
1775
1776    /// Cancels all open orders for a symbol and preserves order-list responses.
1777    ///
1778    /// # Errors
1779    ///
1780    /// Returns an error if the request fails or SBE decoding fails.
1781    pub async fn cancel_open_order_responses(
1782        &self,
1783        symbol: &str,
1784    ) -> BinanceSpotHttpResult<Vec<BinanceCancelOpenOrdersResponse>> {
1785        let params = CancelOpenOrdersParams::new(symbol.to_string());
1786        let bytes = self.delete_order("openOrders", Some(&params)).await?;
1787        if self.json_responses {
1788            spot_cancel_open_orders_from_json(&bytes)
1789        } else {
1790            Ok(parse::decode_cancel_open_orders(&bytes)?)
1791        }
1792    }
1793
1794    fn decode_new_order_response(
1795        &self,
1796        bytes: &[u8],
1797    ) -> BinanceSpotHttpResult<BinanceNewOrderResponse> {
1798        if self.json_responses {
1799            let response: SpotOrderJson = serde_json::from_slice(bytes)
1800                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1801            spot_new_order_from_json(response)
1802        } else {
1803            Ok(parse::decode_new_order_full(bytes)?)
1804        }
1805    }
1806
1807    fn decode_cancel_order_response(
1808        &self,
1809        bytes: &[u8],
1810    ) -> BinanceSpotHttpResult<BinanceCancelOrderResponse> {
1811        if self.json_responses {
1812            let response: SpotOrderJson = serde_json::from_slice(bytes)
1813                .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1814            spot_cancel_order_from_json(response)
1815        } else {
1816            Ok(parse::decode_cancel_order(bytes)?)
1817        }
1818    }
1819
1820    /// Performs an API-key authenticated request (no signature) that returns JSON.
1821    async fn request_with_api_key<P>(
1822        &self,
1823        method: Method,
1824        path: &str,
1825        params: Option<&P>,
1826    ) -> BinanceSpotHttpResult<Vec<u8>>
1827    where
1828        P: Serialize + ?Sized,
1829    {
1830        let cred = self
1831            .credential
1832            .as_ref()
1833            .ok_or(BinanceSpotHttpError::MissingCredentials)?;
1834
1835        let query = params
1836            .map(serde_urlencoded::to_string)
1837            .transpose()
1838            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?
1839            .unwrap_or_default();
1840
1841        let url = self.build_url(path, &query);
1842
1843        let mut headers = HashMap::new();
1844        headers.insert(
1845            BINANCE_API_KEY_HEADER.to_string(),
1846            cred.api_key().to_string(),
1847        );
1848
1849        let keys = vec![BINANCE_GLOBAL_RATE_KEY.to_string()];
1850
1851        let response = self
1852            .client
1853            .request_with_url_redacted(
1854                method,
1855                url,
1856                None::<&HashMap<String, Vec<String>>>,
1857                Some(headers),
1858                None,
1859                None,
1860                Some(keys),
1861            )
1862            .await?;
1863
1864        if !response.status.is_success() {
1865            return self.parse_error_response(&response);
1866        }
1867
1868        Ok(response.body.to_vec())
1869    }
1870
1871    /// Creates a new listen key for the user data stream.
1872    ///
1873    /// Listen keys are valid for 60 minutes. Use `extend_listen_key` to keep
1874    /// the stream alive.
1875    ///
1876    /// # Errors
1877    ///
1878    /// Returns an error if credentials are missing or the request fails.
1879    pub async fn create_listen_key(&self) -> BinanceSpotHttpResult<ListenKeyResponse> {
1880        let bytes = self
1881            .request_with_api_key(Method::POST, "userDataStream", None::<&()>)
1882            .await?;
1883
1884        let response: ListenKeyResponse = serde_json::from_slice(&bytes)
1885            .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
1886
1887        Ok(response)
1888    }
1889
1890    /// Extends the validity of a listen key by 60 minutes.
1891    ///
1892    /// Should be called periodically to keep the user data stream alive.
1893    ///
1894    /// # Errors
1895    ///
1896    /// Returns an error if credentials are missing or the request fails.
1897    pub async fn extend_listen_key(&self, listen_key: &str) -> BinanceSpotHttpResult<()> {
1898        let params = ListenKeyParams::new(listen_key);
1899        self.request_with_api_key(Method::PUT, "userDataStream", Some(&params))
1900            .await?;
1901        Ok(())
1902    }
1903
1904    /// Closes a listen key, terminating the user data stream.
1905    ///
1906    /// # Errors
1907    ///
1908    /// Returns an error if credentials are missing or the request fails.
1909    pub async fn close_listen_key(&self, listen_key: &str) -> BinanceSpotHttpResult<()> {
1910        let params = ListenKeyParams::new(listen_key);
1911        self.request_with_api_key(Method::DELETE, "userDataStream", Some(&params))
1912            .await?;
1913        Ok(())
1914    }
1915}
1916
1917fn spot_depth_from_json(response: SpotDepthJson) -> BinanceSpotHttpResult<BinanceDepth> {
1918    let price_scale = decimal_common_scale(
1919        response
1920            .bids
1921            .iter()
1922            .chain(&response.asks)
1923            .map(|level| level[0].as_str()),
1924    )?;
1925    let qty_scale = decimal_common_scale(
1926        response
1927            .bids
1928            .iter()
1929            .chain(&response.asks)
1930            .map(|level| level[1].as_str()),
1931    )?;
1932    let parse_levels = |levels: Vec<[String; 2]>| {
1933        levels
1934            .into_iter()
1935            .map(|level| {
1936                Ok(BinancePriceLevel {
1937                    price_mantissa: decimal_mantissa(&level[0], price_scale)?,
1938                    qty_mantissa: decimal_mantissa(&level[1], qty_scale)?,
1939                })
1940            })
1941            .collect::<BinanceSpotHttpResult<Vec<_>>>()
1942    };
1943
1944    Ok(BinanceDepth {
1945        last_update_id: response.last_update_id,
1946        price_exponent: decimal_exponent(price_scale)?,
1947        qty_exponent: decimal_exponent(qty_scale)?,
1948        bids: parse_levels(response.bids)?,
1949        asks: parse_levels(response.asks)?,
1950    })
1951}
1952
1953fn spot_trades_from_json(response: Vec<SpotTradeJson>) -> BinanceSpotHttpResult<BinanceTrades> {
1954    let price_scale = decimal_common_scale(
1955        response
1956            .iter()
1957            .flat_map(|trade| [trade.price.as_str(), trade.quote_qty.as_str()]),
1958    )?;
1959    let qty_scale = decimal_common_scale(response.iter().map(|trade| trade.qty.as_str()))?;
1960    let trades = response
1961        .into_iter()
1962        .map(|trade| {
1963            Ok(BinanceTrade {
1964                id: trade.id,
1965                price_mantissa: decimal_mantissa(&trade.price, price_scale)?,
1966                qty_mantissa: decimal_mantissa(&trade.qty, qty_scale)?,
1967                quote_qty_mantissa: decimal_mantissa(&trade.quote_qty, price_scale)?,
1968                time: millis_to_micros(trade.time)?,
1969                is_buyer_maker: trade.is_buyer_maker,
1970                is_best_match: trade.is_best_match,
1971            })
1972        })
1973        .collect::<BinanceSpotHttpResult<Vec<_>>>()?;
1974
1975    Ok(BinanceTrades {
1976        price_exponent: decimal_exponent(price_scale)?,
1977        qty_exponent: decimal_exponent(qty_scale)?,
1978        trades,
1979    })
1980}
1981
1982fn spot_agg_trades_from_json(
1983    response: Vec<SpotAggTradeJson>,
1984) -> BinanceSpotHttpResult<BinanceAggTrades> {
1985    let price_scale = decimal_common_scale(response.iter().map(|trade| trade.price.as_str()))?;
1986    let qty_scale = decimal_common_scale(response.iter().map(|trade| trade.qty.as_str()))?;
1987    let trades = response
1988        .into_iter()
1989        .map(|trade| {
1990            Ok(BinanceAggTrade {
1991                id: trade.id,
1992                price_mantissa: decimal_mantissa(&trade.price, price_scale)?,
1993                qty_mantissa: decimal_mantissa(&trade.qty, qty_scale)?,
1994                first_trade_id: trade.first_trade_id,
1995                last_trade_id: trade.last_trade_id,
1996                time: millis_to_micros(trade.time)?,
1997                is_buyer_maker: trade.is_buyer_maker,
1998                is_best_match: trade.is_best_match,
1999            })
2000        })
2001        .collect::<BinanceSpotHttpResult<Vec<_>>>()?;
2002
2003    Ok(BinanceAggTrades {
2004        price_exponent: decimal_exponent(price_scale)?,
2005        qty_exponent: decimal_exponent(qty_scale)?,
2006        trades,
2007    })
2008}
2009
2010fn spot_klines_from_json(response: Vec<SpotKlineJson>) -> BinanceSpotHttpResult<BinanceKlines> {
2011    let price_scale = decimal_common_scale(response.iter().flat_map(|kline| {
2012        [
2013            kline.1.as_str(),
2014            kline.2.as_str(),
2015            kline.3.as_str(),
2016            kline.4.as_str(),
2017            kline.7.as_str(),
2018            kline.10.as_str(),
2019        ]
2020    }))?;
2021    let qty_scale = decimal_common_scale(
2022        response
2023            .iter()
2024            .flat_map(|kline| [kline.5.as_str(), kline.9.as_str()]),
2025    )?;
2026    let klines = response
2027        .into_iter()
2028        .map(|kline| {
2029            let volume = decimal_i128_bytes(&kline.5, qty_scale)?;
2030            let quote_volume = decimal_i128_bytes(&kline.7, price_scale)?;
2031            let taker_buy_base_volume = decimal_i128_bytes(&kline.9, qty_scale)?;
2032            let taker_buy_quote_volume = decimal_i128_bytes(&kline.10, price_scale)?;
2033            Ok(BinanceKline {
2034                open_time: millis_to_micros(kline.0)?,
2035                open_price: decimal_mantissa(&kline.1, price_scale)?,
2036                high_price: decimal_mantissa(&kline.2, price_scale)?,
2037                low_price: decimal_mantissa(&kline.3, price_scale)?,
2038                close_price: decimal_mantissa(&kline.4, price_scale)?,
2039                volume,
2040                close_time: millis_to_micros(kline.6)?,
2041                quote_volume,
2042                num_trades: kline.8,
2043                taker_buy_base_volume,
2044                taker_buy_quote_volume,
2045            })
2046        })
2047        .collect::<BinanceSpotHttpResult<Vec<_>>>()?;
2048
2049    Ok(BinanceKlines {
2050        price_exponent: decimal_exponent(price_scale)?,
2051        qty_exponent: decimal_exponent(qty_scale)?,
2052        klines,
2053    })
2054}
2055
2056fn decimal_i128_bytes(value: &str, scale: u32) -> BinanceSpotHttpResult<[u8; 16]> {
2057    let mut decimal = Decimal::from_str_exact(value)
2058        .map_err(|e| BinanceSpotHttpError::ResponseParseError(e.to_string()))?;
2059    decimal.rescale(scale);
2060    Ok(decimal.mantissa().to_le_bytes())
2061}
2062
2063fn spot_account_from_json(response: SpotAccountJson) -> BinanceSpotHttpResult<BinanceAccountInfo> {
2064    let commission_rates = response.commission_rates.map_or_else(
2065        || {
2066            [
2067                Decimal::new(response.maker_commission, 4).to_string(),
2068                Decimal::new(response.taker_commission, 4).to_string(),
2069                Decimal::new(response.buyer_commission, 4).to_string(),
2070                Decimal::new(response.seller_commission, 4).to_string(),
2071            ]
2072        },
2073        |rates| [rates.maker, rates.taker, rates.buyer, rates.seller],
2074    );
2075    let commission_scale = decimal_common_scale(commission_rates.iter().map(String::as_str))?;
2076    let balances = response
2077        .balances
2078        .into_iter()
2079        .map(|balance| {
2080            let scale = decimal_common_scale([balance.free.as_str(), balance.locked.as_str()])?;
2081            Ok(BinanceBalance {
2082                asset: balance.asset,
2083                free_mantissa: decimal_mantissa(&balance.free, scale)?,
2084                locked_mantissa: decimal_mantissa(&balance.locked, scale)?,
2085                exponent: decimal_exponent(scale)?,
2086            })
2087        })
2088        .collect::<BinanceSpotHttpResult<Vec<_>>>()?;
2089
2090    Ok(BinanceAccountInfo {
2091        commission_exponent: decimal_exponent(commission_scale)?,
2092        maker_commission_mantissa: decimal_mantissa(&commission_rates[0], commission_scale)?,
2093        taker_commission_mantissa: decimal_mantissa(&commission_rates[1], commission_scale)?,
2094        buyer_commission_mantissa: decimal_mantissa(&commission_rates[2], commission_scale)?,
2095        seller_commission_mantissa: decimal_mantissa(&commission_rates[3], commission_scale)?,
2096        can_trade: response.can_trade,
2097        can_withdraw: response.can_withdraw,
2098        can_deposit: response.can_deposit,
2099        require_self_trade_prevention: response.require_self_trade_prevention,
2100        prevent_sor: response.prevent_sor,
2101        update_time: millis_to_micros(response.update_time)?,
2102        account_type: response.account_type,
2103        balances,
2104    })
2105}
2106
2107fn spot_new_order_from_json(
2108    response: SpotOrderJson,
2109) -> BinanceSpotHttpResult<BinanceNewOrderResponse> {
2110    let (price_scale, qty_scale) = spot_order_scales(&response)?;
2111    let fills = response
2112        .fills
2113        .iter()
2114        .map(|fill| {
2115            let commission_scale = decimal_common_scale([fill.commission.as_str()])?;
2116            Ok(BinanceOrderFill {
2117                price_mantissa: decimal_mantissa(&fill.price, price_scale)?,
2118                qty_mantissa: decimal_mantissa(&fill.qty, qty_scale)?,
2119                commission_mantissa: decimal_mantissa(&fill.commission, commission_scale)?,
2120                commission_exponent: decimal_exponent(commission_scale)?,
2121                commission_asset: fill.commission_asset.clone(),
2122                trade_id: fill.trade_id,
2123            })
2124        })
2125        .collect::<BinanceSpotHttpResult<Vec<_>>>()?;
2126
2127    Ok(BinanceNewOrderResponse {
2128        price_exponent: decimal_exponent(price_scale)?,
2129        qty_exponent: decimal_exponent(qty_scale)?,
2130        order_id: response.order_id,
2131        order_list_id: valid_order_list_id(response.order_list_id),
2132        transact_time: millis_to_micros(response.transact_time)?,
2133        price_mantissa: decimal_mantissa(&response.price, price_scale)?,
2134        orig_qty_mantissa: decimal_mantissa(&response.orig_qty, qty_scale)?,
2135        executed_qty_mantissa: decimal_mantissa(&response.executed_qty, qty_scale)?,
2136        cummulative_quote_qty_mantissa: decimal_mantissa(
2137            &response.cummulative_quote_qty,
2138            price_scale + qty_scale,
2139        )?,
2140        status: spot_sbe_order_status(response.status),
2141        time_in_force: spot_sbe_time_in_force(response.time_in_force),
2142        order_type: spot_sbe_order_type(response.order_type),
2143        side: spot_sbe_order_side(response.side),
2144        stop_price_mantissa: decimal_optional_mantissa(&response.stop_price, price_scale)?,
2145        working_time: response.working_time.map(millis_to_micros).transpose()?,
2146        self_trade_prevention_mode: spot_sbe_stp(response.self_trade_prevention_mode),
2147        client_order_id: response.client_order_id,
2148        symbol: response.symbol,
2149        fills,
2150        expiry_reason: None,
2151    })
2152}
2153
2154fn spot_cancel_order_from_json(
2155    response: SpotOrderJson,
2156) -> BinanceSpotHttpResult<BinanceCancelOrderResponse> {
2157    let (price_scale, qty_scale) = spot_order_scales(&response)?;
2158    Ok(BinanceCancelOrderResponse {
2159        price_exponent: decimal_exponent(price_scale)?,
2160        qty_exponent: decimal_exponent(qty_scale)?,
2161        order_id: response.order_id,
2162        order_list_id: valid_order_list_id(response.order_list_id),
2163        transact_time: millis_to_micros(response.transact_time)?,
2164        price_mantissa: decimal_mantissa(&response.price, price_scale)?,
2165        orig_qty_mantissa: decimal_mantissa(&response.orig_qty, qty_scale)?,
2166        executed_qty_mantissa: decimal_mantissa(&response.executed_qty, qty_scale)?,
2167        cummulative_quote_qty_mantissa: decimal_mantissa(
2168            &response.cummulative_quote_qty,
2169            price_scale + qty_scale,
2170        )?,
2171        status: spot_sbe_order_status(response.status),
2172        time_in_force: spot_sbe_time_in_force(response.time_in_force),
2173        order_type: spot_sbe_order_type(response.order_type),
2174        side: spot_sbe_order_side(response.side),
2175        self_trade_prevention_mode: spot_sbe_stp(response.self_trade_prevention_mode),
2176        client_order_id: response.client_order_id,
2177        orig_client_order_id: response.orig_client_order_id,
2178        symbol: response.symbol,
2179    })
2180}
2181
2182fn spot_cancel_open_orders_from_json(
2183    bytes: &[u8],
2184) -> BinanceSpotHttpResult<Vec<BinanceCancelOpenOrdersResponse>> {
2185    let responses: Vec<Value> = serde_json::from_slice(bytes)
2186        .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
2187
2188    responses
2189        .into_iter()
2190        .map(|response| {
2191            let is_order_list =
2192                response.get("orders").is_some() || response.get("orderReports").is_some();
2193            if is_order_list {
2194                let response: SpotCancelOrderListJson = serde_json::from_value(response)
2195                    .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
2196                spot_cancel_order_list_from_json(response)
2197                    .map(BinanceCancelOpenOrdersResponse::OrderList)
2198            } else {
2199                let response: SpotOrderJson = serde_json::from_value(response)
2200                    .map_err(|e| BinanceSpotHttpError::JsonError(e.to_string()))?;
2201                spot_cancel_order_from_json(response).map(BinanceCancelOpenOrdersResponse::Order)
2202            }
2203        })
2204        .collect()
2205}
2206
2207fn spot_cancel_order_list_from_json(
2208    response: SpotCancelOrderListJson,
2209) -> BinanceSpotHttpResult<BinanceCancelOrderListResponse> {
2210    let contingency_type = match response.contingency_type.as_str() {
2211        "OCO" => SbeContingencyType::Oco,
2212        "OTO" => SbeContingencyType::Oto,
2213        _ => {
2214            return Err(BinanceSpotHttpError::ResponseParseError(format!(
2215                "unknown order-list contingency type: {}",
2216                response.contingency_type
2217            )));
2218        }
2219    };
2220    let list_status_type = match response.list_status_type.as_str() {
2221        "RESPONSE" => SbeListStatusType::Response,
2222        "EXEC_STARTED" => SbeListStatusType::ExecStarted,
2223        "ALL_DONE" => SbeListStatusType::AllDone,
2224        "UPDATED" => SbeListStatusType::Updated,
2225        _ => {
2226            return Err(BinanceSpotHttpError::ResponseParseError(format!(
2227                "unknown order-list status type: {}",
2228                response.list_status_type
2229            )));
2230        }
2231    };
2232    let list_order_status = match response.list_order_status.as_str() {
2233        "CANCELING" => SbeListOrderStatus::Canceling,
2234        "EXECUTING" => SbeListOrderStatus::Executing,
2235        "ALL_DONE" => SbeListOrderStatus::AllDone,
2236        "REJECT" => SbeListOrderStatus::Reject,
2237        _ => {
2238            return Err(BinanceSpotHttpError::ResponseParseError(format!(
2239                "unknown aggregate order-list status: {}",
2240                response.list_order_status
2241            )));
2242        }
2243    };
2244    let order_reports = response
2245        .order_reports
2246        .into_iter()
2247        .map(spot_cancel_order_from_json)
2248        .collect::<BinanceSpotHttpResult<Vec<_>>>()?;
2249
2250    Ok(BinanceCancelOrderListResponse {
2251        order_list_id: response.order_list_id,
2252        contingency_type,
2253        list_status_type,
2254        list_order_status,
2255        transaction_time: millis_to_micros(response.transaction_time)?,
2256        list_client_order_id: response.list_client_order_id,
2257        symbol: response.symbol,
2258        orders: response
2259            .orders
2260            .into_iter()
2261            .map(|order| super::models::BinanceCancelOrderListOrder {
2262                symbol: order.symbol,
2263                order_id: order.order_id,
2264                client_order_id: order.client_order_id,
2265            })
2266            .collect(),
2267        order_reports,
2268    })
2269}
2270
2271fn spot_order_from_json(response: SpotOrderJson) -> BinanceSpotHttpResult<BinanceOrderResponse> {
2272    let (price_scale, qty_scale) = spot_order_scales(&response)?;
2273    Ok(BinanceOrderResponse {
2274        price_exponent: decimal_exponent(price_scale)?,
2275        qty_exponent: decimal_exponent(qty_scale)?,
2276        order_id: response.order_id,
2277        order_list_id: valid_order_list_id(response.order_list_id),
2278        price_mantissa: decimal_mantissa(&response.price, price_scale)?,
2279        orig_qty_mantissa: decimal_mantissa(&response.orig_qty, qty_scale)?,
2280        executed_qty_mantissa: decimal_mantissa(&response.executed_qty, qty_scale)?,
2281        cummulative_quote_qty_mantissa: decimal_mantissa(
2282            &response.cummulative_quote_qty,
2283            price_scale + qty_scale,
2284        )?,
2285        status: spot_sbe_order_status(response.status),
2286        time_in_force: spot_sbe_time_in_force(response.time_in_force),
2287        order_type: spot_sbe_order_type(response.order_type),
2288        side: spot_sbe_order_side(response.side),
2289        stop_price_mantissa: decimal_optional_mantissa(&response.stop_price, price_scale)?,
2290        iceberg_qty_mantissa: decimal_optional_mantissa(&response.iceberg_qty, qty_scale)?,
2291        time: millis_to_micros(response.time)?,
2292        update_time: millis_to_micros(response.update_time)?,
2293        is_working: response.is_working,
2294        working_time: response.working_time.map(millis_to_micros).transpose()?,
2295        orig_quote_order_qty_mantissa: decimal_mantissa(
2296            &response.orig_quote_order_qty,
2297            price_scale + qty_scale,
2298        )?,
2299        self_trade_prevention_mode: spot_sbe_stp(response.self_trade_prevention_mode),
2300        client_order_id: response.client_order_id,
2301        symbol: response.symbol,
2302        expiry_reason: None,
2303    })
2304}
2305
2306fn spot_account_trade_from_json(
2307    response: SpotAccountTradeJson,
2308) -> BinanceSpotHttpResult<BinanceAccountTrade> {
2309    let price_scale = decimal_common_scale([response.price.as_str()])?;
2310    let qty_scale = decimal_common_scale([response.qty.as_str()])?;
2311    let commission_scale = decimal_common_scale([response.commission.as_str()])?;
2312    Ok(BinanceAccountTrade {
2313        price_exponent: decimal_exponent(price_scale)?,
2314        qty_exponent: decimal_exponent(qty_scale)?,
2315        commission_exponent: decimal_exponent(commission_scale)?,
2316        id: response.id,
2317        order_id: response.order_id,
2318        order_list_id: valid_order_list_id(response.order_list_id),
2319        price_mantissa: decimal_mantissa(&response.price, price_scale)?,
2320        qty_mantissa: decimal_mantissa(&response.qty, qty_scale)?,
2321        quote_qty_mantissa: decimal_mantissa(&response.quote_qty, price_scale + qty_scale)?,
2322        commission_mantissa: decimal_mantissa(&response.commission, commission_scale)?,
2323        time: millis_to_micros(response.time)?,
2324        is_buyer: response.is_buyer,
2325        is_maker: response.is_maker,
2326        is_best_match: response.is_best_match,
2327        symbol: response.symbol,
2328        commission_asset: response.commission_asset,
2329    })
2330}
2331
2332fn spot_order_scales(response: &SpotOrderJson) -> BinanceSpotHttpResult<(u32, u32)> {
2333    let price_scale = decimal_common_scale(
2334        [response.price.as_str(), response.stop_price.as_str()]
2335            .into_iter()
2336            .chain(response.fills.iter().map(|fill| fill.price.as_str())),
2337    )?;
2338    let qty_scale = decimal_common_scale(
2339        [
2340            response.orig_qty.as_str(),
2341            response.executed_qty.as_str(),
2342            response.iceberg_qty.as_str(),
2343        ]
2344        .into_iter()
2345        .chain(response.fills.iter().map(|fill| fill.qty.as_str())),
2346    )?;
2347    Ok((price_scale, qty_scale))
2348}
2349
2350fn decimal_common_scale<'a>(
2351    values: impl IntoIterator<Item = &'a str>,
2352) -> BinanceSpotHttpResult<u32> {
2353    values
2354        .into_iter()
2355        .filter(|value| !value.is_empty())
2356        .try_fold(0, |scale, value| {
2357            let decimal = Decimal::from_str_exact(value)
2358                .map_err(|e| BinanceSpotHttpError::ResponseParseError(e.to_string()))?;
2359            Ok(scale.max(decimal.scale()))
2360        })
2361}
2362
2363fn decimal_mantissa(value: &str, scale: u32) -> BinanceSpotHttpResult<i64> {
2364    if value.is_empty() {
2365        return Ok(0);
2366    }
2367    let mut decimal = Decimal::from_str_exact(value)
2368        .map_err(|e| BinanceSpotHttpError::ResponseParseError(e.to_string()))?;
2369    decimal.rescale(scale);
2370    i64::try_from(decimal.mantissa()).map_err(|_| {
2371        BinanceSpotHttpError::ResponseParseError(format!(
2372            "decimal mantissa is outside i64 range: {value}"
2373        ))
2374    })
2375}
2376
2377fn decimal_optional_mantissa(value: &str, scale: u32) -> BinanceSpotHttpResult<Option<i64>> {
2378    let mantissa = decimal_mantissa(value, scale)?;
2379    Ok((mantissa != 0).then_some(mantissa))
2380}
2381
2382fn decimal_exponent(scale: u32) -> BinanceSpotHttpResult<i8> {
2383    i8::try_from(scale)
2384        .map(|scale| -scale)
2385        .map_err(|_| BinanceSpotHttpError::ResponseParseError("decimal scale exceeds i8".into()))
2386}
2387
2388fn millis_to_micros(timestamp: i64) -> BinanceSpotHttpResult<i64> {
2389    timestamp.checked_mul(1_000).ok_or_else(|| {
2390        BinanceSpotHttpError::ResponseParseError(format!(
2391            "timestamp overflows microseconds: {timestamp}"
2392        ))
2393    })
2394}
2395
2396fn valid_order_list_id(order_list_id: Option<i64>) -> Option<i64> {
2397    order_list_id.filter(|value| *value >= 0)
2398}
2399
2400const fn spot_sbe_order_status(status: BinanceOrderStatus) -> SbeOrderStatus {
2401    match status {
2402        BinanceOrderStatus::New => SbeOrderStatus::New,
2403        BinanceOrderStatus::PendingNew => SbeOrderStatus::PendingNew,
2404        BinanceOrderStatus::PartiallyFilled => SbeOrderStatus::PartiallyFilled,
2405        BinanceOrderStatus::Filled => SbeOrderStatus::Filled,
2406        BinanceOrderStatus::Canceled => SbeOrderStatus::Canceled,
2407        BinanceOrderStatus::PendingCancel => SbeOrderStatus::PendingCancel,
2408        BinanceOrderStatus::Rejected => SbeOrderStatus::Rejected,
2409        BinanceOrderStatus::Expired => SbeOrderStatus::Expired,
2410        BinanceOrderStatus::ExpiredInMatch => SbeOrderStatus::ExpiredInMatch,
2411        BinanceOrderStatus::NewInsurance
2412        | BinanceOrderStatus::NewAdl
2413        | BinanceOrderStatus::Unknown => SbeOrderStatus::Unknown,
2414    }
2415}
2416
2417const fn spot_sbe_time_in_force(time_in_force: BinanceTimeInForce) -> SbeTimeInForce {
2418    match time_in_force {
2419        BinanceTimeInForce::Gtc => SbeTimeInForce::Gtc,
2420        BinanceTimeInForce::Ioc => SbeTimeInForce::Ioc,
2421        BinanceTimeInForce::Fok => SbeTimeInForce::Fok,
2422        BinanceTimeInForce::Gtx
2423        | BinanceTimeInForce::Gtd
2424        | BinanceTimeInForce::Rpi
2425        | BinanceTimeInForce::Unknown => SbeTimeInForce::NonRepresentable,
2426    }
2427}
2428
2429const fn spot_sbe_order_type(order_type: BinanceSpotOrderType) -> SbeOrderType {
2430    match order_type {
2431        BinanceSpotOrderType::Market => SbeOrderType::Market,
2432        BinanceSpotOrderType::Limit => SbeOrderType::Limit,
2433        BinanceSpotOrderType::StopLoss => SbeOrderType::StopLoss,
2434        BinanceSpotOrderType::StopLossLimit => SbeOrderType::StopLossLimit,
2435        BinanceSpotOrderType::TakeProfit => SbeOrderType::TakeProfit,
2436        BinanceSpotOrderType::TakeProfitLimit => SbeOrderType::TakeProfitLimit,
2437        BinanceSpotOrderType::LimitMaker => SbeOrderType::LimitMaker,
2438        BinanceSpotOrderType::Unknown => SbeOrderType::NonRepresentable,
2439    }
2440}
2441
2442const fn spot_sbe_order_side(side: BinanceSide) -> SbeOrderSide {
2443    match side {
2444        BinanceSide::Buy => SbeOrderSide::Buy,
2445        BinanceSide::Sell => SbeOrderSide::Sell,
2446    }
2447}
2448
2449fn spot_sbe_stp(mode: Option<BinanceSelfTradePreventionMode>) -> SbeSelfTradePreventionMode {
2450    match mode.unwrap_or(BinanceSelfTradePreventionMode::None) {
2451        BinanceSelfTradePreventionMode::None => SbeSelfTradePreventionMode::None,
2452        BinanceSelfTradePreventionMode::ExpireMaker => SbeSelfTradePreventionMode::ExpireMaker,
2453        BinanceSelfTradePreventionMode::ExpireTaker => SbeSelfTradePreventionMode::ExpireTaker,
2454        BinanceSelfTradePreventionMode::ExpireBoth => SbeSelfTradePreventionMode::ExpireBoth,
2455        BinanceSelfTradePreventionMode::Decrement => SbeSelfTradePreventionMode::Decrement,
2456        BinanceSelfTradePreventionMode::Transfer => SbeSelfTradePreventionMode::Transfer,
2457        BinanceSelfTradePreventionMode::Unknown => SbeSelfTradePreventionMode::NonRepresentable,
2458    }
2459}
2460
2461/// High-level HTTP client for Binance Spot API.
2462///
2463/// Wraps [`BinanceRawSpotHttpClient`] and provides domain-level methods:
2464/// - Simple types (ping, server_time): Pass through from raw client.
2465/// - Complex types (instruments, orders): Transform to Nautilus domain types.
2466pub struct BinanceSpotHttpClient {
2467    inner: Arc<BinanceRawSpotHttpClient>,
2468    clock: &'static AtomicTime,
2469    instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
2470}
2471
2472impl Clone for BinanceSpotHttpClient {
2473    fn clone(&self) -> Self {
2474        Self {
2475            inner: self.inner.clone(),
2476            clock: self.clock,
2477            instruments_cache: self.instruments_cache.clone(),
2478        }
2479    }
2480}
2481
2482impl Debug for BinanceSpotHttpClient {
2483    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2484        f.debug_struct(stringify!(BinanceSpotHttpClient))
2485            .field("inner", &self.inner)
2486            .field("instruments_cached", &self.instruments_cache.len())
2487            .finish()
2488    }
2489}
2490
2491impl BinanceSpotHttpClient {
2492    /// Creates a new Binance Spot HTTP client.
2493    ///
2494    /// # Errors
2495    ///
2496    /// Returns an error if the underlying HTTP client cannot be created.
2497    #[expect(clippy::too_many_arguments)]
2498    pub fn new(
2499        environment: BinanceEnvironment,
2500        clock: &'static AtomicTime,
2501        api_key: Option<String>,
2502        api_secret: Option<String>,
2503        base_url_override: Option<String>,
2504        recv_window: Option<u64>,
2505        timeout_secs: Option<u64>,
2506        proxy_url: Option<String>,
2507    ) -> BinanceSpotHttpResult<Self> {
2508        Self::new_with_json_responses(
2509            environment,
2510            clock,
2511            api_key,
2512            api_secret,
2513            base_url_override,
2514            recv_window,
2515            timeout_secs,
2516            proxy_url,
2517            false,
2518        )
2519    }
2520
2521    pub(crate) fn with_retry_config(mut self, config: RetryConfig) -> Self {
2522        Arc::make_mut(&mut self.inner).retry_manager = Arc::new(RetryManager::new(config));
2523        self
2524    }
2525
2526    /// Creates a Spot client for an endpoint that returns JSON REST payloads.
2527    ///
2528    /// # Errors
2529    ///
2530    /// Returns an error if the underlying HTTP client cannot be created.
2531    #[expect(clippy::too_many_arguments)]
2532    pub fn new_with_json_responses(
2533        environment: BinanceEnvironment,
2534        clock: &'static AtomicTime,
2535        api_key: Option<String>,
2536        api_secret: Option<String>,
2537        base_url_override: Option<String>,
2538        recv_window: Option<u64>,
2539        timeout_secs: Option<u64>,
2540        proxy_url: Option<String>,
2541        json_responses: bool,
2542    ) -> BinanceSpotHttpResult<Self> {
2543        let inner = BinanceRawSpotHttpClient::new_with_json_responses(
2544            environment,
2545            api_key,
2546            api_secret,
2547            base_url_override,
2548            recv_window,
2549            timeout_secs,
2550            proxy_url,
2551            json_responses,
2552        )?;
2553
2554        Ok(Self {
2555            inner: Arc::new(inner),
2556            clock,
2557            instruments_cache: Arc::new(AtomicMap::new()),
2558        })
2559    }
2560
2561    /// Returns a reference to the inner raw client.
2562    #[must_use]
2563    pub fn inner(&self) -> &BinanceRawSpotHttpClient {
2564        &self.inner
2565    }
2566
2567    /// Returns whether signed requests can be made.
2568    #[must_use]
2569    pub fn has_credentials(&self) -> bool {
2570        self.inner.has_credentials()
2571    }
2572
2573    /// Returns the SBE schema ID.
2574    #[must_use]
2575    pub const fn schema_id() -> u16 {
2576        SBE_SCHEMA_ID
2577    }
2578
2579    /// Returns the SBE schema version.
2580    #[must_use]
2581    pub const fn schema_version() -> u16 {
2582        SBE_SCHEMA_VERSION
2583    }
2584
2585    /// Generates a timestamp for initialization.
2586    fn generate_ts_init(&self) -> UnixNanos {
2587        self.clock.get_time_ns()
2588    }
2589
2590    fn command_validation_error(message: impl Into<String>) -> anyhow::Error {
2591        anyhow::anyhow!(BinanceSpotHttpError::ValidationError(message.into()))
2592    }
2593
2594    fn response_parse_error(message: impl Into<String>) -> anyhow::Error {
2595        anyhow::anyhow!(BinanceSpotHttpError::ResponseParseError(message.into()))
2596    }
2597
2598    /// Retrieves an instrument from the cache.
2599    fn instrument_from_cache(&self, symbol: Ustr) -> anyhow::Result<InstrumentAny> {
2600        self.instruments_cache
2601            .get_cloned(&symbol)
2602            .ok_or_else(|| anyhow::anyhow!("Instrument {symbol} not in cache"))
2603    }
2604
2605    /// Caches multiple instruments.
2606    pub fn cache_instruments(&self, instruments: Vec<InstrumentAny>) {
2607        self.instruments_cache.rcu(move |cache| {
2608            for instrument in &instruments {
2609                cache.insert(instrument.raw_symbol().inner(), instrument.clone());
2610            }
2611        });
2612    }
2613
2614    /// Replaces the complete instrument cache.
2615    pub fn replace_instruments(&self, instruments: &[InstrumentAny]) {
2616        let cache = instruments
2617            .iter()
2618            .map(|instrument| (instrument.raw_symbol().inner(), instrument.clone()))
2619            .collect();
2620        self.instruments_cache.store(cache);
2621    }
2622
2623    /// Caches a single instrument.
2624    pub fn cache_instrument(&self, instrument: InstrumentAny) {
2625        self.instruments_cache
2626            .insert(instrument.raw_symbol().inner(), instrument);
2627    }
2628
2629    /// Gets an instrument from the cache by symbol.
2630    #[must_use]
2631    pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
2632        self.instruments_cache.get_cloned(symbol)
2633    }
2634
2635    /// Tests connectivity to the API.
2636    ///
2637    /// # Errors
2638    ///
2639    /// Returns an error if the request fails or SBE decoding fails.
2640    pub async fn ping(&self) -> BinanceSpotHttpResult<()> {
2641        self.inner.ping().await
2642    }
2643
2644    /// Returns the server time in **microseconds** since epoch.
2645    ///
2646    /// Note: SBE provides microsecond precision vs JSON's milliseconds.
2647    ///
2648    /// # Errors
2649    ///
2650    /// Returns an error if the request fails or SBE decoding fails.
2651    pub async fn server_time(&self) -> BinanceSpotHttpResult<i64> {
2652        self.inner.server_time().await
2653    }
2654
2655    /// Returns exchange information including trading symbols.
2656    ///
2657    /// # Errors
2658    ///
2659    /// Returns an error if the request fails or SBE decoding fails.
2660    pub async fn exchange_info(
2661        &self,
2662    ) -> BinanceSpotHttpResult<super::models::BinanceExchangeInfoSbe> {
2663        self.inner.exchange_info().await
2664    }
2665
2666    /// Returns a fresh status snapshot for Global or US Spot symbols.
2667    ///
2668    /// # Errors
2669    ///
2670    /// Returns an error if exchange info cannot be requested or decoded.
2671    pub async fn request_symbol_statuses(
2672        &self,
2673        us: bool,
2674    ) -> BinanceSpotHttpResult<AHashMap<InstrumentId, MarketStatusAction>> {
2675        let mut statuses = AHashMap::new();
2676
2677        if us {
2678            let info = self.inner.exchange_info_json().await?;
2679            for symbol in info.symbols {
2680                let instrument_id =
2681                    InstrumentId::new(Symbol::from(symbol.symbol.as_str()), *BINANCE_VENUE);
2682                statuses.insert(instrument_id, spot_json_market_status(&symbol.status));
2683            }
2684        } else {
2685            let info = self.exchange_info().await?;
2686            for symbol in info.symbols {
2687                let instrument_id =
2688                    InstrumentId::new(Symbol::from(symbol.symbol.as_str()), *BINANCE_VENUE);
2689                statuses.insert(
2690                    instrument_id,
2691                    MarketStatusAction::from(SymbolStatus::from(symbol.status)),
2692                );
2693            }
2694        }
2695        Ok(statuses)
2696    }
2697
2698    /// Requests Nautilus instruments for all trading symbols.
2699    ///
2700    /// Fetches exchange info via SBE and parses each symbol into a CurrencyPair.
2701    /// Non-trading symbols are skipped with a debug log.
2702    ///
2703    /// # Errors
2704    ///
2705    /// Returns an error if the request fails or SBE decoding fails.
2706    pub async fn request_instruments(&self) -> BinanceSpotHttpResult<Vec<InstrumentAny>> {
2707        self.request_instruments_with_config(&BinanceInstrumentProviderConfig::default(), false)
2708            .await
2709    }
2710
2711    /// Requests configured Nautilus instruments with populated maker and taker fees.
2712    ///
2713    /// Non-trading symbols are skipped with a debug log unless explicitly
2714    /// selected via `load_ids` or the `symbols` filter.
2715    ///
2716    /// # Errors
2717    ///
2718    /// Returns an error if configuration, exchange info, or required parsing fails.
2719    pub async fn request_instruments_with_config(
2720        &self,
2721        config: &BinanceInstrumentProviderConfig,
2722        us: bool,
2723    ) -> BinanceSpotHttpResult<Vec<InstrumentAny>> {
2724        config
2725            .validate(BinanceProductType::Spot)
2726            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?;
2727        let selector = BinanceInstrumentSelector::new(config)
2728            .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?;
2729        let ts_init = self.generate_ts_init();
2730        let fallback_fees = self.spot_fallback_fees(us).await;
2731
2732        let mut instruments = if us {
2733            if config.query_commission_rates {
2734                if config.log_warnings {
2735                    log::warn!(
2736                        "Binance US does not expose the Global account/commission endpoint; using account-wide commission rates"
2737                    );
2738                } else {
2739                    log::debug!(
2740                        "Binance US exact per-symbol commission query disabled; using account-wide rates"
2741                    );
2742                }
2743            }
2744            let info = self.inner.exchange_info_json().await?;
2745            let mut instruments = Vec::with_capacity(info.symbols.len());
2746            for symbol in &info.symbols {
2747                let instrument_id =
2748                    InstrumentId::new(Symbol::from(symbol.symbol.as_str()), *BINANCE_VENUE);
2749
2750                if !selector.includes(
2751                    instrument_id,
2752                    &symbol.symbol,
2753                    &symbol.base_asset,
2754                    &symbol.quote_asset,
2755                    None,
2756                ) {
2757                    continue;
2758                }
2759
2760                match parse_spot_instrument_json_with_fees(
2761                    symbol,
2762                    Some(fallback_fees.0),
2763                    Some(fallback_fees.1),
2764                    ts_init,
2765                    ts_init,
2766                ) {
2767                    Ok(instrument) => instruments.push(instrument),
2768                    Err(e) => log_instrument_parse_error(
2769                        config,
2770                        &selector,
2771                        instrument_id,
2772                        &symbol.symbol,
2773                        &e,
2774                    ),
2775                }
2776            }
2777            instruments
2778        } else {
2779            let info = self.exchange_info().await?;
2780            let mut instruments = Vec::with_capacity(info.symbols.len());
2781            for symbol in &info.symbols {
2782                let instrument_id =
2783                    InstrumentId::new(Symbol::from(symbol.symbol.as_str()), *BINANCE_VENUE);
2784
2785                if !selector.includes(
2786                    instrument_id,
2787                    &symbol.symbol,
2788                    &symbol.base_asset,
2789                    &symbol.quote_asset,
2790                    None,
2791                ) {
2792                    continue;
2793                }
2794
2795                let fees = self
2796                    .spot_symbol_fees(config, &symbol.symbol, fallback_fees)
2797                    .await;
2798
2799                match parse_spot_instrument_sbe_with_fees(
2800                    symbol,
2801                    Some(fees.0),
2802                    Some(fees.1),
2803                    ts_init,
2804                    ts_init,
2805                ) {
2806                    Ok(instrument) => instruments.push(instrument),
2807                    Err(e) => log_instrument_parse_error(
2808                        config,
2809                        &selector,
2810                        instrument_id,
2811                        &symbol.symbol,
2812                        &e,
2813                    ),
2814                }
2815            }
2816            instruments
2817        };
2818
2819        instruments.shrink_to_fit();
2820        self.replace_instruments(&instruments);
2821
2822        log::debug!("Loaded spot instruments: count={}", instruments.len());
2823        Ok(instruments)
2824    }
2825
2826    async fn spot_fallback_fees(&self, us: bool) -> (Decimal, Decimal) {
2827        if !self.has_credentials() {
2828            return (BINANCE_SPOT_FEE_DEFAULT, BINANCE_SPOT_FEE_DEFAULT);
2829        }
2830
2831        let result = if us {
2832            self.inner.account_rates_json().await.map(|account| {
2833                parse_commission_rates(
2834                    &account.commission_rates.maker,
2835                    &account.commission_rates.taker,
2836                )
2837            })
2838        } else {
2839            self.inner
2840                .account(&AccountInfoParams::default())
2841                .await
2842                .map(|account| {
2843                    Ok((
2844                        decimal_from_mantissa_exponent(
2845                            account.maker_commission_mantissa,
2846                            account.commission_exponent,
2847                        ),
2848                        decimal_from_mantissa_exponent(
2849                            account.taker_commission_mantissa,
2850                            account.commission_exponent,
2851                        ),
2852                    ))
2853                })
2854        };
2855
2856        match result {
2857            Ok(Ok(fees)) => fees,
2858            Ok(Err(e)) => {
2859                log::warn!("Invalid Binance Spot account commission rates: {e}; using fallback");
2860                (BINANCE_SPOT_FEE_DEFAULT, BINANCE_SPOT_FEE_DEFAULT)
2861            }
2862            Err(e) => {
2863                log::warn!("Binance Spot account commission query failed: {e}; using fallback");
2864                (BINANCE_SPOT_FEE_DEFAULT, BINANCE_SPOT_FEE_DEFAULT)
2865            }
2866        }
2867    }
2868
2869    async fn spot_symbol_fees(
2870        &self,
2871        config: &BinanceInstrumentProviderConfig,
2872        symbol: &str,
2873        fallback: (Decimal, Decimal),
2874    ) -> (Decimal, Decimal) {
2875        if !config.query_commission_rates || !self.has_credentials() {
2876            return fallback;
2877        }
2878
2879        match self.inner.account_commission(symbol).await {
2880            Ok(response) => match parse_commission_rates(
2881                &response.standard_commission.maker,
2882                &response.standard_commission.taker,
2883            ) {
2884                Ok(fees) => fees,
2885                Err(e) => {
2886                    log::warn!(
2887                        "Invalid Binance Spot commission response for {symbol}: {e}; using fallback"
2888                    );
2889                    fallback
2890                }
2891            },
2892            Err(e) => {
2893                log::warn!(
2894                    "Binance Spot commission query failed for {symbol}: {e}; using fallback"
2895                );
2896                fallback
2897            }
2898        }
2899    }
2900
2901    /// Requests recent trades for an instrument.
2902    ///
2903    /// # Errors
2904    ///
2905    /// Returns an error if the request fails, the instrument is not cached,
2906    /// or trade parsing fails.
2907    pub async fn request_trades(
2908        &self,
2909        instrument_id: InstrumentId,
2910        limit: Option<u32>,
2911    ) -> anyhow::Result<Vec<TradeTick>> {
2912        let symbol = instrument_id.symbol.inner();
2913        let instrument = self.instrument_from_cache_by_id(instrument_id)?;
2914        let ts_init = self.generate_ts_init();
2915
2916        let trades = self
2917            .inner
2918            .trades(symbol.as_str(), limit)
2919            .await
2920            .map_err(|e| anyhow::anyhow!(e))?;
2921
2922        parse_spot_trades_sbe(&trades, &instrument, ts_init)
2923    }
2924
2925    /// Requests bounded aggregate trades for an instrument.
2926    ///
2927    /// # Errors
2928    ///
2929    /// Returns an error if the request fails, the instrument is not cached, or parsing fails.
2930    pub async fn request_agg_trades(
2931        &self,
2932        instrument_id: InstrumentId,
2933        start: Option<Timestamp>,
2934        end: Option<Timestamp>,
2935        limit: Option<u32>,
2936    ) -> anyhow::Result<Vec<TradeTick>> {
2937        let symbol = instrument_id.symbol.inner();
2938        let instrument = self.instrument_from_cache_by_id(instrument_id)?;
2939        let params = AggTradesParams {
2940            symbol: symbol.to_string(),
2941            from_id: None,
2942            start_time: start.map(|dt| dt.as_millisecond()),
2943            end_time: end.map(|dt| dt.as_millisecond()),
2944            limit,
2945        };
2946        let response = self.inner.agg_trades(&params).await?;
2947        let trades = BinanceTrades {
2948            price_exponent: response.price_exponent,
2949            qty_exponent: response.qty_exponent,
2950            trades: response
2951                .trades
2952                .into_iter()
2953                .map(|trade| super::models::BinanceTrade {
2954                    id: trade.id,
2955                    price_mantissa: trade.price_mantissa,
2956                    qty_mantissa: trade.qty_mantissa,
2957                    quote_qty_mantissa: 0,
2958                    time: trade.time,
2959                    is_buyer_maker: trade.is_buyer_maker,
2960                    is_best_match: trade.is_best_match,
2961                })
2962                .collect(),
2963        };
2964
2965        let mut parsed = parse_spot_trades_sbe(&trades, &instrument, UnixNanos::default())?;
2966        for trade in &mut parsed {
2967            trade.ts_init = trade.ts_event;
2968        }
2969        Ok(parsed)
2970    }
2971
2972    /// Requests bar (kline/candlestick) data.
2973    ///
2974    /// # Errors
2975    ///
2976    /// Returns an error if the bar type is not supported, instrument is not cached,
2977    /// or the request fails.
2978    pub async fn request_binance_bars(
2979        &self,
2980        bar_type: BarType,
2981        start: Option<Timestamp>,
2982        end: Option<Timestamp>,
2983        limit: Option<u32>,
2984    ) -> anyhow::Result<Vec<crate::common::bar::BinanceBar>> {
2985        anyhow::ensure!(
2986            bar_type.aggregation_source() == AggregationSource::External,
2987            "Only EXTERNAL aggregation is supported"
2988        );
2989
2990        let spec = bar_type.spec();
2991        let step = spec.step.get();
2992        let interval = match spec.aggregation {
2993            BarAggregation::Second if step == 1 => "1s".to_string(),
2994            BarAggregation::Second => {
2995                anyhow::bail!("Binance Spot supports only the 1s kline interval")
2996            }
2997            BarAggregation::Minute => format!("{step}m"),
2998            BarAggregation::Hour => format!("{step}h"),
2999            BarAggregation::Day => format!("{step}d"),
3000            BarAggregation::Week => format!("{step}w"),
3001            BarAggregation::Month => format!("{step}M"),
3002            a => anyhow::bail!("Binance does not support {a:?} aggregation"),
3003        };
3004
3005        let instrument_id = bar_type.instrument_id();
3006        let symbol = instrument_id.symbol;
3007        let instrument = self.instrument_from_cache_by_id(instrument_id)?;
3008        let klines = self
3009            .inner
3010            .klines(
3011                symbol.as_str(),
3012                &interval,
3013                start.map(|dt| dt.as_millisecond()),
3014                end.map(|dt| dt.as_millisecond()),
3015                limit,
3016            )
3017            .await
3018            .map_err(|e| anyhow::anyhow!(e))?;
3019
3020        let mut bars =
3021            parse_klines_to_binance_bars(&klines, bar_type, &instrument, UnixNanos::default())?;
3022        let now = self.clock.get_time_ns();
3023        bars.retain(|bar| bar.ts_event < now);
3024        for bar in &mut bars {
3025            bar.ts_init = bar.ts_event;
3026        }
3027        Ok(bars)
3028    }
3029
3030    /// Requests core bars for an instrument.
3031    ///
3032    /// # Errors
3033    ///
3034    /// Returns an error if the bar type is unsupported or the request fails.
3035    pub async fn request_bars(
3036        &self,
3037        bar_type: BarType,
3038        start: Option<Timestamp>,
3039        end: Option<Timestamp>,
3040        limit: Option<u32>,
3041    ) -> anyhow::Result<Vec<Bar>> {
3042        Ok(self
3043            .request_binance_bars(bar_type, start, end, limit)
3044            .await?
3045            .into_iter()
3046            .map(|bar| bar.bar())
3047            .collect())
3048    }
3049
3050    /// Requests an explicit L2 order-book snapshot.
3051    ///
3052    /// # Errors
3053    ///
3054    /// Returns an error for an invalid depth, missing instrument, request failure, or invalid level.
3055    pub async fn request_book_snapshot(
3056        &self,
3057        instrument_id: InstrumentId,
3058        depth: Option<u32>,
3059    ) -> anyhow::Result<OrderBook> {
3060        if depth.is_some_and(|value| value == 0 || value > 5000) {
3061            anyhow::bail!("Binance Spot order-book depth must be between 1 and 5000");
3062        }
3063        let instrument = self.instrument_from_cache_by_id(instrument_id)?;
3064        let params = DepthParams {
3065            symbol: instrument_id.symbol.to_string(),
3066            limit: depth,
3067        };
3068        let snapshot = self.inner.depth(&params).await?;
3069        let ts_event = self.generate_ts_init();
3070        Self::parse_book_snapshot_response(instrument_id, &instrument, &snapshot, ts_event)
3071    }
3072
3073    fn parse_book_snapshot_response(
3074        instrument_id: InstrumentId,
3075        instrument: &InstrumentAny,
3076        snapshot: &BinanceDepth,
3077        ts_event: UnixNanos,
3078    ) -> anyhow::Result<OrderBook> {
3079        let sequence = u64::try_from(snapshot.last_update_id)
3080            .map_err(|_| anyhow::anyhow!("invalid negative order-book update ID"))?;
3081        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
3082        let mut add_level = |level: &super::models::BinancePriceLevel,
3083                             side: OrderSide,
3084                             order_id: usize,
3085                             name: &str|
3086         -> anyhow::Result<()> {
3087            let price = Price::from_mantissa_exponent_checked(
3088                level.price_mantissa,
3089                snapshot.price_exponent,
3090                instrument.price_precision(),
3091            )
3092            .map_err(|e| anyhow::anyhow!("invalid {name} price: {e}"))?;
3093            anyhow::ensure!(price.is_positive(), "invalid non-positive {name} price");
3094            let qty_mantissa = u64::try_from(level.qty_mantissa)
3095                .map_err(|_| anyhow::anyhow!("invalid negative {name} quantity"))?;
3096            let quantity = Quantity::from_mantissa_exponent_checked(
3097                qty_mantissa,
3098                snapshot.qty_exponent,
3099                instrument.size_precision(),
3100            )
3101            .map_err(|e| anyhow::anyhow!("invalid {name} quantity: {e}"))?;
3102            anyhow::ensure!(
3103                quantity.is_positive(),
3104                "invalid non-positive {name} quantity"
3105            );
3106            let order = BookOrder::new(
3107                side,
3108                price,
3109                quantity,
3110                u64::try_from(order_id)
3111                    .map_err(|_| anyhow::anyhow!("order-book level index overflow"))?,
3112            );
3113            book.add(order, 0, sequence, ts_event);
3114            Ok(())
3115        };
3116
3117        for (index, level) in snapshot.bids.iter().enumerate() {
3118            add_level(level, OrderSide::Buy, index, "bid")?;
3119        }
3120        let bid_count = snapshot.bids.len();
3121        for (index, level) in snapshot.asks.iter().enumerate() {
3122            let order_id = bid_count
3123                .checked_add(index)
3124                .ok_or_else(|| anyhow::anyhow!("order-book level index overflow"))?;
3125            add_level(level, OrderSide::Sell, order_id, "ask")?;
3126        }
3127        Ok(book)
3128    }
3129
3130    fn instrument_from_cache_by_id(
3131        &self,
3132        instrument_id: InstrumentId,
3133    ) -> anyhow::Result<InstrumentAny> {
3134        self.instruments_cache
3135            .get_cloned(&instrument_id.symbol.inner())
3136            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id).into())
3137    }
3138
3139    /// Requests the account state with Nautilus types.
3140    ///
3141    /// # Errors
3142    ///
3143    /// Returns an error if the request fails or SBE decoding fails.
3144    pub async fn request_account_state(
3145        &self,
3146        account_id: AccountId,
3147    ) -> anyhow::Result<AccountState> {
3148        let ts_init = self.clock.get_time_ns();
3149        let params = AccountInfoParams::default();
3150        let account_info = self.inner.account(&params).await?;
3151        Ok(account_info.to_account_state(account_id, ts_init))
3152    }
3153
3154    /// Requests the status of a specific order.
3155    ///
3156    /// Either `venue_order_id` or `client_order_id` must be provided.
3157    ///
3158    /// # Errors
3159    ///
3160    /// Returns an error if neither identifier is provided, the request fails for any
3161    /// reason other than a missing order, instrument is not cached, or parsing fails.
3162    pub async fn request_order_status_report(
3163        &self,
3164        account_id: AccountId,
3165        instrument_id: InstrumentId,
3166        venue_order_id: Option<VenueOrderId>,
3167        client_order_id: Option<ClientOrderId>,
3168    ) -> anyhow::Result<Option<OrderStatusReport>> {
3169        anyhow::ensure!(
3170            venue_order_id.is_some() || client_order_id.is_some(),
3171            "Either venue_order_id or client_order_id must be provided"
3172        );
3173
3174        let symbol = instrument_id.symbol.inner();
3175        let instrument = self.instrument_from_cache(symbol)?;
3176        let ts_init = self.generate_ts_init();
3177
3178        let order_id = venue_order_id
3179            .map(|id| id.inner().parse::<i64>())
3180            .transpose()
3181            .map_err(|_| anyhow::anyhow!("Invalid venue order ID"))?;
3182
3183        let client_id_str =
3184            client_order_id.map(|id| encode_broker_id(&id, BINANCE_NAUTILUS_SPOT_BROKER_ID));
3185
3186        let order = match self
3187            .inner
3188            .query_order(symbol.as_str(), order_id, client_id_str.as_deref())
3189            .await
3190        {
3191            Ok(order) => order,
3192            Err(e) if Self::is_no_such_order_error(&e) => {
3193                log::debug!("Binance Spot order not found: instrument_id={instrument_id}");
3194                return Ok(None);
3195            }
3196            Err(e) => anyhow::bail!(e),
3197        };
3198
3199        parse_order_status_report_sbe(
3200            &order,
3201            account_id,
3202            &instrument,
3203            BINANCE_NAUTILUS_SPOT_BROKER_ID,
3204            ts_init,
3205        )
3206        .map(Some)
3207    }
3208
3209    const fn is_no_such_order_error(error: &BinanceSpotHttpError) -> bool {
3210        matches!(
3211            error,
3212            BinanceSpotHttpError::BinanceError { code, .. } if *code == BINANCE_NO_SUCH_ORDER_CODE
3213        )
3214    }
3215
3216    /// Requests order status reports.
3217    ///
3218    /// When `open_only` is true, returns only open orders (instrument_id optional).
3219    /// When `open_only` is false, returns order history (instrument_id required).
3220    ///
3221    /// # Errors
3222    ///
3223    /// Returns an error if the request fails, any order's instrument is not cached,
3224    /// or parsing fails.
3225    pub async fn request_order_status_reports(
3226        &self,
3227        account_id: AccountId,
3228        instrument_id: Option<InstrumentId>,
3229        start: Option<Timestamp>,
3230        end: Option<Timestamp>,
3231        open_only: bool,
3232        limit: Option<u32>,
3233    ) -> anyhow::Result<Vec<OrderStatusReport>> {
3234        self.request_order_status_reports_scoped(
3235            account_id,
3236            instrument_id,
3237            start,
3238            end,
3239            open_only,
3240            limit,
3241            None,
3242        )
3243        .await
3244    }
3245
3246    #[expect(clippy::too_many_arguments)]
3247    pub(crate) async fn request_order_status_reports_scoped(
3248        &self,
3249        account_id: AccountId,
3250        instrument_id: Option<InstrumentId>,
3251        start: Option<Timestamp>,
3252        end: Option<Timestamp>,
3253        open_only: bool,
3254        limit: Option<u32>,
3255        provider: Option<&BinanceInstrumentProviderConfig>,
3256    ) -> anyhow::Result<Vec<OrderStatusReport>> {
3257        if instrument_id.is_some_and(|id| provider.is_some_and(|provider| provider.excludes(id))) {
3258            log::debug!("Dropping out-of-scope Binance Spot order request for {instrument_id:?}");
3259            return Ok(Vec::new());
3260        }
3261        let ts_init = self.generate_ts_init();
3262        let symbol = instrument_id.map(|id| id.symbol.to_string());
3263
3264        let orders = if open_only {
3265            self.inner
3266                .open_orders(symbol.as_deref())
3267                .await
3268                .map_err(|e| anyhow::anyhow!(e))?
3269        } else {
3270            let symbol = symbol
3271                .ok_or_else(|| anyhow::anyhow!("instrument_id is required when open_only=false"))?;
3272            self.inner
3273                .all_orders(
3274                    &symbol,
3275                    start.map(|dt| dt.as_millisecond()),
3276                    end.map(|dt| dt.as_millisecond()),
3277                    limit,
3278                )
3279                .await
3280                .map_err(|e| anyhow::anyhow!(e))?
3281        };
3282
3283        orders
3284            .iter()
3285            .filter(|order| {
3286                let id = format_instrument_id(&Ustr::from(&order.symbol), BinanceProductType::Spot);
3287                if provider.is_some_and(|provider| provider.excludes(id)) {
3288                    log::debug!("Dropping out-of-scope Binance Spot order for {id}");
3289                    false
3290                } else {
3291                    true
3292                }
3293            })
3294            .map(|order| {
3295                let symbol = Ustr::from(&order.symbol);
3296                let instrument = self.instrument_from_cache(symbol)?;
3297                parse_order_status_report_sbe(
3298                    order,
3299                    account_id,
3300                    &instrument,
3301                    BINANCE_NAUTILUS_SPOT_BROKER_ID,
3302                    ts_init,
3303                )
3304            })
3305            .collect()
3306    }
3307
3308    /// Requests fill reports (trade history) for an instrument.
3309    ///
3310    /// # Errors
3311    ///
3312    /// Returns an error if the request fails, any trade's instrument is not cached,
3313    /// or parsing fails.
3314    pub async fn request_fill_reports(
3315        &self,
3316        account_id: AccountId,
3317        instrument_id: InstrumentId,
3318        venue_order_id: Option<VenueOrderId>,
3319        start: Option<Timestamp>,
3320        end: Option<Timestamp>,
3321        limit: Option<u32>,
3322    ) -> anyhow::Result<Vec<FillReport>> {
3323        self.request_fill_reports_with_cursor(
3324            account_id,
3325            instrument_id,
3326            venue_order_id,
3327            start,
3328            end,
3329            None,
3330            limit,
3331        )
3332        .await
3333    }
3334
3335    #[expect(clippy::too_many_arguments)]
3336    pub(crate) async fn request_fill_reports_with_cursor(
3337        &self,
3338        account_id: AccountId,
3339        instrument_id: InstrumentId,
3340        venue_order_id: Option<VenueOrderId>,
3341        start: Option<Timestamp>,
3342        end: Option<Timestamp>,
3343        from_id: Option<i64>,
3344        limit: Option<u32>,
3345    ) -> anyhow::Result<Vec<FillReport>> {
3346        let ts_init = self.generate_ts_init();
3347        let symbol = instrument_id.symbol.inner();
3348
3349        let order_id = venue_order_id
3350            .map(|id| id.inner().parse::<i64>())
3351            .transpose()
3352            .map_err(|_| anyhow::anyhow!("Invalid venue order ID"))?;
3353
3354        let trades = self
3355            .inner
3356            .account_trades_with_cursor(
3357                symbol.as_str(),
3358                order_id,
3359                start.map(|dt| dt.as_millisecond()),
3360                end.map(|dt| dt.as_millisecond()),
3361                from_id,
3362                limit,
3363            )
3364            .await
3365            .map_err(|e| anyhow::anyhow!(e))?;
3366
3367        trades
3368            .iter()
3369            .map(|trade| {
3370                let symbol = Ustr::from(&trade.symbol);
3371                let instrument = self.instrument_from_cache(symbol)?;
3372                let commission_currency = get_currency(&trade.commission_asset);
3373                parse_fill_report_sbe(trade, account_id, &instrument, commission_currency, ts_init)
3374            })
3375            .collect()
3376    }
3377
3378    /// Submits a new order to the venue.
3379    ///
3380    /// Converts Nautilus domain types to Binance-specific parameters
3381    /// and returns an `OrderStatusReport`.
3382    ///
3383    /// # Errors
3384    ///
3385    /// Returns an error if:
3386    /// - The instrument is not cached.
3387    /// - The order type or time-in-force is unsupported.
3388    /// - Stop orders are submitted without a trigger price.
3389    /// - The request fails or SBE decoding fails.
3390    #[expect(clippy::too_many_arguments)]
3391    pub async fn submit_order(
3392        &self,
3393        account_id: AccountId,
3394        instrument_id: InstrumentId,
3395        client_order_id: ClientOrderId,
3396        order_side: OrderSide,
3397        order_type: OrderType,
3398        quantity: Quantity,
3399        time_in_force: TimeInForce,
3400        price: Option<Price>,
3401        trigger_price: Option<Price>,
3402        post_only: bool,
3403        quote_quantity: bool,
3404        display_qty: Option<Quantity>,
3405        use_gtd: bool,
3406    ) -> anyhow::Result<OrderStatusReport> {
3407        let symbol = instrument_id.symbol.inner();
3408        let instrument = self
3409            .instrument_from_cache(symbol)
3410            .map_err(|e| Self::command_validation_error(e.to_string()))?;
3411        let ts_init = self.generate_ts_init();
3412
3413        let binance_side = BinanceSide::try_from(order_side)
3414            .map_err(|e| Self::command_validation_error(e.to_string()))?;
3415        let binance_order_type = order_type_to_binance_spot(order_type, post_only)
3416            .map_err(|e| Self::command_validation_error(e.to_string()))?;
3417
3418        // Validate trigger price for conditional orders
3419        let requires_trigger = matches!(
3420            order_type,
3421            OrderType::StopMarket
3422                | OrderType::StopLimit
3423                | OrderType::MarketIfTouched
3424                | OrderType::LimitIfTouched
3425        );
3426
3427        if requires_trigger && trigger_price.is_none() {
3428            return Err(Self::command_validation_error(
3429                "Conditional orders require a trigger price",
3430            ));
3431        }
3432
3433        // Validate price for order types that require it
3434        let requires_price = matches!(
3435            binance_order_type,
3436            BinanceSpotOrderType::Limit
3437                | BinanceSpotOrderType::StopLossLimit
3438                | BinanceSpotOrderType::TakeProfitLimit
3439                | BinanceSpotOrderType::LimitMaker
3440        );
3441
3442        if requires_price && price.is_none() {
3443            return Err(Self::command_validation_error(format!(
3444                "{binance_order_type:?} orders require a price"
3445            )));
3446        }
3447
3448        // Only send TIF for order types that support it
3449        let supports_tif = matches!(
3450            binance_order_type,
3451            BinanceSpotOrderType::Limit
3452                | BinanceSpotOrderType::StopLossLimit
3453                | BinanceSpotOrderType::TakeProfitLimit
3454        );
3455        let binance_tif = if supports_tif {
3456            Some(
3457                time_in_force_to_binance_spot(time_in_force, use_gtd)
3458                    .map_err(|e| Self::command_validation_error(e.to_string()))?,
3459            )
3460        } else {
3461            None
3462        };
3463
3464        let qty_str = quantity.to_string();
3465        let price_str = price.map(|p| p.to_string());
3466        let stop_price_str = trigger_price.map(|p| p.to_string());
3467        let iceberg_qty_str = display_qty.map(|q| q.to_string());
3468        let client_id_str = encode_broker_id(&client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID);
3469
3470        if quote_quantity && binance_order_type != BinanceSpotOrderType::Market {
3471            return Err(Self::command_validation_error(
3472                "quoteOrderQty is only supported for MARKET orders",
3473            ));
3474        }
3475
3476        let (base_qty, quote_qty) = if quote_quantity {
3477            (None, Some(qty_str.as_str()))
3478        } else {
3479            (Some(qty_str.as_str()), None)
3480        };
3481
3482        let response = self
3483            .inner
3484            .new_order_full(
3485                symbol.as_str(),
3486                binance_side,
3487                binance_order_type,
3488                binance_tif,
3489                base_qty,
3490                quote_qty,
3491                price_str.as_deref(),
3492                Some(&client_id_str),
3493                stop_price_str.as_deref(),
3494                iceberg_qty_str.as_deref(),
3495            )
3496            .await?;
3497
3498        parse_new_order_response_sbe(
3499            &response,
3500            account_id,
3501            &instrument,
3502            BINANCE_NAUTILUS_SPOT_BROKER_ID,
3503            ts_init,
3504        )
3505        .map_err(|e| Self::response_parse_error(e.to_string()))
3506    }
3507
3508    /// Submits multiple orders in a single batch request.
3509    ///
3510    /// Binance limits batch submit to 5 orders maximum.
3511    ///
3512    /// # Errors
3513    ///
3514    /// Returns an error if the request fails or JSON parsing fails.
3515    pub async fn submit_order_list(
3516        &self,
3517        orders: &[BatchOrderItem],
3518    ) -> BinanceSpotHttpResult<Vec<BatchOrderResult>> {
3519        self.inner.batch_submit_orders(orders).await
3520    }
3521
3522    /// Submits a Spot OCO order list.
3523    ///
3524    /// # Errors
3525    ///
3526    /// Returns an error if the request fails or JSON parsing fails.
3527    pub async fn submit_oco_order_list(
3528        &self,
3529        params: &NewOcoOrderListParams,
3530    ) -> BinanceSpotHttpResult<NewOcoOrderListResponse> {
3531        self.inner.new_oco_order_list(params).await
3532    }
3533
3534    /// Modifies an existing order (cancel and replace atomically).
3535    ///
3536    /// # Errors
3537    ///
3538    /// Returns an error if:
3539    /// - The instrument is not cached.
3540    /// - The order type or time-in-force is unsupported.
3541    /// - The request fails or SBE decoding fails.
3542    #[expect(clippy::too_many_arguments)]
3543    pub async fn modify_order(
3544        &self,
3545        account_id: AccountId,
3546        instrument_id: InstrumentId,
3547        venue_order_id: VenueOrderId,
3548        client_order_id: ClientOrderId,
3549        order_side: OrderSide,
3550        order_type: OrderType,
3551        quantity: Quantity,
3552        time_in_force: TimeInForce,
3553        price: Option<Price>,
3554        use_gtd: bool,
3555        cancel_new_client_order_id: &str,
3556    ) -> anyhow::Result<OrderStatusReport> {
3557        let symbol = instrument_id.symbol.inner();
3558        let instrument = self
3559            .instrument_from_cache(symbol)
3560            .map_err(|e| Self::command_validation_error(e.to_string()))?;
3561        let ts_init = self.generate_ts_init();
3562
3563        let binance_side = BinanceSide::try_from(order_side)
3564            .map_err(|e| Self::command_validation_error(e.to_string()))?;
3565        let binance_order_type = order_type_to_binance_spot(order_type, false)
3566            .map_err(|e| Self::command_validation_error(e.to_string()))?;
3567        let binance_tif = time_in_force_to_binance_spot(time_in_force, use_gtd)
3568            .map_err(|e| Self::command_validation_error(e.to_string()))?;
3569
3570        let cancel_order_id: i64 = venue_order_id.inner().parse().map_err(|_| {
3571            Self::command_validation_error(format!("Invalid venue order ID: {venue_order_id}"))
3572        })?;
3573
3574        let qty_str = quantity.to_string();
3575        let price_str = price.map(|p| p.to_string());
3576        let client_id_str = encode_broker_id(&client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID);
3577
3578        let response = self
3579            .inner
3580            .cancel_replace_order(
3581                symbol.as_str(),
3582                binance_side,
3583                binance_order_type,
3584                Some(binance_tif),
3585                Some(&qty_str),
3586                price_str.as_deref(),
3587                Some(cancel_order_id),
3588                None,
3589                Some(cancel_new_client_order_id),
3590                Some(&client_id_str),
3591            )
3592            .await
3593            .map_err(|e| anyhow::anyhow!(e))?;
3594
3595        parse_new_order_response_sbe(
3596            &response,
3597            account_id,
3598            &instrument,
3599            BINANCE_NAUTILUS_SPOT_BROKER_ID,
3600            ts_init,
3601        )
3602        .map_err(|e| Self::response_parse_error(e.to_string()))
3603    }
3604
3605    /// Cancels an existing order on the venue.
3606    ///
3607    /// Either `venue_order_id` or `client_order_id` must be provided.
3608    ///
3609    /// # Errors
3610    ///
3611    /// Returns an error if the request fails or SBE decoding fails.
3612    pub async fn cancel_order(
3613        &self,
3614        instrument_id: InstrumentId,
3615        venue_order_id: Option<VenueOrderId>,
3616        client_order_id: Option<ClientOrderId>,
3617    ) -> anyhow::Result<VenueOrderId> {
3618        let symbol = instrument_id.symbol.inner();
3619
3620        let order_id = match venue_order_id {
3621            Some(venue_order_id) => match venue_order_id.inner().parse::<i64>() {
3622                Ok(order_id) => Some(order_id),
3623                Err(e) if client_order_id.is_some() => {
3624                    log::warn!(
3625                        "Unable to parse venue_order_id {venue_order_id} for cancel, canceling by client_order_id: {e}"
3626                    );
3627                    None
3628                }
3629                Err(e) => {
3630                    return Err(Self::command_validation_error(format!(
3631                        "Invalid venue order ID: {e}"
3632                    )));
3633                }
3634            },
3635            None => None,
3636        };
3637
3638        let client_id_str =
3639            client_order_id.map(|id| encode_broker_id(&id, BINANCE_NAUTILUS_SPOT_BROKER_ID));
3640
3641        let response = self
3642            .inner
3643            .cancel_order(symbol.as_str(), order_id, client_id_str.as_deref())
3644            .await
3645            .map_err(|e| anyhow::anyhow!(e))?;
3646
3647        Ok(VenueOrderId::new(response.order_id.to_string()))
3648    }
3649
3650    /// Cancels multiple orders in a single batch request.
3651    ///
3652    /// Binance limits batch cancel to 5 orders maximum.
3653    ///
3654    /// # Errors
3655    ///
3656    /// Returns an error if the request fails or JSON parsing fails.
3657    pub async fn batch_cancel_orders(
3658        &self,
3659        cancels: &[BatchCancelItem],
3660    ) -> BinanceSpotHttpResult<Vec<BatchCancelResult>> {
3661        self.inner.batch_cancel_orders(cancels).await
3662    }
3663
3664    /// Cancels all open orders for a symbol.
3665    ///
3666    /// Returns the venue order IDs of all canceled orders.
3667    ///
3668    /// # Errors
3669    ///
3670    /// Returns an error if the request fails or SBE decoding fails.
3671    pub async fn cancel_all_orders(
3672        &self,
3673        instrument_id: InstrumentId,
3674    ) -> anyhow::Result<Vec<(VenueOrderId, ClientOrderId)>> {
3675        let responses = self.cancel_all_order_responses(instrument_id).await?;
3676
3677        responses
3678            .into_iter()
3679            .flat_map(|response| match response {
3680                BinanceCancelOpenOrdersResponse::Order(response) => vec![response],
3681                BinanceCancelOpenOrdersResponse::OrderList(response) => response.order_reports,
3682            })
3683            .map(|response| {
3684                Ok((
3685                    VenueOrderId::new(response.order_id.to_string()),
3686                    decode_client_order_id(
3687                        &response.orig_client_order_id,
3688                        BINANCE_NAUTILUS_SPOT_BROKER_ID,
3689                    )?,
3690                ))
3691            })
3692            .collect()
3693    }
3694
3695    /// Cancels all open orders and preserves the venue response shape.
3696    ///
3697    /// # Errors
3698    ///
3699    /// Returns an error if the request or response decoding fails.
3700    pub(crate) async fn cancel_all_order_responses(
3701        &self,
3702        instrument_id: InstrumentId,
3703    ) -> anyhow::Result<Vec<BinanceCancelOpenOrdersResponse>> {
3704        let symbol = instrument_id.symbol.inner();
3705
3706        let responses = self
3707            .inner
3708            .cancel_open_order_responses(symbol.as_str())
3709            .await
3710            .map_err(|e| anyhow::anyhow!(e))?;
3711
3712        Ok(responses)
3713    }
3714}
3715
3716fn parse_commission_rates(maker: &str, taker: &str) -> anyhow::Result<(Decimal, Decimal)> {
3717    Ok((
3718        Decimal::from_str_exact(maker)?,
3719        Decimal::from_str_exact(taker)?,
3720    ))
3721}
3722
3723fn spot_json_market_status(status: &str) -> MarketStatusAction {
3724    match status {
3725        "TRADING" => MarketStatusAction::Trading,
3726        "BREAK" => MarketStatusAction::Pause,
3727        _ => MarketStatusAction::NotAvailableForTrading,
3728    }
3729}
3730
3731fn decimal_from_mantissa_exponent(mantissa: i64, exponent: i8) -> Decimal {
3732    if exponent >= 0 {
3733        Decimal::from(mantissa) * Decimal::from(10_i64.pow(exponent as u32))
3734    } else {
3735        Decimal::new(mantissa, (-exponent) as u32)
3736    }
3737}
3738
3739fn log_instrument_parse_error(
3740    config: &BinanceInstrumentProviderConfig,
3741    selector: &BinanceInstrumentSelector,
3742    instrument_id: InstrumentId,
3743    symbol: &str,
3744    error: &anyhow::Error,
3745) {
3746    let explicit = selector.is_explicit(instrument_id, symbol);
3747    if should_warn_on_instrument_parse_error(config.log_warnings, explicit, error) {
3748        log::warn!("Skipping Binance Spot instrument {symbol}: {error}");
3749    } else {
3750        log::debug!("Skipping Binance Spot instrument {symbol}: {error}");
3751    }
3752}
3753
3754#[cfg(test)]
3755mod tests {
3756    use nautilus_model::instruments::stubs::currency_pair_btcusdt;
3757    use nautilus_testkit::http::assert_http_redirect_rejected;
3758    use rstest::rstest;
3759
3760    use super::*;
3761    use crate::spot::http::models::BinancePriceLevel;
3762
3763    #[tokio::test]
3764    async fn test_authenticated_client_rejects_redirects() {
3765        let client = BinanceRawSpotHttpClient::new(
3766            BinanceEnvironment::Testnet,
3767            Some("key".into()),
3768            Some("secret".into()),
3769            None,
3770            None,
3771            Some(3),
3772            None,
3773        )
3774        .unwrap()
3775        .client;
3776        assert_http_redirect_rejected(|url| async move {
3777            client
3778                .get(url, None, None, Some(3), None)
3779                .await
3780                .unwrap()
3781                .status
3782                .as_u16()
3783        })
3784        .await;
3785    }
3786
3787    #[rstest]
3788    fn test_schema_constants() {
3789        assert_eq!(BinanceRawSpotHttpClient::schema_id(), 3);
3790        assert_eq!(BinanceRawSpotHttpClient::schema_version(), 5);
3791        assert_eq!(BinanceSpotHttpClient::schema_id(), 3);
3792        assert_eq!(BinanceSpotHttpClient::schema_version(), 5);
3793    }
3794
3795    #[rstest]
3796    fn test_sbe_schema_header() {
3797        assert_eq!(SBE_SCHEMA_HEADER, "3:5");
3798    }
3799
3800    #[rstest]
3801    fn test_parse_book_snapshot_response_rejects_negative_update_id() {
3802        let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
3803        let snapshot = BinanceDepth {
3804            last_update_id: -1,
3805            price_exponent: -2,
3806            qty_exponent: -5,
3807            bids: vec![],
3808            asks: vec![],
3809        };
3810
3811        let error = BinanceSpotHttpClient::parse_book_snapshot_response(
3812            InstrumentId::from("BTCUSDT.BINANCE"),
3813            &instrument,
3814            &snapshot,
3815            UnixNanos::from(1_700_000_000_000_000_001u64),
3816        )
3817        .unwrap_err();
3818
3819        assert_eq!(error.to_string(), "invalid negative order-book update ID");
3820    }
3821
3822    #[rstest]
3823    fn test_parse_book_snapshot_response_rejects_negative_quantity() {
3824        let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
3825        let snapshot = BinanceDepth {
3826            last_update_id: 12345,
3827            price_exponent: -2,
3828            qty_exponent: -5,
3829            bids: vec![BinancePriceLevel {
3830                price_mantissa: 4_200_001,
3831                qty_mantissa: -1,
3832            }],
3833            asks: vec![],
3834        };
3835
3836        let error = BinanceSpotHttpClient::parse_book_snapshot_response(
3837            InstrumentId::from("BTCUSDT.BINANCE"),
3838            &instrument,
3839            &snapshot,
3840            UnixNanos::from(1_700_000_000_000_000_001u64),
3841        )
3842        .unwrap_err();
3843
3844        assert_eq!(error.to_string(), "invalid negative bid quantity");
3845    }
3846
3847    #[rstest]
3848    fn test_default_headers_include_sbe() {
3849        let headers = BinanceRawSpotHttpClient::default_headers(&None, false);
3850
3851        assert_eq!(headers.get("Accept"), Some(&"application/sbe".to_string()));
3852        assert_eq!(headers.get("X-MBX-SBE"), Some(&"3:5".to_string()));
3853    }
3854
3855    #[rstest]
3856    fn test_json_headers_exclude_sbe() {
3857        let headers = BinanceRawSpotHttpClient::default_headers(&None, true);
3858
3859        assert_eq!(headers.get("Accept"), Some(&"application/json".to_string()));
3860        assert_eq!(headers.get("X-MBX-SBE"), None);
3861    }
3862
3863    #[rstest]
3864    fn test_spot_cancel_open_orders_from_json_decodes_mixed_results() {
3865        let bytes = serde_json::to_vec(&serde_json::json!([
3866            {
3867                "symbol": "BTCUSDT",
3868                "orderId": 11,
3869                "orderListId": -1,
3870                "clientOrderId": "cancel-11",
3871                "origClientOrderId": "orig-11",
3872                "transactTime": 1_734_300_000_000_i64,
3873                "price": "100.00",
3874                "origQty": "1.000",
3875                "executedQty": "0.000",
3876                "cummulativeQuoteQty": "0.00",
3877                "status": "CANCELED",
3878                "timeInForce": "GTC",
3879                "type": "LIMIT",
3880                "side": "SELL"
3881            },
3882            {
3883                "orderListId": 44,
3884                "contingencyType": "OCO",
3885                "listStatusType": "ALL_DONE",
3886                "listOrderStatus": "ALL_DONE",
3887                "listClientOrderId": "list-44",
3888                "transactionTime": 1_734_300_000_000_i64,
3889                "symbol": "BTCUSDT",
3890                "orders": [
3891                    {"symbol": "BTCUSDT", "orderId": 21, "clientOrderId": "orig-21"},
3892                    {"symbol": "BTCUSDT", "orderId": 22, "clientOrderId": "orig-22"}
3893                ],
3894                "orderReports": [
3895                    {
3896                        "symbol": "BTCUSDT",
3897                        "orderId": 21,
3898                        "orderListId": 44,
3899                        "clientOrderId": "cancel-list",
3900                        "origClientOrderId": "orig-21",
3901                        "transactTime": 1_734_300_000_000_i64,
3902                        "price": "101.00",
3903                        "origQty": "1.000",
3904                        "executedQty": "0.000",
3905                        "cummulativeQuoteQty": "0.00",
3906                        "status": "CANCELED",
3907                        "timeInForce": "GTC",
3908                        "type": "LIMIT_MAKER",
3909                        "side": "SELL"
3910                    },
3911                    {
3912                        "symbol": "BTCUSDT",
3913                        "orderId": 22,
3914                        "orderListId": 44,
3915                        "clientOrderId": "cancel-list",
3916                        "origClientOrderId": "orig-22",
3917                        "transactTime": 1_734_300_000_000_i64,
3918                        "price": "99.00",
3919                        "origQty": "1.000",
3920                        "executedQty": "0.000",
3921                        "cummulativeQuoteQty": "0.00",
3922                        "status": "CANCELED",
3923                        "timeInForce": "GTC",
3924                        "type": "STOP_LOSS_LIMIT",
3925                        "side": "SELL"
3926                    }
3927                ]
3928            }
3929        ]))
3930        .unwrap();
3931
3932        let responses = spot_cancel_open_orders_from_json(&bytes).unwrap();
3933
3934        assert_eq!(responses.len(), 2);
3935        assert!(matches!(
3936            &responses[0],
3937            BinanceCancelOpenOrdersResponse::Order(order) if order.order_id == 11
3938        ));
3939        let BinanceCancelOpenOrdersResponse::OrderList(order_list) = &responses[1] else {
3940            panic!("Expected JSON order-list result");
3941        };
3942        assert_eq!(order_list.order_list_id, 44);
3943        assert_eq!(order_list.transaction_time, 1_734_300_000_000_000);
3944        assert_eq!(order_list.orders.len(), 2);
3945        assert_eq!(order_list.order_reports.len(), 2);
3946        assert_eq!(order_list.order_reports[0].orig_client_order_id, "orig-21");
3947        assert_eq!(order_list.order_reports[1].orig_client_order_id, "orig-22");
3948    }
3949
3950    #[rstest]
3951    fn test_spot_cancel_open_orders_from_json_rejects_partial_order_list_shape() {
3952        let bytes = br#"[{"orders": []}]"#;
3953
3954        let error = spot_cancel_open_orders_from_json(bytes).unwrap_err();
3955
3956        assert!(matches!(error, BinanceSpotHttpError::JsonError(_)));
3957    }
3958
3959    #[rstest]
3960    fn test_spot_trades_from_json_preserves_all_fields() {
3961        let response: Vec<SpotTradeJson> = serde_json::from_value(serde_json::json!([{
3962            "id": 17,
3963            "price": "123.45",
3964            "qty": "0.06789",
3965            "quoteQty": "8.3810205",
3966            "time": 1_700_000_000_123_i64,
3967            "isBuyerMaker": true,
3968            "isBestMatch": false
3969        }]))
3970        .unwrap();
3971
3972        let parsed = spot_trades_from_json(response).unwrap();
3973
3974        assert_eq!(parsed.price_exponent, -7);
3975        assert_eq!(parsed.qty_exponent, -5);
3976        assert_eq!(parsed.trades.len(), 1);
3977        assert_eq!(parsed.trades[0].id, 17);
3978        assert_eq!(parsed.trades[0].price_mantissa, 1_234_500_000);
3979        assert_eq!(parsed.trades[0].qty_mantissa, 6_789);
3980        assert_eq!(parsed.trades[0].quote_qty_mantissa, 83_810_205);
3981        assert_eq!(parsed.trades[0].time, 1_700_000_000_123_000);
3982        assert!(parsed.trades[0].is_buyer_maker);
3983        assert!(!parsed.trades[0].is_best_match);
3984    }
3985
3986    #[rstest]
3987    fn test_spot_agg_trades_from_json_preserves_all_fields() {
3988        let response: Vec<SpotAggTradeJson> = serde_json::from_value(serde_json::json!([{
3989            "a": 21,
3990            "p": "432.10",
3991            "q": "1.234",
3992            "f": 31,
3993            "l": 32,
3994            "T": 1_700_000_000_456_i64,
3995            "m": false,
3996            "M": true
3997        }]))
3998        .unwrap();
3999
4000        let parsed = spot_agg_trades_from_json(response).unwrap();
4001
4002        assert_eq!(parsed.price_exponent, -2);
4003        assert_eq!(parsed.qty_exponent, -3);
4004        assert_eq!(parsed.trades.len(), 1);
4005        assert_eq!(parsed.trades[0].id, 21);
4006        assert_eq!(parsed.trades[0].price_mantissa, 43_210);
4007        assert_eq!(parsed.trades[0].qty_mantissa, 1_234);
4008        assert_eq!(parsed.trades[0].first_trade_id, 31);
4009        assert_eq!(parsed.trades[0].last_trade_id, 32);
4010        assert_eq!(parsed.trades[0].time, 1_700_000_000_456_000);
4011        assert!(!parsed.trades[0].is_buyer_maker);
4012        assert!(parsed.trades[0].is_best_match);
4013    }
4014
4015    #[rstest]
4016    fn test_spot_klines_from_json_preserves_all_fields() {
4017        let response: Vec<SpotKlineJson> = serde_json::from_value(serde_json::json!([[
4018            1_700_000_000_000_i64,
4019            "10.10",
4020            "11.20",
4021            "9.30",
4022            "10.40",
4023            "12.345",
4024            1_700_000_059_999_i64,
4025            "128.765",
4026            37,
4027            "5.432",
4028            "56.789",
4029            "0"
4030        ]]))
4031        .unwrap();
4032
4033        let parsed = spot_klines_from_json(response).unwrap();
4034        let kline = &parsed.klines[0];
4035
4036        assert_eq!(parsed.price_exponent, -3);
4037        assert_eq!(parsed.qty_exponent, -3);
4038        assert_eq!(parsed.klines.len(), 1);
4039        assert_eq!(kline.open_time, 1_700_000_000_000_000);
4040        assert_eq!(kline.open_price, 10_100);
4041        assert_eq!(kline.high_price, 11_200);
4042        assert_eq!(kline.low_price, 9_300);
4043        assert_eq!(kline.close_price, 10_400);
4044        assert_eq!(i128::from_le_bytes(kline.volume), 12_345);
4045        assert_eq!(kline.close_time, 1_700_000_059_999_000);
4046        assert_eq!(i128::from_le_bytes(kline.quote_volume), 128_765);
4047        assert_eq!(kline.num_trades, 37);
4048        assert_eq!(i128::from_le_bytes(kline.taker_buy_base_volume), 5_432);
4049        assert_eq!(i128::from_le_bytes(kline.taker_buy_quote_volume), 56_789);
4050    }
4051
4052    #[rstest]
4053    fn test_spot_account_from_json_preserves_all_fields() {
4054        let response: SpotAccountJson = serde_json::from_value(serde_json::json!({
4055            "commissionRates": {
4056                "maker": "0.0008",
4057                "taker": "0.0011",
4058                "buyer": "0.0002",
4059                "seller": "0.0003"
4060            },
4061            "canTrade": true,
4062            "canWithdraw": false,
4063            "canDeposit": true,
4064            "requireSelfTradePrevention": true,
4065            "preventSor": false,
4066            "updateTime": 1_700_000_000_789_i64,
4067            "accountType": "SPOT",
4068            "balances": [{"asset": "USD", "free": "123.45", "locked": "6.789"}]
4069        }))
4070        .unwrap();
4071
4072        let account = spot_account_from_json(response).unwrap();
4073
4074        assert_eq!(account.commission_exponent, -4);
4075        assert_eq!(account.maker_commission_mantissa, 8);
4076        assert_eq!(account.taker_commission_mantissa, 11);
4077        assert_eq!(account.buyer_commission_mantissa, 2);
4078        assert_eq!(account.seller_commission_mantissa, 3);
4079        assert!(account.can_trade);
4080        assert!(!account.can_withdraw);
4081        assert!(account.can_deposit);
4082        assert!(account.require_self_trade_prevention);
4083        assert!(!account.prevent_sor);
4084        assert_eq!(account.update_time, 1_700_000_000_789_000);
4085        assert_eq!(account.account_type, "SPOT");
4086        assert_eq!(account.balances.len(), 1);
4087        assert_eq!(account.balances[0].asset, "USD");
4088        assert_eq!(account.balances[0].free_mantissa, 123_450);
4089        assert_eq!(account.balances[0].locked_mantissa, 6_789);
4090        assert_eq!(account.balances[0].exponent, -3);
4091    }
4092
4093    #[rstest]
4094    fn test_spot_cancel_replace_json_uses_nested_new_order_response() {
4095        let response: SpotCancelReplaceJson = serde_json::from_value(serde_json::json!({
4096            "cancelResult": "SUCCESS",
4097            "newOrderResult": "SUCCESS",
4098            "cancelResponse": {},
4099            "newOrderResponse": {
4100                "symbol": "ETHUSD",
4101                "orderId": 101,
4102                "orderListId": -1,
4103                "clientOrderId": "new-order",
4104                "transactTime": 1_700_000_000_123_i64,
4105                "price": "12.34",
4106                "origQty": "5.678",
4107                "executedQty": "1.234",
4108                "cummulativeQuoteQty": "15.22756",
4109                "status": "PARTIALLY_FILLED",
4110                "timeInForce": "GTC",
4111                "type": "LIMIT",
4112                "side": "BUY",
4113                "stopPrice": "11.11",
4114                "workingTime": 1_700_000_000_124_i64,
4115                "selfTradePreventionMode": "EXPIRE_MAKER",
4116                "fills": [{
4117                    "price": "12.34",
4118                    "qty": "1.234",
4119                    "commission": "0.001234",
4120                    "commissionAsset": "USD",
4121                    "tradeId": 44
4122                }]
4123            }
4124        }))
4125        .unwrap();
4126
4127        let order = spot_new_order_from_json(response.new_order_response).unwrap();
4128
4129        assert_eq!(order.price_exponent, -2);
4130        assert_eq!(order.qty_exponent, -3);
4131        assert_eq!(order.order_id, 101);
4132        assert_eq!(order.order_list_id, None);
4133        assert_eq!(order.transact_time, 1_700_000_000_123_000);
4134        assert_eq!(order.price_mantissa, 1_234);
4135        assert_eq!(order.orig_qty_mantissa, 5_678);
4136        assert_eq!(order.executed_qty_mantissa, 1_234);
4137        assert_eq!(order.cummulative_quote_qty_mantissa, 1_522_756);
4138        assert_eq!(order.status, SbeOrderStatus::PartiallyFilled);
4139        assert_eq!(order.time_in_force, SbeTimeInForce::Gtc);
4140        assert_eq!(order.order_type, SbeOrderType::Limit);
4141        assert_eq!(order.side, SbeOrderSide::Buy);
4142        assert_eq!(order.stop_price_mantissa, Some(1_111));
4143        assert_eq!(order.working_time, Some(1_700_000_000_124_000));
4144        assert_eq!(
4145            order.self_trade_prevention_mode,
4146            SbeSelfTradePreventionMode::ExpireMaker
4147        );
4148        assert_eq!(order.client_order_id, "new-order");
4149        assert_eq!(order.symbol, "ETHUSD");
4150        assert_eq!(order.fills.len(), 1);
4151        assert_eq!(order.fills[0].price_mantissa, 1_234);
4152        assert_eq!(order.fills[0].qty_mantissa, 1_234);
4153        assert_eq!(order.fills[0].commission_mantissa, 1_234);
4154        assert_eq!(order.fills[0].commission_exponent, -6);
4155        assert_eq!(order.fills[0].commission_asset, "USD");
4156        assert_eq!(order.fills[0].trade_id, Some(44));
4157        assert_eq!(order.expiry_reason, None);
4158    }
4159
4160    #[rstest]
4161    fn test_rate_limit_config() {
4162        let config = BinanceRawSpotHttpClient::rate_limit_config();
4163
4164        assert!(config.default_quota.is_some());
4165        // Spot has 2 ORDERS quotas (SECOND and DAY)
4166        assert_eq!(config.order_keys.len(), 2);
4167    }
4168
4169    #[rstest]
4170    fn test_quota_from_unknown_interval_returns_none() {
4171        let quota = BinanceRateLimitQuota {
4172            rate_limit_type: BinanceRateLimitType::Orders,
4173            interval: BinanceRateLimitInterval::Unknown,
4174            interval_num: 1,
4175            limit: 10,
4176        };
4177
4178        assert!(BinanceRawSpotHttpClient::quota_from(&quota).is_none());
4179    }
4180
4181    fn create_test_raw_client() -> BinanceRawSpotHttpClient {
4182        BinanceRawSpotHttpClient::new(
4183            BinanceEnvironment::Live,
4184            None,
4185            None,
4186            Some("http://127.0.0.1:1".to_string()),
4187            None,
4188            Some(1),
4189            None,
4190        )
4191        .unwrap()
4192    }
4193
4194    #[rstest]
4195    #[case::limit(
4196        AggTradesParams {
4197            symbol: "BTCUSDT".to_string(),
4198            from_id: None,
4199            start_time: None,
4200            end_time: None,
4201            limit: Some(1001),
4202        },
4203        "Validation error: aggregate trade limit must not exceed 1000"
4204    )]
4205    #[case::bounds(
4206        AggTradesParams {
4207            symbol: "BTCUSDT".to_string(),
4208            from_id: None,
4209            start_time: Some(2000),
4210            end_time: Some(1000),
4211            limit: Some(1000),
4212        },
4213        "Validation error: aggregate trade startTime must not exceed endTime"
4214    )]
4215    #[tokio::test]
4216    async fn test_agg_trades_rejects_invalid_bounds(
4217        #[case] params: AggTradesParams,
4218        #[case] expected: &str,
4219    ) {
4220        let error = create_test_raw_client()
4221            .agg_trades(&params)
4222            .await
4223            .unwrap_err();
4224
4225        assert_eq!(error.to_string(), expected);
4226    }
4227}