1use std::{collections::HashMap, num::NonZeroU32, sync::Arc, time::Duration};
19
20use chrono::Utc;
21use dashmap::DashMap;
22use nautilus_core::{consts::NAUTILUS_USER_AGENT, nanos::UnixNanos};
23use nautilus_model::instruments::any::InstrumentAny;
24use nautilus_network::{
25 http::{HttpClient, HttpResponse, Method},
26 ratelimiter::quota::Quota,
27};
28use serde::{Deserialize, Serialize, de::DeserializeOwned};
29use ustr::Ustr;
30
31use super::{
32 error::{BinanceFuturesHttpError, BinanceFuturesHttpResult},
33 models::{
34 BinanceBookTicker, BinanceFuturesCoinExchangeInfo, BinanceFuturesCoinSymbol,
35 BinanceFuturesMarkPrice, BinanceFuturesTicker24hr, BinanceFuturesUsdExchangeInfo,
36 BinanceFuturesUsdSymbol, BinanceOrderBook, BinancePriceTicker, BinanceServerTime,
37 },
38 query::{BinanceBookTickerParams, BinanceDepthParams, BinanceTicker24hrParams},
39};
40use crate::common::{
41 consts::{
42 BINANCE_DAPI_PATH, BINANCE_DAPI_RATE_LIMITS, BINANCE_FAPI_PATH, BINANCE_FAPI_RATE_LIMITS,
43 BinanceRateLimitQuota,
44 },
45 credential::Credential,
46 enums::{
47 BinanceEnvironment, BinanceProductType, BinanceRateLimitInterval, BinanceRateLimitType,
48 },
49 models::BinanceErrorResponse,
50 parse::{parse_coinm_instrument, parse_usdm_instrument},
51 urls::get_http_base_url,
52};
53
54const BINANCE_GLOBAL_RATE_KEY: &str = "binance:global";
55const BINANCE_ORDERS_RATE_KEY: &str = "binance:orders";
56
57#[derive(Debug, Clone)]
59pub struct BinanceRawFuturesHttpClient {
60 client: HttpClient,
61 base_url: String,
62 api_path: &'static str,
63 credential: Option<Credential>,
64 recv_window: Option<u64>,
65 order_rate_keys: Vec<String>,
66}
67
68impl BinanceRawFuturesHttpClient {
69 #[allow(clippy::too_many_arguments)]
71 pub fn new(
72 product_type: BinanceProductType,
73 environment: BinanceEnvironment,
74 api_key: Option<String>,
75 api_secret: Option<String>,
76 base_url_override: Option<String>,
77 recv_window: Option<u64>,
78 timeout_secs: Option<u64>,
79 proxy_url: Option<String>,
80 ) -> BinanceFuturesHttpResult<Self> {
81 let RateLimitConfig {
82 default_quota,
83 keyed_quotas,
84 order_keys,
85 } = Self::rate_limit_config(product_type);
86
87 let credential = match (api_key, api_secret) {
88 (Some(key), Some(secret)) => Some(Credential::new(key, secret)),
89 (None, None) => None,
90 _ => return Err(BinanceFuturesHttpError::MissingCredentials),
91 };
92
93 let base_url = base_url_override
94 .unwrap_or_else(|| get_http_base_url(product_type, environment).to_string());
95
96 let api_path = Self::resolve_api_path(product_type);
97 let headers = Self::default_headers(&credential);
98
99 let client = HttpClient::new(
100 headers,
101 vec!["X-MBX-APIKEY".to_string()],
102 keyed_quotas,
103 default_quota,
104 timeout_secs,
105 proxy_url,
106 )?;
107
108 Ok(Self {
109 client,
110 base_url,
111 api_path,
112 credential,
113 recv_window,
114 order_rate_keys: order_keys,
115 })
116 }
117
118 pub async fn get<P, T>(
120 &self,
121 path: &str,
122 params: Option<&P>,
123 signed: bool,
124 use_order_quota: bool,
125 ) -> BinanceFuturesHttpResult<T>
126 where
127 P: Serialize + ?Sized,
128 T: DeserializeOwned,
129 {
130 self.request(Method::GET, path, params, signed, use_order_quota, None)
131 .await
132 }
133
134 pub async fn post<P, T>(
136 &self,
137 path: &str,
138 params: Option<&P>,
139 body: Option<Vec<u8>>,
140 signed: bool,
141 use_order_quota: bool,
142 ) -> BinanceFuturesHttpResult<T>
143 where
144 P: Serialize + ?Sized,
145 T: DeserializeOwned,
146 {
147 self.request(Method::POST, path, params, signed, use_order_quota, body)
148 .await
149 }
150
151 pub async fn request_put<P, T>(
153 &self,
154 path: &str,
155 params: Option<&P>,
156 signed: bool,
157 use_order_quota: bool,
158 ) -> BinanceFuturesHttpResult<T>
159 where
160 P: Serialize + ?Sized,
161 T: DeserializeOwned,
162 {
163 self.request(Method::PUT, path, params, signed, use_order_quota, None)
164 .await
165 }
166
167 pub async fn request_delete<P, T>(
169 &self,
170 path: &str,
171 params: Option<&P>,
172 signed: bool,
173 use_order_quota: bool,
174 ) -> BinanceFuturesHttpResult<T>
175 where
176 P: Serialize + ?Sized,
177 T: DeserializeOwned,
178 {
179 self.request(Method::DELETE, path, params, signed, use_order_quota, None)
180 .await
181 }
182
183 async fn request<P, T>(
184 &self,
185 method: Method,
186 path: &str,
187 params: Option<&P>,
188 signed: bool,
189 use_order_quota: bool,
190 body: Option<Vec<u8>>,
191 ) -> BinanceFuturesHttpResult<T>
192 where
193 P: Serialize + ?Sized,
194 T: DeserializeOwned,
195 {
196 let mut query = params
197 .map(serde_urlencoded::to_string)
198 .transpose()
199 .map_err(|e| BinanceFuturesHttpError::ValidationError(e.to_string()))?
200 .unwrap_or_default();
201
202 let mut headers = HashMap::new();
203 if signed {
204 let cred = self
205 .credential
206 .as_ref()
207 .ok_or(BinanceFuturesHttpError::MissingCredentials)?;
208
209 if !query.is_empty() {
210 query.push('&');
211 }
212
213 let timestamp = Utc::now().timestamp_millis();
214 query.push_str(&format!("timestamp={timestamp}"));
215
216 if let Some(recv_window) = self.recv_window {
217 query.push_str(&format!("&recvWindow={recv_window}"));
218 }
219
220 let signature = cred.sign(&query);
221 query.push_str(&format!("&signature={signature}"));
222 headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
223 }
224
225 let url = self.build_url(path, &query);
226 let keys = self.rate_limit_keys(use_order_quota);
227
228 let response = self
229 .client
230 .request(
231 method,
232 url,
233 None::<&HashMap<String, Vec<String>>>,
234 Some(headers),
235 body,
236 None,
237 Some(keys),
238 )
239 .await?;
240
241 if !response.status.is_success() {
242 return self.parse_error_response(response);
243 }
244
245 serde_json::from_slice::<T>(&response.body)
246 .map_err(|e| BinanceFuturesHttpError::JsonError(e.to_string()))
247 }
248
249 fn build_url(&self, path: &str, query: &str) -> String {
250 let normalized = if path.starts_with('/') {
251 path.to_string()
252 } else {
253 format!("/{path}")
254 };
255 let mut url = format!("{}{}{}", self.base_url, self.api_path, normalized);
256 if !query.is_empty() {
257 url.push('?');
258 url.push_str(query);
259 }
260 url
261 }
262
263 fn rate_limit_keys(&self, use_orders: bool) -> Vec<String> {
264 if use_orders {
265 let mut keys = Vec::with_capacity(1 + self.order_rate_keys.len());
266 keys.push(BINANCE_GLOBAL_RATE_KEY.to_string());
267 keys.extend(self.order_rate_keys.iter().cloned());
268 keys
269 } else {
270 vec![BINANCE_GLOBAL_RATE_KEY.to_string()]
271 }
272 }
273
274 fn parse_error_response<T>(&self, response: HttpResponse) -> BinanceFuturesHttpResult<T> {
275 let status = response.status.as_u16();
276 let body = String::from_utf8_lossy(&response.body).to_string();
277
278 if let Ok(err) = serde_json::from_str::<BinanceErrorResponse>(&body) {
279 return Err(BinanceFuturesHttpError::BinanceError {
280 code: err.code,
281 message: err.msg,
282 });
283 }
284
285 Err(BinanceFuturesHttpError::UnexpectedStatus { status, body })
286 }
287
288 fn default_headers(credential: &Option<Credential>) -> HashMap<String, String> {
289 let mut headers = HashMap::new();
290 headers.insert("User-Agent".to_string(), NAUTILUS_USER_AGENT.to_string());
291 if let Some(cred) = credential {
292 headers.insert("X-MBX-APIKEY".to_string(), cred.api_key().to_string());
293 }
294 headers
295 }
296
297 fn resolve_api_path(product_type: BinanceProductType) -> &'static str {
298 match product_type {
299 BinanceProductType::UsdM => BINANCE_FAPI_PATH,
300 BinanceProductType::CoinM => BINANCE_DAPI_PATH,
301 _ => BINANCE_FAPI_PATH, }
303 }
304
305 fn rate_limit_config(product_type: BinanceProductType) -> RateLimitConfig {
306 let quotas = match product_type {
307 BinanceProductType::UsdM => BINANCE_FAPI_RATE_LIMITS,
308 BinanceProductType::CoinM => BINANCE_DAPI_RATE_LIMITS,
309 _ => BINANCE_FAPI_RATE_LIMITS,
310 };
311
312 let mut keyed = Vec::new();
313 let mut order_keys = Vec::new();
314 let mut default = None;
315
316 for quota in quotas {
317 if let Some(q) = Self::quota_from(quota) {
318 match quota.rate_limit_type {
319 BinanceRateLimitType::RequestWeight if default.is_none() => {
320 default = Some(q);
321 }
322 BinanceRateLimitType::Orders => {
323 let key = format!("{}:{:?}", BINANCE_ORDERS_RATE_KEY, quota.interval);
324 order_keys.push(key.clone());
325 keyed.push((key, q));
326 }
327 _ => {}
328 }
329 }
330 }
331
332 let default_quota =
333 default.unwrap_or_else(|| Quota::per_second(NonZeroU32::new(10).unwrap()));
334
335 keyed.push((BINANCE_GLOBAL_RATE_KEY.to_string(), default_quota));
336
337 RateLimitConfig {
338 default_quota: Some(default_quota),
339 keyed_quotas: keyed,
340 order_keys,
341 }
342 }
343
344 fn quota_from(quota: &BinanceRateLimitQuota) -> Option<Quota> {
345 let burst = NonZeroU32::new(quota.limit)?;
346 match quota.interval {
347 BinanceRateLimitInterval::Second => Some(Quota::per_second(burst)),
348 BinanceRateLimitInterval::Minute => Some(Quota::per_minute(burst)),
349 BinanceRateLimitInterval::Day => {
350 Quota::with_period(Duration::from_secs(86_400)).map(|q| q.allow_burst(burst))
351 }
352 }
353 }
354}
355
356struct RateLimitConfig {
357 default_quota: Option<Quota>,
358 keyed_quotas: Vec<(String, Quota)>,
359 order_keys: Vec<String>,
360}
361
362#[derive(Clone, Debug)]
364pub enum BinanceFuturesInstrument {
365 UsdM(BinanceFuturesUsdSymbol),
367 CoinM(BinanceFuturesCoinSymbol),
369}
370
371#[derive(Debug, Clone, Serialize)]
373#[serde(rename_all = "camelCase")]
374pub struct MarkPriceParams {
375 #[serde(skip_serializing_if = "Option::is_none")]
377 pub symbol: Option<String>,
378}
379
380#[derive(Debug, Deserialize)]
382#[serde(untagged)]
383enum MarkPriceResponse {
384 Single(BinanceFuturesMarkPrice),
385 Multiple(Vec<BinanceFuturesMarkPrice>),
386}
387
388impl From<MarkPriceResponse> for Vec<BinanceFuturesMarkPrice> {
389 fn from(response: MarkPriceResponse) -> Self {
390 match response {
391 MarkPriceResponse::Single(price) => vec![price],
392 MarkPriceResponse::Multiple(prices) => prices,
393 }
394 }
395}
396
397#[derive(Debug, Clone, Serialize)]
399#[serde(rename_all = "camelCase")]
400pub struct FundingRateParams {
401 pub symbol: String,
403 #[serde(skip_serializing_if = "Option::is_none")]
405 pub start_time: Option<i64>,
406 #[serde(skip_serializing_if = "Option::is_none")]
408 pub end_time: Option<i64>,
409 #[serde(skip_serializing_if = "Option::is_none")]
411 pub limit: Option<u32>,
412}
413
414#[derive(Debug, Clone, Serialize)]
416#[serde(rename_all = "camelCase")]
417pub struct OpenInterestParams {
418 pub symbol: String,
420}
421
422#[derive(Debug, Clone, Deserialize)]
424#[serde(rename_all = "camelCase")]
425pub struct BinanceOpenInterest {
426 pub symbol: String,
428 pub open_interest: String,
430 pub time: i64,
432}
433
434#[derive(Debug, Clone, Deserialize)]
436#[serde(rename_all = "camelCase")]
437pub struct BinanceFundingRate {
438 pub symbol: String,
440 pub funding_rate: String,
442 pub funding_time: i64,
444 #[serde(default)]
446 pub mark_price: Option<String>,
447}
448
449#[derive(Debug, Clone, Deserialize)]
451#[serde(rename_all = "camelCase")]
452pub struct ListenKeyResponse {
453 pub listen_key: String,
455}
456
457#[derive(Debug, Clone, Serialize)]
459#[serde(rename_all = "camelCase")]
460struct ListenKeyParams {
461 listen_key: String,
462}
463
464#[derive(Debug, Clone)]
466#[cfg_attr(
467 feature = "python",
468 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.binance")
469)]
470pub struct BinanceFuturesHttpClient {
471 raw: BinanceRawFuturesHttpClient,
472 product_type: BinanceProductType,
473 instruments: Arc<DashMap<Ustr, BinanceFuturesInstrument>>,
474}
475
476impl BinanceFuturesHttpClient {
477 #[allow(clippy::too_many_arguments)]
479 pub fn new(
480 product_type: BinanceProductType,
481 environment: BinanceEnvironment,
482 api_key: Option<String>,
483 api_secret: Option<String>,
484 base_url_override: Option<String>,
485 recv_window: Option<u64>,
486 timeout_secs: Option<u64>,
487 proxy_url: Option<String>,
488 ) -> BinanceFuturesHttpResult<Self> {
489 match product_type {
490 BinanceProductType::UsdM | BinanceProductType::CoinM => {}
491 _ => {
492 return Err(BinanceFuturesHttpError::ValidationError(format!(
493 "BinanceFuturesHttpClient requires UsdM or CoinM product type, got {product_type:?}"
494 )));
495 }
496 }
497
498 let raw = BinanceRawFuturesHttpClient::new(
499 product_type,
500 environment,
501 api_key,
502 api_secret,
503 base_url_override,
504 recv_window,
505 timeout_secs,
506 proxy_url,
507 )?;
508
509 Ok(Self {
510 raw,
511 product_type,
512 instruments: Arc::new(DashMap::new()),
513 })
514 }
515
516 #[must_use]
518 pub const fn product_type(&self) -> BinanceProductType {
519 self.product_type
520 }
521
522 #[must_use]
524 pub const fn raw(&self) -> &BinanceRawFuturesHttpClient {
525 &self.raw
526 }
527
528 #[must_use]
530 pub fn instruments_cache(&self) -> &DashMap<Ustr, BinanceFuturesInstrument> {
531 &self.instruments
532 }
533
534 pub async fn server_time(&self) -> BinanceFuturesHttpResult<BinanceServerTime> {
536 self.raw
537 .get::<_, BinanceServerTime>("time", None::<&()>, false, false)
538 .await
539 }
540
541 pub async fn exchange_info(&self) -> BinanceFuturesHttpResult<()> {
543 match self.product_type {
544 BinanceProductType::UsdM => {
545 let info: BinanceFuturesUsdExchangeInfo = self
546 .raw
547 .get("exchangeInfo", None::<&()>, false, false)
548 .await?;
549 for symbol in info.symbols {
550 self.instruments
551 .insert(symbol.symbol, BinanceFuturesInstrument::UsdM(symbol));
552 }
553 }
554 BinanceProductType::CoinM => {
555 let info: BinanceFuturesCoinExchangeInfo = self
556 .raw
557 .get("exchangeInfo", None::<&()>, false, false)
558 .await?;
559 for symbol in info.symbols {
560 self.instruments
561 .insert(symbol.symbol, BinanceFuturesInstrument::CoinM(symbol));
562 }
563 }
564 _ => {
565 return Err(BinanceFuturesHttpError::ValidationError(
566 "Invalid product type for futures".to_string(),
567 ));
568 }
569 }
570
571 Ok(())
572 }
573
574 pub async fn request_instruments(&self) -> BinanceFuturesHttpResult<Vec<InstrumentAny>> {
576 let ts_init = UnixNanos::default();
577
578 let instruments = match self.product_type {
579 BinanceProductType::UsdM => {
580 let info: BinanceFuturesUsdExchangeInfo = self
581 .raw
582 .get("exchangeInfo", None::<&()>, false, false)
583 .await?;
584
585 let mut instruments = Vec::with_capacity(info.symbols.len());
586 for symbol in &info.symbols {
587 match parse_usdm_instrument(symbol, ts_init, ts_init) {
588 Ok(instrument) => instruments.push(instrument),
589 Err(e) => {
590 log::debug!(
591 "Skipping symbol during instrument parsing: symbol={}, error={e}",
592 symbol.symbol
593 );
594 }
595 }
596 }
597
598 log::info!(
599 "Loaded USD-M perpetual instruments: count={}",
600 instruments.len()
601 );
602 instruments
603 }
604 BinanceProductType::CoinM => {
605 let info: BinanceFuturesCoinExchangeInfo = self
606 .raw
607 .get("exchangeInfo", None::<&()>, false, false)
608 .await?;
609
610 let mut instruments = Vec::with_capacity(info.symbols.len());
611 for symbol in &info.symbols {
612 match parse_coinm_instrument(symbol, ts_init, ts_init) {
613 Ok(instrument) => instruments.push(instrument),
614 Err(e) => {
615 log::debug!(
616 "Skipping symbol during instrument parsing: symbol={}, error={e}",
617 symbol.symbol
618 );
619 }
620 }
621 }
622
623 log::info!(
624 "Loaded COIN-M perpetual instruments: count={}",
625 instruments.len()
626 );
627 instruments
628 }
629 _ => {
630 return Err(BinanceFuturesHttpError::ValidationError(
631 "Invalid product type for futures".to_string(),
632 ));
633 }
634 };
635
636 Ok(instruments)
637 }
638
639 pub async fn ticker_24h(
641 &self,
642 params: &BinanceTicker24hrParams,
643 ) -> BinanceFuturesHttpResult<Vec<BinanceFuturesTicker24hr>> {
644 self.raw
645 .get("ticker/24hr", Some(params), false, false)
646 .await
647 }
648
649 pub async fn book_ticker(
651 &self,
652 params: &BinanceBookTickerParams,
653 ) -> BinanceFuturesHttpResult<Vec<BinanceBookTicker>> {
654 self.raw
655 .get("ticker/bookTicker", Some(params), false, false)
656 .await
657 }
658
659 pub async fn price_ticker(
661 &self,
662 symbol: Option<&str>,
663 ) -> BinanceFuturesHttpResult<Vec<BinancePriceTicker>> {
664 #[derive(Serialize)]
665 struct Params<'a> {
666 #[serde(skip_serializing_if = "Option::is_none")]
667 symbol: Option<&'a str>,
668 }
669 self.raw
670 .get("ticker/price", Some(&Params { symbol }), false, false)
671 .await
672 }
673
674 pub async fn depth(
676 &self,
677 params: &BinanceDepthParams,
678 ) -> BinanceFuturesHttpResult<BinanceOrderBook> {
679 self.raw.get("depth", Some(params), false, false).await
680 }
681
682 pub async fn mark_price(
684 &self,
685 params: &MarkPriceParams,
686 ) -> BinanceFuturesHttpResult<Vec<BinanceFuturesMarkPrice>> {
687 let response: MarkPriceResponse = self
688 .raw
689 .get("premiumIndex", Some(params), false, false)
690 .await?;
691 Ok(response.into())
692 }
693
694 pub async fn funding_rate(
696 &self,
697 params: &FundingRateParams,
698 ) -> BinanceFuturesHttpResult<Vec<BinanceFundingRate>> {
699 self.raw
700 .get("fundingRate", Some(params), false, false)
701 .await
702 }
703
704 pub async fn open_interest(
706 &self,
707 params: &OpenInterestParams,
708 ) -> BinanceFuturesHttpResult<BinanceOpenInterest> {
709 self.raw
710 .get("openInterest", Some(params), false, false)
711 .await
712 }
713
714 pub async fn create_listen_key(&self) -> BinanceFuturesHttpResult<ListenKeyResponse> {
716 self.raw
717 .post::<(), ListenKeyResponse>("listenKey", None, None, true, false)
718 .await
719 }
720
721 pub async fn keepalive_listen_key(&self, listen_key: &str) -> BinanceFuturesHttpResult<()> {
723 let params = ListenKeyParams {
724 listen_key: listen_key.to_string(),
725 };
726 let _: serde_json::Value = self
727 .raw
728 .request_put("listenKey", Some(¶ms), true, false)
729 .await?;
730 Ok(())
731 }
732
733 pub async fn close_listen_key(&self, listen_key: &str) -> BinanceFuturesHttpResult<()> {
735 let params = ListenKeyParams {
736 listen_key: listen_key.to_string(),
737 };
738 let _: serde_json::Value = self
739 .raw
740 .request_delete("listenKey", Some(¶ms), true, false)
741 .await?;
742 Ok(())
743 }
744}
745
746#[cfg(test)]
747mod tests {
748 use nautilus_network::http::{HttpStatus, StatusCode};
749 use rstest::rstest;
750 use tokio_util::bytes::Bytes;
751
752 use super::*;
753
754 #[rstest]
755 fn test_rate_limit_config_usdm_has_request_weight_and_orders() {
756 let config = BinanceRawFuturesHttpClient::rate_limit_config(BinanceProductType::UsdM);
757
758 assert!(config.default_quota.is_some());
759 assert_eq!(config.order_keys.len(), 2);
760 assert!(config.order_keys.iter().any(|k| k.contains("Second")));
761 assert!(config.order_keys.iter().any(|k| k.contains("Minute")));
762 }
763
764 #[rstest]
765 fn test_rate_limit_config_coinm_has_request_weight_and_orders() {
766 let config = BinanceRawFuturesHttpClient::rate_limit_config(BinanceProductType::CoinM);
767
768 assert!(config.default_quota.is_some());
769 assert_eq!(config.order_keys.len(), 2);
770 }
771
772 #[rstest]
773 fn test_create_client_rejects_spot_product_type() {
774 let result = BinanceFuturesHttpClient::new(
775 BinanceProductType::Spot,
776 BinanceEnvironment::Mainnet,
777 None,
778 None,
779 None,
780 None,
781 None,
782 None,
783 );
784
785 assert!(result.is_err());
786 }
787
788 fn create_test_raw_client() -> BinanceRawFuturesHttpClient {
789 BinanceRawFuturesHttpClient::new(
790 BinanceProductType::UsdM,
791 BinanceEnvironment::Mainnet,
792 None,
793 None,
794 None,
795 None,
796 None,
797 None,
798 )
799 .expect("Failed to create test client")
800 }
801
802 #[rstest]
803 fn test_parse_error_response_binance_error() {
804 let client = create_test_raw_client();
805 let response = HttpResponse {
806 status: HttpStatus::new(StatusCode::BAD_REQUEST),
807 headers: HashMap::new(),
808 body: Bytes::from(r#"{"code":-1121,"msg":"Invalid symbol."}"#),
809 };
810
811 let result: BinanceFuturesHttpResult<()> = client.parse_error_response(response);
812
813 match result {
814 Err(BinanceFuturesHttpError::BinanceError { code, message }) => {
815 assert_eq!(code, -1121);
816 assert_eq!(message, "Invalid symbol.");
817 }
818 other => panic!("Expected BinanceError, got {other:?}"),
819 }
820 }
821}