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, BinanceFuturesPositionRisk, BinanceFuturesUserTrade,
14 BinanceMyTrade, BinanceOrderResponse, 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 get_futures_position_risk(&self) -> Result<Vec<BinanceFuturesPositionRisk>> {
209 self.check_rate_limit();
210 for attempt in 0..=1 {
211 let signed = self.sign_futures("");
212 let url = format!("{}/fapi/v2/positionRisk?{}", self.futures_base_url, signed);
213 let resp = self
214 .http
215 .get(&url)
216 .header("X-MBX-APIKEY", &self.futures_api_key)
217 .send()
218 .await
219 .context("get_futures_position_risk HTTP failed")?;
220 if resp.status().is_success() {
221 return Ok(resp.json().await?);
222 }
223 let body = resp.text().await.unwrap_or_default();
224 if let Some(err) = Self::parse_binance_api_error(&body) {
225 if err.code == -1021 && attempt == 0 {
226 tracing::warn!(
227 "futures positionRisk got -1021; syncing server time and retrying once"
228 );
229 self.sync_time_offset().await?;
230 continue;
231 }
232 return Err(AppError::BinanceApi {
233 code: err.code,
234 msg: err.msg,
235 }
236 .into());
237 }
238 return Err(anyhow::anyhow!(
239 "Futures positionRisk request failed: {}",
240 body
241 ));
242 }
243 Err(anyhow::anyhow!(
244 "Futures positionRisk request failed after retry"
245 ))
246 }
247
248 pub async fn place_market_order(
249 &self,
250 symbol: &str,
251 side: OrderSide,
252 quantity: f64,
253 client_order_id: &str,
254 ) -> Result<BinanceOrderResponse> {
255 self.check_rate_limit();
256
257 let query = format!(
258 "symbol={}&side={}&type=MARKET&quantity={:.5}&newClientOrderId={}&newOrderRespType=FULL",
259 symbol,
260 side.as_binance_str(),
261 quantity,
262 client_order_id,
263 );
264 let signed = self.sign(&query);
265 let url = format!("{}/api/v3/order?{}", self.base_url, signed);
266
267 tracing::info!(
268 symbol,
269 side = %side,
270 quantity,
271 client_order_id,
272 "Placing market order"
273 );
274
275 let resp = self
276 .http
277 .post(&url)
278 .header("X-MBX-APIKEY", &self.api_key)
279 .send()
280 .await
281 .context("place_market_order HTTP failed")?;
282
283 if !resp.status().is_success() {
284 let body = resp.text().await.unwrap_or_default();
285 if let Ok(err) = serde_json::from_str::<super::types::BinanceApiErrorResponse>(&body) {
286 return Err(AppError::BinanceApi {
287 code: err.code,
288 msg: err.msg,
289 }
290 .into());
291 }
292 return Err(anyhow::anyhow!("Order request failed: {}", body));
293 }
294
295 let order: BinanceOrderResponse = resp.json().await?;
296 tracing::info!(
297 order_id = order.order_id,
298 status = %order.status,
299 client_order_id = %order.client_order_id,
300 "Order response received"
301 );
302 Ok(order)
303 }
304
305 pub async fn place_futures_market_order(
306 &self,
307 symbol: &str,
308 side: OrderSide,
309 quantity: f64,
310 client_order_id: &str,
311 ) -> Result<BinanceOrderResponse> {
312 self.check_rate_limit();
313
314 let query = format!(
315 "symbol={}&side={}&type=MARKET&quantity={:.5}&newClientOrderId={}&newOrderRespType=RESULT",
316 symbol,
317 side.as_binance_str(),
318 quantity,
319 client_order_id,
320 );
321 let signed = self.sign_futures(&query);
322 let url = format!("{}/fapi/v1/order?{}", self.futures_base_url, signed);
323
324 tracing::info!(
325 symbol,
326 side = %side,
327 quantity,
328 client_order_id,
329 "Placing futures market order"
330 );
331
332 let resp = self
333 .http
334 .post(&url)
335 .header("X-MBX-APIKEY", &self.futures_api_key)
336 .send()
337 .await
338 .context("place_futures_market_order HTTP failed")?;
339
340 if !resp.status().is_success() {
341 let body = resp.text().await.unwrap_or_default();
342 if let Ok(err) = serde_json::from_str::<super::types::BinanceApiErrorResponse>(&body) {
343 return Err(AppError::BinanceApi {
344 code: err.code,
345 msg: err.msg,
346 }
347 .into());
348 }
349 return Err(anyhow::anyhow!("Futures order request failed: {}", body));
350 }
351
352 let fut: BinanceFuturesOrderResponse = resp.json().await?;
353 let avg = if fut.avg_price > 0.0 {
354 fut.avg_price
355 } else if fut.price > 0.0 {
356 fut.price
357 } else {
358 0.0
359 };
360 let fills = if fut.executed_qty > 0.0 && avg > 0.0 {
361 vec![super::types::BinanceFill {
362 price: avg,
363 qty: fut.executed_qty,
364 commission: 0.0,
365 commission_asset: "USDT".to_string(),
366 }]
367 } else {
368 Vec::new()
369 };
370
371 Ok(BinanceOrderResponse {
372 symbol: fut.symbol,
373 order_id: fut.order_id,
374 client_order_id: fut.client_order_id,
375 price: if fut.price > 0.0 { fut.price } else { avg },
376 orig_qty: fut.orig_qty,
377 executed_qty: fut.executed_qty,
378 status: fut.status,
379 r#type: fut.r#type,
380 side: fut.side,
381 fills,
382 })
383 }
384
385 pub async fn place_futures_stop_market_order(
386 &self,
387 symbol: &str,
388 side: OrderSide,
389 quantity: f64,
390 stop_price: f64,
391 client_order_id: &str,
392 ) -> Result<BinanceOrderResponse> {
393 self.check_rate_limit();
394
395 let query = format!(
396 "symbol={}&side={}&type=STOP_MARKET&quantity={:.5}&stopPrice={:.5}&reduceOnly=true&newClientOrderId={}&newOrderRespType=RESULT",
397 symbol,
398 side.as_binance_str(),
399 quantity,
400 stop_price,
401 client_order_id,
402 );
403 let signed = self.sign_futures(&query);
404 let url = format!("{}/fapi/v1/order?{}", self.futures_base_url, signed);
405
406 tracing::info!(
407 symbol,
408 side = %side,
409 quantity,
410 stop_price,
411 client_order_id,
412 "Placing futures stop-market order"
413 );
414
415 let resp = self
416 .http
417 .post(&url)
418 .header("X-MBX-APIKEY", &self.futures_api_key)
419 .send()
420 .await
421 .context("place_futures_stop_market_order HTTP failed")?;
422
423 if !resp.status().is_success() {
424 let body = resp.text().await.unwrap_or_default();
425 if let Ok(err) = serde_json::from_str::<super::types::BinanceApiErrorResponse>(&body) {
426 return Err(AppError::BinanceApi {
427 code: err.code,
428 msg: err.msg,
429 }
430 .into());
431 }
432 return Err(anyhow::anyhow!(
433 "Futures stop order request failed: {}",
434 body
435 ));
436 }
437
438 let fut: BinanceFuturesOrderResponse = resp.json().await?;
439 let avg = if fut.avg_price > 0.0 {
440 fut.avg_price
441 } else if fut.price > 0.0 {
442 fut.price
443 } else {
444 0.0
445 };
446 let fills = if fut.executed_qty > 0.0 && avg > 0.0 {
447 vec![super::types::BinanceFill {
448 price: avg,
449 qty: fut.executed_qty,
450 commission: 0.0,
451 commission_asset: "USDT".to_string(),
452 }]
453 } else {
454 Vec::new()
455 };
456
457 Ok(BinanceOrderResponse {
458 symbol: fut.symbol,
459 order_id: fut.order_id,
460 client_order_id: fut.client_order_id,
461 price: if fut.price > 0.0 { fut.price } else { avg },
462 orig_qty: fut.orig_qty,
463 executed_qty: fut.executed_qty,
464 status: fut.status,
465 r#type: fut.r#type,
466 side: fut.side,
467 fills,
468 })
469 }
470
471 pub async fn get_spot_symbol_order_rules(&self, symbol: &str) -> Result<SymbolOrderRules> {
472 let url = format!("{}/api/v3/exchangeInfo?symbol={}", self.base_url, symbol);
473 let payload: serde_json::Value = self
474 .http
475 .get(&url)
476 .send()
477 .await
478 .context("get_spot_symbol_order_rules HTTP failed")?
479 .error_for_status()
480 .context("get_spot_symbol_order_rules returned error status")?
481 .json()
482 .await
483 .context("get_spot_symbol_order_rules JSON parse failed")?;
484 parse_symbol_order_rules_from_exchange_info(&payload, symbol, true)
485 }
486
487 pub async fn get_futures_symbol_order_rules(&self, symbol: &str) -> Result<SymbolOrderRules> {
488 let url = format!(
489 "{}/fapi/v1/exchangeInfo?symbol={}",
490 self.futures_base_url, symbol
491 );
492 let payload: serde_json::Value = self
493 .http
494 .get(&url)
495 .send()
496 .await
497 .context("get_futures_symbol_order_rules HTTP failed")?
498 .error_for_status()
499 .context("get_futures_symbol_order_rules returned error status")?
500 .json()
501 .await
502 .context("get_futures_symbol_order_rules JSON parse failed")?;
503 parse_symbol_order_rules_from_exchange_info(&payload, symbol, false)
504 }
505
506 pub async fn get_klines(
509 &self,
510 symbol: &str,
511 interval: &str,
512 limit: usize,
513 ) -> Result<Vec<Candle>> {
514 self.get_klines_for_market(symbol, interval, limit, false)
515 .await
516 }
517
518 pub async fn get_klines_for_market(
519 &self,
520 symbol: &str,
521 interval: &str,
522 limit: usize,
523 is_futures: bool,
524 ) -> Result<Vec<Candle>> {
525 self.check_rate_limit();
526
527 let url = if is_futures {
528 format!(
529 "{}/fapi/v1/klines?symbol={}&interval={}&limit={}",
530 self.futures_base_url, symbol, interval, limit,
531 )
532 } else {
533 format!(
534 "{}/api/v3/klines?symbol={}&interval={}&limit={}",
535 self.base_url, symbol, interval, limit,
536 )
537 };
538
539 let resp: Vec<Vec<serde_json::Value>> = self
540 .http
541 .get(&url)
542 .send()
543 .await
544 .context("get_klines HTTP failed")?
545 .error_for_status()
546 .context("get_klines returned error status")?
547 .json()
548 .await
549 .context("get_klines JSON parse failed")?;
550
551 let candles: Vec<Candle> = resp
552 .iter()
553 .filter_map(|kline| {
554 let open_time = kline.get(0)?.as_u64()?;
555 let open = kline.get(1)?.as_str()?.parse::<f64>().ok()?;
556 let high = kline.get(2)?.as_str()?.parse::<f64>().ok()?;
557 let low = kline.get(3)?.as_str()?.parse::<f64>().ok()?;
558 let close = kline.get(4)?.as_str()?.parse::<f64>().ok()?;
559 let close_time = kline
561 .get(6)?
562 .as_u64()
563 .map(|v| v.saturating_add(1))
564 .unwrap_or(open_time.saturating_add(60_000));
565 Some(Candle {
566 open,
567 high,
568 low,
569 close,
570 open_time,
571 close_time,
572 })
573 })
574 .collect();
575
576 Ok(candles)
577 }
578
579 pub async fn cancel_order(
580 &self,
581 symbol: &str,
582 client_order_id: &str,
583 ) -> Result<BinanceOrderResponse> {
584 self.check_rate_limit();
585
586 let query = format!("symbol={}&origClientOrderId={}", symbol, client_order_id);
587 let signed = self.sign(&query);
588 let url = format!("{}/api/v3/order?{}", self.base_url, signed);
589
590 tracing::info!(symbol, client_order_id, "Cancelling order");
591
592 let resp = self
593 .http
594 .delete(&url)
595 .header("X-MBX-APIKEY", &self.api_key)
596 .send()
597 .await
598 .context("cancel_order HTTP failed")?;
599
600 if !resp.status().is_success() {
601 let body = resp.text().await.unwrap_or_default();
602 if let Ok(err) = serde_json::from_str::<super::types::BinanceApiErrorResponse>(&body) {
603 return Err(AppError::BinanceApi {
604 code: err.code,
605 msg: err.msg,
606 }
607 .into());
608 }
609 return Err(anyhow::anyhow!("Cancel request failed: {}", body));
610 }
611
612 Ok(resp.json().await?)
613 }
614
615 async fn get_all_orders_page(
617 &self,
618 symbol: &str,
619 limit: usize,
620 from_order_id: Option<u64>,
621 ) -> Result<Vec<BinanceAllOrder>> {
622 self.check_rate_limit();
623
624 let limit = limit.clamp(1, 1000);
625 let query = match from_order_id {
626 Some(order_id) => format!("symbol={}&limit={}&orderId={}", symbol, limit, order_id),
627 None => format!("symbol={}&limit={}", symbol, limit),
628 };
629 for attempt in 0..=1 {
630 let signed = self.sign(&query);
631 let url = format!("{}/api/v3/allOrders?{}", self.base_url, signed);
632
633 let resp = self
634 .http
635 .get(&url)
636 .header("X-MBX-APIKEY", &self.api_key)
637 .send()
638 .await
639 .context("get_all_orders HTTP failed")?;
640
641 if resp.status().is_success() {
642 return Ok(resp.json().await?);
643 }
644
645 let body = resp.text().await.unwrap_or_default();
646 if let Some(err) = Self::parse_binance_api_error(&body) {
647 if err.code == -1021 && attempt == 0 {
648 tracing::warn!("allOrders got -1021; syncing server time and retrying once");
649 self.sync_time_offset().await?;
650 continue;
651 }
652 return Err(AppError::BinanceApi {
653 code: err.code,
654 msg: err.msg,
655 }
656 .into());
657 }
658 return Err(anyhow::anyhow!("All orders request failed: {}", body));
659 }
660
661 Err(anyhow::anyhow!("All orders request failed after retry"))
662 }
663
664 pub async fn get_all_orders(&self, symbol: &str, limit: usize) -> Result<Vec<BinanceAllOrder>> {
667 self.get_all_orders_page(symbol, limit, None).await
668 }
669
670 async fn get_futures_all_orders_page(
671 &self,
672 symbol: &str,
673 limit: usize,
674 from_order_id: Option<u64>,
675 ) -> Result<Vec<BinanceAllOrder>> {
676 self.check_rate_limit();
677 let limit = limit.clamp(1, 1000);
678 let query = match from_order_id {
679 Some(order_id) => format!("symbol={}&limit={}&orderId={}", symbol, limit, order_id),
680 None => format!("symbol={}&limit={}", symbol, limit),
681 };
682 let signed = self.sign_futures(&query);
683 let url = format!("{}/fapi/v1/allOrders?{}", self.futures_base_url, signed);
684 let resp = self
685 .http
686 .get(&url)
687 .header("X-MBX-APIKEY", &self.futures_api_key)
688 .send()
689 .await
690 .context("get_futures_all_orders HTTP failed")?;
691 if !resp.status().is_success() {
692 let body = resp.text().await.unwrap_or_default();
693 if let Some(err) = Self::parse_binance_api_error(&body) {
694 return Err(AppError::BinanceApi {
695 code: err.code,
696 msg: err.msg,
697 }
698 .into());
699 }
700 return Err(anyhow::anyhow!(
701 "Futures allOrders request failed: {}",
702 body
703 ));
704 }
705 let rows: Vec<BinanceFuturesAllOrder> = resp.json().await?;
706 Ok(rows
707 .into_iter()
708 .map(|o| {
709 let cumm_quote = if o.cum_quote > 0.0 {
710 o.cum_quote
711 } else {
712 o.avg_price * o.executed_qty
713 };
714 BinanceAllOrder {
715 symbol: o.symbol,
716 order_id: o.order_id,
717 client_order_id: o.client_order_id,
718 price: o.price,
719 orig_qty: o.orig_qty,
720 executed_qty: o.executed_qty,
721 cummulative_quote_qty: cumm_quote,
722 status: o.status,
723 r#type: o.r#type,
724 side: o.side,
725 time: o.time,
726 update_time: o.update_time,
727 }
728 })
729 .collect())
730 }
731
732 pub async fn get_futures_all_orders(
733 &self,
734 symbol: &str,
735 limit: usize,
736 ) -> Result<Vec<BinanceAllOrder>> {
737 self.get_futures_all_orders_page(symbol, limit, None).await
738 }
739
740 async fn get_my_trades_page(
741 &self,
742 symbol: &str,
743 limit: usize,
744 from_id: Option<u64>,
745 ) -> Result<Vec<BinanceMyTrade>> {
746 self.check_rate_limit();
747
748 let limit = limit.clamp(1, 1000);
749 let query = match from_id {
750 Some(v) => format!("symbol={}&limit={}&fromId={}", symbol, limit, v),
751 None => format!("symbol={}&limit={}", symbol, limit),
752 };
753 for attempt in 0..=1 {
754 let signed = self.sign(&query);
755 let url = format!("{}/api/v3/myTrades?{}", self.base_url, signed);
756
757 let resp = self
758 .http
759 .get(&url)
760 .header("X-MBX-APIKEY", &self.api_key)
761 .send()
762 .await
763 .context("get_my_trades HTTP failed")?;
764
765 if resp.status().is_success() {
766 return Ok(resp.json().await?);
767 }
768
769 let body = resp.text().await.unwrap_or_default();
770 if let Some(err) = Self::parse_binance_api_error(&body) {
771 if err.code == -1021 && attempt == 0 {
772 tracing::warn!("myTrades got -1021; syncing server time and retrying once");
773 self.sync_time_offset().await?;
774 continue;
775 }
776 return Err(AppError::BinanceApi {
777 code: err.code,
778 msg: err.msg,
779 }
780 .into());
781 }
782 return Err(anyhow::anyhow!("My trades request failed: {}", body));
783 }
784
785 Err(anyhow::anyhow!("My trades request failed after retry"))
786 }
787
788 pub async fn get_my_trades(&self, symbol: &str, limit: usize) -> Result<Vec<BinanceMyTrade>> {
790 self.get_my_trades_page(symbol, limit, None).await
791 }
792
793 pub async fn get_my_trades_history(
795 &self,
796 symbol: &str,
797 max_total: usize,
798 ) -> Result<Vec<BinanceMyTrade>> {
799 let page_size = 1000usize;
800 let target = max_total.max(1);
801 let mut out = Vec::new();
802 let mut cursor: u64 = 0;
803
804 loop {
805 let page = self
806 .get_my_trades_page(
807 symbol,
808 page_size.min(target.saturating_sub(out.len())),
809 Some(cursor),
810 )
811 .await?;
812 if page.is_empty() {
813 break;
814 }
815 let fetched = page.len();
816 let mut max_trade_id = cursor;
817 for t in page {
818 max_trade_id = max_trade_id.max(t.id);
819 out.push(t);
820 if out.len() >= target {
821 break;
822 }
823 }
824 if out.len() >= target || fetched < page_size {
825 break;
826 }
827 let next = max_trade_id.saturating_add(1);
828 if next <= cursor {
829 break;
830 }
831 cursor = next;
832 }
833
834 Ok(out)
835 }
836
837 pub async fn get_my_trades_since(
839 &self,
840 symbol: &str,
841 from_id: u64,
842 max_pages: usize,
843 ) -> Result<Vec<BinanceMyTrade>> {
844 let page_size = 1000usize;
845 let mut out = Vec::new();
846 let mut cursor = from_id;
847 let mut pages = 0usize;
848
849 while pages < max_pages.max(1) {
850 let page = self
851 .get_my_trades_page(symbol, page_size, Some(cursor))
852 .await?;
853 if page.is_empty() {
854 break;
855 }
856 pages += 1;
857 let fetched = page.len();
858 let mut max_trade_id = cursor;
859 for t in page {
860 max_trade_id = max_trade_id.max(t.id);
861 out.push(t);
862 }
863 if fetched < page_size {
864 break;
865 }
866 let next = max_trade_id.saturating_add(1);
867 if next <= cursor {
868 break;
869 }
870 cursor = next;
871 }
872
873 Ok(out)
874 }
875
876 async fn get_futures_my_trades_page(
877 &self,
878 symbol: &str,
879 limit: usize,
880 from_id: Option<u64>,
881 ) -> Result<Vec<BinanceMyTrade>> {
882 self.check_rate_limit();
883 let limit = limit.clamp(1, 1000);
884 let query = match from_id {
885 Some(v) => format!("symbol={}&limit={}&fromId={}", symbol, limit, v),
886 None => format!("symbol={}&limit={}", symbol, limit),
887 };
888 let signed = self.sign_futures(&query);
889 let url = format!("{}/fapi/v1/userTrades?{}", self.futures_base_url, signed);
890 let resp = self
891 .http
892 .get(&url)
893 .header("X-MBX-APIKEY", &self.futures_api_key)
894 .send()
895 .await
896 .context("get_futures_my_trades HTTP failed")?;
897 if !resp.status().is_success() {
898 let body = resp.text().await.unwrap_or_default();
899 if let Some(err) = Self::parse_binance_api_error(&body) {
900 return Err(AppError::BinanceApi {
901 code: err.code,
902 msg: err.msg,
903 }
904 .into());
905 }
906 return Err(anyhow::anyhow!("Futures myTrades request failed: {}", body));
907 }
908 let rows: Vec<BinanceFuturesUserTrade> = resp.json().await?;
909 Ok(rows
910 .into_iter()
911 .map(|t| BinanceMyTrade {
912 symbol: t.symbol,
913 id: t.id,
914 order_id: t.order_id,
915 price: t.price,
916 qty: t.qty,
917 commission: t.commission,
918 commission_asset: t.commission_asset,
919 time: t.time,
920 is_buyer: t.buyer,
921 is_maker: t.maker,
922 realized_pnl: t.realized_pnl,
923 })
924 .collect())
925 }
926
927 pub async fn get_futures_my_trades_history(
928 &self,
929 symbol: &str,
930 max_total: usize,
931 ) -> Result<Vec<BinanceMyTrade>> {
932 let page_size = 1000usize;
933 let target = max_total.max(1);
934 let mut out = Vec::new();
935 let mut cursor: u64 = 0;
936 loop {
937 let page = self
938 .get_futures_my_trades_page(
939 symbol,
940 page_size.min(target.saturating_sub(out.len())),
941 Some(cursor),
942 )
943 .await?;
944 if page.is_empty() {
945 break;
946 }
947 let fetched = page.len();
948 let mut max_trade_id = cursor;
949 for t in page {
950 max_trade_id = max_trade_id.max(t.id);
951 out.push(t);
952 if out.len() >= target {
953 break;
954 }
955 }
956 if out.len() >= target || fetched < page_size {
957 break;
958 }
959 let next = max_trade_id.saturating_add(1);
960 if next <= cursor {
961 break;
962 }
963 cursor = next;
964 }
965 Ok(out)
966 }
967}
968
969fn parse_symbol_order_rules_from_exchange_info(
970 payload: &serde_json::Value,
971 symbol: &str,
972 prefer_market_lot_size: bool,
973) -> Result<SymbolOrderRules> {
974 let symbols = payload
975 .get("symbols")
976 .and_then(|v| v.as_array())
977 .context("exchangeInfo missing symbols")?;
978 let symbol_row = symbols
979 .iter()
980 .find(|row| row.get("symbol").and_then(|v| v.as_str()) == Some(symbol))
981 .with_context(|| format!("exchangeInfo symbol not found: {}", symbol))?;
982 let filters = symbol_row
983 .get("filters")
984 .and_then(|v| v.as_array())
985 .context("exchangeInfo symbol missing filters")?;
986
987 let primary_type = if prefer_market_lot_size {
988 "MARKET_LOT_SIZE"
989 } else {
990 "LOT_SIZE"
991 };
992 let fallback_type = if prefer_market_lot_size {
993 "LOT_SIZE"
994 } else {
995 "MARKET_LOT_SIZE"
996 };
997 let parsed = find_filter(filters, primary_type)
998 .and_then(parse_lot_filter_values)
999 .or_else(|| find_filter(filters, fallback_type).and_then(parse_lot_filter_values))
1000 .context("exchangeInfo missing valid LOT_SIZE/MARKET_LOT_SIZE")?;
1001 let (min_qty, max_qty, step_size) = parsed;
1002
1003 let min_notional = find_filter(filters, "MIN_NOTIONAL")
1004 .and_then(|f| f.get("notional").or_else(|| f.get("minNotional")))
1005 .and_then(|v| v.as_str())
1006 .and_then(|s| s.parse::<f64>().ok());
1007
1008 Ok(SymbolOrderRules {
1009 min_qty,
1010 max_qty,
1011 step_size,
1012 min_notional,
1013 })
1014}
1015
1016fn find_filter<'a>(
1017 filters: &'a [serde_json::Value],
1018 filter_type: &str,
1019) -> Option<&'a serde_json::Value> {
1020 filters
1021 .iter()
1022 .find(|f| f.get("filterType").and_then(|v| v.as_str()) == Some(filter_type))
1023}
1024
1025fn parse_lot_filter_values(filter: &serde_json::Value) -> Option<(f64, f64, f64)> {
1026 let min_qty = json_str_to_f64(filter, "minQty").ok()?;
1027 let max_qty = json_str_to_f64(filter, "maxQty").ok()?;
1028 let step_size = json_str_to_f64(filter, "stepSize").ok()?;
1029 if step_size <= 0.0 {
1030 return None;
1031 }
1032 Some((min_qty, max_qty, step_size))
1033}
1034
1035fn json_str_to_f64(row: &serde_json::Value, key: &str) -> Result<f64> {
1036 let s = row
1037 .get(key)
1038 .and_then(|v| v.as_str())
1039 .with_context(|| format!("missing field {}", key))?;
1040 s.parse::<f64>()
1041 .with_context(|| format!("invalid {} value {}", key, s))
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047 use serde_json::json;
1048
1049 #[test]
1050 fn hmac_signing_produces_hex_signature() {
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 let signed = client.sign("symbol=BTCUSDT&side=BUY");
1061 assert!(signed.contains("symbol=BTCUSDT&side=BUY"));
1063 assert!(signed.contains("recvWindow=5000"));
1064 assert!(signed.contains("timestamp="));
1065 assert!(signed.contains("&signature="));
1066
1067 let sig = signed.split("&signature=").nth(1).unwrap();
1069 assert_eq!(sig.len(), 64);
1070 assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
1071 }
1072
1073 #[test]
1074 fn hmac_known_vector() {
1075 let secret = "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j";
1077 let query = "symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000×tamp=1499827319559";
1078
1079 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
1080 mac.update(query.as_bytes());
1081 let signature = hex::encode(mac.finalize().into_bytes());
1082
1083 assert_eq!(
1084 signature,
1085 "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71"
1086 );
1087 }
1088
1089 #[test]
1090 fn check_rate_limit_does_not_panic_on_poisoned_mutex() {
1091 let client = BinanceRestClient::new(
1092 "https://testnet.binance.vision",
1093 "https://testnet.binancefuture.com",
1094 "test_key",
1095 "test_secret",
1096 "test_fut_key",
1097 "test_fut_secret",
1098 5000,
1099 );
1100
1101 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1102 let _guard = client.window_start.lock().unwrap();
1103 panic!("poison window_start mutex");
1104 }));
1105
1106 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1107 client.check_rate_limit();
1108 }));
1109 assert!(
1110 result.is_ok(),
1111 "check_rate_limit should recover from poison"
1112 );
1113 }
1114
1115 #[test]
1116 fn parse_symbol_rules_prefers_market_lot_size_for_spot() {
1117 let payload = json!({
1118 "symbols": [{
1119 "symbol": "BTCUSDT",
1120 "filters": [
1121 {"filterType":"LOT_SIZE","minQty":"0.00100000","maxQty":"100.00000000","stepSize":"0.00100000"},
1122 {"filterType":"MARKET_LOT_SIZE","minQty":"0.00001000","maxQty":"50.00000000","stepSize":"0.00001000"},
1123 {"filterType":"MIN_NOTIONAL","minNotional":"5.00000000"}
1124 ]
1125 }]
1126 });
1127 let rules = parse_symbol_order_rules_from_exchange_info(&payload, "BTCUSDT", true).unwrap();
1128 assert!((rules.step_size - 0.00001).abs() < 1e-12);
1129 assert!((rules.min_qty - 0.00001).abs() < 1e-12);
1130 assert_eq!(rules.min_notional, Some(5.0));
1131 }
1132
1133 #[test]
1134 fn parse_symbol_rules_uses_lot_size_for_futures() {
1135 let payload = json!({
1136 "symbols": [{
1137 "symbol": "ETHUSDT",
1138 "filters": [
1139 {"filterType":"LOT_SIZE","minQty":"0.001","maxQty":"10000","stepSize":"0.001"},
1140 {"filterType":"MARKET_LOT_SIZE","minQty":"0.01","maxQty":"1000","stepSize":"0.01"}
1141 ]
1142 }]
1143 });
1144 let rules =
1145 parse_symbol_order_rules_from_exchange_info(&payload, "ETHUSDT", false).unwrap();
1146 assert!((rules.step_size - 0.001).abs() < 1e-12);
1147 assert!((rules.min_qty - 0.001).abs() < 1e-12);
1148 }
1149
1150 #[test]
1151 fn parse_symbol_rules_fallback_when_market_lot_size_is_invalid() {
1152 let payload = json!({
1153 "symbols": [{
1154 "symbol": "BTCUSDT",
1155 "filters": [
1156 {"filterType":"LOT_SIZE","minQty":"0.00001000","maxQty":"50.00000000","stepSize":"0.00001000"},
1157 {"filterType":"MARKET_LOT_SIZE","minQty":"0.00001000","maxQty":"50.00000000","stepSize":"0.00000000"}
1158 ]
1159 }]
1160 });
1161 let rules = parse_symbol_order_rules_from_exchange_info(&payload, "BTCUSDT", true).unwrap();
1162 assert!((rules.step_size - 0.00001).abs() < 1e-12);
1163 }
1164}