use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration};
use axum::{
Router,
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Json, Response},
routing::{get, post},
};
use chrono::Utc;
use nautilus_bybit::{
common::{
consts::BYBIT_VENUE,
enums::{
BybitAccountType, BybitBboSideType, BybitMarginMode, BybitPositionIdx,
BybitProductType, BybitUnifiedMarginStatus,
},
},
http::{
client::{BybitHttpClient, BybitRawHttpClient},
query::{
BybitFeeRateParams, BybitInstrumentsInfoParamsBuilder, BybitPositionListParamsBuilder,
BybitWalletBalanceParams,
},
},
};
use nautilus_common::testing::wait_until_async;
use nautilus_model::{
data::BarType,
enums::{OrderSide, OrderType, PositionSideSpecified, TimeInForce, TriggerType},
identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol},
instruments::{CurrencyPair, InstrumentAny},
types::{Currency, Price, Quantity},
};
use nautilus_network::http::HttpClient;
use rstest::rstest;
use serde_json::{Value, json};
type SettleCoinQueries = Arc<tokio::sync::Mutex<Vec<(String, Option<String>)>>>;
#[allow(dead_code)]
#[derive(Clone, Debug, Default)]
struct CapturedOrder {
category: String,
symbol: String,
side: String,
order_type: String,
qty: String,
price: Option<String>,
trigger_price: Option<String>,
trigger_direction: Option<String>,
time_in_force: Option<String>,
market_unit: Option<String>,
reduce_only: Option<bool>,
is_leverage: Option<i32>,
order_link_id: Option<String>,
position_idx: Option<i64>,
bbo_side_type: Option<String>,
bbo_level: Option<String>,
}
#[allow(dead_code)]
#[derive(Clone)]
struct TestServerState {
request_count: Arc<tokio::sync::Mutex<usize>>,
settle_coin_queries: SettleCoinQueries,
realtime_requests: Arc<tokio::sync::Mutex<usize>>,
history_requests: Arc<tokio::sync::Mutex<usize>>,
order_submissions: Arc<tokio::sync::Mutex<Vec<CapturedOrder>>>,
}
impl Default for TestServerState {
fn default() -> Self {
Self {
request_count: Arc::new(tokio::sync::Mutex::new(0)),
settle_coin_queries: Arc::new(tokio::sync::Mutex::new(Vec::new())),
realtime_requests: Arc::new(tokio::sync::Mutex::new(0)),
history_requests: Arc::new(tokio::sync::Mutex::new(0)),
order_submissions: Arc::new(tokio::sync::Mutex::new(Vec::new())),
}
}
}
async fn wait_for_server(addr: SocketAddr, path: &str) {
let health_url = format!("http://{addr}{path}");
let http_client =
HttpClient::new(HashMap::new(), Vec::new(), Vec::new(), None, None, None).unwrap();
wait_until_async(
|| {
let url = health_url.clone();
let client = http_client.clone();
async move { client.get(url, None, None, Some(1), None).await.is_ok() }
},
Duration::from_secs(5),
)
.await;
}
#[allow(dead_code)]
fn load_test_data(filename: &str) -> Value {
let path = format!("test_data/{filename}");
let content = std::fs::read_to_string(path).unwrap();
serde_json::from_str(&content).unwrap()
}
#[allow(dead_code)]
async fn handle_get_server_time() -> impl IntoResponse {
Json(json!({
"retCode": 0,
"retMsg": "OK",
"result": {
"timeSecond": "1704470400",
"timeNano": "1704470400123456789"
},
"retExtInfo": {},
"time": 1704470400123i64
}))
}
#[allow(dead_code)]
async fn handle_get_instruments(query: Query<HashMap<String, String>>) -> impl IntoResponse {
let category = query.get("category").map(String::as_str);
let filename = match category {
Some("linear") => "http_get_instruments_linear.json",
Some("spot") => "http_get_instruments_spot.json",
Some("inverse") => "http_get_instruments_inverse.json",
Some("option") => "http_get_instruments_option.json",
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Invalid category parameter",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
};
let instruments = load_test_data(filename);
Json(instruments).into_response()
}
#[allow(dead_code)]
async fn handle_get_klines(query: Query<HashMap<String, String>>) -> impl IntoResponse {
if !query.contains_key("category") || !query.contains_key("symbol") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Missing required parameters",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let klines = load_test_data("http_get_klines_linear.json");
Json(klines).into_response()
}
#[allow(dead_code)]
async fn handle_get_trades(query: Query<HashMap<String, String>>) -> impl IntoResponse {
if !query.contains_key("category") || !query.contains_key("symbol") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Missing required parameters",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let trades = load_test_data("http_get_trades_recent.json");
Json(trades).into_response()
}
#[allow(dead_code)]
async fn handle_get_orders(
State(state): State<TestServerState>,
headers: axum::http::HeaderMap,
) -> Response {
if !headers.contains_key("X-BAPI-API-KEY")
|| !headers.contains_key("X-BAPI-SIGN")
|| !headers.contains_key("X-BAPI-TIMESTAMP")
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let mut count = state.request_count.lock().await;
*count += 1;
if *count > 5 {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"retCode": 10006,
"retMsg": "Too many requests. Please retry after 1 second.",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let orders = load_test_data("http_get_orders_history.json");
Json(orders).into_response()
}
#[allow(dead_code)]
async fn handle_post_order(headers: axum::http::HeaderMap, body: axum::body::Bytes) -> Response {
if !headers.contains_key("X-BAPI-API-KEY")
|| !headers.contains_key("X-BAPI-SIGN")
|| !headers.contains_key("X-BAPI-TIMESTAMP")
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let Ok(order_req): Result<Value, _> = serde_json::from_slice(&body) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Invalid JSON body",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
};
if order_req.get("category").is_none()
|| order_req.get("symbol").is_none()
|| order_req.get("side").is_none()
|| order_req.get("orderType").is_none()
|| order_req.get("qty").is_none()
{
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Missing required order parameters",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
Json(json!({
"retCode": 0,
"retMsg": "OK",
"result": {
"orderId": "test-order-id-12345",
"orderLinkId": order_req.get("orderLinkId").and_then(|v| v.as_str()).unwrap_or("")
},
"retExtInfo": {},
"time": 1704470400123i64
}))
.into_response()
}
#[allow(dead_code)]
async fn handle_post_order_with_capture(
State(state): State<TestServerState>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Response {
if !headers.contains_key("X-BAPI-API-KEY")
|| !headers.contains_key("X-BAPI-SIGN")
|| !headers.contains_key("X-BAPI-TIMESTAMP")
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let Ok(order_req): Result<Value, _> = serde_json::from_slice(&body) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Invalid JSON body",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
};
if order_req.get("category").is_none()
|| order_req.get("symbol").is_none()
|| order_req.get("side").is_none()
|| order_req.get("orderType").is_none()
|| order_req.get("qty").is_none()
{
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Missing required order parameters",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let captured = CapturedOrder {
category: order_req
.get("category")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
symbol: order_req
.get("symbol")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
side: order_req
.get("side")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
order_type: order_req
.get("orderType")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
qty: order_req
.get("qty")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
price: order_req
.get("price")
.and_then(|v| v.as_str())
.map(String::from),
trigger_price: order_req
.get("triggerPrice")
.and_then(|v| v.as_str())
.map(String::from),
trigger_direction: order_req
.get("triggerDirection")
.and_then(|v| v.as_i64())
.map(|v| v.to_string()),
time_in_force: order_req
.get("timeInForce")
.and_then(|v| v.as_str())
.map(String::from),
market_unit: order_req
.get("marketUnit")
.and_then(|v| v.as_str())
.map(String::from),
reduce_only: order_req.get("reduceOnly").and_then(|v| v.as_bool()),
is_leverage: order_req
.get("isLeverage")
.and_then(|v| v.as_i64())
.map(|v| v as i32),
order_link_id: order_req
.get("orderLinkId")
.and_then(|v| v.as_str())
.map(String::from),
position_idx: order_req.get("positionIdx").and_then(|v| v.as_i64()),
bbo_side_type: order_req
.get("bboSideType")
.and_then(|v| v.as_str())
.map(String::from),
bbo_level: order_req
.get("bboLevel")
.and_then(|v| v.as_str())
.map(String::from),
};
{
let mut orders = state.order_submissions.lock().await;
orders.push(captured);
}
Json(json!({
"retCode": 0,
"retMsg": "OK",
"result": {
"orderId": "test-order-id-12345",
"orderLinkId": order_req.get("orderLinkId").and_then(|v| v.as_str()).unwrap_or("")
},
"retExtInfo": {},
"time": 1704470400123i64
}))
.into_response()
}
#[allow(dead_code)]
async fn handle_get_wallet_balance(headers: axum::http::HeaderMap) -> Response {
if !headers.contains_key("X-BAPI-API-KEY")
|| !headers.contains_key("X-BAPI-SIGN")
|| !headers.contains_key("X-BAPI-TIMESTAMP")
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let wallet = load_test_data("http_get_wallet_balance.json");
Json(wallet).into_response()
}
#[allow(dead_code)]
async fn handle_get_account_info(headers: axum::http::HeaderMap) -> Response {
if !headers.contains_key("X-BAPI-API-KEY")
|| !headers.contains_key("X-BAPI-SIGN")
|| !headers.contains_key("X-BAPI-TIMESTAMP")
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let account_info = load_test_data("http_get_account_info.json");
Json(account_info).into_response()
}
#[allow(dead_code)]
async fn handle_cancel_order(headers: axum::http::HeaderMap, body: axum::body::Bytes) -> Response {
if !headers.contains_key("X-BAPI-API-KEY")
|| !headers.contains_key("X-BAPI-SIGN")
|| !headers.contains_key("X-BAPI-TIMESTAMP")
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let Ok(cancel_req): Result<Value, _> = serde_json::from_slice(&body) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Invalid JSON body",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
};
if cancel_req.get("category").is_none() || cancel_req.get("symbol").is_none() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Missing required parameters",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
Json(json!({
"retCode": 0,
"retMsg": "OK",
"result": {
"orderId": "test-canceled-order-id",
"orderLinkId": cancel_req.get("orderLinkId").and_then(|v| v.as_str()).unwrap_or("")
},
"retExtInfo": {},
"time": 1704470400123i64
}))
.into_response()
}
#[allow(dead_code)]
async fn handle_get_positions(headers: axum::http::HeaderMap) -> Response {
if !headers.contains_key("X-BAPI-API-KEY")
|| !headers.contains_key("X-BAPI-SIGN")
|| !headers.contains_key("X-BAPI-TIMESTAMP")
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let positions = load_test_data("http_get_positions.json");
Json(positions).into_response()
}
#[allow(dead_code)]
async fn handle_get_fee_rate(headers: axum::http::HeaderMap) -> Response {
if !headers.contains_key("X-BAPI-API-KEY")
|| !headers.contains_key("X-BAPI-SIGN")
|| !headers.contains_key("X-BAPI-TIMESTAMP")
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let fee_rate = load_test_data("http_get_fee_rate.json");
Json(fee_rate).into_response()
}
#[allow(dead_code)]
async fn handle_no_convert_repay(
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Response {
if !headers.contains_key("X-BAPI-API-KEY")
|| !headers.contains_key("X-BAPI-SIGN")
|| !headers.contains_key("X-BAPI-TIMESTAMP")
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let Ok(repay_req): Result<Value, _> = serde_json::from_slice(&body) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Invalid JSON body",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
};
if repay_req.get("coin").is_none() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Missing required parameter: coin",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
Json(json!({
"retCode": 0,
"retMsg": "OK",
"result": {
"resultStatus": "SU"
},
"retExtInfo": {},
"time": 1704470400123i64
}))
.into_response()
}
#[allow(dead_code)]
async fn handle_get_orders_realtime(
query: Query<HashMap<String, String>>,
State(state): State<TestServerState>,
headers: axum::http::HeaderMap,
) -> Response {
if !headers.contains_key("X-BAPI-API-KEY")
|| !headers.contains_key("X-BAPI-SIGN")
|| !headers.contains_key("X-BAPI-TIMESTAMP")
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
if !query.contains_key("category") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Missing required parameter: category",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let category = query.get("category").map(String::as_str);
let has_symbol = query.contains_key("symbol");
let has_settle_coin = query.contains_key("settleCoin");
if category == Some("linear") && !has_symbol && !has_settle_coin {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Missing some parameters that must be filled in, symbol or settleCoin or baseCoin",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let settle_coin = query.get("settleCoin").cloned();
{
let mut queries = state.settle_coin_queries.lock().await;
queries.push(("realtime".to_string(), settle_coin.clone()));
}
{
let mut count = state.realtime_requests.lock().await;
*count += 1;
}
let mut orders = load_test_data("http_get_orders_realtime.json");
if let Some(coin) = &settle_coin
&& let Some(result) = orders.get_mut("result")
&& let Some(list) = result.get_mut("list")
&& let Some(array) = list.as_array_mut()
{
for order in array.iter_mut() {
if let Some(order_obj) = order.as_object_mut()
&& let Some(order_id) = order_obj.get("orderId")
{
let base_id = order_id.as_str().unwrap_or("");
order_obj.insert(
"orderId".to_string(),
json!(format!("{}-{}", base_id, coin)),
);
}
}
}
if let Some(limit_str) = query.get("limit")
&& let Ok(limit) = limit_str.parse::<usize>()
&& let Some(result) = orders.get_mut("result")
&& let Some(list) = result.get_mut("list")
&& let Some(array) = list.as_array_mut()
{
array.truncate(limit);
}
Json(orders).into_response()
}
#[allow(dead_code)]
async fn handle_get_orders_history_reconciliation(
query: Query<HashMap<String, String>>,
State(state): State<TestServerState>,
headers: axum::http::HeaderMap,
) -> Response {
if !headers.contains_key("X-BAPI-API-KEY")
|| !headers.contains_key("X-BAPI-SIGN")
|| !headers.contains_key("X-BAPI-TIMESTAMP")
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
if !query.contains_key("category") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Missing required parameter: category",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let category = query.get("category").map(String::as_str);
let has_symbol = query.contains_key("symbol");
let has_settle_coin = query.contains_key("settleCoin");
if category == Some("linear") && !has_symbol && !has_settle_coin {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Missing some parameters that must be filled in, symbol or settleCoin or baseCoin",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let settle_coin = query.get("settleCoin").cloned();
{
let mut queries = state.settle_coin_queries.lock().await;
queries.push(("history".to_string(), settle_coin.clone()));
}
{
let mut count = state.history_requests.lock().await;
*count += 1;
}
let mut orders = load_test_data("http_get_orders_history_with_duplicate.json");
if let Some(coin) = &settle_coin
&& let Some(result) = orders.get_mut("result")
&& let Some(list) = result.get_mut("list")
&& let Some(array) = list.as_array_mut()
{
for order in array.iter_mut() {
if let Some(order_obj) = order.as_object_mut()
&& let Some(order_id) = order_obj.get("orderId")
{
let base_id = order_id.as_str().unwrap_or("");
order_obj.insert(
"orderId".to_string(),
json!(format!("{}-{}", base_id, coin)),
);
}
}
}
if let Some(limit_str) = query.get("limit")
&& let Ok(limit) = limit_str.parse::<usize>()
&& let Some(result) = orders.get_mut("result")
&& let Some(list) = result.get_mut("list")
&& let Some(array) = list.as_array_mut()
{
array.truncate(limit);
}
Json(orders).into_response()
}
#[allow(dead_code)]
fn create_test_router(state: TestServerState) -> Router {
Router::new()
.route("/v5/market/time", get(handle_get_server_time))
.route("/v5/market/instruments-info", get(handle_get_instruments))
.route("/v5/market/kline", get(handle_get_klines))
.route("/v5/market/recent-trade", get(handle_get_trades))
.route("/v5/order/history", get(handle_get_orders))
.route("/v5/order/realtime", get(handle_get_orders))
.route("/v5/order/create", post(handle_post_order))
.route("/v5/order/cancel", post(handle_cancel_order))
.route("/v5/account/wallet-balance", get(handle_get_wallet_balance))
.route("/v5/account/info", get(handle_get_account_info))
.route("/v5/position/list", get(handle_get_positions))
.route("/v5/account/fee-rate", get(handle_get_fee_rate))
.route(
"/v5/account/no-convert-repay",
post(handle_no_convert_repay),
)
.with_state(state)
}
#[allow(dead_code)]
async fn start_test_server()
-> Result<(SocketAddr, TestServerState), Box<dyn std::error::Error + Send + Sync>> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let state = TestServerState::default();
let router = create_test_router(state.clone());
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
wait_for_server(addr, "/v5/market/time").await;
Ok((addr, state))
}
#[rstest]
#[tokio::test]
async fn test_client_creation() {
let client = BybitHttpClient::new(None, 60, 3, 1000, 10_000, 5_000, None).unwrap();
assert!(client.base_url().contains("bybit.com"));
assert!(client.credential().is_none());
}
#[rstest]
#[tokio::test]
async fn test_client_with_credentials() {
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some("https://api.bybit.com".to_string()),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
assert!(client.credential().is_some());
}
#[rstest]
#[tokio::test]
async fn test_testnet_urls() {
let client = BybitHttpClient::new(
Some("https://api-testnet.bybit.com".to_string()),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
assert!(client.base_url().contains("testnet"));
}
#[rstest]
#[tokio::test]
async fn test_custom_base_url() {
let custom_url = "https://custom.bybit.com";
let client = BybitHttpClient::new(
Some(custom_url.to_string()),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
assert_eq!(client.base_url(), custom_url);
}
#[rstest]
#[tokio::test]
async fn test_get_server_time() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::new(Some(base_url), 60, 3, 1000, 10_000, 5_000, None).unwrap();
let response = client.get_server_time().await.unwrap();
assert!(!response.result.time_second.is_empty());
assert!(!response.result.time_nano.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_get_instruments_linear() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::new(Some(base_url), 60, 3, 1000, 10_000, 5_000, None).unwrap();
let params = BybitInstrumentsInfoParamsBuilder::default()
.category(BybitProductType::Linear)
.build()
.unwrap();
let response = client.get_instruments_linear(¶ms).await.unwrap();
assert!(!response.result.list.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_get_instruments_spot() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::new(Some(base_url), 60, 3, 1000, 10_000, 5_000, None).unwrap();
let params = BybitInstrumentsInfoParamsBuilder::default()
.category(BybitProductType::Spot)
.build()
.unwrap();
let response = client.get_instruments_spot(¶ms).await.unwrap();
assert!(!response.result.list.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_get_instruments_inverse() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::new(Some(base_url), 60, 3, 1000, 10_000, 5_000, None).unwrap();
let params = BybitInstrumentsInfoParamsBuilder::default()
.category(BybitProductType::Inverse)
.build()
.unwrap();
let response = client.get_instruments_inverse(¶ms).await.unwrap();
assert!(!response.result.list.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_get_instruments_option() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::new(Some(base_url), 60, 3, 1000, 10_000, 5_000, None).unwrap();
let params = BybitInstrumentsInfoParamsBuilder::default()
.category(BybitProductType::Option)
.build()
.unwrap();
let response = client.get_instruments_option(¶ms).await.unwrap();
assert!(!response.result.list.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_place_order() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let order_request = serde_json::json!({
"category": "linear",
"symbol": "BTCUSDT",
"side": "Buy",
"orderType": "Limit",
"qty": "0.001",
"price": "50000",
"orderLinkId": "test-order-123"
});
let response = client.place_order(&order_request).await.unwrap();
assert_eq!(response.ret_code, 0);
assert!(response.result.order_id.is_some());
}
#[rstest]
#[tokio::test]
async fn test_authenticated_endpoint_requires_credentials() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::new(Some(base_url), 60, 3, 1000, 10_000, 5_000, None).unwrap();
let result = client
.get_open_orders(
BybitProductType::Linear,
Some("BTCUSDT".to_owned()),
None,
None,
None,
None,
None,
None,
None,
None,
)
.await;
result.unwrap_err();
}
#[rstest]
#[tokio::test]
async fn test_rate_limiting_returns_error() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let mut last_error = None;
for _ in 0..10 {
match client
.get_open_orders(
BybitProductType::Linear,
Some("BTCUSDT".to_owned()),
None,
None,
None,
None,
None,
None,
None,
None,
)
.await
{
Ok(_) => {}
Err(e) => {
last_error = Some(e);
break;
}
}
}
assert!(last_error.is_some());
let error = last_error.unwrap();
assert!(error.to_string().contains("10006") || error.to_string().contains("Too many"));
}
#[rstest]
#[tokio::test]
async fn test_get_open_orders_with_symbol() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let response = client
.get_open_orders(
BybitProductType::Linear,
Some("BTCUSDT".to_owned()),
None,
None,
None,
None,
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(response.ret_code, 0);
assert!(response.result.list.is_empty() || !response.result.list.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_get_open_orders_without_symbol() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let response = client
.get_open_orders(
BybitProductType::Linear,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(response.ret_code, 0);
}
#[rstest]
#[tokio::test]
async fn test_get_wallet_balance_requires_credentials() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::new(Some(base_url), 60, 3, 1000, 10_000, 5_000, None).unwrap();
let params = BybitWalletBalanceParams {
account_type: BybitAccountType::Unified,
coin: None,
};
let result = client.get_wallet_balance(¶ms).await;
result.unwrap_err();
}
#[rstest]
#[tokio::test]
async fn test_get_wallet_balance_with_credentials() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let params = BybitWalletBalanceParams {
account_type: BybitAccountType::Unified,
coin: None,
};
let response = client.get_wallet_balance(¶ms).await.unwrap();
assert_eq!(response.ret_code, 0);
assert!(!response.result.list.is_empty());
assert_eq!(
response.result.list[0].account_type,
BybitAccountType::Unified
);
}
#[rstest]
#[tokio::test]
async fn test_get_positions_requires_credentials() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::new(Some(base_url), 60, 3, 1000, 10_000, 5_000, None).unwrap();
let params = BybitPositionListParamsBuilder::default()
.category(BybitProductType::Linear)
.build()
.unwrap();
let result = client.get_positions(¶ms).await;
result.unwrap_err();
}
#[rstest]
#[tokio::test]
async fn test_get_positions_with_credentials() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let params = BybitPositionListParamsBuilder::default()
.category(BybitProductType::Linear)
.build()
.unwrap();
let response = client.get_positions(¶ms).await.unwrap();
assert_eq!(response.ret_code, 0);
}
#[rstest]
#[tokio::test]
async fn test_get_fee_rate_requires_credentials() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::new(Some(base_url), 60, 3, 1000, 10_000, 5_000, None).unwrap();
let params = BybitFeeRateParams {
category: BybitProductType::Linear,
symbol: Some("BTCUSDT".to_string()),
base_coin: None,
};
let result = client.get_fee_rate(¶ms).await;
result.unwrap_err();
}
#[rstest]
#[tokio::test]
async fn test_get_fee_rate_with_credentials() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let params = BybitFeeRateParams {
category: BybitProductType::Linear,
symbol: Some("BTCUSDT".to_string()),
base_coin: None,
};
let response = client.get_fee_rate(¶ms).await.unwrap();
assert_eq!(response.ret_code, 0);
assert!(!response.result.list.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_get_account_info_requires_credentials() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::new(Some(base_url), 60, 3, 1000, 10_000, 5_000, None).unwrap();
let result = client.get_account_info().await;
result.unwrap_err();
}
#[rstest]
#[tokio::test]
async fn test_get_account_info_with_credentials() {
use nautilus_bybit::http::models::BybitAccountInfoResponse;
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let response: BybitAccountInfoResponse = client.get_account_info().await.unwrap();
assert_eq!(response.ret_code, 0);
assert_eq!(response.result.margin_mode, BybitMarginMode::RegularMargin);
assert_eq!(
response.result.unified_margin_status,
BybitUnifiedMarginStatus::UnifiedTradingAccount10Pro
);
assert!(!response.result.is_master_trader);
assert!(!response.result.spot_hedging_status);
}
#[allow(dead_code)]
fn create_reconciliation_test_router(state: TestServerState) -> Router {
Router::new()
.route("/v5/market/time", get(handle_get_server_time))
.route("/v5/market/instruments-info", get(handle_get_instruments))
.route("/v5/account/fee-rate", get(handle_get_fee_rate))
.route("/v5/order/realtime", get(handle_get_orders_realtime))
.route(
"/v5/order/history",
get(handle_get_orders_history_reconciliation),
)
.with_state(state)
}
#[allow(dead_code)]
async fn start_reconciliation_test_server()
-> Result<(SocketAddr, TestServerState), Box<dyn std::error::Error + Send + Sync>> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let state = TestServerState::default();
let router = create_reconciliation_test_router(state.clone());
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
wait_for_server(addr, "/v5/market/time").await;
Ok((addr, state))
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_reports_calls_both_endpoints() {
let (addr, _state) = start_reconciliation_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let reports = client
.request_order_status_reports(
account_id,
BybitProductType::Linear,
None, false, None, None, Some(3), )
.await
.unwrap();
let order_ids: Vec<String> = reports
.iter()
.map(|r| r.venue_order_id.to_string())
.collect();
assert_eq!(
reports.len(),
3,
"Should have 3 orders total (respecting limit)"
);
assert!(order_ids.contains(&"open-order-1-USDT".to_string()));
assert!(order_ids.contains(&"open-order-2-USDT".to_string()));
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_reports_requires_settle_coin_for_linear() {
let (addr, _state) = start_reconciliation_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let result = client
.request_order_status_reports(
account_id,
BybitProductType::Linear,
None, true, None, None, None, )
.await;
assert!(result.is_ok(), "Should succeed with automatic settleCoin");
}
#[rstest]
#[tokio::test]
async fn test_order_deduplication_by_order_id() {
let (addr, _state) = start_reconciliation_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let instrument_id = InstrumentId::new(Symbol::from("ETHUSDT-LINEAR"), *BYBIT_VENUE);
let reports = client
.request_order_status_reports(
account_id,
BybitProductType::Linear,
Some(instrument_id), false, None, None, None, )
.await
.unwrap();
let open_order_1_count = reports
.iter()
.filter(|r| r.venue_order_id.to_string() == "open-order-1")
.count();
assert_eq!(
open_order_1_count, 1,
"open-order-1 should appear exactly once (deduplicated across realtime/history)"
);
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_reports_linear_queries_all_settle_coins() {
let (addr, state) = start_reconciliation_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let _reports = client
.request_order_status_reports(
account_id,
BybitProductType::Linear,
None,
true,
None,
None,
None,
)
.await
.unwrap();
let queries = state.settle_coin_queries.lock().await;
let realtime_queries: Vec<&Option<String>> = queries
.iter()
.filter(|(endpoint, _)| endpoint == "realtime")
.map(|(_, coin)| coin)
.collect();
assert_eq!(
realtime_queries.len(),
4,
"Should query realtime endpoint for each settle coin and order filter"
);
assert!(
realtime_queries.contains(&&Some("USDT".to_string())),
"Should query USDT settle coin"
);
assert!(
realtime_queries.contains(&&Some("USDC".to_string())),
"Should query USDC settle coin"
);
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_reports_respects_limit_across_settle_coins() {
let (addr, state) = start_reconciliation_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let reports = client
.request_order_status_reports(
account_id,
BybitProductType::Linear,
None,
true,
None,
None,
Some(3),
)
.await
.unwrap();
assert!(
reports.len() <= 3,
"Should return at most 3 reports, was {}",
reports.len()
);
let queries = state.settle_coin_queries.lock().await;
let realtime_query_count = queries
.iter()
.filter(|(endpoint, _)| endpoint == "realtime")
.count();
assert!(
realtime_query_count >= 2,
"Should query both settle coins, was {realtime_query_count}",
);
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_reports_stops_before_next_coin() {
let (addr, state) = start_reconciliation_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let reports = client
.request_order_status_reports(
account_id,
BybitProductType::Linear,
None,
true,
None,
None,
Some(1),
)
.await
.unwrap();
assert_eq!(reports.len(), 1, "Should return exactly 1 report");
let queries = state.settle_coin_queries.lock().await;
let realtime_queries: Vec<&Option<String>> = queries
.iter()
.filter(|(endpoint, _)| endpoint == "realtime")
.map(|(_, coin)| coin)
.collect();
assert_eq!(
realtime_queries.len(),
1,
"Should only query first settle coin when limit reached"
);
assert_eq!(
realtime_queries[0],
&Some("USDT".to_string()),
"Should query USDT first"
);
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_reports_combines_orders_from_each_settle_coin() {
let (addr, state) = start_reconciliation_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let reports = client
.request_order_status_reports(
account_id,
BybitProductType::Linear,
None,
true,
None,
None,
None,
)
.await
.unwrap();
let queries = state.settle_coin_queries.lock().await;
let realtime_queries: Vec<&Option<String>> = queries
.iter()
.filter(|(endpoint, _)| endpoint == "realtime")
.map(|(_, coin)| coin)
.collect();
assert_eq!(
realtime_queries.len(),
4,
"Should query both USDT and USDC with both order filters"
);
assert!(
realtime_queries.contains(&&Some("USDT".to_string())),
"Should query USDT"
);
assert!(
realtime_queries.contains(&&Some("USDC".to_string())),
"Should query USDC"
);
let order_ids: Vec<String> = reports
.iter()
.map(|r| r.venue_order_id.to_string())
.collect();
assert_eq!(
reports.len(),
4,
"Should get exactly 4 orders (2 from USDT + 2 from USDC), was {}",
reports.len()
);
assert!(
order_ids.contains(&"open-order-1-USDT".to_string()),
"Should contain open-order-1-USDT from USDT settle coin"
);
assert!(
order_ids.contains(&"open-order-2-USDT".to_string()),
"Should contain open-order-2-USDT from USDT settle coin"
);
assert!(
order_ids.contains(&"open-order-1-USDC".to_string()),
"Should contain open-order-1-USDC from USDC settle coin"
);
assert!(
order_ids.contains(&"open-order-2-USDC".to_string()),
"Should contain open-order-2-USDC from USDC settle coin"
);
}
#[rstest]
#[tokio::test]
async fn test_repay_spot_borrow_with_amount() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let amount = Quantity::new_checked(0.5, 8).unwrap();
let response = client.repay_spot_borrow("ETH", Some(amount)).await.unwrap();
assert_eq!(response.ret_code, 0);
assert_eq!(response.ret_msg, "OK");
assert_eq!(response.result.result_status, "SU");
}
#[rstest]
#[tokio::test]
async fn test_repay_spot_borrow_without_amount() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let response = client.repay_spot_borrow("ETH", None).await.unwrap();
assert_eq!(response.ret_code, 0);
assert_eq!(response.ret_msg, "OK");
assert_eq!(response.result.result_status, "SU");
}
#[rstest]
#[tokio::test]
async fn test_repay_spot_borrow_requires_credentials() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::new(Some(base_url), 60, 3, 1000, 10_000, 5_000, None).unwrap();
let amount = Quantity::new_checked(0.5, 8).unwrap();
let result = client.repay_spot_borrow("ETH", Some(amount)).await;
assert!(result.is_err(), "Should fail without credentials");
}
#[rstest]
#[tokio::test]
async fn test_get_spot_borrow_amount_returns_zero_when_no_borrow() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let borrow_amount = client.get_spot_borrow_amount("BTC").await.unwrap();
assert_eq!(borrow_amount, rust_decimal::Decimal::ZERO);
}
#[rstest]
#[tokio::test]
async fn test_get_spot_borrow_amount_returns_zero_when_coin_not_found() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let borrow_amount = client.get_spot_borrow_amount("UNKNOWN").await.unwrap();
assert_eq!(borrow_amount, rust_decimal::Decimal::ZERO);
}
#[rstest]
#[tokio::test]
async fn test_spot_position_report_short_from_borrowed_balance() {
let (addr, _state) = start_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
client.set_use_spot_position_reports(true);
let eth = Currency::from("ETH");
let usdt = Currency::from("USDT");
let ethusdt = CurrencyPair::new(
"ETHUSDT-SPOT.BYBIT".into(),
"ETHUSDT".into(),
eth,
usdt,
2,
5,
Price::from("0.01"),
Quantity::from("0.00001"),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
0.into(),
0.into(),
);
client.cache_instrument(InstrumentAny::CurrencyPair(ethusdt));
let account_id = AccountId::new("BYBIT-UNIFIED");
let reports = client
.request_position_status_reports(account_id, BybitProductType::Spot, None)
.await
.unwrap();
let eth_report = reports
.iter()
.find(|r| r.instrument_id.symbol.as_str() == "ETHUSDT-SPOT")
.expect("ETH SPOT position report not found");
assert_eq!(eth_report.position_side, PositionSideSpecified::Short);
assert_eq!(eth_report.quantity, Quantity::new(0.06142, 5));
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_reports_with_time_filtering() {
let (addr, state) = start_reconciliation_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let start_time = Utc::now() - chrono::Duration::days(7);
let end_time = Utc::now();
let _reports = client
.request_order_status_reports(
account_id,
BybitProductType::Linear,
None,
false, Some(start_time),
Some(end_time),
Some(10),
)
.await
.unwrap();
let queries = state.settle_coin_queries.lock().await;
assert!(
queries.len() >= 2,
"Should have called history endpoint at least twice (one per settle coin)"
);
}
#[tokio::test]
#[ignore] async fn test_request_tickers_spot_live() {
use nautilus_bybit::http::query::BybitTickersParamsBuilder;
let client = BybitHttpClient::new(None, 60, 3, 1000, 10_000, 5_000, None).unwrap();
let params = BybitTickersParamsBuilder::default()
.category(BybitProductType::Spot)
.build()
.unwrap();
let tickers = client.request_tickers(¶ms).await.unwrap();
assert!(!tickers.is_empty(), "Should receive at least one ticker");
for ticker in tickers.iter().take(5) {
assert!(!ticker.symbol.is_empty(), "Symbol should not be empty");
assert!(
!ticker.last_price.is_empty(),
"Last price should not be empty"
);
assert!(
!ticker.bid1_price.is_empty(),
"Bid price should not be empty"
);
assert!(
!ticker.ask1_price.is_empty(),
"Ask price should not be empty"
);
assert!(
!ticker.volume24h.is_empty(),
"Volume 24h should not be empty"
);
assert!(
!ticker.turnover24h.is_empty(),
"Turnover 24h should not be empty"
);
assert!(
ticker.open_interest.is_none(),
"Spot ticker should not have open_interest"
);
assert!(
ticker.funding_rate.is_none(),
"Spot ticker should not have funding_rate"
);
assert!(
ticker.next_funding_time.is_none(),
"Spot ticker should not have next_funding_time"
);
assert!(
ticker.mark_price.is_none(),
"Spot ticker should not have mark_price"
);
assert!(
ticker.index_price.is_none(),
"Spot ticker should not have index_price"
);
}
println!("[SUCCESS] Fetched {} spot tickers", tickers.len());
}
#[tokio::test]
#[ignore] async fn test_request_tickers_linear_live() {
use nautilus_bybit::http::query::BybitTickersParamsBuilder;
let client = BybitHttpClient::new(None, 60, 3, 1000, 10_000, 5_000, None).unwrap();
let params = BybitTickersParamsBuilder::default()
.category(BybitProductType::Linear)
.build()
.unwrap();
let tickers = client.request_tickers(¶ms).await.unwrap();
assert!(
!tickers.is_empty(),
"Should receive at least one linear ticker"
);
for ticker in tickers.iter().take(5) {
assert!(!ticker.symbol.is_empty(), "Symbol should not be empty");
assert!(
!ticker.last_price.is_empty(),
"Last price should not be empty"
);
assert!(
!ticker.bid1_price.is_empty(),
"Bid price should not be empty"
);
assert!(
!ticker.ask1_price.is_empty(),
"Ask price should not be empty"
);
assert!(
!ticker.volume24h.is_empty(),
"Volume 24h should not be empty"
);
assert!(
!ticker.turnover24h.is_empty(),
"Turnover 24h should not be empty"
);
assert!(
ticker.open_interest.is_some(),
"Linear ticker should have open_interest"
);
assert!(
ticker.funding_rate.is_some(),
"Linear ticker should have funding_rate"
);
assert!(
ticker.next_funding_time.is_some(),
"Linear ticker should have next_funding_time"
);
assert!(
ticker.mark_price.is_some(),
"Linear ticker should have mark_price"
);
assert!(
ticker.index_price.is_some(),
"Linear ticker should have index_price"
);
let open_interest = ticker.open_interest.as_ref().unwrap();
assert!(
!open_interest.is_empty(),
"Open interest should not be empty"
);
let funding_rate = ticker.funding_rate.as_ref().unwrap();
assert!(!funding_rate.is_empty(), "Funding rate should not be empty");
let next_funding_time = ticker.next_funding_time.as_ref().unwrap();
assert!(
!next_funding_time.is_empty(),
"Next funding time should not be empty"
);
let mark_price = ticker.mark_price.as_ref().unwrap();
assert!(!mark_price.is_empty(), "Mark price should not be empty");
let index_price = ticker.index_price.as_ref().unwrap();
assert!(!index_price.is_empty(), "Index price should not be empty");
}
println!("[SUCCESS] Fetched {} linear tickers", tickers.len());
}
#[tokio::test]
#[ignore] async fn test_request_tickers_inverse_live() {
use nautilus_bybit::http::query::BybitTickersParamsBuilder;
let client = BybitHttpClient::new(None, 60, 3, 1000, 10_000, 5_000, None).unwrap();
let params = BybitTickersParamsBuilder::default()
.category(BybitProductType::Inverse)
.build()
.unwrap();
let tickers = client.request_tickers(¶ms).await.unwrap();
assert!(
!tickers.is_empty(),
"Should receive at least one inverse ticker"
);
for ticker in tickers.iter().take(5) {
assert!(!ticker.symbol.is_empty(), "Symbol should not be empty");
assert!(
!ticker.last_price.is_empty(),
"Last price should not be empty"
);
assert!(
ticker.open_interest.is_some(),
"Inverse ticker should have open_interest"
);
assert!(
ticker.funding_rate.is_some(),
"Inverse ticker should have funding_rate"
);
assert!(
ticker.mark_price.is_some(),
"Inverse ticker should have mark_price"
);
assert!(
ticker.index_price.is_some(),
"Inverse ticker should have index_price"
);
}
println!("[SUCCESS] Fetched {} inverse tickers", tickers.len());
}
#[tokio::test]
#[ignore] async fn test_request_tickers_with_symbol_filter() {
use nautilus_bybit::http::query::BybitTickersParamsBuilder;
let client = BybitHttpClient::new(None, 60, 3, 1000, 10_000, 5_000, None).unwrap();
let params = BybitTickersParamsBuilder::default()
.category(BybitProductType::Linear)
.symbol("BTCUSDT".to_string())
.build()
.unwrap();
let tickers = client.request_tickers(¶ms).await.unwrap();
assert_eq!(tickers.len(), 1, "Should receive exactly one ticker");
assert_eq!(
tickers[0].symbol.as_str(),
"BTCUSDT",
"Symbol should be BTCUSDT"
);
let ticker = &tickers[0];
assert!(ticker.open_interest.is_some());
assert!(ticker.funding_rate.is_some());
assert!(ticker.next_funding_time.is_some());
assert!(ticker.mark_price.is_some());
assert!(ticker.index_price.is_some());
println!("[SUCCESS] Fetched ticker for BTCUSDT with all expected fields");
}
async fn handle_get_klines_partial_first_page(
query: Query<HashMap<String, String>>,
State(state): State<TestServerState>,
) -> impl IntoResponse {
if !query.contains_key("category") || !query.contains_key("symbol") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"retCode": 10001,
"retMsg": "Missing required parameters",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let mut count = state.request_count.lock().await;
*count += 1;
let page = *count;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let bar_duration_ms = 60_000i64;
let partial_bar_start = (now_ms / bar_duration_ms) * bar_duration_ms;
if page == 1 {
Json(json!({
"retCode": 0,
"retMsg": "OK",
"result": {
"category": "linear",
"symbol": "BTCUSDT",
"list": [
[partial_bar_start.to_string(), "100000", "100100", "99900", "100050", "1000", "100000000"]
]
},
"retExtInfo": {},
"time": now_ms
}))
.into_response()
} else {
let closed_bar_2_start = partial_bar_start - 2 * bar_duration_ms;
let closed_bar_1_start = partial_bar_start - 3 * bar_duration_ms;
Json(json!({
"retCode": 0,
"retMsg": "OK",
"result": {
"category": "linear",
"symbol": "BTCUSDT",
"list": [
[closed_bar_2_start.to_string(), "99800", "99900", "99700", "99850", "600", "60000000"],
[closed_bar_1_start.to_string(), "99700", "99800", "99600", "99750", "500", "50000000"]
]
},
"retExtInfo": {},
"time": now_ms
}))
.into_response()
}
}
fn create_partial_first_page_test_router(state: TestServerState) -> Router {
Router::new()
.route("/v5/market/time", get(handle_get_server_time))
.route("/v5/market/instruments-info", get(handle_get_instruments))
.route(
"/v5/market/kline",
get(handle_get_klines_partial_first_page),
)
.with_state(state)
}
async fn start_partial_first_page_test_server()
-> Result<(SocketAddr, TestServerState), Box<dyn std::error::Error + Send + Sync>> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let state = TestServerState::default();
let router = create_partial_first_page_test_router(state.clone());
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
wait_for_server(addr, "/v5/market/time").await;
Ok((addr, state))
}
#[rstest]
#[tokio::test]
async fn test_request_bars_continues_pagination_when_first_page_only_partial() {
let (addr, state) = start_partial_first_page_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::new(Some(base_url), 60, 3, 1000, 10_000, 5_000, None).unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let bar_type = BarType::from("BTCUSDT-LINEAR.BYBIT-1-MINUTE-LAST-EXTERNAL");
let bars = client
.request_bars(BybitProductType::Linear, bar_type, None, None, None, true)
.await
.unwrap();
assert_eq!(
bars.len(),
2,
"Should continue pagination and return closed bars from second page"
);
let request_count = *state.request_count.lock().await;
assert!(
request_count >= 2,
"Should have made at least 2 requests to paginate past partial bars"
);
}
#[allow(dead_code)]
fn create_order_capture_test_router(state: TestServerState) -> Router {
Router::new()
.route("/v5/market/time", get(handle_get_server_time))
.route("/v5/market/instruments-info", get(handle_get_instruments))
.route("/v5/order/create", post(handle_post_order_with_capture))
.route("/v5/order/realtime", get(handle_get_orders))
.route("/v5/account/fee-rate", get(handle_get_fee_rate))
.with_state(state)
}
async fn start_order_capture_test_server()
-> Result<(SocketAddr, TestServerState), Box<dyn std::error::Error + Send + Sync>> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let state = TestServerState::default();
let router = create_order_capture_test_router(state.clone());
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
wait_for_server(addr, "/v5/market/time").await;
Ok((addr, state))
}
#[rstest]
#[tokio::test]
async fn test_submit_order_stop_market_with_trigger_price() {
let (addr, state) = start_order_capture_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let instrument_id = InstrumentId::new(Symbol::from("BTCUSDT-LINEAR"), *BYBIT_VENUE);
let client_order_id = ClientOrderId::from("stop-market-test-1");
let quantity = Quantity::new(0.001, 3);
let trigger_price = Price::new(100_000.0, 2);
let result = client
.submit_order(
account_id,
BybitProductType::Linear,
instrument_id,
client_order_id,
OrderSide::Buy,
OrderType::StopMarket,
quantity,
None, None, Some(trigger_price),
None, false, false, false, None, None, None, )
.await;
assert!(result.is_ok(), "Order submission should succeed");
let orders = state.order_submissions.lock().await;
assert_eq!(orders.len(), 1, "Should have captured one order");
let order = &orders[0];
assert_eq!(order.category, "linear");
assert_eq!(order.symbol, "BTCUSDT");
assert_eq!(order.side, "Buy");
assert_eq!(order.order_type, "Market");
assert_eq!(
order.trigger_price.as_deref(),
Some("100000.00"),
"Should have trigger price"
);
assert_eq!(
order.trigger_direction.as_deref(),
Some("1"),
"Buy stop should trigger on rise"
);
assert!(
order.time_in_force.is_none(),
"Market orders should not have timeInForce"
);
}
#[rstest]
#[tokio::test]
async fn test_submit_order_stop_limit_with_trigger_price_and_limit_price() {
let (addr, state) = start_order_capture_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let instrument_id = InstrumentId::new(Symbol::from("BTCUSDT-LINEAR"), *BYBIT_VENUE);
let client_order_id = ClientOrderId::from("stop-limit-test-1");
let quantity = Quantity::new(0.001, 3);
let trigger_price = Price::new(99_000.0, 2);
let limit_price = Price::new(98_500.0, 2);
let result = client
.submit_order(
account_id,
BybitProductType::Linear,
instrument_id,
client_order_id,
OrderSide::Sell,
OrderType::StopLimit,
quantity,
Some(TimeInForce::Gtc),
Some(limit_price),
Some(trigger_price),
None, true, false, false, None, None, None, )
.await;
assert!(result.is_ok(), "Order submission should succeed");
let orders = state.order_submissions.lock().await;
assert_eq!(orders.len(), 1, "Should have captured one order");
let order = &orders[0];
assert_eq!(order.category, "linear");
assert_eq!(order.symbol, "BTCUSDT");
assert_eq!(order.side, "Sell");
assert_eq!(order.order_type, "Limit");
assert_eq!(
order.price.as_deref(),
Some("98500.00"),
"Should have limit price"
);
assert_eq!(
order.trigger_price.as_deref(),
Some("99000.00"),
"Should have trigger price"
);
assert_eq!(
order.trigger_direction.as_deref(),
Some("2"),
"Sell stop should trigger on fall"
);
assert_eq!(order.time_in_force.as_deref(), Some("GTC"));
assert_eq!(order.reduce_only, Some(true));
}
#[rstest]
#[tokio::test]
async fn test_submit_order_market_if_touched_trigger_direction() {
let (addr, state) = start_order_capture_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let instrument_id = InstrumentId::new(Symbol::from("BTCUSDT-LINEAR"), *BYBIT_VENUE);
let client_order_id = ClientOrderId::from("mit-test-1");
let quantity = Quantity::new(0.001, 3);
let trigger_price = Price::new(95_000.0, 2);
let result = client
.submit_order(
account_id,
BybitProductType::Linear,
instrument_id,
client_order_id,
OrderSide::Buy,
OrderType::MarketIfTouched,
quantity,
None,
None,
Some(trigger_price),
None,
false,
false,
false,
None,
None,
None,
)
.await;
assert!(result.is_ok(), "Order submission should succeed");
let orders = state.order_submissions.lock().await;
assert_eq!(orders.len(), 1);
let order = &orders[0];
assert_eq!(
order.trigger_direction.as_deref(),
Some("2"),
"Buy MIT should trigger on fall"
);
}
#[rstest]
#[tokio::test]
async fn test_submit_order_post_only() {
let (addr, state) = start_order_capture_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let instrument_id = InstrumentId::new(Symbol::from("BTCUSDT-LINEAR"), *BYBIT_VENUE);
let client_order_id = ClientOrderId::from("post-only-test-1");
let quantity = Quantity::new(0.001, 3);
let price = Price::new(100_000.0, 2);
let result = client
.submit_order(
account_id,
BybitProductType::Linear,
instrument_id,
client_order_id,
OrderSide::Buy,
OrderType::Limit,
quantity,
Some(TimeInForce::Gtc), Some(price),
None,
Some(true), false,
false,
false,
None,
None,
None,
)
.await;
assert!(result.is_ok(), "Order submission should succeed");
let orders = state.order_submissions.lock().await;
assert_eq!(orders.len(), 1);
let order = &orders[0];
assert_eq!(order.order_type, "Limit");
assert_eq!(
order.time_in_force.as_deref(),
Some("PostOnly"),
"Post-only orders should have timeInForce=PostOnly"
);
}
#[rstest]
#[tokio::test]
async fn test_submit_order_with_bbo_sends_bbo_and_omits_price() {
let (addr, state) = start_order_capture_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let result = client
.submit_order(
AccountId::from("BYBIT-UNIFIED"),
BybitProductType::Linear,
InstrumentId::new(Symbol::from("BTCUSDT-LINEAR"), *BYBIT_VENUE),
ClientOrderId::from("bbo-test-1"),
OrderSide::Buy,
OrderType::Limit,
Quantity::new(0.001, 3),
Some(TimeInForce::Gtc),
Some(Price::new(50_000.0, 2)),
None,
None,
false,
false,
false,
None,
Some(BybitBboSideType::Queue),
Some("3".to_string()),
)
.await;
assert!(result.is_ok(), "Order submission should succeed");
let orders = state.order_submissions.lock().await;
assert_eq!(orders.len(), 1);
let order = &orders[0];
assert_eq!(order.order_type, "Limit");
assert_eq!(order.price, None);
assert_eq!(order.bbo_side_type.as_deref(), Some("Queue"));
assert_eq!(order.bbo_level.as_deref(), Some("3"));
}
#[rstest]
#[tokio::test]
async fn test_submit_order_spot_market_base_quantity() {
let (addr, state) = start_order_capture_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Spot, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let instrument_id = InstrumentId::new(Symbol::from("BTCUSDT-SPOT"), *BYBIT_VENUE);
let client_order_id = ClientOrderId::from("spot-base-qty-test-1");
let quantity = Quantity::new(0.001, 3);
let result = client
.submit_order(
account_id,
BybitProductType::Spot,
instrument_id,
client_order_id,
OrderSide::Buy,
OrderType::Market,
quantity,
None,
None,
None,
None,
false,
false, true, None, None, None, )
.await;
assert!(result.is_ok(), "Order submission should succeed");
let orders = state.order_submissions.lock().await;
assert_eq!(orders.len(), 1);
let order = &orders[0];
assert_eq!(order.category, "spot");
assert_eq!(order.order_type, "Market");
assert_eq!(
order.market_unit.as_deref(),
Some("baseCoin"),
"SPOT market order with is_quote_quantity=false should use baseCoin"
);
assert_eq!(
order.is_leverage,
Some(1),
"is_leverage=true should send isLeverage=1"
);
}
#[rstest]
#[tokio::test]
async fn test_submit_order_spot_market_quote_quantity() {
let (addr, state) = start_order_capture_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Spot, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let instrument_id = InstrumentId::new(Symbol::from("BTCUSDT-SPOT"), *BYBIT_VENUE);
let client_order_id = ClientOrderId::from("spot-quote-qty-test-1");
let quantity = Quantity::new(100.0, 2);
let result = client
.submit_order(
account_id,
BybitProductType::Spot,
instrument_id,
client_order_id,
OrderSide::Buy,
OrderType::Market,
quantity,
None,
None,
None,
None,
false,
true, false, None, None, None, )
.await;
assert!(result.is_ok(), "Order submission should succeed");
let orders = state.order_submissions.lock().await;
assert_eq!(orders.len(), 1);
let order = &orders[0];
assert_eq!(order.category, "spot");
assert_eq!(order.order_type, "Market");
assert_eq!(
order.market_unit.as_deref(),
Some("quoteCoin"),
"SPOT market order with is_quote_quantity=true should use quoteCoin"
);
assert_eq!(
order.is_leverage,
Some(0),
"is_leverage=false should send isLeverage=0"
);
}
#[rstest]
#[tokio::test]
async fn test_submit_order_linear_does_not_send_market_unit() {
let (addr, state) = start_order_capture_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let instrument_id = InstrumentId::new(Symbol::from("BTCUSDT-LINEAR"), *BYBIT_VENUE);
let client_order_id = ClientOrderId::from("linear-market-test-1");
let quantity = Quantity::new(0.001, 3);
let result = client
.submit_order(
account_id,
BybitProductType::Linear,
instrument_id,
client_order_id,
OrderSide::Buy,
OrderType::Market,
quantity,
None,
None,
None,
None,
false,
true, false, None, None, None, )
.await;
assert!(result.is_ok(), "Order submission should succeed");
let orders = state.order_submissions.lock().await;
assert_eq!(orders.len(), 1);
let order = &orders[0];
assert_eq!(order.category, "linear");
assert!(
order.market_unit.is_none(),
"LINEAR market orders should not have marketUnit"
);
assert!(
order.is_leverage.is_none(),
"LINEAR orders should not have isLeverage"
);
}
#[rstest]
#[tokio::test]
async fn test_submit_order_limit_if_touched_trigger_direction() {
let (addr, state) = start_order_capture_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let instrument_id = InstrumentId::new(Symbol::from("BTCUSDT-LINEAR"), *BYBIT_VENUE);
let client_order_id = ClientOrderId::from("lit-test-1");
let quantity = Quantity::new(0.001, 3);
let trigger_price = Price::new(105_000.0, 2);
let limit_price = Price::new(105_500.0, 2);
let result = client
.submit_order(
account_id,
BybitProductType::Linear,
instrument_id,
client_order_id,
OrderSide::Sell,
OrderType::LimitIfTouched,
quantity,
Some(TimeInForce::Gtc),
Some(limit_price),
Some(trigger_price),
None,
false,
false,
false,
None,
None,
None,
)
.await;
assert!(result.is_ok(), "Order submission should succeed");
let orders = state.order_submissions.lock().await;
assert_eq!(orders.len(), 1);
let order = &orders[0];
assert_eq!(order.order_type, "Limit");
assert_eq!(
order.trigger_price.as_deref(),
Some("105000.00"),
"Should have trigger price"
);
assert_eq!(
order.price.as_deref(),
Some("105500.00"),
"Should have limit price"
);
assert_eq!(
order.trigger_direction.as_deref(),
Some("1"),
"Sell LIT should trigger on rise"
);
}
#[rstest]
#[case::omitted(None, None)]
#[case::one_way(Some(BybitPositionIdx::OneWay), Some(0))]
#[case::buy_hedge(Some(BybitPositionIdx::BuyHedge), Some(1))]
#[case::sell_hedge(Some(BybitPositionIdx::SellHedge), Some(2))]
#[tokio::test]
async fn test_submit_order_serializes_position_idx(
#[case] position_idx: Option<BybitPositionIdx>,
#[case] expected: Option<i64>,
) {
let (addr, state) = start_order_capture_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let result = client
.submit_order(
AccountId::from("BYBIT-UNIFIED"),
BybitProductType::Linear,
InstrumentId::new(Symbol::from("BTCUSDT-LINEAR"), *BYBIT_VENUE),
ClientOrderId::from("posidx-test"),
OrderSide::Buy,
OrderType::Limit,
Quantity::new(0.001, 3),
Some(TimeInForce::Gtc),
Some(Price::new(50_000.0, 2)),
None,
None,
false,
false,
false,
position_idx,
None,
None,
)
.await;
assert!(result.is_ok(), "Order submission should succeed");
let orders = state.order_submissions.lock().await;
assert_eq!(orders.len(), 1);
assert_eq!(orders[0].position_idx, expected);
}
async fn handle_empty_orders(headers: axum::http::HeaderMap) -> Response {
if !headers.contains_key("X-BAPI-API-KEY") {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
Json(json!({
"retCode": 0,
"retMsg": "OK",
"result": {
"list": [],
"nextPageCursor": ""
},
"retExtInfo": {},
"time": 1704470400123i64
}))
.into_response()
}
fn create_empty_orders_test_router(state: TestServerState) -> Router {
Router::new()
.route("/v5/market/time", get(handle_get_server_time))
.route("/v5/market/instruments-info", get(handle_get_instruments))
.route("/v5/account/fee-rate", get(handle_get_fee_rate))
.route("/v5/order/realtime", get(handle_empty_orders))
.route("/v5/order/history", get(handle_empty_orders))
.with_state(state)
}
async fn start_empty_orders_test_server()
-> Result<(SocketAddr, TestServerState), Box<dyn std::error::Error + Send + Sync>> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let state = TestServerState::default();
let router = create_empty_orders_test_router(state.clone());
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
wait_for_server(addr, "/v5/market/time").await;
Ok((addr, state))
}
#[rstest]
#[tokio::test]
async fn test_query_order_option_not_found_returns_none() {
let (addr, _state) = start_empty_orders_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Option, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let instrument_id = InstrumentId::new(Symbol::from("BTC-27MAR26-70000-C-OPTION"), *BYBIT_VENUE);
let client_order_id = ClientOrderId::from("option-query-test-1");
let result = client
.query_order(
account_id,
BybitProductType::Option,
instrument_id,
Some(client_order_id),
None,
)
.await;
assert!(result.is_ok(), "query_order should not error for options");
assert!(
result.unwrap().is_none(),
"query_order should return None when option order not found"
);
}
#[allow(dead_code)]
async fn handle_get_orders_realtime_tp_sl(
query: Query<HashMap<String, String>>,
headers: axum::http::HeaderMap,
) -> Response {
if !headers.contains_key("X-BAPI-API-KEY") {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1704470400123i64
})),
)
.into_response();
}
let order_filter = query.get("orderFilter").map(String::as_str);
if order_filter == Some("StopOrder") {
let orders = load_test_data("http_get_orders_realtime_tp_sl.json");
Json(orders).into_response()
} else {
Json(json!({
"retCode": 0,
"retMsg": "OK",
"result": { "list": [], "nextPageCursor": "" },
"retExtInfo": {},
"time": 1704470400123i64
}))
.into_response()
}
}
#[allow(dead_code)]
fn create_tp_sl_test_router() -> Router {
Router::new()
.route("/v5/market/time", get(handle_get_server_time))
.route("/v5/market/instruments-info", get(handle_get_instruments))
.route("/v5/account/fee-rate", get(handle_get_fee_rate))
.route("/v5/order/realtime", get(handle_get_orders_realtime_tp_sl))
}
#[allow(dead_code)]
async fn start_tp_sl_test_server() -> Result<SocketAddr, Box<dyn std::error::Error + Send + Sync>> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let router = create_tp_sl_test_router();
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
wait_for_server(addr, "/v5/market/time").await;
Ok(addr)
}
#[rstest]
#[tokio::test]
async fn test_request_order_status_reports_tp_sl_orders() {
let addr = start_tp_sl_test_server().await.unwrap();
let base_url = format!("http://{addr}");
let client = BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap();
let instruments = client
.request_instruments(BybitProductType::Linear, None, None)
.await
.unwrap();
for instrument in instruments {
client.cache_instrument(instrument);
}
let account_id = AccountId::from("BYBIT-UNIFIED");
let instrument_id = InstrumentId::new(Symbol::from("BTCUSDT-LINEAR"), *BYBIT_VENUE);
let reports = client
.request_order_status_reports(
account_id,
BybitProductType::Linear,
Some(instrument_id),
true,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(reports.len(), 2, "Should have 2 TP/SL orders");
let tp_report = reports
.iter()
.find(|r| r.venue_order_id.as_str() == "tp-order-001")
.unwrap();
assert_eq!(tp_report.order_type, OrderType::MarketIfTouched);
assert_eq!(tp_report.order_side, OrderSide::Sell);
assert_eq!(tp_report.trigger_price, Some(Price::from("55000.00")));
assert_eq!(tp_report.trigger_type, Some(TriggerType::LastPrice));
assert!(tp_report.reduce_only);
let sl_report = reports
.iter()
.find(|r| r.venue_order_id.as_str() == "sl-order-001")
.unwrap();
assert_eq!(sl_report.order_type, OrderType::StopLimit);
assert_eq!(sl_report.order_side, OrderSide::Sell);
assert_eq!(sl_report.trigger_price, Some(Price::from("48000.00")));
assert_eq!(sl_report.price, Some(Price::from("47500.00")));
assert_eq!(sl_report.trigger_type, Some(TriggerType::LastPrice));
assert!(sl_report.reduce_only);
}
#[derive(Clone, Default)]
struct UserApiCapture {
last_query: Arc<tokio::sync::Mutex<Option<String>>>,
last_body: Arc<tokio::sync::Mutex<Option<Value>>>,
}
fn require_bybit_auth(headers: &axum::http::HeaderMap) -> Option<Response> {
if !headers.contains_key("X-BAPI-API-KEY")
|| !headers.contains_key("X-BAPI-SIGN")
|| !headers.contains_key("X-BAPI-TIMESTAMP")
{
return Some(
(
StatusCode::UNAUTHORIZED,
Json(json!({
"retCode": 10003,
"retMsg": "Invalid API key",
"result": {},
"retExtInfo": {},
"time": 1700000000000i64
})),
)
.into_response(),
);
}
None
}
async fn user_handle_sub_members(headers: axum::http::HeaderMap) -> Response {
if let Some(r) = require_bybit_auth(&headers) {
return r;
}
Json(load_test_data("http_get_user_sub_members.json")).into_response()
}
async fn user_handle_sub_members_paged(
State(cap): State<UserApiCapture>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
) -> Response {
if let Some(r) = require_bybit_auth(&headers) {
return r;
}
*cap.last_query.lock().await = uri.query().map(str::to_owned);
Json(load_test_data("http_get_user_sub_members_paged.json")).into_response()
}
async fn user_handle_escrow_sub_members(
State(cap): State<UserApiCapture>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
) -> Response {
if let Some(r) = require_bybit_auth(&headers) {
return r;
}
*cap.last_query.lock().await = uri.query().map(str::to_owned);
Json(load_test_data("http_get_user_escrow_sub_members.json")).into_response()
}
async fn user_handle_sub_apikeys(
State(cap): State<UserApiCapture>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
) -> Response {
if let Some(r) = require_bybit_auth(&headers) {
return r;
}
*cap.last_query.lock().await = uri.query().map(str::to_owned);
Json(load_test_data("http_get_user_sub_apikeys.json")).into_response()
}
async fn user_handle_update_sub_api(
State(cap): State<UserApiCapture>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Response {
if let Some(r) = require_bybit_auth(&headers) {
return r;
}
if let Ok(value) = serde_json::from_slice::<Value>(&body) {
*cap.last_body.lock().await = Some(value);
}
Json(load_test_data("http_post_user_update_sub_api.json")).into_response()
}
async fn user_handle_update_master_api(
State(cap): State<UserApiCapture>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Response {
if let Some(r) = require_bybit_auth(&headers) {
return r;
}
if let Ok(value) = serde_json::from_slice::<Value>(&body) {
*cap.last_body.lock().await = Some(value);
}
Json(load_test_data("http_post_user_update_master_api.json")).into_response()
}
fn create_user_api_router(cap: UserApiCapture) -> Router {
Router::new()
.route("/v5/user/query-sub-members", get(user_handle_sub_members))
.route("/v5/user/submembers", get(user_handle_sub_members_paged))
.route(
"/v5/user/escrow_sub_members",
get(user_handle_escrow_sub_members),
)
.route("/v5/user/sub-apikeys", get(user_handle_sub_apikeys))
.route("/v5/user/update-sub-api", post(user_handle_update_sub_api))
.route("/v5/user/update-api", post(user_handle_update_master_api))
.route("/v5/market/time", get(handle_get_server_time))
.with_state(cap)
}
async fn start_user_api_server()
-> Result<(SocketAddr, UserApiCapture), Box<dyn std::error::Error + Send + Sync>> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let cap = UserApiCapture::default();
let router = create_user_api_router(cap.clone());
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
wait_for_server(addr, "/v5/market/time").await;
Ok((addr, cap))
}
fn user_test_client(base_url: String) -> BybitHttpClient {
BybitHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap()
}
fn user_raw_test_client(base_url: String) -> BybitRawHttpClient {
BybitRawHttpClient::with_credentials(
"test_api_key".to_string(),
"test_api_secret".to_string(),
Some(base_url),
60,
3,
1000,
10_000,
5_000,
None,
)
.unwrap()
}
#[rstest]
#[tokio::test]
async fn test_get_sub_members_no_params() {
use nautilus_bybit::http::models::BybitSubMembersResponse;
let (addr, _cap) = start_user_api_server().await.unwrap();
let client = user_test_client(format!("http://{addr}"));
let response: BybitSubMembersResponse = client.get_sub_members().await.unwrap();
assert_eq!(response.ret_code, 0);
assert_eq!(response.result.sub_members.len(), 2);
assert_eq!(response.result.sub_members[0].uid, "106314365");
}
#[rstest]
#[tokio::test]
async fn test_get_sub_members_paged_sends_cursor_and_size() {
use nautilus_bybit::http::query::BybitSubMembersPageParamsBuilder;
let (addr, cap) = start_user_api_server().await.unwrap();
let client = user_test_client(format!("http://{addr}"));
let params = BybitSubMembersPageParamsBuilder::default()
.page_size(50u32)
.next_cursor("page-token-xyz".to_string())
.build()
.unwrap();
let response = client.get_sub_members_paged(¶ms).await.unwrap();
assert_eq!(response.result.sub_members.len(), 2);
assert_eq!(response.result.next_cursor.as_deref(), Some("0"));
let q = cap.last_query.lock().await.clone().expect("query captured");
assert!(
q.contains("pageSize=50"),
"query should carry pageSize: {q}"
);
assert!(
q.contains("nextCursor=page-token-xyz"),
"query should carry nextCursor: {q}"
);
}
#[rstest]
#[tokio::test]
async fn test_get_escrow_sub_members_hits_escrow_route() {
use nautilus_bybit::http::query::BybitSubMembersPageParamsBuilder;
let (addr, cap) = start_user_api_server().await.unwrap();
let client = user_test_client(format!("http://{addr}"));
let params = BybitSubMembersPageParamsBuilder::default()
.page_size(20u32)
.build()
.unwrap();
let response = client.get_escrow_sub_members(¶ms).await.unwrap();
assert_eq!(response.result.sub_members[0].member_type, 12);
assert_eq!(response.result.next_cursor.as_deref(), Some("344"));
let q = cap.last_query.lock().await.clone().expect("query captured");
assert!(
q.contains("pageSize=20"),
"query should carry pageSize: {q}"
);
assert!(
!q.contains("nextCursor"),
"nextCursor should be omitted when None: {q}"
);
}
#[rstest]
#[tokio::test]
async fn test_get_sub_api_keys_sends_required_params() {
use nautilus_bybit::http::query::BybitSubApiKeysParamsBuilder;
let (addr, cap) = start_user_api_server().await.unwrap();
let client = user_test_client(format!("http://{addr}"));
let params = BybitSubApiKeysParamsBuilder::default()
.sub_member_id("533285".to_string())
.limit(10u32)
.cursor("next-token".to_string())
.build()
.unwrap();
let response = client.get_sub_api_keys(¶ms).await.unwrap();
assert_eq!(response.result.keys.len(), 1);
assert!(!response.result.keys[0].read_only);
assert_eq!(response.result.keys[0].secret, None);
let q = cap.last_query.lock().await.clone().expect("query captured");
assert!(q.contains("subMemberId=533285"), "query: {q}");
assert!(q.contains("limit=10"), "query: {q}");
assert!(q.contains("cursor=next-token"), "query: {q}");
}
#[rstest]
#[tokio::test]
async fn test_update_sub_api_key_omits_none_permissions() {
use nautilus_bybit::http::query::BybitUpdateSubApiParamsBuilder;
let (addr, cap) = start_user_api_server().await.unwrap();
let client = user_test_client(format!("http://{addr}"));
let params = BybitUpdateSubApiParamsBuilder::default()
.read_only(true)
.build()
.unwrap();
let response = client.update_sub_api_key(¶ms).await.unwrap();
assert!(!response.result.read_only);
let body = cap.last_body.lock().await.clone().expect("body captured");
let obj = body.as_object().expect("json object");
assert_eq!(obj.get("readOnly").and_then(Value::as_i64), Some(1));
assert!(
!obj.contains_key("permissions"),
"permissions must be skipped when None: {body}"
);
assert!(
!obj.contains_key("apiKey"),
"apiKey must be skipped when None: {body}"
);
}
#[rstest]
#[tokio::test]
async fn test_update_sub_api_key_serializes_permissions_pascal_case() {
use nautilus_bybit::http::query::{
BybitApiKeyPermissionUpdateBuilder, BybitUpdateSubApiParamsBuilder,
};
let (addr, cap) = start_user_api_server().await.unwrap();
let client = user_test_client(format!("http://{addr}"));
let permissions = BybitApiKeyPermissionUpdateBuilder::default()
.spot(vec!["SpotTrade".to_string()])
.wallet(vec!["AccountTransfer".to_string()])
.build()
.unwrap();
let params = BybitUpdateSubApiParamsBuilder::default()
.api_key("sub-key-id".to_string())
.permissions(permissions)
.build()
.unwrap();
client.update_sub_api_key(¶ms).await.unwrap();
let body = cap.last_body.lock().await.clone().expect("body captured");
let perms = body
.get("permissions")
.and_then(Value::as_object)
.expect("permissions object");
assert!(perms.contains_key("Spot"));
assert!(perms.contains_key("Wallet"));
assert!(
!perms.contains_key("ContractTrade"),
"unset permission keys must be skipped: {body}"
);
}
#[rstest]
#[tokio::test]
async fn test_update_master_api_key_emits_renamed_permission_keys() {
use nautilus_bybit::http::query::{
BybitApiKeyPermissionUpdateBuilder, BybitUpdateMasterApiParamsBuilder,
};
let (addr, cap) = start_user_api_server().await.unwrap();
let client = user_test_client(format!("http://{addr}"));
let permissions = BybitApiKeyPermissionUpdateBuilder::default()
.nft(vec!["NFTQueryProductList".to_string()])
.fiat_p2p(vec!["P2PDeposit".to_string()])
.byx_post(vec!["PostContent".to_string()])
.build()
.unwrap();
let params = BybitUpdateMasterApiParamsBuilder::default()
.permissions(permissions)
.build()
.unwrap();
client.update_master_api_key(¶ms).await.unwrap();
let body = cap.last_body.lock().await.clone().expect("body captured");
let perms = body
.get("permissions")
.and_then(Value::as_object)
.expect("permissions object");
assert!(perms.contains_key("NFT"), "expected key `NFT`: {body}");
assert!(
perms.contains_key("FiatP2P"),
"expected key `FiatP2P`: {body}"
);
assert!(
perms.contains_key("ByXPost"),
"expected key `ByXPost`: {body}"
);
assert!(
!perms.contains_key("Nft")
&& !perms.contains_key("FiatP2p")
&& !perms.contains_key("ByxPost"),
"default PascalCase casing must not leak through: {body}"
);
}
#[rstest]
#[tokio::test]
async fn test_update_master_api_key_body_has_no_ips() {
use nautilus_bybit::http::query::{
BybitApiKeyPermissionUpdateBuilder, BybitUpdateMasterApiParamsBuilder,
};
let (addr, cap) = start_user_api_server().await.unwrap();
let client = user_test_client(format!("http://{addr}"));
let permissions = BybitApiKeyPermissionUpdateBuilder::default()
.contract_trade(vec!["Order".to_string(), "Position".to_string()])
.build()
.unwrap();
let params = BybitUpdateMasterApiParamsBuilder::default()
.read_only(false)
.permissions(permissions)
.build()
.unwrap();
let response = client.update_master_api_key(¶ms).await.unwrap();
assert_eq!(response.result.permissions.nft, vec!["NFTQueryProductList"]);
let body = cap.last_body.lock().await.clone().expect("body captured");
let obj = body.as_object().expect("json object");
assert!(
!obj.contains_key("ips"),
"update-api must not send ips: {body}"
);
assert!(
!obj.contains_key("apiKey"),
"update-api has no apiKey: {body}"
);
assert_eq!(obj.get("readOnly").and_then(Value::as_i64), Some(0));
}
async fn paged_sub_members_handler(
State(calls): State<Arc<tokio::sync::Mutex<Vec<String>>>>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
) -> Response {
if let Some(r) = require_bybit_auth(&headers) {
return r;
}
let query = uri.query().unwrap_or_default().to_string();
let mut log = calls.lock().await;
log.push(query.clone());
let body = if query.contains("nextCursor=page-2") {
json!({
"retCode": 0,
"retMsg": "",
"result": {
"subMembers": [{
"uid": "300",
"username": "third",
"memberType": 1,
"status": 1,
"accountMode": 5
}],
"nextCursor": "0"
},
"retExtInfo": {},
"time": 1760388041006i64
})
} else {
json!({
"retCode": 0,
"retMsg": "",
"result": {
"subMembers": [
{"uid": "100", "username": "first", "memberType": 1, "status": 1, "accountMode": 5},
{"uid": "200", "username": "second", "memberType": 1, "status": 1, "accountMode": 6}
],
"nextCursor": "page-2"
},
"retExtInfo": {},
"time": 1760388041006i64
})
};
Json(body).into_response()
}
#[rstest]
#[tokio::test]
async fn test_fetch_all_sub_members_paged_walks_cursor() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let calls: Arc<tokio::sync::Mutex<Vec<String>>> = Arc::default();
let router = Router::new()
.route("/v5/user/submembers", get(paged_sub_members_handler))
.route("/v5/market/time", get(handle_get_server_time))
.with_state(calls.clone());
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
wait_for_server(addr, "/v5/market/time").await;
let client = user_raw_test_client(format!("http://{addr}"));
let members = client.fetch_all_sub_members_paged(Some(2)).await.unwrap();
assert_eq!(members.len(), 3);
assert_eq!(members[0].uid, "100");
assert_eq!(members[2].uid, "300");
let log = calls.lock().await.clone();
assert_eq!(log.len(), 2, "expected exactly two page requests: {log:?}");
assert!(
!log[0].contains("nextCursor"),
"first page must not carry a cursor: {log:?}"
);
assert!(
log[1].contains("nextCursor=page-2"),
"second page must carry cursor: {log:?}"
);
}
async fn paged_escrow_sub_members_handler(
State(calls): State<Arc<tokio::sync::Mutex<Vec<String>>>>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
) -> Response {
if let Some(r) = require_bybit_auth(&headers) {
return r;
}
let query = uri.query().unwrap_or_default().to_string();
calls.lock().await.push(query.clone());
let body = if query.contains("nextCursor=escrow-2") {
json!({
"retCode": 0,
"retMsg": "",
"result": {
"subMembers": [{
"uid": "777",
"username": "Fund3",
"memberType": 12,
"status": 1,
"remark": "earn fund",
"accountMode": 3
}],
"nextCursor": "0"
},
"retExtInfo": {},
"time": 1760388041006i64
})
} else {
json!({
"retCode": 0,
"retMsg": "",
"result": {
"subMembers": [
{"uid": "555", "username": "Fund1", "memberType": 12, "status": 1, "remark": "earn fund", "accountMode": 3},
{"uid": "666", "username": "Fund2", "memberType": 12, "status": 1, "remark": "earn fund", "accountMode": 3}
],
"nextCursor": "escrow-2"
},
"retExtInfo": {},
"time": 1760388041006i64
})
};
Json(body).into_response()
}
#[rstest]
#[tokio::test]
async fn test_fetch_all_escrow_sub_members_walks_cursor() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let calls: Arc<tokio::sync::Mutex<Vec<String>>> = Arc::default();
let router = Router::new()
.route(
"/v5/user/escrow_sub_members",
get(paged_escrow_sub_members_handler),
)
.route("/v5/market/time", get(handle_get_server_time))
.with_state(calls.clone());
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
wait_for_server(addr, "/v5/market/time").await;
let client = user_raw_test_client(format!("http://{addr}"));
let members = client.fetch_all_escrow_sub_members(Some(50)).await.unwrap();
assert_eq!(members.len(), 3);
assert_eq!(members[0].uid, "555");
assert_eq!(members[2].uid, "777");
assert_eq!(members[2].member_type, 12);
let log = calls.lock().await.clone();
assert_eq!(log.len(), 2, "expected exactly two page requests: {log:?}");
assert!(
log[1].contains("nextCursor=escrow-2"),
"second page must carry cursor: {log:?}"
);
}
async fn paged_sub_apikeys_handler(
State(calls): State<Arc<tokio::sync::Mutex<Vec<String>>>>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
) -> Response {
if let Some(r) = require_bybit_auth(&headers) {
return r;
}
let query = uri.query().unwrap_or_default().to_string();
calls.lock().await.push(query.clone());
let body = if query.contains("cursor=keys-2") {
json!({
"retCode": 0,
"retMsg": "",
"result": {
"result": [{
"id": "33", "ips": ["*"], "apiKey": "K3", "note": "",
"status": 1, "createdAt": "2026-01-01T00:00:00Z",
"type": 1, "permissions": {}, "secret": "******",
"readOnly": true, "flag": "hmac"
}],
"nextPageCursor": ""
},
"retExtInfo": {},
"time": 1699515251698i64
})
} else {
json!({
"retCode": 0,
"retMsg": "",
"result": {
"result": [
{"id": "11", "ips": ["*"], "apiKey": "K1", "note": "", "status": 1, "createdAt": "2026-01-01T00:00:00Z", "type": 1, "permissions": {}, "secret": "******", "readOnly": false, "flag": "hmac"},
{"id": "22", "ips": ["*"], "apiKey": "K2", "note": "", "status": 1, "createdAt": "2026-01-01T00:00:00Z", "type": 1, "permissions": {}, "secret": "******", "readOnly": false, "flag": "hmac"}
],
"nextPageCursor": "keys-2"
},
"retExtInfo": {},
"time": 1699515251698i64
})
};
Json(body).into_response()
}
#[rstest]
#[tokio::test]
async fn test_fetch_all_sub_api_keys_walks_cursor() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let calls: Arc<tokio::sync::Mutex<Vec<String>>> = Arc::default();
let router = Router::new()
.route("/v5/user/sub-apikeys", get(paged_sub_apikeys_handler))
.route("/v5/market/time", get(handle_get_server_time))
.with_state(calls.clone());
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
wait_for_server(addr, "/v5/market/time").await;
let client = user_raw_test_client(format!("http://{addr}"));
let keys = client
.fetch_all_sub_api_keys("533285", Some(10))
.await
.unwrap();
assert_eq!(keys.len(), 3);
assert_eq!(keys[0].id, "11");
assert_eq!(keys[2].id, "33");
assert!(keys[2].read_only);
let log = calls.lock().await.clone();
assert_eq!(log.len(), 2, "expected exactly two page requests: {log:?}");
assert!(
log[0].contains("subMemberId=533285"),
"subMemberId must be sent: {log:?}"
);
assert!(log[0].contains("limit=10"), "limit must be sent: {log:?}");
assert!(
!log[0].contains("cursor="),
"first page must not carry a cursor: {log:?}"
);
assert!(
log[1].contains("cursor=keys-2"),
"second page must carry cursor: {log:?}"
);
}