asterdex-sdk 0.1.5

AsterDex Futures SDK v3 — Rust async client for REST and WebSocket APIs
Documentation
// US-009: Batch order endpoints

use serde_json::{Map, Value};

use crate::futures::models::account::{BatchOrderResult, CancelAllResponse};
use crate::futures::models::trading::PlaceOrderParams;
use crate::rest::client::RestClient;
use crate::rest::error::AsterDexError;
use crate::rest::response::ApiResponse;

const BATCH_ORDER_MAX: usize = 5;

/// Serialize a single order the same way as `place_order` — all values as strings,
/// booleans as `"true"`/`"false"`. This matches what the exchange expects in the
/// `batchOrders` JSON array.
fn order_to_json_obj(params: PlaceOrderParams) -> Map<String, Value> {
    let mut obj = Map::new();
    obj.insert("symbol".into(), Value::String(params.symbol));
    obj.insert("side".into(), Value::String(params.side));
    obj.insert("type".into(), Value::String(params.order_type));
    obj.insert("quantity".into(), Value::String(params.quantity));
    if let Some(p) = params.price {
        obj.insert("price".into(), Value::String(p));
    }
    if let Some(tif) = params.time_in_force {
        obj.insert("timeInForce".into(), Value::String(tif));
    }
    if let Some(b) = params.reduce_only {
        obj.insert("reduceOnly".into(), Value::String(if b { "true" } else { "false" }.into()));
    }
    if let Some(id) = params.new_client_order_id {
        obj.insert("newClientOrderId".into(), Value::String(id));
    }
    if let Some(sp) = params.stop_price {
        obj.insert("stopPrice".into(), Value::String(sp));
    }
    if let Some(ap) = params.activation_price {
        obj.insert("activationPrice".into(), Value::String(ap));
    }
    if let Some(cr) = params.callback_rate {
        obj.insert("callbackRate".into(), Value::String(cr));
    }
    if let Some(wt) = params.working_type {
        obj.insert("workingType".into(), Value::String(wt));
    }
    if let Some(b) = params.price_protect {
        obj.insert("priceProtect".into(), Value::String(if b { "true" } else { "false" }.into()));
    }
    if let Some(ort) = params.new_order_resp_type {
        obj.insert("newOrderRespType".into(), Value::String(ort));
    }
    obj
}

impl RestClient {
    /// Place multiple orders (max 5).
    /// Returns a mixed array — check each item for order_id (success) or code/msg (error).
    pub async fn place_batch_orders(
        &self,
        orders: Vec<PlaceOrderParams>,
    ) -> Result<ApiResponse<Vec<BatchOrderResult>>, AsterDexError> {
        if orders.len() > BATCH_ORDER_MAX {
            return Err(AsterDexError::InvalidParams {
                message: format!("Too many orders: max {BATCH_ORDER_MAX}, got {}", orders.len()),
            });
        }
        // Build each order as a JSON object with string values (same as place_order URL params)
        // so booleans like reduceOnly are "true"/"false" strings, not JSON booleans.
        let arr: Vec<Value> = orders.into_iter().map(|p| Value::Object(order_to_json_obj(p))).collect();
        let orders_json = serde_json::to_string(&arr).map_err(|e| AsterDexError::SerdeError {
            message: e.to_string(),
        })?;
        tracing::debug!(orders = %orders_json, "place_batch_orders request");
        let resp = self.signed_post::<serde_json::Value>("/fapi/v3/batchOrders", &[("batchOrders", &orders_json)]).await?;
        let has_failed = resp.data.as_array()
            .map(|arr| arr.iter().any(|item| item.get("orderId").is_none()))
            .unwrap_or(false);
        if has_failed {
            tracing::error!(raw = %resp.data, "place_batch_orders raw response (partial failure)");
        }
        let results: Vec<BatchOrderResult> = serde_json::from_value(resp.data).map_err(|e| AsterDexError::SerdeError {
            message: format!("failed to deserialize batch results: {e}"),
        })?;
        Ok(ApiResponse {
            data: results,
            used_weight: resp.used_weight,
            order_count: resp.order_count,
        })
    }

    /// Cancel multiple orders by ID list.
    pub async fn cancel_batch_orders(
        &self,
        symbol: &str,
        order_ids: Vec<i64>,
    ) -> Result<ApiResponse<Vec<BatchOrderResult>>, AsterDexError> {
        let ids_json = serde_json::to_string(&order_ids).map_err(|e| AsterDexError::SerdeError {
            message: e.to_string(),
        })?;
        self.signed_delete("/fapi/v3/batchOrders", &[
            ("symbol", symbol),
            ("orderIdList", &ids_json),
        ]).await
    }

    /// Cancel all open orders for a symbol.
    pub async fn cancel_all_open_orders(
        &self,
        symbol: &str,
    ) -> Result<ApiResponse<CancelAllResponse>, AsterDexError> {
        self.signed_delete("/fapi/v3/allOpenOrders", &[("symbol", symbol)]).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::auth::Credentials;

    // US-009 test 1: place_batch_orders with 6 orders returns InvalidParams before network call
    // (formerly ApiError -1111; reclassified — this is a client-side validation failure,
    // not an exchange-originated error)
    #[tokio::test]
    async fn place_batch_orders_too_many_returns_invalid_params() {
        let client = RestClient::new_public("http://localhost:9999").unwrap();
        let orders: Vec<PlaceOrderParams> = (0..6).map(|_| PlaceOrderParams::default()).collect();
        let result = client.place_batch_orders(orders).await;
        assert!(
            matches!(result, Err(AsterDexError::InvalidParams { .. })),
            "expected InvalidParams, got: {:?}",
            result
        );
    }

    // US-009 test 2: cancel_all_open_orders with mock returns code == 200
    #[tokio::test]
    async fn cancel_all_open_orders_mock() {
        let mut server = mockito::Server::new_async().await;
        let _mock = server
            .mock("DELETE", "/fapi/v3/allOpenOrders")
            .match_query(mockito::Matcher::Any)
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"code":200,"msg":"The operation of cancel orders is done."}"#)
            .create_async()
            .await;

        let creds = Credentials::new(
            "0x0000000000000000000000000000000000000001",
            "0x0000000000000000000000000000000000000002",
            "0000000000000000000000000000000000000000000000000000000000000001",
            714,
        )
        .unwrap();
        let client = RestClient::new(&server.url(), creds).unwrap();
        let resp = client.cancel_all_open_orders("BTCUSDT").await.unwrap();
        assert_eq!(resp.data.code, 200);
    }
}