1use std::{collections::HashMap, num::NonZeroU32, sync::Arc, time::Duration};
19
20use chrono::Utc;
21use dashmap::DashMap;
22use nautilus_core::consts::NAUTILUS_USER_AGENT;
23use nautilus_network::{
24 http::{HttpClient, HttpResponse, Method},
25 ratelimiter::quota::Quota,
26};
27use serde::{Serialize, de::DeserializeOwned};
28use ustr::Ustr;
29
30use super::error::{BinanceHttpError, BinanceHttpResult};
31use crate::{
32 common::{
33 consts::{
34 BINANCE_DAPI_PATH, BINANCE_DAPI_RATE_LIMITS, BINANCE_EAPI_PATH,
35 BINANCE_EAPI_RATE_LIMITS, BINANCE_FAPI_PATH, BINANCE_FAPI_RATE_LIMITS,
36 BINANCE_SPOT_API_PATH, BINANCE_SPOT_RATE_LIMITS,
37 },
38 credential::Credential,
39 enums::{BinanceEnvironment, BinanceProductType},
40 models::BinanceErrorResponse,
41 urls::get_http_base_url,
42 },
43 http::{
44 models::{
45 BinanceBookTicker, BinanceFuturesCoinExchangeInfo, BinanceFuturesCoinSymbol,
46 BinanceFuturesTicker24hr, BinanceFuturesUsdExchangeInfo, BinanceFuturesUsdSymbol,
47 BinanceOrderBook, BinancePriceTicker, BinanceServerTime, BinanceSpotExchangeInfo,
48 BinanceSpotSymbol, BinanceSpotTicker24hr,
49 },
50 query::{
51 BinanceBookTickerParams, BinanceDepthParams, BinancePriceTickerParams,
52 BinanceSpotExchangeInfoParams, BinanceTicker24hrParams,
53 },
54 },
55};
56
57const BINANCE_GLOBAL_RATE_KEY: &str = "binance:global";
58const BINANCE_ORDERS_RATE_KEY: &str = "binance:orders";
59
60#[derive(Debug, Clone)]
68pub struct BinanceRawHttpClient {
69 client: HttpClient,
70 base_url: String,
71 api_path: &'static str,
72 credential: Option<Credential>,
73 recv_window: Option<u64>,
74 order_rate_keys: Vec<String>,
75}
76
77impl BinanceRawHttpClient {
78 #[allow(clippy::too_many_arguments)]
84 pub fn new(
85 product_type: BinanceProductType,
86 environment: BinanceEnvironment,
87 api_key: Option<String>,
88 api_secret: Option<String>,
89 base_url_override: Option<String>,
90 recv_window: Option<u64>,
91 timeout_secs: Option<u64>,
92 proxy_url: Option<String>,
93 ) -> BinanceHttpResult<Self> {
94 let RateLimitConfig {
95 default_quota,
96 keyed_quotas,
97 order_keys,
98 } = Self::rate_limit_config(product_type);
99
100 let credential = match (api_key, api_secret) {
101 (Some(key), Some(secret)) => Some(Credential::new(key, secret)),
102 (None, None) => None,
103 _ => return Err(BinanceHttpError::MissingCredentials),
104 };
105
106 let base_url = base_url_override
107 .unwrap_or_else(|| get_http_base_url(product_type, environment).to_string());
108
109 let api_path = Self::resolve_api_path(product_type);
110 let headers = Self::default_headers(&credential);
111
112 let client = HttpClient::new(
113 headers,
114 vec!["X-MBX-APIKEY".to_string()],
115 keyed_quotas,
116 default_quota,
117 timeout_secs,
118 proxy_url,
119 )?;
120
121 Ok(Self {
122 client,
123 base_url,
124 api_path,
125 credential,
126 recv_window,
127 order_rate_keys: order_keys,
128 })
129 }
130
131 pub async fn get<P, T>(
135 &self,
136 path: &str,
137 params: Option<&P>,
138 signed: bool,
139 use_order_quota: bool,
140 ) -> BinanceHttpResult<T>
141 where
142 P: Serialize + ?Sized,
143 T: DeserializeOwned,
144 {
145 self.request(Method::GET, path, params, signed, use_order_quota, None)
146 .await
147 }
148
149 pub async fn post<P, T>(
151 &self,
152 path: &str,
153 params: Option<&P>,
154 body: Option<Vec<u8>>,
155 signed: bool,
156 use_order_quota: bool,
157 ) -> BinanceHttpResult<T>
158 where
159 P: Serialize + ?Sized,
160 T: DeserializeOwned,
161 {
162 self.request(Method::POST, path, params, signed, use_order_quota, body)
163 .await
164 }
165
166 pub async fn request_put<P, T>(
168 &self,
169 path: &str,
170 params: Option<&P>,
171 signed: bool,
172 use_order_quota: bool,
173 ) -> BinanceHttpResult<T>
174 where
175 P: Serialize + ?Sized,
176 T: DeserializeOwned,
177 {
178 self.request(Method::PUT, path, params, signed, use_order_quota, None)
179 .await
180 }
181
182 pub async fn request_delete<P, T>(
184 &self,
185 path: &str,
186 params: Option<&P>,
187 signed: bool,
188 use_order_quota: bool,
189 ) -> BinanceHttpResult<T>
190 where
191 P: Serialize + ?Sized,
192 T: DeserializeOwned,
193 {
194 self.request(Method::DELETE, path, params, signed, use_order_quota, None)
195 .await
196 }
197
198 async fn request<P, T>(
199 &self,
200 method: Method,
201 path: &str,
202 params: Option<&P>,
203 signed: bool,
204 use_order_quota: bool,
205 body: Option<Vec<u8>>,
206 ) -> BinanceHttpResult<T>
207 where
208 P: Serialize + ?Sized,
209 T: DeserializeOwned,
210 {
211 let mut query = params
212 .map(serde_urlencoded::to_string)
213 .transpose()
214 .map_err(|e| BinanceHttpError::ValidationError(e.to_string()))?
215 .unwrap_or_default();
216
217 let mut headers = HashMap::new();
218 if signed {
219 let cred = self
220 .credential
221 .as_ref()
222 .ok_or(BinanceHttpError::MissingCredentials)?;
223
224 if !query.is_empty() {
225 query.push('&');
226 }
227
228 let timestamp = Utc::now().timestamp_millis();
229 query.push_str(&format!("timestamp={timestamp}"));
230
231 if let Some(recv_window) = self.recv_window {
232 query.push_str(&format!("&recvWindow={recv_window}"));
233 }
234
235 let signature = cred.sign(&query);
236 query.push_str(&format!("&signature={signature}"));
237 headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
238 }
239
240 let url = self.build_url(path, &query);
241 let keys = self.rate_limit_keys(use_order_quota);
242
243 let response = self
244 .client
245 .request(
246 method,
247 url,
248 None::<&HashMap<String, Vec<String>>>,
249 Some(headers),
250 body,
251 None,
252 Some(keys),
253 )
254 .await?;
255
256 if !response.status.is_success() {
257 return self.parse_error_response(response);
258 }
259
260 serde_json::from_slice::<T>(&response.body)
261 .map_err(|e| BinanceHttpError::JsonError(e.to_string()))
262 }
263
264 #[cfg(not(test))]
265 fn build_url(&self, path: &str, query: &str) -> String {
266 Self::build_url_impl(&self.base_url, self.api_path, path, query)
267 }
268
269 #[cfg(test)]
270 pub(crate) fn build_url(&self, path: &str, query: &str) -> String {
271 Self::build_url_impl(&self.base_url, self.api_path, path, query)
272 }
273
274 fn build_url_impl(base_url: &str, api_path: &str, path: &str, query: &str) -> String {
275 let mut url = format!("{}{}{}", base_url, api_path, Self::normalize_path(path));
276 if !query.is_empty() {
277 url.push('?');
278 url.push_str(query);
279 }
280 url
281 }
282
283 pub(crate) fn normalize_path(path: &str) -> String {
284 if path.starts_with('/') {
285 path.to_string()
286 } else {
287 format!("/{path}")
288 }
289 }
290
291 #[cfg(not(test))]
292 fn rate_limit_keys(&self, use_orders: bool) -> Vec<String> {
293 Self::rate_limit_keys_impl(&self.order_rate_keys, use_orders)
294 }
295
296 #[cfg(test)]
297 pub(crate) fn rate_limit_keys(&self, use_orders: bool) -> Vec<String> {
298 Self::rate_limit_keys_impl(&self.order_rate_keys, use_orders)
299 }
300
301 fn rate_limit_keys_impl(order_rate_keys: &[String], use_orders: bool) -> Vec<String> {
302 if use_orders {
303 let mut keys = Vec::with_capacity(1 + order_rate_keys.len());
304 keys.push(BINANCE_GLOBAL_RATE_KEY.to_string());
305 keys.extend(order_rate_keys.iter().cloned());
306 keys
307 } else {
308 vec![BINANCE_GLOBAL_RATE_KEY.to_string()]
309 }
310 }
311
312 pub(crate) fn parse_error_response<T>(&self, response: HttpResponse) -> BinanceHttpResult<T> {
313 let status = response.status.as_u16();
314 let body = String::from_utf8_lossy(&response.body).to_string();
315
316 if let Ok(err) = serde_json::from_str::<BinanceErrorResponse>(&body) {
317 return Err(BinanceHttpError::BinanceError {
318 code: err.code,
319 message: err.msg,
320 });
321 }
322
323 Err(BinanceHttpError::UnexpectedStatus { status, body })
324 }
325
326 fn default_headers(credential: &Option<Credential>) -> HashMap<String, String> {
327 let mut headers = HashMap::new();
328 headers.insert("User-Agent".to_string(), NAUTILUS_USER_AGENT.to_string());
329 if let Some(cred) = credential {
330 headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
331 }
332 headers
333 }
334
335 fn resolve_api_path(product_type: BinanceProductType) -> &'static str {
336 match product_type {
337 BinanceProductType::Spot | BinanceProductType::Margin => BINANCE_SPOT_API_PATH,
338 BinanceProductType::UsdM => BINANCE_FAPI_PATH,
339 BinanceProductType::CoinM => BINANCE_DAPI_PATH,
340 BinanceProductType::Options => BINANCE_EAPI_PATH,
341 }
342 }
343
344 pub(crate) fn rate_limit_config(product_type: BinanceProductType) -> RateLimitConfig {
345 let quotas = match product_type {
346 BinanceProductType::Spot | BinanceProductType::Margin => BINANCE_SPOT_RATE_LIMITS,
347 BinanceProductType::UsdM => BINANCE_FAPI_RATE_LIMITS,
348 BinanceProductType::CoinM => BINANCE_DAPI_RATE_LIMITS,
349 BinanceProductType::Options => BINANCE_EAPI_RATE_LIMITS,
350 };
351
352 let mut keyed = Vec::new();
353 let mut order_keys = Vec::new();
354 let mut default = None;
355
356 for quota in quotas {
357 if let Some(q) = Self::quota_from(quota) {
358 if quota.rate_limit_type == "REQUEST_WEIGHT" && default.is_none() {
359 default = Some(q);
360 } else if quota.rate_limit_type == "ORDERS" {
361 let key = format!("{}:{}", BINANCE_ORDERS_RATE_KEY, quota.interval);
362 order_keys.push(key.clone());
363 keyed.push((key, q));
364 }
365 }
366 }
367
368 let default_quota =
369 default.unwrap_or_else(|| Quota::per_second(NonZeroU32::new(10).unwrap()));
370
371 keyed.push((BINANCE_GLOBAL_RATE_KEY.to_string(), default_quota));
372
373 RateLimitConfig {
374 default_quota: Some(default_quota),
375 keyed_quotas: keyed,
376 order_keys,
377 }
378 }
379
380 fn quota_from(quota: &crate::common::consts::BinanceRateLimitQuota) -> Option<Quota> {
381 let burst = NonZeroU32::new(quota.limit)?;
382 match quota.interval {
383 "SECOND" => Some(Quota::per_second(burst)),
384 "MINUTE" => Some(Quota::per_minute(burst)),
385 "DAY" => Quota::with_period(Duration::from_secs(86_400)).map(|q| q.allow_burst(burst)),
386 _ => None,
387 }
388 }
389}
390pub(crate) struct RateLimitConfig {
391 pub(crate) default_quota: Option<Quota>,
392 pub(crate) keyed_quotas: Vec<(String, Quota)>,
393 pub(crate) order_keys: Vec<String>,
394}
395
396#[derive(Clone, Debug)]
398#[allow(dead_code)]
399pub enum BinanceInstrument {
400 Spot(BinanceSpotSymbol),
401 UsdM(BinanceFuturesUsdSymbol),
402 CoinM(BinanceFuturesCoinSymbol),
403}
404
405#[derive(Clone, Debug)]
407pub enum BinanceTicker24hrEither {
408 Spot(Vec<BinanceSpotTicker24hr>),
409 Futures(Vec<BinanceFuturesTicker24hr>),
410}
411
412#[derive(Debug, Clone)]
414pub struct BinanceHttpClient {
415 raw: BinanceRawHttpClient,
416 product_type: BinanceProductType,
417 instruments: Arc<DashMap<Ustr, BinanceInstrument>>,
418}
419
420impl BinanceHttpClient {
421 #[allow(clippy::too_many_arguments)]
427 pub fn new(
428 product_type: BinanceProductType,
429 environment: BinanceEnvironment,
430 api_key: Option<String>,
431 api_secret: Option<String>,
432 base_url_override: Option<String>,
433 recv_window: Option<u64>,
434 timeout_secs: Option<u64>,
435 proxy_url: Option<String>,
436 ) -> BinanceHttpResult<Self> {
437 let raw = BinanceRawHttpClient::new(
438 product_type,
439 environment,
440 api_key,
441 api_secret,
442 base_url_override,
443 recv_window,
444 timeout_secs,
445 proxy_url,
446 )?;
447
448 Ok(Self {
449 raw,
450 product_type,
451 instruments: Arc::new(DashMap::new()),
452 })
453 }
454
455 #[must_use]
457 pub const fn raw(&self) -> &BinanceRawHttpClient {
458 &self.raw
459 }
460
461 #[must_use]
463 pub fn instruments(&self) -> &DashMap<Ustr, BinanceInstrument> {
464 &self.instruments
465 }
466
467 pub async fn server_time(&self) -> BinanceHttpResult<BinanceServerTime> {
469 self.raw
470 .get::<_, BinanceServerTime>("time", None::<&()>, false, false)
471 .await
472 }
473
474 pub async fn exchange_info(&self) -> BinanceHttpResult<()> {
476 match self.product_type {
477 BinanceProductType::Spot | BinanceProductType::Margin => {
478 let info: BinanceSpotExchangeInfo = self
479 .raw
480 .get(
481 "exchangeInfo",
482 None::<&BinanceSpotExchangeInfoParams>,
483 false,
484 false,
485 )
486 .await?;
487 for symbol in info.symbols {
488 self.instruments
489 .insert(symbol.symbol, BinanceInstrument::Spot(symbol));
490 }
491 }
492 BinanceProductType::UsdM => {
493 let info: BinanceFuturesUsdExchangeInfo = self
494 .raw
495 .get("exchangeInfo", None::<&()>, false, false)
496 .await?;
497 for symbol in info.symbols {
498 self.instruments
499 .insert(symbol.symbol, BinanceInstrument::UsdM(symbol));
500 }
501 }
502 BinanceProductType::CoinM => {
503 let info: BinanceFuturesCoinExchangeInfo = self
504 .raw
505 .get("exchangeInfo", None::<&()>, false, false)
506 .await?;
507 for symbol in info.symbols {
508 self.instruments
509 .insert(symbol.symbol, BinanceInstrument::CoinM(symbol));
510 }
511 }
512 BinanceProductType::Options => {
513 return Err(BinanceHttpError::ValidationError(
515 "Options exchangeInfo not yet implemented".to_string(),
516 ));
517 }
518 }
519
520 Ok(())
521 }
522
523 pub async fn get_instrument(
525 &self,
526 symbol: &str,
527 ) -> BinanceHttpResult<Option<BinanceInstrument>> {
528 let key = Ustr::from(symbol);
529
530 if let Some(entry) = self.instruments.get(&key) {
531 return Ok(Some(entry.value().clone()));
532 }
533
534 self.exchange_info().await?;
536 Ok(self.instruments.get(&key).map(|e| e.value().clone()))
537 }
538
539 pub async fn ticker_24h(
541 &self,
542 params: &BinanceTicker24hrParams,
543 ) -> BinanceHttpResult<BinanceTicker24hrEither> {
544 match self.product_type {
545 BinanceProductType::Spot | BinanceProductType::Margin => {
546 let data: Vec<BinanceSpotTicker24hr> = self
547 .raw
548 .get("ticker/24hr", Some(params), false, false)
549 .await?;
550 Ok(BinanceTicker24hrEither::Spot(data))
551 }
552 _ => {
553 let data: Vec<BinanceFuturesTicker24hr> = self
554 .raw
555 .get("ticker/24hr", Some(params), false, false)
556 .await?;
557 Ok(BinanceTicker24hrEither::Futures(data))
558 }
559 }
560 }
561
562 pub async fn book_ticker(
564 &self,
565 params: &BinanceBookTickerParams,
566 ) -> BinanceHttpResult<Vec<BinanceBookTicker>> {
567 self.raw
568 .get("ticker/bookTicker", Some(params), false, false)
569 .await
570 }
571
572 pub async fn price_ticker(
574 &self,
575 params: &BinancePriceTickerParams,
576 ) -> BinanceHttpResult<Vec<BinancePriceTicker>> {
577 self.raw
578 .get("ticker/price", Some(params), false, false)
579 .await
580 }
581
582 pub async fn depth(&self, params: &BinanceDepthParams) -> BinanceHttpResult<BinanceOrderBook> {
584 self.raw.get("depth", Some(params), false, false).await
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use nautilus_network::http::{HttpStatus, StatusCode};
591 use rstest::rstest;
592 use tokio_util::bytes::Bytes;
593
594 use super::*;
595
596 #[rstest]
601 #[case("time", "/time")]
602 #[case("/time", "/time")]
603 #[case("ticker/24hr", "/ticker/24hr")]
604 #[case("/ticker/24hr", "/ticker/24hr")]
605 fn test_normalize_path(#[case] input: &str, #[case] expected: &str) {
606 assert_eq!(BinanceRawHttpClient::normalize_path(input), expected);
607 }
608
609 #[rstest]
610 fn test_build_url_without_query() {
611 let client = create_test_client(None);
612 let url = client.build_url("time", "");
613
614 assert_eq!(url, "https://api.binance.com/api/v3/time");
615 }
616
617 #[rstest]
618 fn test_build_url_with_query() {
619 let client = create_test_client(None);
620 let url = client.build_url("depth", "symbol=BTCUSDT&limit=100");
621
622 assert_eq!(
623 url,
624 "https://api.binance.com/api/v3/depth?symbol=BTCUSDT&limit=100"
625 );
626 }
627
628 #[rstest]
629 fn test_build_url_path_with_leading_slash() {
630 let client = create_test_client(None);
631 let url = client.build_url("/exchangeInfo", "");
632
633 assert_eq!(url, "https://api.binance.com/api/v3/exchangeInfo");
634 }
635
636 #[rstest]
641 fn test_parse_error_response_binance_error() {
642 let client = create_test_client(None);
643 let response = HttpResponse {
644 status: HttpStatus::new(StatusCode::BAD_REQUEST),
645 headers: HashMap::new(),
646 body: Bytes::from(r#"{"code":-1121,"msg":"Invalid symbol."}"#),
647 };
648
649 let result: BinanceHttpResult<()> = client.parse_error_response(response);
650
651 match result {
652 Err(BinanceHttpError::BinanceError { code, message }) => {
653 assert_eq!(code, -1121);
654 assert_eq!(message, "Invalid symbol.");
655 }
656 other => panic!("Expected BinanceError, got {other:?}"),
657 }
658 }
659
660 #[rstest]
661 fn test_parse_error_response_unexpected_status_non_json() {
662 let client = create_test_client(None);
663 let response = HttpResponse {
664 status: HttpStatus::new(StatusCode::INTERNAL_SERVER_ERROR),
665 headers: HashMap::new(),
666 body: Bytes::from("Internal Server Error"),
667 };
668
669 let result: BinanceHttpResult<()> = client.parse_error_response(response);
670
671 match result {
672 Err(BinanceHttpError::UnexpectedStatus { status, body }) => {
673 assert_eq!(status, 500);
674 assert_eq!(body, "Internal Server Error");
675 }
676 other => panic!("Expected UnexpectedStatus, got {other:?}"),
677 }
678 }
679
680 #[rstest]
681 fn test_parse_error_response_malformed_json() {
682 let client = create_test_client(None);
683 let response = HttpResponse {
684 status: HttpStatus::new(StatusCode::BAD_REQUEST),
685 headers: HashMap::new(),
686 body: Bytes::from(r#"{"error": "not binance format"}"#),
687 };
688
689 let result: BinanceHttpResult<()> = client.parse_error_response(response);
690
691 match result {
692 Err(BinanceHttpError::UnexpectedStatus { status, body }) => {
693 assert_eq!(status, 400);
694 assert!(body.contains("not binance format"));
695 }
696 other => panic!("Expected UnexpectedStatus, got {other:?}"),
697 }
698 }
699
700 #[rstest]
705 fn test_rate_limit_config_spot_has_request_weight_and_orders() {
706 let config = BinanceRawHttpClient::rate_limit_config(BinanceProductType::Spot);
707
708 assert!(config.default_quota.is_some());
709 assert_eq!(config.order_keys.len(), 2);
711 assert!(config.order_keys.iter().any(|k| k.contains("SECOND")));
712 assert!(config.order_keys.iter().any(|k| k.contains("DAY")));
713 }
714
715 #[rstest]
716 fn test_rate_limit_config_usdm_has_request_weight_and_orders() {
717 let config = BinanceRawHttpClient::rate_limit_config(BinanceProductType::UsdM);
718
719 assert!(config.default_quota.is_some());
720 assert_eq!(config.order_keys.len(), 2);
722 assert!(config.order_keys.iter().any(|k| k.contains("SECOND")));
723 assert!(config.order_keys.iter().any(|k| k.contains("MINUTE")));
724 }
725
726 #[rstest]
727 fn test_rate_limit_config_coinm_has_request_weight_and_orders() {
728 let config = BinanceRawHttpClient::rate_limit_config(BinanceProductType::CoinM);
729
730 assert!(config.default_quota.is_some());
731 assert_eq!(config.order_keys.len(), 2);
733 }
734
735 #[rstest]
736 fn test_rate_limit_config_options_has_request_weight_and_orders() {
737 let config = BinanceRawHttpClient::rate_limit_config(BinanceProductType::Options);
738
739 assert!(config.default_quota.is_some());
740 assert_eq!(config.order_keys.len(), 2);
742 }
743
744 #[rstest]
745 fn test_rate_limit_keys_without_orders() {
746 let client = create_test_client(None);
747 let keys = client.rate_limit_keys(false);
748
749 assert_eq!(keys.len(), 1);
750 assert_eq!(keys[0], BINANCE_GLOBAL_RATE_KEY);
751 }
752
753 #[rstest]
754 fn test_rate_limit_keys_with_orders() {
755 let client = create_test_client(None);
756 let keys = client.rate_limit_keys(true);
757
758 assert!(keys.len() >= 2);
760 assert!(keys.contains(&BINANCE_GLOBAL_RATE_KEY.to_string()));
761 assert!(keys.iter().any(|k| k.starts_with(BINANCE_ORDERS_RATE_KEY)));
762 }
763
764 fn create_test_client(recv_window: Option<u64>) -> BinanceRawHttpClient {
769 BinanceRawHttpClient::new(
770 BinanceProductType::Spot,
771 BinanceEnvironment::Mainnet,
772 None,
773 None,
774 None,
775 recv_window,
776 None,
777 None,
778 )
779 .expect("Failed to create test client")
780 }
781}