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 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    /// Fetch historical kline (candlestick) OHLC data.
467    /// Returns `Vec<Candle>` oldest first.
468    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                // Binance kline close time is inclusive end ms; convert to half-open [open, close+1).
520                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    /// Fetch one page of orders for a symbol.
576    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    /// Fetch recent orders for a symbol from `/api/v3/allOrders`.
625    /// `limit` controls max rows returned (1..=1000).
626    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    /// Fetch recent personal trades for a symbol.
749    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    /// Fetch all personal trades from the oldest side (fromId=0), up to `max_total`.
754    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    /// Fetch new personal trades since `from_id` (inclusive), paging forward.
798    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        // Should contain original query, recvWindow, timestamp, and signature
1022        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        // Signature should be 64-char hex (SHA256)
1028        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        // Binance docs example: queryString with known secret should produce known signature
1036        let secret = "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j";
1037        let query = "symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000&timestamp=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}