Skip to main content

asterdex_sdk/futures/endpoints/
batch.rs

1// US-009: Batch order endpoints
2
3use serde_json::{Map, Value};
4
5use crate::futures::models::account::{BatchOrderResult, CancelAllResponse};
6use crate::futures::models::trading::PlaceOrderParams;
7use crate::rest::client::RestClient;
8use crate::rest::error::AsterDexError;
9use crate::rest::response::ApiResponse;
10
11const BATCH_ORDER_MAX: usize = 5;
12
13/// Serialize a single order the same way as `place_order` — all values as strings,
14/// booleans as `"true"`/`"false"`. This matches what the exchange expects in the
15/// `batchOrders` JSON array.
16fn order_to_json_obj(params: PlaceOrderParams) -> Map<String, Value> {
17    let mut obj = Map::new();
18    obj.insert("symbol".into(), Value::String(params.symbol));
19    obj.insert("side".into(), Value::String(params.side));
20    obj.insert("type".into(), Value::String(params.order_type));
21    obj.insert("quantity".into(), Value::String(params.quantity));
22    if let Some(p) = params.price {
23        obj.insert("price".into(), Value::String(p));
24    }
25    if let Some(tif) = params.time_in_force {
26        obj.insert("timeInForce".into(), Value::String(tif));
27    }
28    if let Some(b) = params.reduce_only {
29        obj.insert("reduceOnly".into(), Value::String(if b { "true" } else { "false" }.into()));
30    }
31    if let Some(id) = params.new_client_order_id {
32        obj.insert("newClientOrderId".into(), Value::String(id));
33    }
34    if let Some(sp) = params.stop_price {
35        obj.insert("stopPrice".into(), Value::String(sp));
36    }
37    if let Some(ap) = params.activation_price {
38        obj.insert("activationPrice".into(), Value::String(ap));
39    }
40    if let Some(cr) = params.callback_rate {
41        obj.insert("callbackRate".into(), Value::String(cr));
42    }
43    if let Some(wt) = params.working_type {
44        obj.insert("workingType".into(), Value::String(wt));
45    }
46    if let Some(b) = params.price_protect {
47        obj.insert("priceProtect".into(), Value::String(if b { "true" } else { "false" }.into()));
48    }
49    if let Some(ort) = params.new_order_resp_type {
50        obj.insert("newOrderRespType".into(), Value::String(ort));
51    }
52    obj
53}
54
55impl RestClient {
56    /// Place multiple orders (max 5).
57    /// Returns a mixed array — check each item for order_id (success) or code/msg (error).
58    pub async fn place_batch_orders(
59        &self,
60        orders: Vec<PlaceOrderParams>,
61    ) -> Result<ApiResponse<Vec<BatchOrderResult>>, AsterDexError> {
62        if orders.len() > BATCH_ORDER_MAX {
63            return Err(AsterDexError::InvalidParams {
64                message: format!("Too many orders: max {BATCH_ORDER_MAX}, got {}", orders.len()),
65            });
66        }
67        // Build each order as a JSON object with string values (same as place_order URL params)
68        // so booleans like reduceOnly are "true"/"false" strings, not JSON booleans.
69        let arr: Vec<Value> = orders.into_iter().map(|p| Value::Object(order_to_json_obj(p))).collect();
70        let orders_json = serde_json::to_string(&arr).map_err(|e| AsterDexError::SerdeError {
71            message: e.to_string(),
72        })?;
73        tracing::debug!(orders = %orders_json, "place_batch_orders request");
74        let resp = self.signed_post::<serde_json::Value>("/fapi/v3/batchOrders", &[("batchOrders", &orders_json)]).await?;
75        let has_failed = resp.data.as_array()
76            .map(|arr| arr.iter().any(|item| item.get("orderId").is_none()))
77            .unwrap_or(false);
78        if has_failed {
79            tracing::error!(raw = %resp.data, "place_batch_orders raw response (partial failure)");
80        }
81        let results: Vec<BatchOrderResult> = serde_json::from_value(resp.data).map_err(|e| AsterDexError::SerdeError {
82            message: format!("failed to deserialize batch results: {e}"),
83        })?;
84        Ok(ApiResponse {
85            data: results,
86            used_weight: resp.used_weight,
87            order_count: resp.order_count,
88        })
89    }
90
91    /// Cancel multiple orders by ID list.
92    pub async fn cancel_batch_orders(
93        &self,
94        symbol: &str,
95        order_ids: Vec<i64>,
96    ) -> Result<ApiResponse<Vec<BatchOrderResult>>, AsterDexError> {
97        let ids_json = serde_json::to_string(&order_ids).map_err(|e| AsterDexError::SerdeError {
98            message: e.to_string(),
99        })?;
100        self.signed_delete("/fapi/v3/batchOrders", &[
101            ("symbol", symbol),
102            ("orderIdList", &ids_json),
103        ]).await
104    }
105
106    /// Cancel all open orders for a symbol.
107    pub async fn cancel_all_open_orders(
108        &self,
109        symbol: &str,
110    ) -> Result<ApiResponse<CancelAllResponse>, AsterDexError> {
111        self.signed_delete("/fapi/v3/allOpenOrders", &[("symbol", symbol)]).await
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::auth::Credentials;
119
120    // US-009 test 1: place_batch_orders with 6 orders returns InvalidParams before network call
121    // (formerly ApiError -1111; reclassified — this is a client-side validation failure,
122    // not an exchange-originated error)
123    #[tokio::test]
124    async fn place_batch_orders_too_many_returns_invalid_params() {
125        let client = RestClient::new_public("http://localhost:9999").unwrap();
126        let orders: Vec<PlaceOrderParams> = (0..6).map(|_| PlaceOrderParams::default()).collect();
127        let result = client.place_batch_orders(orders).await;
128        assert!(
129            matches!(result, Err(AsterDexError::InvalidParams { .. })),
130            "expected InvalidParams, got: {:?}",
131            result
132        );
133    }
134
135    // US-009 test 2: cancel_all_open_orders with mock returns code == 200
136    #[tokio::test]
137    async fn cancel_all_open_orders_mock() {
138        let mut server = mockito::Server::new_async().await;
139        let _mock = server
140            .mock("DELETE", "/fapi/v3/allOpenOrders")
141            .match_query(mockito::Matcher::Any)
142            .with_status(200)
143            .with_header("content-type", "application/json")
144            .with_body(r#"{"code":200,"msg":"The operation of cancel orders is done."}"#)
145            .create_async()
146            .await;
147
148        let creds = Credentials::new(
149            "0x0000000000000000000000000000000000000001",
150            "0x0000000000000000000000000000000000000002",
151            "0000000000000000000000000000000000000000000000000000000000000001",
152            714,
153        )
154        .unwrap();
155        let client = RestClient::new(&server.url(), creds).unwrap();
156        let resp = client.cancel_all_open_orders("BTCUSDT").await.unwrap();
157        assert_eq!(resp.data.code, 200);
158    }
159}