asterdex-sdk 0.1.4

AsterDex Futures SDK v3 — Rust async client for REST and WebSocket APIs
Documentation
// Integration tests against AsterDex testnet.
//
// Prerequisites:
//   1. Configure `.env` at the repo root (copy from `.env.example`, fill in real credentials).
//   2. Run: `cargo test --test aster_integration -- --include-ignored --test-threads=1`
//
// IMPORTANT: must run with `--test-threads=1`.
// Each test creates its own `RestClient` (and thus its own `Credentials`).
// Parallel tests would produce the same EIP-712 nonce (nonce = unix_seconds * 1_000_000
// counted independently per instance), causing `-4226 Nonce used` rejections.
// Sequential execution ensures monotonically-increasing nonces across all tests.
//
// All tests are marked `#[ignore]` so they don't run in CI by default.
// They perform real network calls against https://fapi.asterdex-testnet.com.

use asterdex_sdk::{
    CancelOrderParams, ModifyOrderParams, PlaceOrderParams, FuturesClient,
};

/// Load `.env` file and build an authenticated `RestClient`.
/// Panics if the `.env` cannot be loaded or credentials are missing.
fn build_client() -> FuturesClient {
    // Allow failure — env vars may already be set in the shell.
    let _ = dotenvy::dotenv();
    FuturesClient::from_env().expect("FuturesClient::from_env() failed — check .env credentials")
}

// ---------------------------------------------------------------------------
// 1. Public connectivity — no credentials needed
// ---------------------------------------------------------------------------

/// Verify the testnet REST endpoint is reachable.
#[tokio::test]
#[ignore]
async fn test_ping_testnet() {
    let _ = dotenvy::dotenv();
    let client = FuturesClient::new_public("https://fapi.asterdex-testnet.com")
        .expect("failed to build public client");
    let resp = client.raw_get("/fapi/v3/ping", &[]).await;
    assert!(resp.is_ok(), "ping failed: {resp:?}");
}

/// Verify server time is within 30 s of local clock.
#[tokio::test]
#[ignore]
async fn test_server_time_within_tolerance() {
    let _ = dotenvy::dotenv();
    let client = FuturesClient::new_public("https://fapi.asterdex-testnet.com")
        .expect("failed to build public client");
    let resp = client.get_server_time().await.expect("get_server_time failed");
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_millis() as i64;
    let diff = (resp.data.server_time as i64 - now_ms).abs();
    assert!(diff < 30_000, "server time offset too large: {diff} ms");
}

// ---------------------------------------------------------------------------
// 2. Place order
// ---------------------------------------------------------------------------

/// Place a BTCUSDT LIMIT BUY order far below market so it rests in the book.
/// Asserts the response contains a valid order_id and status = NEW.
#[tokio::test]
#[ignore]
async fn test_place_limit_order_btcusdt() {
    let client = build_client();
    let params = PlaceOrderParams {
        symbol: "BTCUSDT".to_string(),
        side: "BUY".to_string(),
        order_type: "LIMIT".to_string(),
        quantity: "0.001".to_string(),
        price: Some("70000.0".to_string()),
        time_in_force: Some("GTC".to_string()),
        ..Default::default()
    };
    let resp = client.place_order(params).await;
    assert!(resp.is_ok(), "place_order failed: {resp:?}");
    let order = resp.unwrap();
    assert_ne!(order.data.order_id, 0, "order_id should not be 0");
    assert_eq!(order.data.symbol, "BTCUSDT");
    assert_eq!(order.data.side, "BUY");
    assert_eq!(order.data.status, "NEW", "expected NEW status");

    // Cleanup — cancel so testnet state is clean.
    let _ = client
        .cancel_order(CancelOrderParams {
            symbol: "BTCUSDT".to_string(),
            order_id: Some(order.data.order_id),
            ..Default::default()
        })
        .await;
}

// ---------------------------------------------------------------------------
// 3. Modify order
// ---------------------------------------------------------------------------

/// Place a BTCUSDT LIMIT BUY, then modify its price, then verify and clean up.
#[tokio::test]
#[ignore]
async fn test_modify_order_btcusdt() {
    let client = build_client();

    // Step 1 — place order at $10,000.
    let place_resp = client
        .place_order(PlaceOrderParams {
            symbol: "BTCUSDT".to_string(),
            side: "BUY".to_string(),
            order_type: "LIMIT".to_string(),
            quantity: "0.001".to_string(),
            price: Some("70000.0".to_string()),
            time_in_force: Some("GTC".to_string()),
            ..Default::default()
        })
        .await
        .expect("place_order failed");

    let order_id = place_resp.data.order_id;
    assert_ne!(order_id, 0, "place_order returned order_id 0");

    // Step 2 — modify price to $10,500.
    let modify_resp = client
        .modify_order(ModifyOrderParams {
            symbol: "BTCUSDT".to_string(),
            quantity: Some("0.001".to_string()),
            price: Some("70500.0".to_string()),
            order_id: Some(order_id),
            ..Default::default()
        })
        .await;

    assert!(
        modify_resp.is_ok(),
        "modify_order failed: {modify_resp:?}"
    );
    let modified = modify_resp.unwrap();
    // modify_order is implemented as cancel + re-place, so the replacement order
    // has a *new* order_id.
    assert_ne!(modified.data.order_id, 0);
    assert_eq!(modified.data.symbol, "BTCUSDT");
    assert_eq!(modified.data.status, "NEW");
    // Price should contain the updated value (exchange trims trailing zeros).
    assert!(
        modified.data.price.starts_with("70500"),
        "price was not updated — got: {}",
        modified.data.price
    );

    // Step 3 — cancel the replacement order (cleanup).
    let cancel_resp = client
        .cancel_order(CancelOrderParams {
            symbol: "BTCUSDT".to_string(),
            order_id: Some(modified.data.order_id),
            ..Default::default()
        })
        .await;
    assert!(cancel_resp.is_ok(), "cancel after modify failed: {cancel_resp:?}");
    assert_eq!(cancel_resp.unwrap().data.status, "CANCELED");
}

// ---------------------------------------------------------------------------
// 4. Cancel order
// ---------------------------------------------------------------------------

/// Place an order and cancel it by order_id; verify status = CANCELED.
#[tokio::test]
#[ignore]
async fn test_cancel_order_btcusdt() {
    let client = build_client();

    // Place first.
    let place_resp = client
        .place_order(PlaceOrderParams {
            symbol: "BTCUSDT".to_string(),
            side: "BUY".to_string(),
            order_type: "LIMIT".to_string(),
            quantity: "0.001".to_string(),
            price: Some("70000.0".to_string()),
            time_in_force: Some("GTC".to_string()),
            ..Default::default()
        })
        .await
        .expect("place_order failed");

    let order_id = place_resp.data.order_id;

    // Cancel.
    let cancel_resp = client
        .cancel_order(CancelOrderParams {
            symbol: "BTCUSDT".to_string(),
            order_id: Some(order_id),
            ..Default::default()
        })
        .await;

    assert!(cancel_resp.is_ok(), "cancel_order failed: {cancel_resp:?}");
    let canceled = cancel_resp.unwrap();
    assert_eq!(canceled.data.order_id, order_id);
    assert_eq!(canceled.data.status, "CANCELED");
}

/// Cancel a non-existent order returns ApiError -2011 (unknown order).
#[tokio::test]
#[ignore]
async fn test_cancel_nonexistent_order_returns_api_error() {
    use asterdex_sdk::AsterDexError;
    let client = build_client();
    let result = client
        .cancel_order(CancelOrderParams {
            symbol: "BTCUSDT".to_string(),
            order_id: Some(999_999_999_999),
            ..Default::default()
        })
        .await;
    assert!(
        matches!(result, Err(AsterDexError::ApiError { code: -2011, .. })),
        "expected ApiError -2011, got: {result:?}"
    );
}

// ---------------------------------------------------------------------------
// 5. Full order lifecycle — place → verify open → modify → cancel → verify gone
// ---------------------------------------------------------------------------

#[tokio::test]
#[ignore]
async fn test_full_order_lifecycle_btcusdt() {
    let client = build_client();

    // --- Place ---
    let order = client
        .place_order(PlaceOrderParams {
            symbol: "BTCUSDT".to_string(),
            side: "BUY".to_string(),
            order_type: "LIMIT".to_string(),
            quantity: "0.001".to_string(),
            price: Some("70000.0".to_string()),
            time_in_force: Some("GTC".to_string()),
            ..Default::default()
        })
        .await
        .expect("place_order failed");

    let order_id = order.data.order_id;
    assert_ne!(order_id, 0);
    assert_eq!(order.data.status, "NEW");

    // --- Verify it appears in open orders ---
    let open = client
        .get_open_orders(Some("BTCUSDT"))
        .await
        .expect("get_open_orders failed");
    assert!(
        open.data.iter().any(|o| o.order_id == order_id),
        "newly placed order not found in open orders"
    );

    // --- Modify price (cancel + re-place) ---
    let modified = client
        .modify_order(ModifyOrderParams {
            symbol: "BTCUSDT".to_string(),
            quantity: Some("0.001".to_string()),
            price: Some("10200.0".to_string()),
            order_id: Some(order_id),
            ..Default::default()
        })
        .await
        .expect("modify_order failed");
    // modify_order cancels the original and places a replacement → new order_id.
    let replacement_id = modified.data.order_id;
    assert_ne!(replacement_id, 0);
    assert_eq!(modified.data.status, "NEW");

    // --- Cancel the replacement ---
    let canceled = client
        .cancel_order(CancelOrderParams {
            symbol: "BTCUSDT".to_string(),
            order_id: Some(replacement_id),
            ..Default::default()
        })
        .await
        .expect("cancel_order failed");
    assert_eq!(canceled.data.status, "CANCELED");

    // --- Verify replacement no longer appears in open orders ---
    let open_after = client
        .get_open_orders(Some("BTCUSDT"))
        .await
        .expect("get_open_orders after cancel failed");
    assert!(
        !open_after.data.iter().any(|o| o.order_id == replacement_id),
        "canceled replacement order still visible in open orders"
    );
}