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 async fn request<P, T>(
167 &self,
168 method: Method,
169 path: &str,
170 params: Option<&P>,
171 signed: bool,
172 use_order_quota: bool,
173 body: Option<Vec<u8>>,
174 ) -> BinanceHttpResult<T>
175 where
176 P: Serialize + ?Sized,
177 T: DeserializeOwned,
178 {
179 let mut query = params
180 .map(serde_urlencoded::to_string)
181 .transpose()
182 .map_err(|e| BinanceHttpError::ValidationError(e.to_string()))?
183 .unwrap_or_default();
184
185 let mut headers = HashMap::new();
186 if signed {
187 let cred = self
188 .credential
189 .as_ref()
190 .ok_or(BinanceHttpError::MissingCredentials)?;
191
192 if !query.is_empty() {
193 query.push('&');
194 }
195
196 let timestamp = Utc::now().timestamp_millis();
197 query.push_str(&format!("timestamp={timestamp}"));
198
199 if let Some(recv_window) = self.recv_window {
200 query.push_str(&format!("&recvWindow={recv_window}"));
201 }
202
203 let signature = cred.sign(&query);
204 query.push_str(&format!("&signature={signature}"));
205 headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
206 }
207
208 let url = self.build_url(path, &query);
209 let keys = self.rate_limit_keys(use_order_quota);
210
211 let response = self
212 .client
213 .request(
214 method,
215 url,
216 None::<&HashMap<String, Vec<String>>>,
217 Some(headers),
218 body,
219 None,
220 Some(keys),
221 )
222 .await?;
223
224 if !response.status.is_success() {
225 return self.parse_error_response(response);
226 }
227
228 serde_json::from_slice::<T>(&response.body)
229 .map_err(|e| BinanceHttpError::JsonError(e.to_string()))
230 }
231
232 #[cfg(not(test))]
233 fn build_url(&self, path: &str, query: &str) -> String {
234 Self::build_url_impl(&self.base_url, self.api_path, path, query)
235 }
236
237 #[cfg(test)]
238 pub(crate) fn build_url(&self, path: &str, query: &str) -> String {
239 Self::build_url_impl(&self.base_url, self.api_path, path, query)
240 }
241
242 fn build_url_impl(base_url: &str, api_path: &str, path: &str, query: &str) -> String {
243 let mut url = format!("{}{}{}", base_url, api_path, Self::normalize_path(path));
244 if !query.is_empty() {
245 url.push('?');
246 url.push_str(query);
247 }
248 url
249 }
250
251 pub(crate) fn normalize_path(path: &str) -> String {
252 if path.starts_with('/') {
253 path.to_string()
254 } else {
255 format!("/{path}")
256 }
257 }
258
259 #[cfg(not(test))]
260 fn rate_limit_keys(&self, use_orders: bool) -> Vec<String> {
261 Self::rate_limit_keys_impl(&self.order_rate_keys, use_orders)
262 }
263
264 #[cfg(test)]
265 pub(crate) fn rate_limit_keys(&self, use_orders: bool) -> Vec<String> {
266 Self::rate_limit_keys_impl(&self.order_rate_keys, use_orders)
267 }
268
269 fn rate_limit_keys_impl(order_rate_keys: &[String], use_orders: bool) -> Vec<String> {
270 if use_orders {
271 let mut keys = Vec::with_capacity(1 + order_rate_keys.len());
272 keys.push(BINANCE_GLOBAL_RATE_KEY.to_string());
273 keys.extend(order_rate_keys.iter().cloned());
274 keys
275 } else {
276 vec![BINANCE_GLOBAL_RATE_KEY.to_string()]
277 }
278 }
279
280 pub(crate) fn parse_error_response<T>(&self, response: HttpResponse) -> BinanceHttpResult<T> {
281 let status = response.status.as_u16();
282 let body = String::from_utf8_lossy(&response.body).to_string();
283
284 if let Ok(err) = serde_json::from_str::<BinanceErrorResponse>(&body) {
285 return Err(BinanceHttpError::BinanceError {
286 code: err.code,
287 message: err.msg,
288 });
289 }
290
291 Err(BinanceHttpError::UnexpectedStatus { status, body })
292 }
293
294 fn default_headers(credential: &Option<Credential>) -> HashMap<String, String> {
295 let mut headers = HashMap::new();
296 headers.insert("User-Agent".to_string(), NAUTILUS_USER_AGENT.to_string());
297 if let Some(cred) = credential {
298 headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
299 }
300 headers
301 }
302
303 fn resolve_api_path(product_type: BinanceProductType) -> &'static str {
304 match product_type {
305 BinanceProductType::Spot | BinanceProductType::Margin => BINANCE_SPOT_API_PATH,
306 BinanceProductType::UsdM => BINANCE_FAPI_PATH,
307 BinanceProductType::CoinM => BINANCE_DAPI_PATH,
308 BinanceProductType::Options => BINANCE_EAPI_PATH,
309 }
310 }
311
312 pub(crate) fn rate_limit_config(product_type: BinanceProductType) -> RateLimitConfig {
313 let quotas = match product_type {
314 BinanceProductType::Spot | BinanceProductType::Margin => BINANCE_SPOT_RATE_LIMITS,
315 BinanceProductType::UsdM => BINANCE_FAPI_RATE_LIMITS,
316 BinanceProductType::CoinM => BINANCE_DAPI_RATE_LIMITS,
317 BinanceProductType::Options => BINANCE_EAPI_RATE_LIMITS,
318 };
319
320 let mut keyed = Vec::new();
321 let mut order_keys = Vec::new();
322 let mut default = None;
323
324 for quota in quotas {
325 if let Some(q) = Self::quota_from(quota) {
326 if quota.rate_limit_type == "REQUEST_WEIGHT" && default.is_none() {
327 default = Some(q);
328 } else if quota.rate_limit_type == "ORDERS" {
329 let key = format!("{}:{}", BINANCE_ORDERS_RATE_KEY, quota.interval);
330 order_keys.push(key.clone());
331 keyed.push((key, q));
332 }
333 }
334 }
335
336 let default_quota =
337 default.unwrap_or_else(|| Quota::per_second(NonZeroU32::new(10).unwrap()));
338
339 keyed.push((BINANCE_GLOBAL_RATE_KEY.to_string(), default_quota));
340
341 RateLimitConfig {
342 default_quota: Some(default_quota),
343 keyed_quotas: keyed,
344 order_keys,
345 }
346 }
347
348 fn quota_from(quota: &crate::common::consts::BinanceRateLimitQuota) -> Option<Quota> {
349 let burst = NonZeroU32::new(quota.limit)?;
350 match quota.interval {
351 "SECOND" => Some(Quota::per_second(burst)),
352 "MINUTE" => Some(Quota::per_minute(burst)),
353 "DAY" => Quota::with_period(Duration::from_secs(86_400)).map(|q| q.allow_burst(burst)),
354 _ => None,
355 }
356 }
357}
358pub(crate) struct RateLimitConfig {
359 pub(crate) default_quota: Option<Quota>,
360 pub(crate) keyed_quotas: Vec<(String, Quota)>,
361 pub(crate) order_keys: Vec<String>,
362}
363
364#[derive(Clone, Debug)]
366#[allow(dead_code)]
367pub enum BinanceInstrument {
368 Spot(BinanceSpotSymbol),
369 UsdM(BinanceFuturesUsdSymbol),
370 CoinM(BinanceFuturesCoinSymbol),
371}
372
373#[derive(Clone, Debug)]
375pub enum BinanceTicker24hrEither {
376 Spot(Vec<BinanceSpotTicker24hr>),
377 Futures(Vec<BinanceFuturesTicker24hr>),
378}
379
380#[derive(Debug, Clone)]
382pub struct BinanceHttpClient {
383 raw: BinanceRawHttpClient,
384 product_type: BinanceProductType,
385 instruments: Arc<DashMap<Ustr, BinanceInstrument>>,
386}
387
388impl BinanceHttpClient {
389 #[allow(clippy::too_many_arguments)]
395 pub fn new(
396 product_type: BinanceProductType,
397 environment: BinanceEnvironment,
398 api_key: Option<String>,
399 api_secret: Option<String>,
400 base_url_override: Option<String>,
401 recv_window: Option<u64>,
402 timeout_secs: Option<u64>,
403 proxy_url: Option<String>,
404 ) -> BinanceHttpResult<Self> {
405 let raw = BinanceRawHttpClient::new(
406 product_type,
407 environment,
408 api_key,
409 api_secret,
410 base_url_override,
411 recv_window,
412 timeout_secs,
413 proxy_url,
414 )?;
415
416 Ok(Self {
417 raw,
418 product_type,
419 instruments: Arc::new(DashMap::new()),
420 })
421 }
422
423 #[must_use]
425 pub const fn raw(&self) -> &BinanceRawHttpClient {
426 &self.raw
427 }
428
429 pub async fn server_time(&self) -> BinanceHttpResult<BinanceServerTime> {
431 self.raw
432 .get::<_, BinanceServerTime>("time", None::<&()>, false, false)
433 .await
434 }
435
436 pub async fn exchange_info(&self) -> BinanceHttpResult<()> {
438 match self.product_type {
439 BinanceProductType::Spot | BinanceProductType::Margin => {
440 let info: BinanceSpotExchangeInfo = self
441 .raw
442 .get(
443 "exchangeInfo",
444 None::<&BinanceSpotExchangeInfoParams>,
445 false,
446 false,
447 )
448 .await?;
449 for symbol in info.symbols {
450 self.instruments
451 .insert(symbol.symbol, BinanceInstrument::Spot(symbol));
452 }
453 }
454 BinanceProductType::UsdM => {
455 let info: BinanceFuturesUsdExchangeInfo = self
456 .raw
457 .get("exchangeInfo", None::<&()>, false, false)
458 .await?;
459 for symbol in info.symbols {
460 self.instruments
461 .insert(symbol.symbol, BinanceInstrument::UsdM(symbol));
462 }
463 }
464 BinanceProductType::CoinM => {
465 let info: BinanceFuturesCoinExchangeInfo = self
466 .raw
467 .get("exchangeInfo", None::<&()>, false, false)
468 .await?;
469 for symbol in info.symbols {
470 self.instruments
471 .insert(symbol.symbol, BinanceInstrument::CoinM(symbol));
472 }
473 }
474 BinanceProductType::Options => {
475 return Err(BinanceHttpError::ValidationError(
477 "Options exchangeInfo not yet implemented".to_string(),
478 ));
479 }
480 }
481
482 Ok(())
483 }
484
485 pub async fn get_instrument(
487 &self,
488 symbol: &str,
489 ) -> BinanceHttpResult<Option<BinanceInstrument>> {
490 let key = Ustr::from(symbol);
491
492 if let Some(entry) = self.instruments.get(&key) {
493 return Ok(Some(entry.value().clone()));
494 }
495
496 self.exchange_info().await?;
498 Ok(self.instruments.get(&key).map(|e| e.value().clone()))
499 }
500
501 pub async fn ticker_24h(
503 &self,
504 params: &BinanceTicker24hrParams,
505 ) -> BinanceHttpResult<BinanceTicker24hrEither> {
506 match self.product_type {
507 BinanceProductType::Spot | BinanceProductType::Margin => {
508 let data: Vec<BinanceSpotTicker24hr> = self
509 .raw
510 .get("ticker/24hr", Some(params), false, false)
511 .await?;
512 Ok(BinanceTicker24hrEither::Spot(data))
513 }
514 _ => {
515 let data: Vec<BinanceFuturesTicker24hr> = self
516 .raw
517 .get("ticker/24hr", Some(params), false, false)
518 .await?;
519 Ok(BinanceTicker24hrEither::Futures(data))
520 }
521 }
522 }
523
524 pub async fn book_ticker(
526 &self,
527 params: &BinanceBookTickerParams,
528 ) -> BinanceHttpResult<Vec<BinanceBookTicker>> {
529 self.raw
530 .get("ticker/bookTicker", Some(params), false, false)
531 .await
532 }
533
534 pub async fn price_ticker(
536 &self,
537 params: &BinancePriceTickerParams,
538 ) -> BinanceHttpResult<Vec<BinancePriceTicker>> {
539 self.raw
540 .get("ticker/price", Some(params), false, false)
541 .await
542 }
543
544 pub async fn depth(&self, params: &BinanceDepthParams) -> BinanceHttpResult<BinanceOrderBook> {
546 self.raw.get("depth", Some(params), false, false).await
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use nautilus_network::http::{HttpStatus, StatusCode};
553 use rstest::rstest;
554 use tokio_util::bytes::Bytes;
555
556 use super::*;
557
558 #[rstest]
563 #[case("time", "/time")]
564 #[case("/time", "/time")]
565 #[case("ticker/24hr", "/ticker/24hr")]
566 #[case("/ticker/24hr", "/ticker/24hr")]
567 fn test_normalize_path(#[case] input: &str, #[case] expected: &str) {
568 assert_eq!(BinanceRawHttpClient::normalize_path(input), expected);
569 }
570
571 #[rstest]
572 fn test_build_url_without_query() {
573 let client = create_test_client(None);
574 let url = client.build_url("time", "");
575
576 assert_eq!(url, "https://api.binance.com/api/v3/time");
577 }
578
579 #[rstest]
580 fn test_build_url_with_query() {
581 let client = create_test_client(None);
582 let url = client.build_url("depth", "symbol=BTCUSDT&limit=100");
583
584 assert_eq!(
585 url,
586 "https://api.binance.com/api/v3/depth?symbol=BTCUSDT&limit=100"
587 );
588 }
589
590 #[rstest]
591 fn test_build_url_path_with_leading_slash() {
592 let client = create_test_client(None);
593 let url = client.build_url("/exchangeInfo", "");
594
595 assert_eq!(url, "https://api.binance.com/api/v3/exchangeInfo");
596 }
597
598 #[rstest]
603 fn test_parse_error_response_binance_error() {
604 let client = create_test_client(None);
605 let response = HttpResponse {
606 status: HttpStatus::new(StatusCode::BAD_REQUEST),
607 headers: HashMap::new(),
608 body: Bytes::from(r#"{"code":-1121,"msg":"Invalid symbol."}"#),
609 };
610
611 let result: BinanceHttpResult<()> = client.parse_error_response(response);
612
613 match result {
614 Err(BinanceHttpError::BinanceError { code, message }) => {
615 assert_eq!(code, -1121);
616 assert_eq!(message, "Invalid symbol.");
617 }
618 other => panic!("Expected BinanceError, got {other:?}"),
619 }
620 }
621
622 #[rstest]
623 fn test_parse_error_response_unexpected_status_non_json() {
624 let client = create_test_client(None);
625 let response = HttpResponse {
626 status: HttpStatus::new(StatusCode::INTERNAL_SERVER_ERROR),
627 headers: HashMap::new(),
628 body: Bytes::from("Internal Server Error"),
629 };
630
631 let result: BinanceHttpResult<()> = client.parse_error_response(response);
632
633 match result {
634 Err(BinanceHttpError::UnexpectedStatus { status, body }) => {
635 assert_eq!(status, 500);
636 assert_eq!(body, "Internal Server Error");
637 }
638 other => panic!("Expected UnexpectedStatus, got {other:?}"),
639 }
640 }
641
642 #[rstest]
643 fn test_parse_error_response_malformed_json() {
644 let client = create_test_client(None);
645 let response = HttpResponse {
646 status: HttpStatus::new(StatusCode::BAD_REQUEST),
647 headers: HashMap::new(),
648 body: Bytes::from(r#"{"error": "not binance format"}"#),
649 };
650
651 let result: BinanceHttpResult<()> = client.parse_error_response(response);
652
653 match result {
654 Err(BinanceHttpError::UnexpectedStatus { status, body }) => {
655 assert_eq!(status, 400);
656 assert!(body.contains("not binance format"));
657 }
658 other => panic!("Expected UnexpectedStatus, got {other:?}"),
659 }
660 }
661
662 #[rstest]
667 fn test_rate_limit_config_spot_has_request_weight_and_orders() {
668 let config = BinanceRawHttpClient::rate_limit_config(BinanceProductType::Spot);
669
670 assert!(config.default_quota.is_some());
671 assert_eq!(config.order_keys.len(), 2);
673 assert!(config.order_keys.iter().any(|k| k.contains("SECOND")));
674 assert!(config.order_keys.iter().any(|k| k.contains("DAY")));
675 }
676
677 #[rstest]
678 fn test_rate_limit_config_usdm_has_request_weight_and_orders() {
679 let config = BinanceRawHttpClient::rate_limit_config(BinanceProductType::UsdM);
680
681 assert!(config.default_quota.is_some());
682 assert_eq!(config.order_keys.len(), 2);
684 assert!(config.order_keys.iter().any(|k| k.contains("SECOND")));
685 assert!(config.order_keys.iter().any(|k| k.contains("MINUTE")));
686 }
687
688 #[rstest]
689 fn test_rate_limit_config_coinm_has_request_weight_and_orders() {
690 let config = BinanceRawHttpClient::rate_limit_config(BinanceProductType::CoinM);
691
692 assert!(config.default_quota.is_some());
693 assert_eq!(config.order_keys.len(), 2);
695 }
696
697 #[rstest]
698 fn test_rate_limit_config_options_has_request_weight_and_orders() {
699 let config = BinanceRawHttpClient::rate_limit_config(BinanceProductType::Options);
700
701 assert!(config.default_quota.is_some());
702 assert_eq!(config.order_keys.len(), 2);
704 }
705
706 #[rstest]
707 fn test_rate_limit_keys_without_orders() {
708 let client = create_test_client(None);
709 let keys = client.rate_limit_keys(false);
710
711 assert_eq!(keys.len(), 1);
712 assert_eq!(keys[0], BINANCE_GLOBAL_RATE_KEY);
713 }
714
715 #[rstest]
716 fn test_rate_limit_keys_with_orders() {
717 let client = create_test_client(None);
718 let keys = client.rate_limit_keys(true);
719
720 assert!(keys.len() >= 2);
722 assert!(keys.contains(&BINANCE_GLOBAL_RATE_KEY.to_string()));
723 assert!(keys.iter().any(|k| k.starts_with(BINANCE_ORDERS_RATE_KEY)));
724 }
725
726 fn create_test_client(recv_window: Option<u64>) -> BinanceRawHttpClient {
731 BinanceRawHttpClient::new(
732 BinanceProductType::Spot,
733 BinanceEnvironment::Mainnet,
734 None,
735 None,
736 None,
737 recv_window,
738 None,
739 None,
740 )
741 .expect("Failed to create test client")
742 }
743}