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