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::enums::{BybitAccountType, BybitProductType},
http::{
client::BybitHttpClient,
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, Venue},
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>,
}
#[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),
};
{
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_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/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;
assert!(result.is_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;
assert!(result.is_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;
assert!(result.is_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;
assert!(result.is_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());
}
#[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)
.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)
.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)
.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"), Venue::from("BYBIT"));
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)
.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)
.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)
.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)
.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)
.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)
.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)
.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"), Venue::from("BYBIT"));
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, )
.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)
.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"), Venue::from("BYBIT"));
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, )
.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)
.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"), Venue::from("BYBIT"));
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,
)
.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)
.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"), Venue::from("BYBIT"));
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,
)
.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_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)
.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"), Venue::from("BYBIT"));
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, )
.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)
.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"), Venue::from("BYBIT"));
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, )
.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)
.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"), Venue::from("BYBIT"));
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, )
.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)
.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"), Venue::from("BYBIT"));
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,
)
.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"
);
}
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)
.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"),
Venue::from("BYBIT"),
);
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)
.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"), Venue::from("BYBIT"));
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);
}