nautilus_binance/spot/http/
client.rs1use std::{collections::HashMap, num::NonZeroU32, sync::Arc};
35
36use chrono::Utc;
37use nautilus_core::consts::NAUTILUS_USER_AGENT;
38use nautilus_network::{
39 http::{HttpClient, HttpResponse, Method},
40 ratelimiter::quota::Quota,
41};
42use serde::Serialize;
43
44use super::{
45 error::{BinanceSpotHttpError, BinanceSpotHttpResult},
46 models::{BinanceDepth, BinanceTrades},
47 parse,
48 query::{DepthParams, TradesParams},
49};
50use crate::common::{
51 consts::BINANCE_SPOT_RATE_LIMITS,
52 credential::Credential,
53 enums::{BinanceEnvironment, BinanceProductType},
54 models::BinanceErrorResponse,
55 sbe::spot::{SBE_SCHEMA_ID, SBE_SCHEMA_VERSION},
56 urls::get_http_base_url,
57};
58
59pub const SBE_SCHEMA_HEADER: &str = "3:1";
61
62const SPOT_API_PATH: &str = "/api/v3";
64
65const BINANCE_GLOBAL_RATE_KEY: &str = "binance:spot:global";
67
68const BINANCE_ORDERS_RATE_KEY: &str = "binance:spot:orders";
70
71#[derive(Debug, Clone)]
82pub struct BinanceRawSpotHttpClient {
83 client: HttpClient,
84 base_url: String,
85 credential: Option<Credential>,
86 recv_window: Option<u64>,
87 order_rate_keys: Vec<String>,
88}
89
90impl BinanceRawSpotHttpClient {
91 pub fn new(
97 environment: BinanceEnvironment,
98 api_key: Option<String>,
99 api_secret: Option<String>,
100 base_url_override: Option<String>,
101 recv_window: Option<u64>,
102 timeout_secs: Option<u64>,
103 proxy_url: Option<String>,
104 ) -> BinanceSpotHttpResult<Self> {
105 let RateLimitConfig {
106 default_quota,
107 keyed_quotas,
108 order_keys,
109 } = Self::rate_limit_config();
110
111 let credential = match (api_key, api_secret) {
112 (Some(key), Some(secret)) => Some(Credential::new(key, secret)),
113 (None, None) => None,
114 _ => return Err(BinanceSpotHttpError::MissingCredentials),
115 };
116
117 let base_url = base_url_override.unwrap_or_else(|| {
118 get_http_base_url(BinanceProductType::Spot, environment).to_string()
119 });
120
121 let headers = Self::default_headers(&credential);
122
123 let client = HttpClient::new(
124 headers,
125 vec!["X-MBX-APIKEY".to_string()],
126 keyed_quotas,
127 default_quota,
128 timeout_secs,
129 proxy_url,
130 )?;
131
132 Ok(Self {
133 client,
134 base_url,
135 credential,
136 recv_window,
137 order_rate_keys: order_keys,
138 })
139 }
140
141 #[must_use]
143 pub const fn schema_id() -> u16 {
144 SBE_SCHEMA_ID
145 }
146
147 #[must_use]
149 pub const fn schema_version() -> u16 {
150 SBE_SCHEMA_VERSION
151 }
152
153 pub async fn get<P>(&self, path: &str, params: Option<&P>) -> BinanceSpotHttpResult<Vec<u8>>
155 where
156 P: Serialize + ?Sized,
157 {
158 self.request(Method::GET, path, params, false, false).await
159 }
160
161 pub async fn get_signed<P>(
163 &self,
164 path: &str,
165 params: Option<&P>,
166 ) -> BinanceSpotHttpResult<Vec<u8>>
167 where
168 P: Serialize + ?Sized,
169 {
170 self.request(Method::GET, path, params, true, false).await
171 }
172
173 pub async fn post_order<P>(
175 &self,
176 path: &str,
177 params: Option<&P>,
178 ) -> BinanceSpotHttpResult<Vec<u8>>
179 where
180 P: Serialize + ?Sized,
181 {
182 self.request(Method::POST, path, params, true, true).await
183 }
184
185 pub async fn ping(&self) -> BinanceSpotHttpResult<()> {
191 let bytes = self.get("ping", None::<&()>).await?;
192 parse::decode_ping(&bytes)?;
193 Ok(())
194 }
195
196 pub async fn server_time(&self) -> BinanceSpotHttpResult<i64> {
204 let bytes = self.get("time", None::<&()>).await?;
205 let timestamp = parse::decode_server_time(&bytes)?;
206 Ok(timestamp)
207 }
208
209 pub async fn depth(&self, params: &DepthParams) -> BinanceSpotHttpResult<BinanceDepth> {
215 let bytes = self.get("depth", Some(params)).await?;
216 let depth = parse::decode_depth(&bytes)?;
217 Ok(depth)
218 }
219
220 pub async fn trades(&self, params: &TradesParams) -> BinanceSpotHttpResult<BinanceTrades> {
226 let bytes = self.get("trades", Some(params)).await?;
227 let trades = parse::decode_trades(&bytes)?;
228 Ok(trades)
229 }
230
231 async fn request<P>(
232 &self,
233 method: Method,
234 path: &str,
235 params: Option<&P>,
236 signed: bool,
237 use_order_quota: bool,
238 ) -> BinanceSpotHttpResult<Vec<u8>>
239 where
240 P: Serialize + ?Sized,
241 {
242 let mut query = params
243 .map(serde_urlencoded::to_string)
244 .transpose()
245 .map_err(|e| BinanceSpotHttpError::ValidationError(e.to_string()))?
246 .unwrap_or_default();
247
248 let mut headers = HashMap::new();
249 if signed {
250 let cred = self
251 .credential
252 .as_ref()
253 .ok_or(BinanceSpotHttpError::MissingCredentials)?;
254
255 if !query.is_empty() {
256 query.push('&');
257 }
258
259 let timestamp = Utc::now().timestamp_millis();
260 query.push_str(&format!("timestamp={timestamp}"));
261
262 if let Some(recv_window) = self.recv_window {
263 query.push_str(&format!("&recvWindow={recv_window}"));
264 }
265
266 let signature = cred.sign(&query);
267 query.push_str(&format!("&signature={signature}"));
268 headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
269 }
270
271 let url = self.build_url(path, &query);
272 let keys = self.rate_limit_keys(use_order_quota);
273
274 let response = self
275 .client
276 .request(
277 method,
278 url,
279 None::<&HashMap<String, Vec<String>>>,
280 Some(headers),
281 None,
282 None,
283 Some(keys),
284 )
285 .await?;
286
287 if !response.status.is_success() {
288 return self.parse_error_response(response);
289 }
290
291 Ok(response.body.to_vec())
292 }
293
294 fn build_url(&self, path: &str, query: &str) -> String {
295 let normalized_path = if path.starts_with('/') {
296 path.to_string()
297 } else {
298 format!("/{path}")
299 };
300
301 let mut url = format!("{}{}{}", self.base_url, SPOT_API_PATH, normalized_path);
302 if !query.is_empty() {
303 url.push('?');
304 url.push_str(query);
305 }
306 url
307 }
308
309 fn rate_limit_keys(&self, use_orders: bool) -> Vec<String> {
310 if use_orders {
311 let mut keys = Vec::with_capacity(1 + self.order_rate_keys.len());
312 keys.push(BINANCE_GLOBAL_RATE_KEY.to_string());
313 keys.extend(self.order_rate_keys.iter().cloned());
314 keys
315 } else {
316 vec![BINANCE_GLOBAL_RATE_KEY.to_string()]
317 }
318 }
319
320 fn parse_error_response<T>(&self, response: HttpResponse) -> BinanceSpotHttpResult<T> {
321 let status = response.status.as_u16();
322 let body_hex = hex::encode(&response.body);
323
324 if let Ok(body_str) = std::str::from_utf8(&response.body)
326 && let Ok(err) = serde_json::from_str::<BinanceErrorResponse>(body_str)
327 {
328 return Err(BinanceSpotHttpError::BinanceError {
329 code: err.code,
330 message: err.msg,
331 });
332 }
333
334 Err(BinanceSpotHttpError::UnexpectedStatus {
335 status,
336 body: body_hex,
337 })
338 }
339
340 fn default_headers(credential: &Option<Credential>) -> HashMap<String, String> {
341 let mut headers = HashMap::new();
342 headers.insert("User-Agent".to_string(), NAUTILUS_USER_AGENT.to_string());
343 headers.insert("Accept".to_string(), "application/sbe".to_string());
344 headers.insert("X-MBX-SBE".to_string(), SBE_SCHEMA_HEADER.to_string());
345 if let Some(cred) = credential {
346 headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
347 }
348 headers
349 }
350
351 fn rate_limit_config() -> RateLimitConfig {
352 let quotas = BINANCE_SPOT_RATE_LIMITS;
353 let mut keyed = Vec::new();
354 let mut order_keys = Vec::new();
355 let mut default = None;
356
357 for quota in quotas {
358 if let Some(q) = Self::quota_from(quota) {
359 if quota.rate_limit_type == "REQUEST_WEIGHT" && default.is_none() {
360 default = Some(q);
361 } else if quota.rate_limit_type == "ORDERS" {
362 let key = format!("{}:{}", BINANCE_ORDERS_RATE_KEY, quota.interval);
363 order_keys.push(key.clone());
364 keyed.push((key, q));
365 }
366 }
367 }
368
369 let default_quota =
370 default.unwrap_or_else(|| Quota::per_second(NonZeroU32::new(10).unwrap()));
371
372 keyed.push((BINANCE_GLOBAL_RATE_KEY.to_string(), default_quota));
373
374 RateLimitConfig {
375 default_quota: Some(default_quota),
376 keyed_quotas: keyed,
377 order_keys,
378 }
379 }
380
381 fn quota_from(quota: &crate::common::consts::BinanceRateLimitQuota) -> Option<Quota> {
382 let burst = NonZeroU32::new(quota.limit)?;
383 match quota.interval {
384 "SECOND" => Some(Quota::per_second(burst)),
385 "MINUTE" => Some(Quota::per_minute(burst)),
386 "DAY" => Quota::with_period(std::time::Duration::from_secs(86_400))
387 .map(|q| q.allow_burst(burst)),
388 _ => None,
389 }
390 }
391}
392
393struct RateLimitConfig {
394 default_quota: Option<Quota>,
395 keyed_quotas: Vec<(String, Quota)>,
396 order_keys: Vec<String>,
397}
398
399#[derive(Debug, Clone)]
405pub struct BinanceSpotHttpClient {
406 inner: Arc<BinanceRawSpotHttpClient>,
407}
408
409impl BinanceSpotHttpClient {
410 pub fn new(
416 environment: BinanceEnvironment,
417 api_key: Option<String>,
418 api_secret: Option<String>,
419 base_url_override: Option<String>,
420 recv_window: Option<u64>,
421 timeout_secs: Option<u64>,
422 proxy_url: Option<String>,
423 ) -> BinanceSpotHttpResult<Self> {
424 let inner = BinanceRawSpotHttpClient::new(
425 environment,
426 api_key,
427 api_secret,
428 base_url_override,
429 recv_window,
430 timeout_secs,
431 proxy_url,
432 )?;
433
434 Ok(Self {
435 inner: Arc::new(inner),
436 })
437 }
438
439 #[must_use]
441 pub fn inner(&self) -> &BinanceRawSpotHttpClient {
442 &self.inner
443 }
444
445 #[must_use]
447 pub const fn schema_id() -> u16 {
448 SBE_SCHEMA_ID
449 }
450
451 #[must_use]
453 pub const fn schema_version() -> u16 {
454 SBE_SCHEMA_VERSION
455 }
456
457 pub async fn ping(&self) -> BinanceSpotHttpResult<()> {
463 self.inner.ping().await
464 }
465
466 pub async fn server_time(&self) -> BinanceSpotHttpResult<i64> {
474 self.inner.server_time().await
475 }
476
477 pub async fn depth(&self, params: &DepthParams) -> BinanceSpotHttpResult<BinanceDepth> {
483 self.inner.depth(params).await
484 }
485
486 pub async fn trades(&self, params: &TradesParams) -> BinanceSpotHttpResult<BinanceTrades> {
492 self.inner.trades(params).await
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use rstest::rstest;
499
500 use super::*;
501
502 #[rstest]
503 fn test_schema_constants() {
504 assert_eq!(BinanceRawSpotHttpClient::schema_id(), 3);
505 assert_eq!(BinanceRawSpotHttpClient::schema_version(), 1);
506 assert_eq!(BinanceSpotHttpClient::schema_id(), 3);
507 assert_eq!(BinanceSpotHttpClient::schema_version(), 1);
508 }
509
510 #[rstest]
511 fn test_sbe_schema_header() {
512 assert_eq!(SBE_SCHEMA_HEADER, "3:1");
513 }
514
515 #[rstest]
516 fn test_default_headers_include_sbe() {
517 let headers = BinanceRawSpotHttpClient::default_headers(&None);
518
519 assert_eq!(headers.get("Accept"), Some(&"application/sbe".to_string()));
520 assert_eq!(headers.get("X-MBX-SBE"), Some(&"3:1".to_string()));
521 }
522
523 #[rstest]
524 fn test_rate_limit_config() {
525 let config = BinanceRawSpotHttpClient::rate_limit_config();
526
527 assert!(config.default_quota.is_some());
528 assert_eq!(config.order_keys.len(), 2);
530 }
531}