Skip to main content

sandbox_quant/binance/
rest.rs

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    // Simple rate limiter: request count in current minute window
36    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={}&timestamp={}", self.recv_window, timestamp)
70        } else {
71            format!(
72                "{}&recvWindow={}&timestamp={}",
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 get_spot_symbol_order_rules(&self, symbol: &str) -> Result<SymbolOrderRules> {
346        let url = format!("{}/api/v3/exchangeInfo?symbol={}", self.base_url, symbol);
347        let payload: serde_json::Value = self
348            .http
349            .get(&url)
350            .send()
351            .await
352            .context("get_spot_symbol_order_rules HTTP failed")?
353            .error_for_status()
354            .context("get_spot_symbol_order_rules returned error status")?
355            .json()
356            .await
357            .context("get_spot_symbol_order_rules JSON parse failed")?;
358        parse_symbol_order_rules_from_exchange_info(&payload, symbol, true)
359    }
360
361    pub async fn get_futures_symbol_order_rules(&self, symbol: &str) -> Result<SymbolOrderRules> {
362        let url = format!(
363            "{}/fapi/v1/exchangeInfo?symbol={}",
364            self.futures_base_url, symbol
365        );
366        let payload: serde_json::Value = self
367            .http
368            .get(&url)
369            .send()
370            .await
371            .context("get_futures_symbol_order_rules HTTP failed")?
372            .error_for_status()
373            .context("get_futures_symbol_order_rules returned error status")?
374            .json()
375            .await
376            .context("get_futures_symbol_order_rules JSON parse failed")?;
377        parse_symbol_order_rules_from_exchange_info(&payload, symbol, false)
378    }
379
380    /// Fetch historical kline (candlestick) OHLC data.
381    /// Returns `Vec<Candle>` oldest first.
382    pub async fn get_klines(
383        &self,
384        symbol: &str,
385        interval: &str,
386        limit: usize,
387    ) -> Result<Vec<Candle>> {
388        self.get_klines_for_market(symbol, interval, limit, false)
389            .await
390    }
391
392    pub async fn get_klines_for_market(
393        &self,
394        symbol: &str,
395        interval: &str,
396        limit: usize,
397        is_futures: bool,
398    ) -> Result<Vec<Candle>> {
399        self.check_rate_limit();
400
401        let url = if is_futures {
402            format!(
403                "{}/fapi/v1/klines?symbol={}&interval={}&limit={}",
404                self.futures_base_url, symbol, interval, limit,
405            )
406        } else {
407            format!(
408                "{}/api/v3/klines?symbol={}&interval={}&limit={}",
409                self.base_url, symbol, interval, limit,
410            )
411        };
412
413        let resp: Vec<Vec<serde_json::Value>> = self
414            .http
415            .get(&url)
416            .send()
417            .await
418            .context("get_klines HTTP failed")?
419            .error_for_status()
420            .context("get_klines returned error status")?
421            .json()
422            .await
423            .context("get_klines JSON parse failed")?;
424
425        let candles: Vec<Candle> = resp
426            .iter()
427            .filter_map(|kline| {
428                let open_time = kline.get(0)?.as_u64()?;
429                let open = kline.get(1)?.as_str()?.parse::<f64>().ok()?;
430                let high = kline.get(2)?.as_str()?.parse::<f64>().ok()?;
431                let low = kline.get(3)?.as_str()?.parse::<f64>().ok()?;
432                let close = kline.get(4)?.as_str()?.parse::<f64>().ok()?;
433                // Binance kline close time is inclusive end ms; convert to half-open [open, close+1).
434                let close_time = kline
435                    .get(6)?
436                    .as_u64()
437                    .map(|v| v.saturating_add(1))
438                    .unwrap_or(open_time.saturating_add(60_000));
439                Some(Candle {
440                    open,
441                    high,
442                    low,
443                    close,
444                    open_time,
445                    close_time,
446                })
447            })
448            .collect();
449
450        Ok(candles)
451    }
452
453    pub async fn cancel_order(
454        &self,
455        symbol: &str,
456        client_order_id: &str,
457    ) -> Result<BinanceOrderResponse> {
458        self.check_rate_limit();
459
460        let query = format!("symbol={}&origClientOrderId={}", symbol, client_order_id);
461        let signed = self.sign(&query);
462        let url = format!("{}/api/v3/order?{}", self.base_url, signed);
463
464        tracing::info!(symbol, client_order_id, "Cancelling order");
465
466        let resp = self
467            .http
468            .delete(&url)
469            .header("X-MBX-APIKEY", &self.api_key)
470            .send()
471            .await
472            .context("cancel_order HTTP failed")?;
473
474        if !resp.status().is_success() {
475            let body = resp.text().await.unwrap_or_default();
476            if let Ok(err) = serde_json::from_str::<super::types::BinanceApiErrorResponse>(&body) {
477                return Err(AppError::BinanceApi {
478                    code: err.code,
479                    msg: err.msg,
480                }
481                .into());
482            }
483            return Err(anyhow::anyhow!("Cancel request failed: {}", body));
484        }
485
486        Ok(resp.json().await?)
487    }
488
489    /// Fetch one page of orders for a symbol.
490    async fn get_all_orders_page(
491        &self,
492        symbol: &str,
493        limit: usize,
494        from_order_id: Option<u64>,
495    ) -> Result<Vec<BinanceAllOrder>> {
496        self.check_rate_limit();
497
498        let limit = limit.clamp(1, 1000);
499        let query = match from_order_id {
500            Some(order_id) => format!("symbol={}&limit={}&orderId={}", symbol, limit, order_id),
501            None => format!("symbol={}&limit={}", symbol, limit),
502        };
503        for attempt in 0..=1 {
504            let signed = self.sign(&query);
505            let url = format!("{}/api/v3/allOrders?{}", self.base_url, signed);
506
507            let resp = self
508                .http
509                .get(&url)
510                .header("X-MBX-APIKEY", &self.api_key)
511                .send()
512                .await
513                .context("get_all_orders HTTP failed")?;
514
515            if resp.status().is_success() {
516                return Ok(resp.json().await?);
517            }
518
519            let body = resp.text().await.unwrap_or_default();
520            if let Some(err) = Self::parse_binance_api_error(&body) {
521                if err.code == -1021 && attempt == 0 {
522                    tracing::warn!("allOrders got -1021; syncing server time and retrying once");
523                    self.sync_time_offset().await?;
524                    continue;
525                }
526                return Err(AppError::BinanceApi {
527                    code: err.code,
528                    msg: err.msg,
529                }
530                .into());
531            }
532            return Err(anyhow::anyhow!("All orders request failed: {}", body));
533        }
534
535        Err(anyhow::anyhow!("All orders request failed after retry"))
536    }
537
538    /// Fetch recent orders for a symbol from `/api/v3/allOrders`.
539    /// `limit` controls max rows returned (1..=1000).
540    pub async fn get_all_orders(&self, symbol: &str, limit: usize) -> Result<Vec<BinanceAllOrder>> {
541        self.get_all_orders_page(symbol, limit, None).await
542    }
543
544    async fn get_futures_all_orders_page(
545        &self,
546        symbol: &str,
547        limit: usize,
548        from_order_id: Option<u64>,
549    ) -> Result<Vec<BinanceAllOrder>> {
550        self.check_rate_limit();
551        let limit = limit.clamp(1, 1000);
552        let query = match from_order_id {
553            Some(order_id) => format!("symbol={}&limit={}&orderId={}", symbol, limit, order_id),
554            None => format!("symbol={}&limit={}", symbol, limit),
555        };
556        let signed = self.sign_futures(&query);
557        let url = format!("{}/fapi/v1/allOrders?{}", self.futures_base_url, signed);
558        let resp = self
559            .http
560            .get(&url)
561            .header("X-MBX-APIKEY", &self.futures_api_key)
562            .send()
563            .await
564            .context("get_futures_all_orders HTTP failed")?;
565        if !resp.status().is_success() {
566            let body = resp.text().await.unwrap_or_default();
567            if let Some(err) = Self::parse_binance_api_error(&body) {
568                return Err(AppError::BinanceApi {
569                    code: err.code,
570                    msg: err.msg,
571                }
572                .into());
573            }
574            return Err(anyhow::anyhow!(
575                "Futures allOrders request failed: {}",
576                body
577            ));
578        }
579        let rows: Vec<BinanceFuturesAllOrder> = resp.json().await?;
580        Ok(rows
581            .into_iter()
582            .map(|o| {
583                let cumm_quote = if o.cum_quote > 0.0 {
584                    o.cum_quote
585                } else {
586                    o.avg_price * o.executed_qty
587                };
588                BinanceAllOrder {
589                    symbol: o.symbol,
590                    order_id: o.order_id,
591                    client_order_id: o.client_order_id,
592                    price: o.price,
593                    orig_qty: o.orig_qty,
594                    executed_qty: o.executed_qty,
595                    cummulative_quote_qty: cumm_quote,
596                    status: o.status,
597                    r#type: o.r#type,
598                    side: o.side,
599                    time: o.time,
600                    update_time: o.update_time,
601                }
602            })
603            .collect())
604    }
605
606    pub async fn get_futures_all_orders(
607        &self,
608        symbol: &str,
609        limit: usize,
610    ) -> Result<Vec<BinanceAllOrder>> {
611        self.get_futures_all_orders_page(symbol, limit, None).await
612    }
613
614    async fn get_my_trades_page(
615        &self,
616        symbol: &str,
617        limit: usize,
618        from_id: Option<u64>,
619    ) -> Result<Vec<BinanceMyTrade>> {
620        self.check_rate_limit();
621
622        let limit = limit.clamp(1, 1000);
623        let query = match from_id {
624            Some(v) => format!("symbol={}&limit={}&fromId={}", symbol, limit, v),
625            None => format!("symbol={}&limit={}", symbol, limit),
626        };
627        for attempt in 0..=1 {
628            let signed = self.sign(&query);
629            let url = format!("{}/api/v3/myTrades?{}", self.base_url, signed);
630
631            let resp = self
632                .http
633                .get(&url)
634                .header("X-MBX-APIKEY", &self.api_key)
635                .send()
636                .await
637                .context("get_my_trades HTTP failed")?;
638
639            if resp.status().is_success() {
640                return Ok(resp.json().await?);
641            }
642
643            let body = resp.text().await.unwrap_or_default();
644            if let Some(err) = Self::parse_binance_api_error(&body) {
645                if err.code == -1021 && attempt == 0 {
646                    tracing::warn!("myTrades got -1021; syncing server time and retrying once");
647                    self.sync_time_offset().await?;
648                    continue;
649                }
650                return Err(AppError::BinanceApi {
651                    code: err.code,
652                    msg: err.msg,
653                }
654                .into());
655            }
656            return Err(anyhow::anyhow!("My trades request failed: {}", body));
657        }
658
659        Err(anyhow::anyhow!("My trades request failed after retry"))
660    }
661
662    /// Fetch recent personal trades for a symbol.
663    pub async fn get_my_trades(&self, symbol: &str, limit: usize) -> Result<Vec<BinanceMyTrade>> {
664        self.get_my_trades_page(symbol, limit, None).await
665    }
666
667    /// Fetch all personal trades from the oldest side (fromId=0), up to `max_total`.
668    pub async fn get_my_trades_history(
669        &self,
670        symbol: &str,
671        max_total: usize,
672    ) -> Result<Vec<BinanceMyTrade>> {
673        let page_size = 1000usize;
674        let target = max_total.max(1);
675        let mut out = Vec::new();
676        let mut cursor: u64 = 0;
677
678        loop {
679            let page = self
680                .get_my_trades_page(
681                    symbol,
682                    page_size.min(target.saturating_sub(out.len())),
683                    Some(cursor),
684                )
685                .await?;
686            if page.is_empty() {
687                break;
688            }
689            let fetched = page.len();
690            let mut max_trade_id = cursor;
691            for t in page {
692                max_trade_id = max_trade_id.max(t.id);
693                out.push(t);
694                if out.len() >= target {
695                    break;
696                }
697            }
698            if out.len() >= target || fetched < page_size {
699                break;
700            }
701            let next = max_trade_id.saturating_add(1);
702            if next <= cursor {
703                break;
704            }
705            cursor = next;
706        }
707
708        Ok(out)
709    }
710
711    /// Fetch new personal trades since `from_id` (inclusive), paging forward.
712    pub async fn get_my_trades_since(
713        &self,
714        symbol: &str,
715        from_id: u64,
716        max_pages: usize,
717    ) -> Result<Vec<BinanceMyTrade>> {
718        let page_size = 1000usize;
719        let mut out = Vec::new();
720        let mut cursor = from_id;
721        let mut pages = 0usize;
722
723        while pages < max_pages.max(1) {
724            let page = self
725                .get_my_trades_page(symbol, page_size, Some(cursor))
726                .await?;
727            if page.is_empty() {
728                break;
729            }
730            pages += 1;
731            let fetched = page.len();
732            let mut max_trade_id = cursor;
733            for t in page {
734                max_trade_id = max_trade_id.max(t.id);
735                out.push(t);
736            }
737            if fetched < page_size {
738                break;
739            }
740            let next = max_trade_id.saturating_add(1);
741            if next <= cursor {
742                break;
743            }
744            cursor = next;
745        }
746
747        Ok(out)
748    }
749
750    async fn get_futures_my_trades_page(
751        &self,
752        symbol: &str,
753        limit: usize,
754        from_id: Option<u64>,
755    ) -> Result<Vec<BinanceMyTrade>> {
756        self.check_rate_limit();
757        let limit = limit.clamp(1, 1000);
758        let query = match from_id {
759            Some(v) => format!("symbol={}&limit={}&fromId={}", symbol, limit, v),
760            None => format!("symbol={}&limit={}", symbol, limit),
761        };
762        let signed = self.sign_futures(&query);
763        let url = format!("{}/fapi/v1/userTrades?{}", self.futures_base_url, signed);
764        let resp = self
765            .http
766            .get(&url)
767            .header("X-MBX-APIKEY", &self.futures_api_key)
768            .send()
769            .await
770            .context("get_futures_my_trades HTTP failed")?;
771        if !resp.status().is_success() {
772            let body = resp.text().await.unwrap_or_default();
773            if let Some(err) = Self::parse_binance_api_error(&body) {
774                return Err(AppError::BinanceApi {
775                    code: err.code,
776                    msg: err.msg,
777                }
778                .into());
779            }
780            return Err(anyhow::anyhow!("Futures myTrades request failed: {}", body));
781        }
782        let rows: Vec<BinanceFuturesUserTrade> = resp.json().await?;
783        Ok(rows
784            .into_iter()
785            .map(|t| BinanceMyTrade {
786                symbol: t.symbol,
787                id: t.id,
788                order_id: t.order_id,
789                price: t.price,
790                qty: t.qty,
791                commission: t.commission,
792                commission_asset: t.commission_asset,
793                time: t.time,
794                is_buyer: t.buyer,
795                is_maker: t.maker,
796                realized_pnl: t.realized_pnl,
797            })
798            .collect())
799    }
800
801    pub async fn get_futures_my_trades_history(
802        &self,
803        symbol: &str,
804        max_total: usize,
805    ) -> Result<Vec<BinanceMyTrade>> {
806        let page_size = 1000usize;
807        let target = max_total.max(1);
808        let mut out = Vec::new();
809        let mut cursor: u64 = 0;
810        loop {
811            let page = self
812                .get_futures_my_trades_page(
813                    symbol,
814                    page_size.min(target.saturating_sub(out.len())),
815                    Some(cursor),
816                )
817                .await?;
818            if page.is_empty() {
819                break;
820            }
821            let fetched = page.len();
822            let mut max_trade_id = cursor;
823            for t in page {
824                max_trade_id = max_trade_id.max(t.id);
825                out.push(t);
826                if out.len() >= target {
827                    break;
828                }
829            }
830            if out.len() >= target || fetched < page_size {
831                break;
832            }
833            let next = max_trade_id.saturating_add(1);
834            if next <= cursor {
835                break;
836            }
837            cursor = next;
838        }
839        Ok(out)
840    }
841}
842
843fn parse_symbol_order_rules_from_exchange_info(
844    payload: &serde_json::Value,
845    symbol: &str,
846    prefer_market_lot_size: bool,
847) -> Result<SymbolOrderRules> {
848    let symbols = payload
849        .get("symbols")
850        .and_then(|v| v.as_array())
851        .context("exchangeInfo missing symbols")?;
852    let symbol_row = symbols
853        .iter()
854        .find(|row| row.get("symbol").and_then(|v| v.as_str()) == Some(symbol))
855        .with_context(|| format!("exchangeInfo symbol not found: {}", symbol))?;
856    let filters = symbol_row
857        .get("filters")
858        .and_then(|v| v.as_array())
859        .context("exchangeInfo symbol missing filters")?;
860
861    let primary_type = if prefer_market_lot_size {
862        "MARKET_LOT_SIZE"
863    } else {
864        "LOT_SIZE"
865    };
866    let fallback_type = if prefer_market_lot_size {
867        "LOT_SIZE"
868    } else {
869        "MARKET_LOT_SIZE"
870    };
871    let parsed = find_filter(filters, primary_type)
872        .and_then(parse_lot_filter_values)
873        .or_else(|| find_filter(filters, fallback_type).and_then(parse_lot_filter_values))
874        .context("exchangeInfo missing valid LOT_SIZE/MARKET_LOT_SIZE")?;
875    let (min_qty, max_qty, step_size) = parsed;
876
877    let min_notional = find_filter(filters, "MIN_NOTIONAL")
878        .and_then(|f| f.get("notional").or_else(|| f.get("minNotional")))
879        .and_then(|v| v.as_str())
880        .and_then(|s| s.parse::<f64>().ok());
881
882    Ok(SymbolOrderRules {
883        min_qty,
884        max_qty,
885        step_size,
886        min_notional,
887    })
888}
889
890fn find_filter<'a>(
891    filters: &'a [serde_json::Value],
892    filter_type: &str,
893) -> Option<&'a serde_json::Value> {
894    filters
895        .iter()
896        .find(|f| f.get("filterType").and_then(|v| v.as_str()) == Some(filter_type))
897}
898
899fn parse_lot_filter_values(filter: &serde_json::Value) -> Option<(f64, f64, f64)> {
900    let min_qty = json_str_to_f64(filter, "minQty").ok()?;
901    let max_qty = json_str_to_f64(filter, "maxQty").ok()?;
902    let step_size = json_str_to_f64(filter, "stepSize").ok()?;
903    if step_size <= 0.0 {
904        return None;
905    }
906    Some((min_qty, max_qty, step_size))
907}
908
909fn json_str_to_f64(row: &serde_json::Value, key: &str) -> Result<f64> {
910    let s = row
911        .get(key)
912        .and_then(|v| v.as_str())
913        .with_context(|| format!("missing field {}", key))?;
914    s.parse::<f64>()
915        .with_context(|| format!("invalid {} value {}", key, s))
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921    use serde_json::json;
922
923    #[test]
924    fn hmac_signing_produces_hex_signature() {
925        let client = BinanceRestClient::new(
926            "https://testnet.binance.vision",
927            "https://testnet.binancefuture.com",
928            "test_key",
929            "test_secret",
930            "test_fut_key",
931            "test_fut_secret",
932            5000,
933        );
934        let signed = client.sign("symbol=BTCUSDT&side=BUY");
935        // Should contain original query, recvWindow, timestamp, and signature
936        assert!(signed.contains("symbol=BTCUSDT&side=BUY"));
937        assert!(signed.contains("recvWindow=5000"));
938        assert!(signed.contains("timestamp="));
939        assert!(signed.contains("&signature="));
940
941        // Signature should be 64-char hex (SHA256)
942        let sig = signed.split("&signature=").nth(1).unwrap();
943        assert_eq!(sig.len(), 64);
944        assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
945    }
946
947    #[test]
948    fn hmac_known_vector() {
949        // Binance docs example: queryString with known secret should produce known signature
950        let secret = "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j";
951        let query = "symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000&timestamp=1499827319559";
952
953        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
954        mac.update(query.as_bytes());
955        let signature = hex::encode(mac.finalize().into_bytes());
956
957        assert_eq!(
958            signature,
959            "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71"
960        );
961    }
962
963    #[test]
964    fn check_rate_limit_does_not_panic_on_poisoned_mutex() {
965        let client = BinanceRestClient::new(
966            "https://testnet.binance.vision",
967            "https://testnet.binancefuture.com",
968            "test_key",
969            "test_secret",
970            "test_fut_key",
971            "test_fut_secret",
972            5000,
973        );
974
975        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
976            let _guard = client.window_start.lock().unwrap();
977            panic!("poison window_start mutex");
978        }));
979
980        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
981            client.check_rate_limit();
982        }));
983        assert!(
984            result.is_ok(),
985            "check_rate_limit should recover from poison"
986        );
987    }
988
989    #[test]
990    fn parse_symbol_rules_prefers_market_lot_size_for_spot() {
991        let payload = json!({
992            "symbols": [{
993                "symbol": "BTCUSDT",
994                "filters": [
995                    {"filterType":"LOT_SIZE","minQty":"0.00100000","maxQty":"100.00000000","stepSize":"0.00100000"},
996                    {"filterType":"MARKET_LOT_SIZE","minQty":"0.00001000","maxQty":"50.00000000","stepSize":"0.00001000"},
997                    {"filterType":"MIN_NOTIONAL","minNotional":"5.00000000"}
998                ]
999            }]
1000        });
1001        let rules = parse_symbol_order_rules_from_exchange_info(&payload, "BTCUSDT", true).unwrap();
1002        assert!((rules.step_size - 0.00001).abs() < 1e-12);
1003        assert!((rules.min_qty - 0.00001).abs() < 1e-12);
1004        assert_eq!(rules.min_notional, Some(5.0));
1005    }
1006
1007    #[test]
1008    fn parse_symbol_rules_uses_lot_size_for_futures() {
1009        let payload = json!({
1010            "symbols": [{
1011                "symbol": "ETHUSDT",
1012                "filters": [
1013                    {"filterType":"LOT_SIZE","minQty":"0.001","maxQty":"10000","stepSize":"0.001"},
1014                    {"filterType":"MARKET_LOT_SIZE","minQty":"0.01","maxQty":"1000","stepSize":"0.01"}
1015                ]
1016            }]
1017        });
1018        let rules =
1019            parse_symbol_order_rules_from_exchange_info(&payload, "ETHUSDT", false).unwrap();
1020        assert!((rules.step_size - 0.001).abs() < 1e-12);
1021        assert!((rules.min_qty - 0.001).abs() < 1e-12);
1022    }
1023
1024    #[test]
1025    fn parse_symbol_rules_fallback_when_market_lot_size_is_invalid() {
1026        let payload = json!({
1027            "symbols": [{
1028                "symbol": "BTCUSDT",
1029                "filters": [
1030                    {"filterType":"LOT_SIZE","minQty":"0.00001000","maxQty":"50.00000000","stepSize":"0.00001000"},
1031                    {"filterType":"MARKET_LOT_SIZE","minQty":"0.00001000","maxQty":"50.00000000","stepSize":"0.00000000"}
1032                ]
1033            }]
1034        });
1035        let rules = parse_symbol_order_rules_from_exchange_info(&payload, "BTCUSDT", true).unwrap();
1036        assert!((rules.step_size - 0.00001).abs() < 1e-12);
1037    }
1038}