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