rust-okx 0.6.4

Async Rust client for the OKX v5 REST API
Documentation
use crate::model::{InstType, PositionSide, TradeMode};
use crate::test_util::MockTransport;

use super::super::{
    AccountInstrumentsRequest, AdjustLeverageInfoRequest, AdjustMarginRequest, BalanceRequest,
    FeeRatesRequest, LeverageRequest, MaxAvailableSizeRequest, MaxOrderSizeRequest,
    SetAccountLevelRequest, SetCollateralAssetsRequest, SetGreeksRequest, SetIsolatedModeRequest,
    SetLeverageRequest, SetPositionModeRequest,
};
use super::signed_client;

#[tokio::test]
async fn set_position_mode_posts_body() {
    let body = r#"{"code":"0","msg":"","data":[{"posMode":"net_mode"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());

    let result = client
        .account()
        .set_position_mode(&SetPositionModeRequest::new("net_mode"))
        .await
        .unwrap();
    assert_eq!(result[0].pos_mode, "net_mode");

    let req = mock.captured();
    assert_eq!(req.method, http::Method::POST);
    assert!(req.uri.ends_with("/api/v5/account/set-position-mode"));
    let sent: serde_json::Value = serde_json::from_str(req.body_str()).unwrap();
    assert_eq!(sent["posMode"], "net_mode");
    assert!(req.is_signed());
}

#[tokio::test]
async fn set_collateral_assets_posts_all_body() {
    let body = r#"{"code":"0","msg":"","data":[{"type":"all","ccyList":["BTC","ETH"],"collateralEnabled":true}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());
    let request = SetCollateralAssetsRequest::all(true);

    let result = client
        .account()
        .set_collateral_assets(&request)
        .await
        .unwrap();
    assert_eq!(result[0].collateral_type, "all");
    assert_eq!(result[0].ccy_list, ["BTC", "ETH"]);
    assert!(result[0].collateral_enabled);

    let req = mock.captured();
    assert_eq!(req.method, http::Method::POST);
    assert!(req.uri.ends_with("/api/v5/account/set-collateral-assets"));
    let sent: serde_json::Value = serde_json::from_str(req.body_str()).unwrap();
    assert_eq!(sent["type"], "all");
    assert_eq!(sent["collateralEnabled"], true);
    assert!(sent.get("ccyList").is_none());
    assert!(req.is_signed());
}

#[tokio::test]
async fn set_collateral_assets_posts_custom_body() {
    let body = r#"{"code":"0","msg":"","data":[{"type":"custom","ccyList":["BTC","ETH"],"collateralEnabled":false}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());
    let request = SetCollateralAssetsRequest::custom(["BTC", "ETH"], false);

    client
        .account()
        .set_collateral_assets(&request)
        .await
        .unwrap();

    let req = mock.captured();
    assert_eq!(req.method, http::Method::POST);
    assert!(req.uri.ends_with("/api/v5/account/set-collateral-assets"));
    let sent: serde_json::Value = serde_json::from_str(req.body_str()).unwrap();
    assert_eq!(sent["type"], "custom");
    assert_eq!(sent["collateralEnabled"], false);
    assert_eq!(sent["ccyList"], serde_json::json!(["BTC", "ETH"]));
    assert!(req.is_signed());
}

#[tokio::test]
async fn set_leverage_posts_builder_body() {
    let body = r#"{"code":"0","msg":"","data":[{
        "instId":"BTC-USDT-SWAP","lever":"5","mgnMode":"cross","posSide":"long"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());
    let request = SetLeverageRequest::new("5", TradeMode::Cross)
        .inst_id("BTC-USDT-SWAP")
        .position_side(PositionSide::Long);

    let result = client.account().set_leverage(&request).await.unwrap();
    assert_eq!(result[0].lever.as_str(), "5");

    let req = mock.captured();
    let sent: serde_json::Value = serde_json::from_str(req.body_str()).unwrap();
    assert_eq!(sent["lever"], "5");
    assert_eq!(sent["mgnMode"], "cross");
    assert_eq!(sent["instId"], "BTC-USDT-SWAP");
    assert_eq!(sent["posSide"], "long");
    assert!(req.is_signed());
}

#[tokio::test]
async fn get_leverage_uses_builder_query() {
    let body = r#"{"code":"0","msg":"","data":[{
        "instId":"BTC-USDT-SWAP","lever":"5","mgnMode":"cross","posSide":"long"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());
    let request = LeverageRequest::new(TradeMode::Cross).inst_id("BTC-USDT-SWAP");

    let result = client.account().get_leverage(&request).await.unwrap();
    assert_eq!(result[0].mgn_mode, TradeMode::Cross);

    let req = mock.captured();
    assert_eq!(req.query(), Some("mgnMode=cross&instId=BTC-USDT-SWAP"));
    assert!(req.is_signed());
}

#[tokio::test]
async fn get_adjust_leverage_info_uses_builder_query() {
    let body = r#"{"code":"0","msg":"","data":[{
        "estAvailQuoteTrans":"","estAvailTrans":"1.1398040558348279",
        "estLiqPx":"","estMaxAmt":"10.6095865868904898",
        "estMgn":"0.0701959441651721","estQuoteMgn":"",
        "estQuoteMaxAmt":"176889.6871254563042714","existOrd":false,
        "maxLever":"10","minLever":"0.01"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());
    let request = AdjustLeverageInfoRequest::new(InstType::Margin, TradeMode::Isolated, "5")
        .inst_id("BTC-USDT")
        .currency("BTC")
        .position_side(PositionSide::Net);

    let result = client
        .account()
        .get_adjust_leverage_info(&request)
        .await
        .unwrap();
    assert_eq!(result[0].est_avail_trans.as_str(), "1.1398040558348279");
    assert!(!result[0].exist_ord);
    assert_eq!(result[0].max_lever.as_str(), "10");

    let req = mock.captured();
    assert_eq!(req.method, http::Method::GET);
    assert_eq!(
        req.query(),
        Some("instType=MARGIN&mgnMode=isolated&lever=5&instId=BTC-USDT&ccy=BTC&posSide=net")
    );
    assert!(req.uri.contains("/api/v5/account/adjust-leverage-info?"));
    assert!(req.is_signed());
}

#[tokio::test]
async fn get_max_order_size_uses_builder_query() {
    let body = r#"{"code":"0","msg":"","data":[{
        "instId":"BTC-USDT","ccy":"","maxBuy":"9.6782340237","maxSell":"0.0049"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());
    let request = MaxOrderSizeRequest::new("BTC-USDT", TradeMode::Cash).price("41960.8");

    let result = client.account().get_max_order_size(&request).await.unwrap();
    assert_eq!(result[0].max_buy.as_str(), "9.6782340237");

    let req = mock.captured();
    assert_eq!(req.query(), Some("instId=BTC-USDT&tdMode=cash&px=41960.8"));
    assert!(req.is_signed());
}

#[tokio::test]
async fn get_max_avail_size_uses_builder_query() {
    let body = r#"{"code":"0","msg":"","data":[{
        "instId":"BTC-USDT","availBuy":"186.0717690","availSell":"0.12"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());
    let request = MaxAvailableSizeRequest::new("BTC-USDT", TradeMode::Cash).reduce_only(false);

    let result = client.account().get_max_avail_size(&request).await.unwrap();
    assert_eq!(result[0].avail_sell.as_str(), "0.12");

    let req = mock.captured();
    assert_eq!(
        req.query(),
        Some("instId=BTC-USDT&tdMode=cash&reduceOnly=false")
    );
    assert!(req.is_signed());
}

#[tokio::test]
async fn get_fee_rates_uses_builder_query() {
    let body = r#"{"code":"0","msg":"","data":[{
        "category":"1","delivery":"","exercise":"",
        "feeGroup":[{"elpMaker":"-0.0008","groupId":"1","maker":"-0.0008","taker":"-0.001"}],
        "fiat":[{"ccy":"USD","maker":"0.001","taker":"0.002"}],
        "instType":"SPOT","level":"Lv1","maker":"-0.0008","makerU":"","makerUSDC":"",
        "ruleType":"normal","settle":"","taker":"-0.001","takerU":"","takerUSDC":"",
        "ts":"1763979985847"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());
    let request = FeeRatesRequest::new(InstType::Spot).inst_id("BTC-USDT");

    let result = client.account().get_fee_rates(&request).await.unwrap();
    assert_eq!(result[0].maker.as_str(), "-0.0008");
    assert_eq!(result[0].level, "Lv1");
    assert_eq!(result[0].fee_group[0].group_id, "1");
    assert_eq!(result[0].fee_group[0].elp_maker.as_str(), "-0.0008");
    assert_eq!(result[0].taker.as_str(), "-0.001");

    let req = mock.captured();
    assert_eq!(req.query(), Some("instType=SPOT&instId=BTC-USDT"));
    assert!(req.is_signed());
}

#[tokio::test]
async fn get_fee_rates_can_query_group_id() {
    let body = r#"{"code":"0","msg":"","data":[{
        "category":"1","delivery":"","exercise":"","feeGroup":[],"fiat":[],
        "instType":"SPOT","level":"Lv1","maker":"-0.0008","makerU":"","makerUSDC":"",
        "ruleType":"normal","settle":"","taker":"-0.001","takerU":"","takerUSDC":"",
        "ts":"1763979985847"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());
    let request = FeeRatesRequest::new(InstType::Spot).group_id("1");

    let result = client.account().get_fee_rates(&request).await.unwrap();
    assert_eq!(result[0].rule_type, "normal");

    let req = mock.captured();
    assert_eq!(req.query(), Some("instType=SPOT&groupId=1"));
    assert!(req.is_signed());
}

#[tokio::test]
async fn get_fee_rates_can_query_instrument_family() {
    let body = r#"{"code":"0","msg":"","data":[{
        "category":"1","delivery":"","exercise":"","feeGroup":[],"fiat":[],
        "instType":"SWAP","level":"Lv1","maker":"","makerU":"-0.0002","makerUSDC":"",
        "ruleType":"normal","settle":"","taker":"","takerU":"0.0005","takerUSDC":"",
        "ts":"1763979985847"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());
    let request = FeeRatesRequest::new(InstType::Swap).inst_family("BTC-USD");

    let result = client.account().get_fee_rates(&request).await.unwrap();
    assert_eq!(result[0].inst_type, InstType::Swap);
    assert_eq!(result[0].level, "Lv1");

    let req = mock.captured();
    assert_eq!(req.query(), Some("instType=SWAP&instFamily=BTC-USD"));
    assert!(req.is_signed());
}

#[tokio::test]
async fn get_max_withdrawal_queries_currency() {
    let body = r#"{"code":"0","msg":"","data":[{
        "ccy":"BTC","maxWd":"124.82837647","maxWdEx":"125.7","spotOffsetMaxWd":"","spotOffsetMaxWdEx":""}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());

    let result = client
        .account()
        .get_max_withdrawal(BalanceRequest::new().currency("BTC"))
        .await
        .unwrap();
    assert_eq!(result[0].max_wd.as_str(), "124.82837647");

    let req = mock.captured();
    assert_eq!(req.query(), Some("ccy=BTC"));
    assert!(req.is_signed());
}

#[tokio::test]
async fn adjust_margin_posts_body() {
    let body = r#"{"code":"0","msg":"","data":[{
        "instId":"BTC-USDT-SWAP","posSide":"long","type":"add","amt":"100","cTime":"1597026383085"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());
    let request = AdjustMarginRequest::new("BTC-USDT-SWAP", PositionSide::Long, "add", "100")
        .loan_transfer(true);

    let result = client.account().adjust_margin(&request).await.unwrap();
    assert_eq!(result[0].amt.as_str(), "100");

    let req = mock.captured();
    assert_eq!(req.method, http::Method::POST);
    assert!(req.uri.ends_with("/api/v5/account/position/margin-balance"));
    let sent: serde_json::Value = serde_json::from_str(req.body_str()).unwrap();
    assert_eq!(sent["instId"], "BTC-USDT-SWAP");
    assert_eq!(sent["posSide"], "long");
    assert_eq!(sent["type"], "add");
    assert_eq!(sent["amt"], "100");
    assert_eq!(sent["loanTrans"], true);
    assert!(req.is_signed());
}

#[tokio::test]
async fn get_account_instruments_uses_builder_query() {
    let body = r#"{"code":"0","msg":"","data":[{
        "instType":"SWAP","instId":"BTC-USDT-SWAP","uly":"BTC-USDT","instFamily":"BTC-USDT",
        "baseCcy":"","quoteCcy":"","settleCcy":"USDT","ctVal":"0.01","ctMult":"1",
        "ctValCcy":"BTC","optType":"","stk":"","listTime":"1597026383085","expTime":"",
        "lever":"125","tickSz":"0.1","lotSz":"1","minSz":"1","ctType":"linear","alias":"",
        "state":"live","maxLmtSz":"100","maxMktSz":"100","maxMktIcebergSz":"100",
        "maxTwapSz":"100","maxIcebergSz":"100","maxStopSz":"100","maxTriggerSz":"100",
        "maxLmtAmt":"100","maxMktAmt":"100","ruleType":"","riskLimitType":""}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());
    let request = AccountInstrumentsRequest::new(InstType::Swap).inst_id("BTC-USDT-SWAP");

    let result = client
        .account()
        .get_account_instruments(&request)
        .await
        .unwrap();
    assert_eq!(result[0].settle_ccy, "USDT");

    let req = mock.captured();
    assert_eq!(req.query(), Some("instType=SWAP&instId=BTC-USDT-SWAP"));
    assert!(req.is_signed());
}

#[tokio::test]
async fn set_greeks_posts_body() {
    let body = r#"{"code":"0","msg":"","data":[{"greeksType":"PA"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());

    let result = client
        .account()
        .set_greeks(&SetGreeksRequest::new("PA"))
        .await
        .unwrap();
    assert_eq!(result[0].greeks_type, "PA");

    let req = mock.captured();
    assert_eq!(req.method, http::Method::POST);
    assert!(req.uri.ends_with("/api/v5/account/set-greeks"));
    let sent: serde_json::Value = serde_json::from_str(req.body_str()).unwrap();
    assert_eq!(sent["greeksType"], "PA");
    assert!(req.is_signed());
}

#[tokio::test]
async fn set_isolated_mode_posts_body() {
    let body = r#"{"code":"0","msg":"","data":[{"isoMode":"automatic","type":"MARGIN"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());

    let result = client
        .account()
        .set_isolated_mode(&SetIsolatedModeRequest::new("automatic", "MARGIN"))
        .await
        .unwrap();
    assert_eq!(result[0].iso_mode, "automatic");

    let req = mock.captured();
    assert_eq!(req.method, http::Method::POST);
    assert!(req.uri.ends_with("/api/v5/account/set-isolated-mode"));
    let sent: serde_json::Value = serde_json::from_str(req.body_str()).unwrap();
    assert_eq!(sent["isoMode"], "automatic");
    assert_eq!(sent["type"], "MARGIN");
    assert!(req.is_signed());
}

#[tokio::test]
async fn set_account_level_posts_body() {
    let body = r#"{"code":"0","msg":"","data":[{"acctLv":"2"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());

    let result = client
        .account()
        .set_account_level(&SetAccountLevelRequest::new("2"))
        .await
        .unwrap();
    assert_eq!(result[0].acct_lv, "2");

    let req = mock.captured();
    assert_eq!(req.method, http::Method::POST);
    assert!(req.uri.ends_with("/api/v5/account/set-account-level"));
    let sent: serde_json::Value = serde_json::from_str(req.body_str()).unwrap();
    assert_eq!(sent["acctLv"], "2");
    assert!(req.is_signed());
}

#[tokio::test]
async fn activate_option_posts_empty_body() {
    let body = r#"{"code":"0","msg":"","data":[{"ts":"1600000000000"}]}"#;
    let mock = MockTransport::new(body);
    let client = signed_client(mock.clone());

    let result = client.account().activate_option().await.unwrap();
    assert_eq!(result[0].ts.as_str(), "1600000000000");

    let req = mock.captured();
    assert_eq!(req.method, http::Method::POST);
    assert!(req.uri.ends_with("/api/v5/account/activate-option"));
    let sent: serde_json::Value = serde_json::from_str(req.body_str()).unwrap();
    assert_eq!(sent, serde_json::json!({}));
    assert!(req.is_signed());
}