1use anyhow::{Context, Result};
2use hmac::{Hmac, Mac};
3use sha2::Sha256;
4use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
5use std::time::Instant;
6
7use crate::error::AppError;
8use crate::model::candle::Candle;
9use crate::model::order::OrderSide;
10
11use super::types::{
12 AccountInfo, BinanceAllOrder, BinanceFuturesAccountInfo, BinanceFuturesAllOrder,
13 BinanceFuturesOrderResponse, BinanceFuturesUserTrade, BinanceMyTrade, BinanceOrderResponse,
14 ServerTimeResponse,
15};
16
17#[derive(Debug, Clone, Copy)]
18pub struct SymbolOrderRules {
19 pub min_qty: f64,
20 pub max_qty: f64,
21 pub step_size: f64,
22 pub min_notional: Option<f64>,
23}
24
25pub struct BinanceRestClient {
26 http: reqwest::Client,
27 base_url: String,
28 futures_base_url: String,
29 api_key: String,
30 secret_key: String,
31 futures_api_key: String,
32 futures_secret_key: String,
33 recv_window: u64,
34 time_offset_ms: AtomicI64,
35 request_count: AtomicU64,
37 window_start: std::sync::Mutex<Instant>,
38}
39
40impl BinanceRestClient {
41 pub fn new(
42 base_url: &str,
43 futures_base_url: &str,
44 api_key: &str,
45 secret_key: &str,
46 futures_api_key: &str,
47 futures_secret_key: &str,
48 recv_window: u64,
49 ) -> Self {
50 Self {
51 http: reqwest::Client::new(),
52 base_url: base_url.to_string(),
53 futures_base_url: futures_base_url.to_string(),
54 api_key: api_key.to_string(),
55 secret_key: secret_key.to_string(),
56 futures_api_key: futures_api_key.to_string(),
57 futures_secret_key: futures_secret_key.to_string(),
58 recv_window,
59 time_offset_ms: AtomicI64::new(0),
60 request_count: AtomicU64::new(0),
61 window_start: std::sync::Mutex::new(Instant::now()),
62 }
63 }
64
65 fn sign_with_secret(&self, query: &str, secret_key: &str) -> String {
66 let offset = self.time_offset_ms.load(Ordering::Relaxed);
67 let timestamp = chrono::Utc::now().timestamp_millis() + offset;
68 let full_query = if query.is_empty() {
69 format!("recvWindow={}×tamp={}", self.recv_window, timestamp)
70 } else {
71 format!(
72 "{}&recvWindow={}×tamp={}",
73 query, self.recv_window, timestamp
74 )
75 };
76 let mut mac =
77 Hmac::<Sha256>::new_from_slice(secret_key.as_bytes()).expect("HMAC key error");
78 mac.update(full_query.as_bytes());
79 let signature = hex::encode(mac.finalize().into_bytes());
80 format!("{}&signature={}", full_query, signature)
81 }
82
83 fn sign(&self, query: &str) -> String {
84 self.sign_with_secret(query, &self.secret_key)
85 }
86
87 fn sign_futures(&self, query: &str) -> String {
88 self.sign_with_secret(query, &self.futures_secret_key)
89 }
90
91 async fn sync_time_offset(&self) -> Result<()> {
92 let server_ms = self.server_time().await? as i64;
93 let local_ms = chrono::Utc::now().timestamp_millis();
94 let offset = server_ms - local_ms;
95 self.time_offset_ms.store(offset, Ordering::Relaxed);
96 tracing::warn!(
97 offset_ms = offset,
98 "Synchronized Binance server time offset"
99 );
100 Ok(())
101 }
102
103 fn parse_binance_api_error(body: &str) -> Option<super::types::BinanceApiErrorResponse> {
104 serde_json::from_str::<super::types::BinanceApiErrorResponse>(body).ok()
105 }
106
107 fn check_rate_limit(&self) {
108 let mut start = match self.window_start.lock() {
109 Ok(guard) => guard,
110 Err(poisoned) => {
111 tracing::error!("rate-limit mutex poisoned; continuing with recovered state");
112 poisoned.into_inner()
113 }
114 };
115 if start.elapsed().as_secs() >= 60 {
116 *start = Instant::now();
117 self.request_count.store(0, Ordering::Relaxed);
118 }
119 let count = self.request_count.fetch_add(1, Ordering::Relaxed);
120 if count > 960 {
121 tracing::warn!(count, "Approaching rate limit (80% of 1200/min)");
122 }
123 }
124
125 pub async fn ping(&self) -> Result<()> {
126 let url = format!("{}/api/v3/ping", self.base_url);
127 self.http
128 .get(&url)
129 .send()
130 .await
131 .context("ping failed")?
132 .error_for_status()
133 .context("ping returned error status")?;
134 Ok(())
135 }
136
137 pub async fn server_time(&self) -> Result<u64> {
138 let url = format!("{}/api/v3/time", self.base_url);
139 let resp: ServerTimeResponse = self
140 .http
141 .get(&url)
142 .send()
143 .await
144 .context("server_time failed")?
145 .json()
146 .await?;
147 Ok(resp.server_time)
148 }
149
150 pub async fn get_account(&self) -> Result<AccountInfo> {
151 self.check_rate_limit();
152
153 let signed = self.sign("");
154 let url = format!("{}/api/v3/account?{}", self.base_url, signed);
155
156 let resp = self
157 .http
158 .get(&url)
159 .header("X-MBX-APIKEY", &self.api_key)
160 .send()
161 .await
162 .context("get_account HTTP failed")?;
163
164 if !resp.status().is_success() {
165 let body = resp.text().await.unwrap_or_default();
166 if let Ok(err) = serde_json::from_str::<super::types::BinanceApiErrorResponse>(&body) {
167 return Err(AppError::BinanceApi {
168 code: err.code,
169 msg: err.msg,
170 }
171 .into());
172 }
173 return Err(anyhow::anyhow!("Account request failed: {}", body));
174 }
175
176 Ok(resp.json().await?)
177 }
178
179 pub async fn get_futures_account(&self) -> Result<BinanceFuturesAccountInfo> {
180 self.check_rate_limit();
181
182 let signed = self.sign_futures("");
183 let url = format!("{}/fapi/v2/account?{}", self.futures_base_url, signed);
184
185 let resp = self
186 .http
187 .get(&url)
188 .header("X-MBX-APIKEY", &self.futures_api_key)
189 .send()
190 .await
191 .context("get_futures_account HTTP failed")?;
192
193 if !resp.status().is_success() {
194 let body = resp.text().await.unwrap_or_default();
195 if let Ok(err) = serde_json::from_str::<super::types::BinanceApiErrorResponse>(&body) {
196 return Err(AppError::BinanceApi {
197 code: err.code,
198 msg: err.msg,
199 }
200 .into());
201 }
202 return Err(anyhow::anyhow!("Futures account request failed: {}", body));
203 }
204
205 Ok(resp.json().await?)
206 }
207
208 pub async fn place_market_order(
209 &self,
210 symbol: &str,
211 side: OrderSide,
212 quantity: f64,
213 client_order_id: &str,
214 ) -> Result<BinanceOrderResponse> {
215 self.check_rate_limit();
216
217 let query = format!(
218 "symbol={}&side={}&type=MARKET&quantity={:.5}&newClientOrderId={}&newOrderRespType=FULL",
219 symbol,
220 side.as_binance_str(),
221 quantity,
222 client_order_id,
223 );
224 let signed = self.sign(&query);
225 let url = format!("{}/api/v3/order?{}", self.base_url, signed);
226
227 tracing::info!(
228 symbol,
229 side = %side,
230 quantity,
231 client_order_id,
232 "Placing market order"
233 );
234
235 let resp = self
236 .http
237 .post(&url)
238 .header("X-MBX-APIKEY", &self.api_key)
239 .send()
240 .await
241 .context("place_market_order HTTP failed")?;
242
243 if !resp.status().is_success() {
244 let body = resp.text().await.unwrap_or_default();
245 if let Ok(err) = serde_json::from_str::<super::types::BinanceApiErrorResponse>(&body) {
246 return Err(AppError::BinanceApi {
247 code: err.code,
248 msg: err.msg,
249 }
250 .into());
251 }
252 return Err(anyhow::anyhow!("Order request failed: {}", body));
253 }
254
255 let order: BinanceOrderResponse = resp.json().await?;
256 tracing::info!(
257 order_id = order.order_id,
258 status = %order.status,
259 client_order_id = %order.client_order_id,
260 "Order response received"
261 );
262 Ok(order)
263 }
264
265 pub async fn place_futures_market_order(
266 &self,
267 symbol: &str,
268 side: OrderSide,
269 quantity: f64,
270 client_order_id: &str,
271 ) -> Result<BinanceOrderResponse> {
272 self.check_rate_limit();
273
274 let query = format!(
275 "symbol={}&side={}&type=MARKET&quantity={:.5}&newClientOrderId={}&newOrderRespType=RESULT",
276 symbol,
277 side.as_binance_str(),
278 quantity,
279 client_order_id,
280 );
281 let signed = self.sign_futures(&query);
282 let url = format!("{}/fapi/v1/order?{}", self.futures_base_url, signed);
283
284 tracing::info!(
285 symbol,
286 side = %side,
287 quantity,
288 client_order_id,
289 "Placing futures market order"
290 );
291
292 let resp = self
293 .http
294 .post(&url)
295 .header("X-MBX-APIKEY", &self.futures_api_key)
296 .send()
297 .await
298 .context("place_futures_market_order HTTP failed")?;
299
300 if !resp.status().is_success() {
301 let body = resp.text().await.unwrap_or_default();
302 if let Ok(err) = serde_json::from_str::<super::types::BinanceApiErrorResponse>(&body) {
303 return Err(AppError::BinanceApi {
304 code: err.code,
305 msg: err.msg,
306 }
307 .into());
308 }
309 return Err(anyhow::anyhow!("Futures order request failed: {}", body));
310 }
311
312 let fut: BinanceFuturesOrderResponse = resp.json().await?;
313 let avg = if fut.avg_price > 0.0 {
314 fut.avg_price
315 } else if fut.price > 0.0 {
316 fut.price
317 } else {
318 0.0
319 };
320 let fills = if fut.executed_qty > 0.0 && avg > 0.0 {
321 vec![super::types::BinanceFill {
322 price: avg,
323 qty: fut.executed_qty,
324 commission: 0.0,
325 commission_asset: "USDT".to_string(),
326 }]
327 } else {
328 Vec::new()
329 };
330
331 Ok(BinanceOrderResponse {
332 symbol: fut.symbol,
333 order_id: fut.order_id,
334 client_order_id: fut.client_order_id,
335 price: if fut.price > 0.0 { fut.price } else { avg },
336 orig_qty: fut.orig_qty,
337 executed_qty: fut.executed_qty,
338 status: fut.status,
339 r#type: fut.r#type,
340 side: fut.side,
341 fills,
342 })
343 }
344
345 pub async fn place_futures_stop_market_order(
346 &self,
347 symbol: &str,
348 side: OrderSide,
349 quantity: f64,
350 stop_price: f64,
351 client_order_id: &str,
352 ) -> Result<BinanceOrderResponse> {
353 self.check_rate_limit();
354
355 let query = format!(
356 "symbol={}&side={}&type=STOP_MARKET&quantity={:.5}&stopPrice={:.5}&reduceOnly=true&newClientOrderId={}&newOrderRespType=RESULT",
357 symbol,
358 side.as_binance_str(),
359 quantity,
360 stop_price,
361 client_order_id,
362 );
363 let signed = self.sign_futures(&query);
364 let url = format!("{}/fapi/v1/order?{}", self.futures_base_url, signed);
365
366 tracing::info!(
367 symbol,
368 side = %side,
369 quantity,
370 stop_price,
371 client_order_id,
372 "Placing futures stop-market order"
373 );
374
375 let resp = self
376 .http
377 .post(&url)
378 .header("X-MBX-APIKEY", &self.futures_api_key)
379 .send()
380 .await
381 .context("place_futures_stop_market_order HTTP failed")?;
382
383 if !resp.status().is_success() {
384 let body = resp.text().await.unwrap_or_default();
385 if let Ok(err) = serde_json::from_str::<super::types::BinanceApiErrorResponse>(&body) {
386 return Err(AppError::BinanceApi {
387 code: err.code,
388 msg: err.msg,
389 }
390 .into());
391 }
392 return Err(anyhow::anyhow!(
393 "Futures stop order request failed: {}",
394 body
395 ));
396 }
397
398 let fut: BinanceFuturesOrderResponse = resp.json().await?;
399 let avg = if fut.avg_price > 0.0 {
400 fut.avg_price
401 } else if fut.price > 0.0 {
402 fut.price
403 } else {
404 0.0
405 };
406 let fills = if fut.executed_qty > 0.0 && avg > 0.0 {
407 vec![super::types::BinanceFill {
408 price: avg,
409 qty: fut.executed_qty,
410 commission: 0.0,
411 commission_asset: "USDT".to_string(),
412 }]
413 } else {
414 Vec::new()
415 };
416
417 Ok(BinanceOrderResponse {
418 symbol: fut.symbol,
419 order_id: fut.order_id,
420 client_order_id: fut.client_order_id,
421 price: if fut.price > 0.0 { fut.price } else { avg },
422 orig_qty: fut.orig_qty,
423 executed_qty: fut.executed_qty,
424 status: fut.status,
425 r#type: fut.r#type,
426 side: fut.side,
427 fills,
428 })
429 }
430
431 pub async fn get_spot_symbol_order_rules(&self, symbol: &str) -> Result<SymbolOrderRules> {
432 let url = format!("{}/api/v3/exchangeInfo?symbol={}", self.base_url, symbol);
433 let payload: serde_json::Value = self
434 .http
435 .get(&url)
436 .send()
437 .await
438 .context("get_spot_symbol_order_rules HTTP failed")?
439 .error_for_status()
440 .context("get_spot_symbol_order_rules returned error status")?
441 .json()
442 .await
443 .context("get_spot_symbol_order_rules JSON parse failed")?;
444 parse_symbol_order_rules_from_exchange_info(&payload, symbol, true)
445 }
446
447 pub async fn get_futures_symbol_order_rules(&self, symbol: &str) -> Result<SymbolOrderRules> {
448 let url = format!(
449 "{}/fapi/v1/exchangeInfo?symbol={}",
450 self.futures_base_url, symbol
451 );
452 let payload: serde_json::Value = self
453 .http
454 .get(&url)
455 .send()
456 .await
457 .context("get_futures_symbol_order_rules HTTP failed")?
458 .error_for_status()
459 .context("get_futures_symbol_order_rules returned error status")?
460 .json()
461 .await
462 .context("get_futures_symbol_order_rules JSON parse failed")?;
463 parse_symbol_order_rules_from_exchange_info(&payload, symbol, false)
464 }
465
466 pub async fn get_klines(
469 &self,
470 symbol: &str,
471 interval: &str,
472 limit: usize,
473 ) -> Result<Vec<Candle>> {
474 self.get_klines_for_market(symbol, interval, limit, false)
475 .await
476 }
477
478 pub async fn get_klines_for_market(
479 &self,
480 symbol: &str,
481 interval: &str,
482 limit: usize,
483 is_futures: bool,
484 ) -> Result<Vec<Candle>> {
485 self.check_rate_limit();
486
487 let url = if is_futures {
488 format!(
489 "{}/fapi/v1/klines?symbol={}&interval={}&limit={}",
490 self.futures_base_url, symbol, interval, limit,
491 )
492 } else {
493 format!(
494 "{}/api/v3/klines?symbol={}&interval={}&limit={}",
495 self.base_url, symbol, interval, limit,
496 )
497 };
498
499 let resp: Vec<Vec<serde_json::Value>> = self
500 .http
501 .get(&url)
502 .send()
503 .await
504 .context("get_klines HTTP failed")?
505 .error_for_status()
506 .context("get_klines returned error status")?
507 .json()
508 .await
509 .context("get_klines JSON parse failed")?;
510
511 let candles: Vec<Candle> = resp
512 .iter()
513 .filter_map(|kline| {
514 let open_time = kline.get(0)?.as_u64()?;
515 let open = kline.get(1)?.as_str()?.parse::<f64>().ok()?;
516 let high = kline.get(2)?.as_str()?.parse::<f64>().ok()?;
517 let low = kline.get(3)?.as_str()?.parse::<f64>().ok()?;
518 let close = kline.get(4)?.as_str()?.parse::<f64>().ok()?;
519 let close_time = kline
521 .get(6)?
522 .as_u64()
523 .map(|v| v.saturating_add(1))
524 .unwrap_or(open_time.saturating_add(60_000));
525 Some(Candle {
526 open,
527 high,
528 low,
529 close,
530 open_time,
531 close_time,
532 })
533 })
534 .collect();
535
536 Ok(candles)
537 }
538
539 pub async fn cancel_order(
540 &self,
541 symbol: &str,
542 client_order_id: &str,
543 ) -> Result<BinanceOrderResponse> {
544 self.check_rate_limit();
545
546 let query = format!("symbol={}&origClientOrderId={}", symbol, client_order_id);
547 let signed = self.sign(&query);
548 let url = format!("{}/api/v3/order?{}", self.base_url, signed);
549
550 tracing::info!(symbol, client_order_id, "Cancelling order");
551
552 let resp = self
553 .http
554 .delete(&url)
555 .header("X-MBX-APIKEY", &self.api_key)
556 .send()
557 .await
558 .context("cancel_order HTTP failed")?;
559
560 if !resp.status().is_success() {
561 let body = resp.text().await.unwrap_or_default();
562 if let Ok(err) = serde_json::from_str::<super::types::BinanceApiErrorResponse>(&body) {
563 return Err(AppError::BinanceApi {
564 code: err.code,
565 msg: err.msg,
566 }
567 .into());
568 }
569 return Err(anyhow::anyhow!("Cancel request failed: {}", body));
570 }
571
572 Ok(resp.json().await?)
573 }
574
575 async fn get_all_orders_page(
577 &self,
578 symbol: &str,
579 limit: usize,
580 from_order_id: Option<u64>,
581 ) -> Result<Vec<BinanceAllOrder>> {
582 self.check_rate_limit();
583
584 let limit = limit.clamp(1, 1000);
585 let query = match from_order_id {
586 Some(order_id) => format!("symbol={}&limit={}&orderId={}", symbol, limit, order_id),
587 None => format!("symbol={}&limit={}", symbol, limit),
588 };
589 for attempt in 0..=1 {
590 let signed = self.sign(&query);
591 let url = format!("{}/api/v3/allOrders?{}", self.base_url, signed);
592
593 let resp = self
594 .http
595 .get(&url)
596 .header("X-MBX-APIKEY", &self.api_key)
597 .send()
598 .await
599 .context("get_all_orders HTTP failed")?;
600
601 if resp.status().is_success() {
602 return Ok(resp.json().await?);
603 }
604
605 let body = resp.text().await.unwrap_or_default();
606 if let Some(err) = Self::parse_binance_api_error(&body) {
607 if err.code == -1021 && attempt == 0 {
608 tracing::warn!("allOrders got -1021; syncing server time and retrying once");
609 self.sync_time_offset().await?;
610 continue;
611 }
612 return Err(AppError::BinanceApi {
613 code: err.code,
614 msg: err.msg,
615 }
616 .into());
617 }
618 return Err(anyhow::anyhow!("All orders request failed: {}", body));
619 }
620
621 Err(anyhow::anyhow!("All orders request failed after retry"))
622 }
623
624 pub async fn get_all_orders(&self, symbol: &str, limit: usize) -> Result<Vec<BinanceAllOrder>> {
627 self.get_all_orders_page(symbol, limit, None).await
628 }
629
630 async fn get_futures_all_orders_page(
631 &self,
632 symbol: &str,
633 limit: usize,
634 from_order_id: Option<u64>,
635 ) -> Result<Vec<BinanceAllOrder>> {
636 self.check_rate_limit();
637 let limit = limit.clamp(1, 1000);
638 let query = match from_order_id {
639 Some(order_id) => format!("symbol={}&limit={}&orderId={}", symbol, limit, order_id),
640 None => format!("symbol={}&limit={}", symbol, limit),
641 };
642 let signed = self.sign_futures(&query);
643 let url = format!("{}/fapi/v1/allOrders?{}", self.futures_base_url, signed);
644 let resp = self
645 .http
646 .get(&url)
647 .header("X-MBX-APIKEY", &self.futures_api_key)
648 .send()
649 .await
650 .context("get_futures_all_orders HTTP failed")?;
651 if !resp.status().is_success() {
652 let body = resp.text().await.unwrap_or_default();
653 if let Some(err) = Self::parse_binance_api_error(&body) {
654 return Err(AppError::BinanceApi {
655 code: err.code,
656 msg: err.msg,
657 }
658 .into());
659 }
660 return Err(anyhow::anyhow!(
661 "Futures allOrders request failed: {}",
662 body
663 ));
664 }
665 let rows: Vec<BinanceFuturesAllOrder> = resp.json().await?;
666 Ok(rows
667 .into_iter()
668 .map(|o| {
669 let cumm_quote = if o.cum_quote > 0.0 {
670 o.cum_quote
671 } else {
672 o.avg_price * o.executed_qty
673 };
674 BinanceAllOrder {
675 symbol: o.symbol,
676 order_id: o.order_id,
677 client_order_id: o.client_order_id,
678 price: o.price,
679 orig_qty: o.orig_qty,
680 executed_qty: o.executed_qty,
681 cummulative_quote_qty: cumm_quote,
682 status: o.status,
683 r#type: o.r#type,
684 side: o.side,
685 time: o.time,
686 update_time: o.update_time,
687 }
688 })
689 .collect())
690 }
691
692 pub async fn get_futures_all_orders(
693 &self,
694 symbol: &str,
695 limit: usize,
696 ) -> Result<Vec<BinanceAllOrder>> {
697 self.get_futures_all_orders_page(symbol, limit, None).await
698 }
699
700 async fn get_my_trades_page(
701 &self,
702 symbol: &str,
703 limit: usize,
704 from_id: Option<u64>,
705 ) -> Result<Vec<BinanceMyTrade>> {
706 self.check_rate_limit();
707
708 let limit = limit.clamp(1, 1000);
709 let query = match from_id {
710 Some(v) => format!("symbol={}&limit={}&fromId={}", symbol, limit, v),
711 None => format!("symbol={}&limit={}", symbol, limit),
712 };
713 for attempt in 0..=1 {
714 let signed = self.sign(&query);
715 let url = format!("{}/api/v3/myTrades?{}", self.base_url, signed);
716
717 let resp = self
718 .http
719 .get(&url)
720 .header("X-MBX-APIKEY", &self.api_key)
721 .send()
722 .await
723 .context("get_my_trades HTTP failed")?;
724
725 if resp.status().is_success() {
726 return Ok(resp.json().await?);
727 }
728
729 let body = resp.text().await.unwrap_or_default();
730 if let Some(err) = Self::parse_binance_api_error(&body) {
731 if err.code == -1021 && attempt == 0 {
732 tracing::warn!("myTrades got -1021; syncing server time and retrying once");
733 self.sync_time_offset().await?;
734 continue;
735 }
736 return Err(AppError::BinanceApi {
737 code: err.code,
738 msg: err.msg,
739 }
740 .into());
741 }
742 return Err(anyhow::anyhow!("My trades request failed: {}", body));
743 }
744
745 Err(anyhow::anyhow!("My trades request failed after retry"))
746 }
747
748 pub async fn get_my_trades(&self, symbol: &str, limit: usize) -> Result<Vec<BinanceMyTrade>> {
750 self.get_my_trades_page(symbol, limit, None).await
751 }
752
753 pub async fn get_my_trades_history(
755 &self,
756 symbol: &str,
757 max_total: usize,
758 ) -> Result<Vec<BinanceMyTrade>> {
759 let page_size = 1000usize;
760 let target = max_total.max(1);
761 let mut out = Vec::new();
762 let mut cursor: u64 = 0;
763
764 loop {
765 let page = self
766 .get_my_trades_page(
767 symbol,
768 page_size.min(target.saturating_sub(out.len())),
769 Some(cursor),
770 )
771 .await?;
772 if page.is_empty() {
773 break;
774 }
775 let fetched = page.len();
776 let mut max_trade_id = cursor;
777 for t in page {
778 max_trade_id = max_trade_id.max(t.id);
779 out.push(t);
780 if out.len() >= target {
781 break;
782 }
783 }
784 if out.len() >= target || fetched < page_size {
785 break;
786 }
787 let next = max_trade_id.saturating_add(1);
788 if next <= cursor {
789 break;
790 }
791 cursor = next;
792 }
793
794 Ok(out)
795 }
796
797 pub async fn get_my_trades_since(
799 &self,
800 symbol: &str,
801 from_id: u64,
802 max_pages: usize,
803 ) -> Result<Vec<BinanceMyTrade>> {
804 let page_size = 1000usize;
805 let mut out = Vec::new();
806 let mut cursor = from_id;
807 let mut pages = 0usize;
808
809 while pages < max_pages.max(1) {
810 let page = self
811 .get_my_trades_page(symbol, page_size, Some(cursor))
812 .await?;
813 if page.is_empty() {
814 break;
815 }
816 pages += 1;
817 let fetched = page.len();
818 let mut max_trade_id = cursor;
819 for t in page {
820 max_trade_id = max_trade_id.max(t.id);
821 out.push(t);
822 }
823 if fetched < page_size {
824 break;
825 }
826 let next = max_trade_id.saturating_add(1);
827 if next <= cursor {
828 break;
829 }
830 cursor = next;
831 }
832
833 Ok(out)
834 }
835
836 async fn get_futures_my_trades_page(
837 &self,
838 symbol: &str,
839 limit: usize,
840 from_id: Option<u64>,
841 ) -> Result<Vec<BinanceMyTrade>> {
842 self.check_rate_limit();
843 let limit = limit.clamp(1, 1000);
844 let query = match from_id {
845 Some(v) => format!("symbol={}&limit={}&fromId={}", symbol, limit, v),
846 None => format!("symbol={}&limit={}", symbol, limit),
847 };
848 let signed = self.sign_futures(&query);
849 let url = format!("{}/fapi/v1/userTrades?{}", self.futures_base_url, signed);
850 let resp = self
851 .http
852 .get(&url)
853 .header("X-MBX-APIKEY", &self.futures_api_key)
854 .send()
855 .await
856 .context("get_futures_my_trades HTTP failed")?;
857 if !resp.status().is_success() {
858 let body = resp.text().await.unwrap_or_default();
859 if let Some(err) = Self::parse_binance_api_error(&body) {
860 return Err(AppError::BinanceApi {
861 code: err.code,
862 msg: err.msg,
863 }
864 .into());
865 }
866 return Err(anyhow::anyhow!("Futures myTrades request failed: {}", body));
867 }
868 let rows: Vec<BinanceFuturesUserTrade> = resp.json().await?;
869 Ok(rows
870 .into_iter()
871 .map(|t| BinanceMyTrade {
872 symbol: t.symbol,
873 id: t.id,
874 order_id: t.order_id,
875 price: t.price,
876 qty: t.qty,
877 commission: t.commission,
878 commission_asset: t.commission_asset,
879 time: t.time,
880 is_buyer: t.buyer,
881 is_maker: t.maker,
882 realized_pnl: t.realized_pnl,
883 })
884 .collect())
885 }
886
887 pub async fn get_futures_my_trades_history(
888 &self,
889 symbol: &str,
890 max_total: usize,
891 ) -> Result<Vec<BinanceMyTrade>> {
892 let page_size = 1000usize;
893 let target = max_total.max(1);
894 let mut out = Vec::new();
895 let mut cursor: u64 = 0;
896 loop {
897 let page = self
898 .get_futures_my_trades_page(
899 symbol,
900 page_size.min(target.saturating_sub(out.len())),
901 Some(cursor),
902 )
903 .await?;
904 if page.is_empty() {
905 break;
906 }
907 let fetched = page.len();
908 let mut max_trade_id = cursor;
909 for t in page {
910 max_trade_id = max_trade_id.max(t.id);
911 out.push(t);
912 if out.len() >= target {
913 break;
914 }
915 }
916 if out.len() >= target || fetched < page_size {
917 break;
918 }
919 let next = max_trade_id.saturating_add(1);
920 if next <= cursor {
921 break;
922 }
923 cursor = next;
924 }
925 Ok(out)
926 }
927}
928
929fn parse_symbol_order_rules_from_exchange_info(
930 payload: &serde_json::Value,
931 symbol: &str,
932 prefer_market_lot_size: bool,
933) -> Result<SymbolOrderRules> {
934 let symbols = payload
935 .get("symbols")
936 .and_then(|v| v.as_array())
937 .context("exchangeInfo missing symbols")?;
938 let symbol_row = symbols
939 .iter()
940 .find(|row| row.get("symbol").and_then(|v| v.as_str()) == Some(symbol))
941 .with_context(|| format!("exchangeInfo symbol not found: {}", symbol))?;
942 let filters = symbol_row
943 .get("filters")
944 .and_then(|v| v.as_array())
945 .context("exchangeInfo symbol missing filters")?;
946
947 let primary_type = if prefer_market_lot_size {
948 "MARKET_LOT_SIZE"
949 } else {
950 "LOT_SIZE"
951 };
952 let fallback_type = if prefer_market_lot_size {
953 "LOT_SIZE"
954 } else {
955 "MARKET_LOT_SIZE"
956 };
957 let parsed = find_filter(filters, primary_type)
958 .and_then(parse_lot_filter_values)
959 .or_else(|| find_filter(filters, fallback_type).and_then(parse_lot_filter_values))
960 .context("exchangeInfo missing valid LOT_SIZE/MARKET_LOT_SIZE")?;
961 let (min_qty, max_qty, step_size) = parsed;
962
963 let min_notional = find_filter(filters, "MIN_NOTIONAL")
964 .and_then(|f| f.get("notional").or_else(|| f.get("minNotional")))
965 .and_then(|v| v.as_str())
966 .and_then(|s| s.parse::<f64>().ok());
967
968 Ok(SymbolOrderRules {
969 min_qty,
970 max_qty,
971 step_size,
972 min_notional,
973 })
974}
975
976fn find_filter<'a>(
977 filters: &'a [serde_json::Value],
978 filter_type: &str,
979) -> Option<&'a serde_json::Value> {
980 filters
981 .iter()
982 .find(|f| f.get("filterType").and_then(|v| v.as_str()) == Some(filter_type))
983}
984
985fn parse_lot_filter_values(filter: &serde_json::Value) -> Option<(f64, f64, f64)> {
986 let min_qty = json_str_to_f64(filter, "minQty").ok()?;
987 let max_qty = json_str_to_f64(filter, "maxQty").ok()?;
988 let step_size = json_str_to_f64(filter, "stepSize").ok()?;
989 if step_size <= 0.0 {
990 return None;
991 }
992 Some((min_qty, max_qty, step_size))
993}
994
995fn json_str_to_f64(row: &serde_json::Value, key: &str) -> Result<f64> {
996 let s = row
997 .get(key)
998 .and_then(|v| v.as_str())
999 .with_context(|| format!("missing field {}", key))?;
1000 s.parse::<f64>()
1001 .with_context(|| format!("invalid {} value {}", key, s))
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006 use super::*;
1007 use serde_json::json;
1008
1009 #[test]
1010 fn hmac_signing_produces_hex_signature() {
1011 let client = BinanceRestClient::new(
1012 "https://testnet.binance.vision",
1013 "https://testnet.binancefuture.com",
1014 "test_key",
1015 "test_secret",
1016 "test_fut_key",
1017 "test_fut_secret",
1018 5000,
1019 );
1020 let signed = client.sign("symbol=BTCUSDT&side=BUY");
1021 assert!(signed.contains("symbol=BTCUSDT&side=BUY"));
1023 assert!(signed.contains("recvWindow=5000"));
1024 assert!(signed.contains("timestamp="));
1025 assert!(signed.contains("&signature="));
1026
1027 let sig = signed.split("&signature=").nth(1).unwrap();
1029 assert_eq!(sig.len(), 64);
1030 assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
1031 }
1032
1033 #[test]
1034 fn hmac_known_vector() {
1035 let secret = "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j";
1037 let query = "symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000×tamp=1499827319559";
1038
1039 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
1040 mac.update(query.as_bytes());
1041 let signature = hex::encode(mac.finalize().into_bytes());
1042
1043 assert_eq!(
1044 signature,
1045 "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71"
1046 );
1047 }
1048
1049 #[test]
1050 fn check_rate_limit_does_not_panic_on_poisoned_mutex() {
1051 let client = BinanceRestClient::new(
1052 "https://testnet.binance.vision",
1053 "https://testnet.binancefuture.com",
1054 "test_key",
1055 "test_secret",
1056 "test_fut_key",
1057 "test_fut_secret",
1058 5000,
1059 );
1060
1061 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1062 let _guard = client.window_start.lock().unwrap();
1063 panic!("poison window_start mutex");
1064 }));
1065
1066 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1067 client.check_rate_limit();
1068 }));
1069 assert!(
1070 result.is_ok(),
1071 "check_rate_limit should recover from poison"
1072 );
1073 }
1074
1075 #[test]
1076 fn parse_symbol_rules_prefers_market_lot_size_for_spot() {
1077 let payload = json!({
1078 "symbols": [{
1079 "symbol": "BTCUSDT",
1080 "filters": [
1081 {"filterType":"LOT_SIZE","minQty":"0.00100000","maxQty":"100.00000000","stepSize":"0.00100000"},
1082 {"filterType":"MARKET_LOT_SIZE","minQty":"0.00001000","maxQty":"50.00000000","stepSize":"0.00001000"},
1083 {"filterType":"MIN_NOTIONAL","minNotional":"5.00000000"}
1084 ]
1085 }]
1086 });
1087 let rules = parse_symbol_order_rules_from_exchange_info(&payload, "BTCUSDT", true).unwrap();
1088 assert!((rules.step_size - 0.00001).abs() < 1e-12);
1089 assert!((rules.min_qty - 0.00001).abs() < 1e-12);
1090 assert_eq!(rules.min_notional, Some(5.0));
1091 }
1092
1093 #[test]
1094 fn parse_symbol_rules_uses_lot_size_for_futures() {
1095 let payload = json!({
1096 "symbols": [{
1097 "symbol": "ETHUSDT",
1098 "filters": [
1099 {"filterType":"LOT_SIZE","minQty":"0.001","maxQty":"10000","stepSize":"0.001"},
1100 {"filterType":"MARKET_LOT_SIZE","minQty":"0.01","maxQty":"1000","stepSize":"0.01"}
1101 ]
1102 }]
1103 });
1104 let rules =
1105 parse_symbol_order_rules_from_exchange_info(&payload, "ETHUSDT", false).unwrap();
1106 assert!((rules.step_size - 0.001).abs() < 1e-12);
1107 assert!((rules.min_qty - 0.001).abs() < 1e-12);
1108 }
1109
1110 #[test]
1111 fn parse_symbol_rules_fallback_when_market_lot_size_is_invalid() {
1112 let payload = json!({
1113 "symbols": [{
1114 "symbol": "BTCUSDT",
1115 "filters": [
1116 {"filterType":"LOT_SIZE","minQty":"0.00001000","maxQty":"50.00000000","stepSize":"0.00001000"},
1117 {"filterType":"MARKET_LOT_SIZE","minQty":"0.00001000","maxQty":"50.00000000","stepSize":"0.00000000"}
1118 ]
1119 }]
1120 });
1121 let rules = parse_symbol_order_rules_from_exchange_info(&payload, "BTCUSDT", true).unwrap();
1122 assert!((rules.step_size - 0.00001).abs() < 1e-12);
1123 }
1124}