use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use reqwest::header::HeaderMap;
use serde_json::{json, Value};
use crate::core::{
HttpClient, Credentials,
ExchangeId, ExchangeType, AccountType, Symbol,
ExchangeError, ExchangeResult,
Price, Kline, Ticker, OrderBook,
Order, OrderSide, OrderType, Balance, AccountInfo,
Position, FundingRate,
OrderRequest, CancelRequest, CancelScope,
BalanceQuery, PositionQuery, PositionModification,
OrderHistoryFilter, PlaceOrderResponse, FeeInfo,
UserTrade, UserTradeFilter,
MarginType,
AmendRequest, CancelAllResponse, OrderResult,
TransferResponse, DepositAddress, WithdrawResponse, FundsRecord,
};
use crate::core::types::{
TransferRequest, TransferHistoryFilter,
WithdrawRequest, FundsHistoryFilter, FundsRecordType,
SubAccountOperation, SubAccountResult,
};
use crate::core::traits::{
ExchangeIdentity, MarketData, Trading, Account, Positions,
CancelAll, AmendOrder, BatchOrders,
AccountTransfers, CustodialFunds, SubAccounts,
FundingHistory, AccountLedger,
};
use crate::core::types::{
ConnectorStats,
FundingPayment, FundingFilter,
LedgerEntry, LedgerFilter,
};
use crate::core::utils::WeightRateLimiter;
use super::endpoints::{BybitUrls, BybitEndpoint, format_symbol, account_type_to_category, account_type_to_transfer_type, map_kline_interval};
use super::auth::BybitAuth;
use super::parser::BybitParser;
pub struct BybitConnector {
http: HttpClient,
auth: Option<BybitAuth>,
testnet: bool,
rate_limiter: Arc<Mutex<WeightRateLimiter>>,
precision: crate::core::utils::precision::PrecisionCache,
}
impl BybitConnector {
pub async fn new(credentials: Option<Credentials>, testnet: bool) -> ExchangeResult<Self> {
let http = HttpClient::new(30_000)?;
let mut auth = credentials.as_ref().map(BybitAuth::new);
if auth.is_some() {
let base_url = BybitUrls::base_url(testnet);
let url = format!("{}/v5/market/time", base_url);
if let Ok(response) = http.get(&url, &HashMap::new()).await {
if let Some(time_sec) = response.get("result")
.and_then(|r| r.get("timeSecond"))
.and_then(|t| t.as_str())
.and_then(|s| s.parse::<i64>().ok())
{
if let Some(ref mut a) = auth {
a.sync_time(time_sec * 1000); }
}
}
}
let rate_limiter = Arc::new(Mutex::new(
WeightRateLimiter::new(600, Duration::from_secs(5))
));
Ok(Self {
http,
auth,
testnet,
rate_limiter,
precision: crate::core::utils::precision::PrecisionCache::new(),
})
}
pub async fn public(testnet: bool) -> ExchangeResult<Self> {
Self::new(None, testnet).await
}
fn update_rate_from_headers(&self, headers: &HeaderMap) {
let remaining = headers
.get("X-Bapi-Limit-Status")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u32>().ok());
let limit = headers
.get("X-Bapi-Limit")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u32>().ok());
if let (Some(remaining), Some(limit)) = (remaining, limit) {
let used = limit.saturating_sub(remaining);
if let Ok(mut limiter) = self.rate_limiter.lock() {
limiter.update_from_server(used);
}
}
}
async fn rate_limit_wait(&self, weight: u32) {
loop {
let wait_time = {
let mut limiter = self.rate_limiter.lock().expect("Mutex poisoned");
if limiter.try_acquire(weight) {
return; }
limiter.time_until_ready(weight)
};
if wait_time > Duration::ZERO {
tokio::time::sleep(wait_time).await;
}
}
}
async fn get(
&self,
endpoint: BybitEndpoint,
params: HashMap<String, String>,
) -> ExchangeResult<Value> {
self.rate_limit_wait(1).await;
let base_url = BybitUrls::base_url(self.testnet);
let path = endpoint.path();
let query = if params.is_empty() {
String::new()
} else {
let qs: Vec<String> = params.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect();
qs.join("&")
};
let url = if query.is_empty() {
format!("{}{}", base_url, path)
} else {
format!("{}{}?{}", base_url, path, query)
};
let headers = if endpoint.is_private() {
let auth = self.auth.as_ref()
.ok_or_else(|| ExchangeError::Auth("Authentication required".to_string()))?;
auth.sign_request("GET", &query)
} else {
HashMap::new()
};
let (response, resp_headers) = self.http.get_with_response_headers(&url, &HashMap::new(), &headers).await?;
self.update_rate_from_headers(&resp_headers);
self.check_response(&response)?;
Ok(response)
}
async fn post(
&self,
endpoint: BybitEndpoint,
body: Value,
) -> ExchangeResult<Value> {
self.rate_limit_wait(1).await;
let base_url = BybitUrls::base_url(self.testnet);
let path = endpoint.path();
let url = format!("{}{}", base_url, path);
let auth = self.auth.as_ref()
.ok_or_else(|| ExchangeError::Auth("Authentication required".to_string()))?;
let body_str = body.to_string();
let headers = auth.sign_request("POST", &body_str);
let (response, resp_headers) = self.http.post_with_response_headers(&url, &body, &headers).await?;
self.update_rate_from_headers(&resp_headers);
self.check_response(&response)?;
Ok(response)
}
fn check_response(&self, response: &Value) -> ExchangeResult<()> {
let ret_code = response.get("retCode")
.and_then(|c| c.as_i64())
.unwrap_or(-1);
if ret_code != 0 {
let msg = response.get("retMsg")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
return Err(ExchangeError::Api {
code: ret_code as i32,
message: msg.to_string(),
});
}
Ok(())
}
pub async fn get_all_tickers(&self, account_type: AccountType) -> ExchangeResult<Vec<Ticker>> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
let response = self.get(BybitEndpoint::Ticker, params).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing 'result' field".to_string()))?;
let list = result.get("list")
.and_then(|v| v.as_array())
.ok_or_else(|| ExchangeError::Parse("Missing 'result.list' array".to_string()))?;
let timestamp = response.get("time").and_then(|t| t.as_i64()).unwrap_or(0);
let tickers = list.iter().map(|data| {
let parse_str_f64 = |key: &str| -> Option<f64> {
data.get(key).and_then(|v| v.as_str()).and_then(|s| s.parse().ok())
};
let last_price = parse_str_f64("lastPrice").unwrap_or(0.0);
let prev_price = parse_str_f64("prevPrice24h");
let price_change_24h = prev_price.map(|p| last_price - p);
let price_change_percent_24h = data.get("price24hPcnt")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.map(|v| v * 100.0);
Ticker {
symbol: data.get("symbol").and_then(|v| v.as_str()).unwrap_or("").to_string(),
last_price,
bid_price: parse_str_f64("bid1Price"),
ask_price: parse_str_f64("ask1Price"),
high_24h: parse_str_f64("highPrice24h"),
low_24h: parse_str_f64("lowPrice24h"),
volume_24h: parse_str_f64("volume24h"),
quote_volume_24h: parse_str_f64("turnover24h"),
price_change_24h,
price_change_percent_24h,
timestamp,
}
}).collect();
Ok(tickers)
}
pub async fn get_symbols(&self, account_type: AccountType) -> ExchangeResult<Value> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
self.get(BybitEndpoint::Symbols, params).await
}
pub async fn cancel_all_orders(
&self,
symbol: Option<Symbol>,
account_type: AccountType,
) -> ExchangeResult<Vec<String>> {
let mut body = json!({
"category": account_type_to_category(account_type),
});
if let Some(s) = symbol {
body["symbol"] = json!(format_symbol(&s, account_type));
}
let response = self.post(BybitEndpoint::CancelAllOrders, body).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let ids = result.get("list")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.get("orderId").and_then(|id| id.as_str()).map(String::from))
.collect()
})
.unwrap_or_default();
Ok(ids)
}
pub async fn get_open_interest(
&self,
symbol: &str,
interval_time: &str,
account_type: AccountType,
limit: Option<u32>,
) -> ExchangeResult<Value> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
params.insert("symbol".to_string(), symbol.to_string());
params.insert("intervalTime".to_string(), interval_time.to_string());
if let Some(l) = limit {
params.insert("limit".to_string(), l.to_string());
}
self.get(BybitEndpoint::OpenInterest, params).await
}
pub async fn get_long_short_ratio(
&self,
symbol: &str,
period: &str,
account_type: AccountType,
limit: Option<u32>,
) -> ExchangeResult<Value> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
params.insert("symbol".to_string(), symbol.to_string());
params.insert("period".to_string(), period.to_string());
if let Some(l) = limit {
params.insert("limit".to_string(), l.to_string());
}
self.get(BybitEndpoint::LongShortRatio, params).await
}
pub async fn get_mark_price_kline(
&self,
symbol: &str,
interval: &str,
account_type: AccountType,
limit: Option<u32>,
) -> ExchangeResult<Value> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
params.insert("symbol".to_string(), symbol.to_string());
params.insert("interval".to_string(), map_kline_interval(interval).to_string());
if let Some(l) = limit {
params.insert("limit".to_string(), l.to_string());
}
self.get(BybitEndpoint::MarkPriceKline, params).await
}
pub async fn get_index_price_kline(
&self,
symbol: &str,
interval: &str,
account_type: AccountType,
limit: Option<u32>,
) -> ExchangeResult<Value> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
params.insert("symbol".to_string(), symbol.to_string());
params.insert("interval".to_string(), map_kline_interval(interval).to_string());
if let Some(l) = limit {
params.insert("limit".to_string(), l.to_string());
}
self.get(BybitEndpoint::IndexPriceKline, params).await
}
pub async fn get_premium_index_kline(
&self,
symbol: &str,
interval: &str,
account_type: AccountType,
limit: Option<u32>,
) -> ExchangeResult<Value> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
params.insert("symbol".to_string(), symbol.to_string());
params.insert("interval".to_string(), map_kline_interval(interval).to_string());
if let Some(l) = limit {
params.insert("limit".to_string(), l.to_string());
}
self.get(BybitEndpoint::PremiumIndexKline, params).await
}
pub async fn get_my_trades(
&self,
symbol: Option<&str>,
account_type: AccountType,
limit: Option<u32>,
start_time: Option<i64>,
end_time: Option<i64>,
) -> ExchangeResult<Value> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
if let Some(s) = symbol {
params.insert("symbol".to_string(), s.to_string());
}
if let Some(l) = limit {
params.insert("limit".to_string(), l.to_string());
}
if let Some(st) = start_time {
params.insert("startTime".to_string(), st.to_string());
}
if let Some(et) = end_time {
params.insert("endTime".to_string(), et.to_string());
}
self.get(BybitEndpoint::MyTrades, params).await
}
pub async fn get_closed_pnl(
&self,
symbol: Option<&str>,
account_type: AccountType,
limit: Option<u32>,
start_time: Option<i64>,
end_time: Option<i64>,
) -> ExchangeResult<Value> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
if let Some(s) = symbol {
params.insert("symbol".to_string(), s.to_string());
}
if let Some(l) = limit {
params.insert("limit".to_string(), l.to_string());
}
if let Some(st) = start_time {
params.insert("startTime".to_string(), st.to_string());
}
if let Some(et) = end_time {
params.insert("endTime".to_string(), et.to_string());
}
self.get(BybitEndpoint::ClosedPnl, params).await
}
}
impl ExchangeIdentity for BybitConnector {
fn exchange_id(&self) -> ExchangeId {
ExchangeId::Bybit
}
fn metrics(&self) -> ConnectorStats {
let (http_requests, http_errors, last_latency_ms) = self.http.stats();
let (rate_used, rate_max) = if let Ok(mut limiter) = self.rate_limiter.lock() {
(limiter.current_weight(), limiter.max_weight())
} else {
(0, 0)
};
ConnectorStats {
http_requests,
http_errors,
last_latency_ms,
rate_used,
rate_max,
rate_groups: Vec::new(),
ws_ping_rtt_ms: 0,
}
}
fn is_testnet(&self) -> bool {
self.testnet
}
fn supported_account_types(&self) -> Vec<AccountType> {
vec![
AccountType::Spot,
AccountType::Margin,
AccountType::FuturesCross,
AccountType::FuturesIsolated,
]
}
fn exchange_type(&self) -> ExchangeType {
ExchangeType::Cex
}
}
#[async_trait]
impl MarketData for BybitConnector {
async fn get_price(
&self,
symbol: Symbol,
account_type: AccountType,
) -> ExchangeResult<Price> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
params.insert("symbol".to_string(), format_symbol(&symbol, account_type));
let response = self.get(BybitEndpoint::Ticker, params).await?;
let ticker = BybitParser::parse_ticker(&response)?;
Ok(ticker.last_price)
}
async fn get_orderbook(
&self,
symbol: Symbol,
depth: Option<u16>,
account_type: AccountType,
) -> ExchangeResult<OrderBook> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
params.insert("symbol".to_string(), format_symbol(&symbol, account_type));
if let Some(d) = depth {
params.insert("limit".to_string(), d.to_string());
}
let response = self.get(BybitEndpoint::Orderbook, params).await?;
BybitParser::parse_orderbook(&response)
}
async fn get_klines(
&self,
symbol: Symbol,
interval: &str,
limit: Option<u16>,
account_type: AccountType,
end_time: Option<i64>,
) -> ExchangeResult<Vec<Kline>> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
params.insert("symbol".to_string(), format_symbol(&symbol, account_type));
params.insert("interval".to_string(), map_kline_interval(interval).to_string());
if let Some(l) = limit {
params.insert("limit".to_string(), l.min(1000).to_string());
}
if let Some(et) = end_time {
params.insert("end".to_string(), et.to_string());
}
let response = self.get(BybitEndpoint::Klines, params).await?;
BybitParser::parse_klines(&response)
}
async fn get_ticker(
&self,
symbol: Symbol,
account_type: AccountType,
) -> ExchangeResult<Ticker> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
params.insert("symbol".to_string(), format_symbol(&symbol, account_type));
let response = self.get(BybitEndpoint::Ticker, params).await?;
BybitParser::parse_ticker(&response)
}
async fn ping(&self) -> ExchangeResult<()> {
let response = self.get(BybitEndpoint::ServerTime, HashMap::new()).await?;
self.check_response(&response)
}
async fn get_exchange_info(&self, account_type: AccountType) -> ExchangeResult<Vec<crate::core::types::SymbolInfo>> {
let response = self.get_symbols(account_type).await?;
let symbols = BybitParser::parse_exchange_info(&response, account_type)?;
self.precision.load_from_symbols(&symbols);
Ok(symbols)
}
}
#[async_trait]
impl Trading for BybitConnector {
async fn place_order(&self, req: OrderRequest) -> ExchangeResult<PlaceOrderResponse> {
let symbol = req.symbol.clone();
let side = req.side;
let quantity = req.quantity;
let account_type = req.account_type;
let symbol_str = format_symbol(&symbol, account_type);
match req.order_type {
OrderType::Market => {
let order_link_id = format!("cc_{}", crate::core::timestamp_millis());
let body = json!({
"category": account_type_to_category(account_type),
"symbol": format_symbol(&symbol, account_type),
"side": match side {
OrderSide::Buy => "Buy",
OrderSide::Sell => "Sell",
},
"orderType": "Market",
"qty": self.precision.qty(&symbol_str, quantity),
"orderLinkId": order_link_id,
});
let response = self.post(BybitEndpoint::PlaceOrder, body).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let order_id = result.get("orderId")
.and_then(|id| id.as_str())
.ok_or_else(|| ExchangeError::Parse("Missing orderId".to_string()))?
.to_string();
Ok(PlaceOrderResponse::Simple(Order {
id: order_id,
client_order_id: Some(order_link_id),
symbol: symbol.to_string(),
side,
order_type: OrderType::Market,
status: crate::core::OrderStatus::New,
price: None,
stop_price: None,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Gtc,
}))
}
OrderType::Limit { price } => {
let order_link_id = req.client_order_id.clone()
.unwrap_or_else(|| format!("cc_{}", crate::core::timestamp_millis()));
let tif = match req.time_in_force {
crate::core::TimeInForce::Gtc => "GTC",
crate::core::TimeInForce::Ioc => "IOC",
crate::core::TimeInForce::Fok => "FOK",
crate::core::TimeInForce::PostOnly => "PostOnly",
_ => "GTC",
};
let mut body = json!({
"category": account_type_to_category(account_type),
"symbol": format_symbol(&symbol, account_type),
"side": match side {
OrderSide::Buy => "Buy",
OrderSide::Sell => "Sell",
},
"orderType": "Limit",
"qty": self.precision.qty(&symbol_str, quantity),
"price": self.precision.price(&symbol_str, price),
"timeInForce": tif,
"orderLinkId": order_link_id,
});
if req.reduce_only {
body["reduceOnly"] = json!(true);
}
let response = self.post(BybitEndpoint::PlaceOrder, body).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let order_id = result.get("orderId")
.and_then(|id| id.as_str())
.ok_or_else(|| ExchangeError::Parse("Missing orderId".to_string()))?
.to_string();
Ok(PlaceOrderResponse::Simple(Order {
id: order_id,
client_order_id: Some(order_link_id),
symbol: symbol.to_string(),
side,
order_type: OrderType::Limit { price },
status: crate::core::OrderStatus::New,
price: Some(price),
stop_price: None,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: req.time_in_force,
}))
}
OrderType::StopMarket { stop_price } => {
let order_link_id = req.client_order_id.clone()
.unwrap_or_else(|| format!("cc_{}", crate::core::timestamp_millis()));
let mut body = json!({
"category": account_type_to_category(account_type),
"symbol": format_symbol(&symbol, account_type),
"side": match side {
OrderSide::Buy => "Buy",
OrderSide::Sell => "Sell",
},
"orderType": "Market",
"qty": self.precision.qty(&symbol_str, quantity),
"triggerPrice": self.precision.price(&symbol_str, stop_price),
"triggerBy": "MarkPrice",
"orderLinkId": order_link_id,
});
if req.reduce_only {
body["reduceOnly"] = json!(true);
}
let response = self.post(BybitEndpoint::PlaceOrder, body).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let order_id = result.get("orderId")
.and_then(|id| id.as_str())
.ok_or_else(|| ExchangeError::Parse("Missing orderId".to_string()))?
.to_string();
Ok(PlaceOrderResponse::Simple(Order {
id: order_id,
client_order_id: Some(order_link_id),
symbol: symbol.to_string(),
side,
order_type: OrderType::StopMarket { stop_price },
status: crate::core::OrderStatus::New,
price: None,
stop_price: Some(stop_price),
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Gtc,
}))
}
OrderType::StopLimit { stop_price, limit_price } => {
let order_link_id = req.client_order_id.clone()
.unwrap_or_else(|| format!("cc_{}", crate::core::timestamp_millis()));
let mut body = json!({
"category": account_type_to_category(account_type),
"symbol": format_symbol(&symbol, account_type),
"side": match side {
OrderSide::Buy => "Buy",
OrderSide::Sell => "Sell",
},
"orderType": "Limit",
"qty": self.precision.qty(&symbol_str, quantity),
"price": self.precision.price(&symbol_str, limit_price),
"triggerPrice": self.precision.price(&symbol_str, stop_price),
"triggerBy": "MarkPrice",
"orderLinkId": order_link_id,
});
if req.reduce_only {
body["reduceOnly"] = json!(true);
}
let response = self.post(BybitEndpoint::PlaceOrder, body).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let order_id = result.get("orderId")
.and_then(|id| id.as_str())
.ok_or_else(|| ExchangeError::Parse("Missing orderId".to_string()))?
.to_string();
Ok(PlaceOrderResponse::Simple(Order {
id: order_id,
client_order_id: Some(order_link_id),
symbol: symbol.to_string(),
side,
order_type: OrderType::StopLimit { stop_price, limit_price },
status: crate::core::OrderStatus::New,
price: Some(limit_price),
stop_price: Some(stop_price),
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Gtc,
}))
}
OrderType::TrailingStop { callback_rate, activation_price } => {
match account_type {
AccountType::Spot | AccountType::Margin => {
return Err(ExchangeError::UnsupportedOperation(
"TrailingStop not supported for Spot/Margin on Bybit".to_string()
));
}
_ => {}
}
let order_link_id = req.client_order_id.clone()
.unwrap_or_else(|| format!("cc_{}", crate::core::timestamp_millis()));
let mut body = json!({
"category": "linear",
"symbol": format_symbol(&symbol, account_type),
"side": match side {
OrderSide::Buy => "Buy",
OrderSide::Sell => "Sell",
},
"orderType": "Market",
"qty": self.precision.qty(&symbol_str, quantity),
"trailingStop": callback_rate.to_string(),
"orderLinkId": order_link_id,
});
if let Some(ap) = activation_price {
body["activePrice"] = json!(self.precision.price(&symbol_str, ap));
}
if req.reduce_only {
body["reduceOnly"] = json!(true);
}
let response = self.post(BybitEndpoint::PlaceOrder, body).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let order_id = result.get("orderId")
.and_then(|id| id.as_str())
.ok_or_else(|| ExchangeError::Parse("Missing orderId".to_string()))?
.to_string();
Ok(PlaceOrderResponse::Simple(Order {
id: order_id,
client_order_id: Some(order_link_id),
symbol: symbol.to_string(),
side,
order_type: OrderType::TrailingStop { callback_rate, activation_price },
status: crate::core::OrderStatus::New,
price: None,
stop_price: activation_price,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Gtc,
}))
}
OrderType::PostOnly { price } => {
let order_link_id = req.client_order_id.clone()
.unwrap_or_else(|| format!("cc_{}", crate::core::timestamp_millis()));
let body = json!({
"category": account_type_to_category(account_type),
"symbol": format_symbol(&symbol, account_type),
"side": match side {
OrderSide::Buy => "Buy",
OrderSide::Sell => "Sell",
},
"orderType": "Limit",
"qty": self.precision.qty(&symbol_str, quantity),
"price": self.precision.price(&symbol_str, price),
"timeInForce": "PostOnly",
"orderLinkId": order_link_id,
});
let response = self.post(BybitEndpoint::PlaceOrder, body).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let order_id = result.get("orderId")
.and_then(|id| id.as_str())
.ok_or_else(|| ExchangeError::Parse("Missing orderId".to_string()))?
.to_string();
Ok(PlaceOrderResponse::Simple(Order {
id: order_id,
client_order_id: Some(order_link_id),
symbol: symbol.to_string(),
side,
order_type: OrderType::PostOnly { price },
status: crate::core::OrderStatus::New,
price: Some(price),
stop_price: None,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::PostOnly,
}))
}
OrderType::Ioc { price } => {
let order_link_id = req.client_order_id.clone()
.unwrap_or_else(|| format!("cc_{}", crate::core::timestamp_millis()));
let body = json!({
"category": account_type_to_category(account_type),
"symbol": format_symbol(&symbol, account_type),
"side": match side {
OrderSide::Buy => "Buy",
OrderSide::Sell => "Sell",
},
"orderType": if price.is_some() { "Limit" } else { "Market" },
"qty": self.precision.qty(&symbol_str, quantity),
"price": price.map(|p| self.precision.price(&symbol_str, p)).unwrap_or_default(),
"timeInForce": "IOC",
"orderLinkId": order_link_id,
});
let response = self.post(BybitEndpoint::PlaceOrder, body).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let order_id = result.get("orderId")
.and_then(|id| id.as_str())
.ok_or_else(|| ExchangeError::Parse("Missing orderId".to_string()))?
.to_string();
Ok(PlaceOrderResponse::Simple(Order {
id: order_id,
client_order_id: Some(order_link_id),
symbol: symbol.to_string(),
side,
order_type: OrderType::Ioc { price },
status: crate::core::OrderStatus::New,
price,
stop_price: None,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Ioc,
}))
}
OrderType::Fok { price } => {
let order_link_id = req.client_order_id.clone()
.unwrap_or_else(|| format!("cc_{}", crate::core::timestamp_millis()));
let body = json!({
"category": account_type_to_category(account_type),
"symbol": format_symbol(&symbol, account_type),
"side": match side {
OrderSide::Buy => "Buy",
OrderSide::Sell => "Sell",
},
"orderType": "Limit",
"qty": self.precision.qty(&symbol_str, quantity),
"price": self.precision.price(&symbol_str, price),
"timeInForce": "FOK",
"orderLinkId": order_link_id,
});
let response = self.post(BybitEndpoint::PlaceOrder, body).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let order_id = result.get("orderId")
.and_then(|id| id.as_str())
.ok_or_else(|| ExchangeError::Parse("Missing orderId".to_string()))?
.to_string();
Ok(PlaceOrderResponse::Simple(Order {
id: order_id,
client_order_id: Some(order_link_id),
symbol: symbol.to_string(),
side,
order_type: OrderType::Fok { price },
status: crate::core::OrderStatus::New,
price: Some(price),
stop_price: None,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Fok,
}))
}
OrderType::Gtd { .. } => {
Err(ExchangeError::UnsupportedOperation(
"GTD orders are not supported on Bybit (not in TimeInForce enum)".to_string()
))
}
OrderType::ReduceOnly { price } => {
match account_type {
AccountType::Spot | AccountType::Margin => {
return Err(ExchangeError::UnsupportedOperation(
"ReduceOnly not supported for Spot/Margin".to_string()
));
}
_ => {}
}
let order_link_id = req.client_order_id.clone()
.unwrap_or_else(|| format!("cc_{}", crate::core::timestamp_millis()));
let body = json!({
"category": "linear",
"symbol": format_symbol(&symbol, account_type),
"side": match side {
OrderSide::Buy => "Buy",
OrderSide::Sell => "Sell",
},
"orderType": if price.is_some() { "Limit" } else { "Market" },
"qty": self.precision.qty(&symbol_str, quantity),
"price": price.map(|p| self.precision.price(&symbol_str, p)).unwrap_or_default(),
"reduceOnly": true,
"orderLinkId": order_link_id,
});
let response = self.post(BybitEndpoint::PlaceOrder, body).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let order_id = result.get("orderId")
.and_then(|id| id.as_str())
.ok_or_else(|| ExchangeError::Parse("Missing orderId".to_string()))?
.to_string();
Ok(PlaceOrderResponse::Simple(Order {
id: order_id,
client_order_id: Some(order_link_id),
symbol: symbol.to_string(),
side,
order_type: OrderType::ReduceOnly { price },
status: crate::core::OrderStatus::New,
price,
stop_price: None,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Gtc,
}))
}
OrderType::Iceberg { price, display_quantity } => {
let order_link_id = req.client_order_id.clone()
.unwrap_or_else(|| format!("cc_{}", crate::core::timestamp_millis()));
let body = json!({
"category": account_type_to_category(account_type),
"symbol": format_symbol(&symbol, account_type),
"side": match side {
OrderSide::Buy => "Buy",
OrderSide::Sell => "Sell",
},
"orderType": "Limit",
"qty": self.precision.qty(&symbol_str, quantity),
"price": self.precision.price(&symbol_str, price),
"timeInForce": "GTC",
"peakOrderQty": self.precision.qty(&symbol_str, display_quantity),
"orderLinkId": order_link_id,
});
let response = self.post(BybitEndpoint::PlaceOrder, body).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let order_id = result.get("orderId")
.and_then(|id| id.as_str())
.ok_or_else(|| ExchangeError::Parse("Missing orderId".to_string()))?
.to_string();
Ok(PlaceOrderResponse::Simple(Order {
id: order_id,
client_order_id: Some(order_link_id),
symbol: symbol.to_string(),
side,
order_type: OrderType::Iceberg { price, display_quantity },
status: crate::core::OrderStatus::New,
price: Some(price),
stop_price: None,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: crate::core::TimeInForce::Gtc,
}))
}
OrderType::Oco { .. } => Err(ExchangeError::UnsupportedOperation(
"OCO orders are not available via Bybit API (UI only)".to_string()
)),
OrderType::Bracket { .. } => Err(ExchangeError::UnsupportedOperation(
"Bracket orders are not supported on Bybit API (no native bracket type)".to_string()
)),
OrderType::Twap { .. } => Err(ExchangeError::UnsupportedOperation(
"TWAP orders are not available via Bybit API (UI-only strategy feature)".to_string()
)),
_ => Err(ExchangeError::UnsupportedOperation(
"This order type is not supported by Bybit".to_string()
)),
}
}
async fn get_order_history(
&self,
filter: OrderHistoryFilter,
account_type: AccountType,
) -> ExchangeResult<Vec<Order>> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
if let Some(ref s) = filter.symbol {
params.insert("symbol".to_string(), format_symbol(s, account_type));
}
if let Some(st) = filter.start_time {
params.insert("startTime".to_string(), st.to_string());
}
if let Some(et) = filter.end_time {
params.insert("endTime".to_string(), et.to_string());
}
if let Some(lim) = filter.limit {
params.insert("limit".to_string(), lim.min(50).to_string());
}
let response = self.get(BybitEndpoint::OrderHistory, params).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let list = result.get("list")
.and_then(|l| l.as_array())
.ok_or_else(|| ExchangeError::Parse("Missing list".to_string()))?;
let mut orders = Vec::new();
for order_data in list {
let wrapper = serde_json::json!({
"retCode": 0,
"retMsg": "OK",
"result": order_data,
});
if let Ok(order) = BybitParser::parse_order(&wrapper) {
orders.push(order);
}
}
Ok(orders)
}
async fn cancel_order(&self, req: CancelRequest) -> ExchangeResult<Order> {
match req.scope {
CancelScope::Single { ref order_id } => {
let symbol = req.symbol.as_ref()
.ok_or_else(|| ExchangeError::InvalidRequest("Symbol required for cancel".into()))?
.clone();
let account_type = req.account_type;
let body = json!({
"category": account_type_to_category(account_type),
"symbol": format_symbol(&symbol, account_type),
"orderId": order_id,
});
let response = self.post(BybitEndpoint::CancelOrder, body).await?;
self.check_response(&response)?;
Ok(Order {
id: order_id.to_string(),
client_order_id: None,
symbol: symbol.to_string(),
side: OrderSide::Buy,
order_type: OrderType::Limit { price: 0.0 },
status: crate::core::OrderStatus::Canceled,
price: None,
stop_price: None,
quantity: 0.0,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: 0,
updated_at: Some(crate::core::timestamp_millis() as i64),
time_in_force: crate::core::TimeInForce::Gtc,
})
}
CancelScope::All { ref symbol } => {
let sym = symbol.as_ref()
.ok_or_else(|| ExchangeError::InvalidRequest("Symbol required for cancel-all on Bybit".into()))?;
let account_type = req.account_type;
let body = json!({
"category": account_type_to_category(account_type),
"symbol": format_symbol(sym, account_type),
});
let response = self.post(BybitEndpoint::CancelAllOrders, body).await?;
self.check_response(&response)?;
Ok(Order {
id: "cancel-all".to_string(),
client_order_id: None,
symbol: sym.to_string(),
side: OrderSide::Buy,
order_type: OrderType::Market,
status: crate::core::OrderStatus::Canceled,
price: None,
stop_price: None,
quantity: 0.0,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: 0,
updated_at: Some(crate::core::timestamp_millis() as i64),
time_in_force: crate::core::TimeInForce::Gtc,
})
}
CancelScope::BySymbol { ref symbol } => {
let account_type = req.account_type;
let body = json!({
"category": account_type_to_category(account_type),
"symbol": format_symbol(symbol, account_type),
});
let response = self.post(BybitEndpoint::CancelAllOrders, body).await?;
self.check_response(&response)?;
Ok(Order {
id: "cancel-by-symbol".to_string(),
client_order_id: None,
symbol: symbol.to_string(),
side: OrderSide::Buy,
order_type: OrderType::Market,
status: crate::core::OrderStatus::Canceled,
price: None,
stop_price: None,
quantity: 0.0,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: 0,
updated_at: Some(crate::core::timestamp_millis() as i64),
time_in_force: crate::core::TimeInForce::Gtc,
})
}
CancelScope::Batch { ref order_ids } => {
let _ = order_ids;
Err(ExchangeError::UnsupportedOperation(
"Batch cancel not natively supported on Bybit V5 (no atomic batch-cancel endpoint)".to_string()
))
}
_ => Err(ExchangeError::UnsupportedOperation(
"This cancel scope is not supported by Bybit".to_string()
)),
}
}
async fn get_order(
&self,
symbol: &str,
order_id: &str,
account_type: AccountType,
) -> ExchangeResult<Order> {
let symbol_parts: Vec<&str> = symbol.split('/').collect();
let symbol = if symbol_parts.len() == 2 {
crate::core::Symbol::new(symbol_parts[0], symbol_parts[1])
} else {
crate::core::Symbol { base: symbol.to_string(), quote: String::new(), raw: Some(symbol.to_string()) }
};
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
params.insert("symbol".to_string(), format_symbol(&symbol, account_type));
params.insert("orderId".to_string(), order_id.to_string());
let response = self.get(BybitEndpoint::OrderStatus, params).await?;
BybitParser::parse_order(&response)
}
async fn get_open_orders(
&self,
symbol: Option<&str>,
account_type: AccountType,
) -> ExchangeResult<Vec<Order>> {
let symbol_str = symbol;
let symbol: Option<crate::core::Symbol> = symbol_str.map(|s| {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() == 2 {
crate::core::Symbol::new(parts[0], parts[1])
} else {
crate::core::Symbol { base: s.to_string(), quote: String::new(), raw: Some(s.to_string()) }
}
});
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
if let Some(s) = symbol {
params.insert("symbol".to_string(), format_symbol(&s, account_type));
}
let response = self.get(BybitEndpoint::OpenOrders, params).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let list = result.get("list")
.and_then(|l| l.as_array())
.ok_or_else(|| ExchangeError::Parse("Missing list".to_string()))?;
let mut orders = Vec::new();
for order_data in list {
let wrapper = json!({
"retCode": 0,
"retMsg": "OK",
"result": order_data,
});
if let Ok(order) = BybitParser::parse_order(&wrapper) {
orders.push(order);
}
}
Ok(orders)
}
async fn get_user_trades(
&self,
filter: UserTradeFilter,
account_type: AccountType,
) -> ExchangeResult<Vec<UserTrade>> {
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
if let Some(ref s) = filter.symbol {
params.insert("symbol".to_string(), s.clone());
}
if let Some(ref oid) = filter.order_id {
params.insert("orderId".to_string(), oid.clone());
}
if let Some(st) = filter.start_time {
params.insert("startTime".to_string(), st.to_string());
}
if let Some(et) = filter.end_time {
params.insert("endTime".to_string(), et.to_string());
}
if let Some(lim) = filter.limit {
params.insert("limit".to_string(), lim.min(100).to_string());
}
let response = self.get(BybitEndpoint::MyTrades, params).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let list = result.get("list")
.and_then(|l| l.as_array())
.ok_or_else(|| ExchangeError::Parse("Missing list".to_string()))?;
let trades = list
.iter()
.filter_map(|item| BybitParser::parse_user_trade(item).ok())
.collect();
Ok(trades)
}
}
#[async_trait]
impl Account for BybitConnector {
async fn get_balance(&self, query: BalanceQuery) -> ExchangeResult<Vec<Balance>> {
let _asset = query.asset.clone();
let account_type = query.account_type;
let mut params = HashMap::new();
params.insert("accountType".to_string(), match account_type {
AccountType::Spot | AccountType::Margin => "UNIFIED",
AccountType::FuturesCross | AccountType::FuturesIsolated => "CONTRACT",
_ => "UNIFIED",
}.to_string());
let response = self.get(BybitEndpoint::Balance, params).await?;
BybitParser::parse_balance(&response)
}
async fn get_account_info(&self, account_type: AccountType) -> ExchangeResult<AccountInfo> {
let response = self.get(BybitEndpoint::AccountInfo, HashMap::new()).await?;
let balances = self.get_balance(BalanceQuery { asset: None, account_type }).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let can_trade = result.get("unifiedMarginStatus")
.and_then(|s| s.as_i64())
.map(|s| s == 1)
.unwrap_or(true);
Ok(AccountInfo {
account_type,
can_trade,
can_withdraw: true,
can_deposit: true,
maker_commission: 0.1, taker_commission: 0.1,
balances,
})
}
async fn get_fees(&self, symbol: Option<&str>) -> ExchangeResult<FeeInfo> {
let mut params = HashMap::new();
params.insert("category".to_string(), "spot".to_string());
if let Some(s) = symbol {
params.insert("symbol".to_string(), s.to_string());
}
let response = self.get(BybitEndpoint::FeeRate, params).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let list = result.get("list")
.and_then(|l| l.as_array())
.and_then(|a| a.first())
.ok_or_else(|| ExchangeError::Parse("Empty fee list".to_string()))?;
let maker_rate = list.get("makerFeeRate")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.001);
let taker_rate = list.get("takerFeeRate")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.001);
Ok(FeeInfo {
maker_rate,
taker_rate,
symbol: symbol.map(String::from),
tier: None,
})
}
}
#[async_trait]
impl Positions for BybitConnector {
async fn get_positions(&self, query: PositionQuery) -> ExchangeResult<Vec<Position>> {
let symbol = query.symbol.clone();
let account_type = query.account_type;
match account_type {
AccountType::Spot | AccountType::Margin => {
return Err(ExchangeError::UnsupportedOperation(
"Positions not supported for Spot/Margin".to_string()
));
}
_ => {}
}
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
if let Some(ref s) = symbol {
params.insert("symbol".to_string(), format_symbol(s, account_type));
}
let response = self.get(BybitEndpoint::Positions, params).await?;
let result = response.get("result")
.ok_or_else(|| ExchangeError::Parse("Missing result".to_string()))?;
let list = result.get("list")
.and_then(|l| l.as_array())
.ok_or_else(|| ExchangeError::Parse("Missing list".to_string()))?;
let mut positions = Vec::new();
for pos_data in list {
let symbol_str = pos_data.get("symbol")
.and_then(|s| s.as_str())
.unwrap_or("");
let quantity = pos_data.get("size")
.and_then(|s| s.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
if quantity == 0.0 {
continue;
}
let side = pos_data.get("side")
.and_then(|s| s.as_str())
.map(|s| match s {
"Buy" => crate::core::PositionSide::Long,
"Sell" => crate::core::PositionSide::Short,
_ => crate::core::PositionSide::Long,
})
.unwrap_or(crate::core::PositionSide::Long);
let entry_price = pos_data.get("avgPrice")
.and_then(|p| p.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let unrealized_pnl = pos_data.get("unrealisedPnl")
.and_then(|p| p.as_str())
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let leverage = pos_data.get("leverage")
.and_then(|l| l.as_str())
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1);
let liquidation_price = pos_data.get("liqPrice")
.and_then(|p| p.as_str())
.and_then(|s| s.parse::<f64>().ok());
let mark_price = pos_data.get("markPrice")
.and_then(|p| p.as_str())
.and_then(|s| s.parse::<f64>().ok());
let margin_type = match account_type {
AccountType::FuturesCross => crate::core::MarginType::Cross,
AccountType::FuturesIsolated => crate::core::MarginType::Isolated,
_ => crate::core::MarginType::Cross,
};
positions.push(Position {
symbol: symbol_str.to_string(),
side,
quantity,
entry_price,
mark_price,
unrealized_pnl,
realized_pnl: None,
liquidation_price,
leverage,
margin_type,
margin: None,
take_profit: None,
stop_loss: None,
});
}
Ok(positions)
}
async fn get_funding_rate(
&self,
symbol: &str,
account_type: AccountType,
) -> ExchangeResult<FundingRate> {
let symbol_str = symbol;
let symbol = {
let parts: Vec<&str> = symbol_str.split('/').collect();
if parts.len() == 2 {
crate::core::Symbol::new(parts[0], parts[1])
} else {
crate::core::Symbol { base: symbol_str.to_string(), quote: String::new(), raw: Some(symbol_str.to_string()) }
}
};
match account_type {
AccountType::Spot | AccountType::Margin => {
return Err(ExchangeError::UnsupportedOperation(
"Funding rate not supported for Spot/Margin".to_string()
));
}
_ => {}
}
let mut params = HashMap::new();
params.insert("category".to_string(), account_type_to_category(account_type).to_string());
params.insert("symbol".to_string(), format_symbol(&symbol, account_type));
let response = self.get(BybitEndpoint::FundingRate, params).await?;
BybitParser::parse_funding_rate(&response)
}
async fn modify_position(&self, req: PositionModification) -> ExchangeResult<()> {
match req {
PositionModification::SetLeverage { ref symbol, leverage, account_type } => {
let symbol = symbol.clone();
match account_type {
AccountType::Spot | AccountType::Margin => {
return Err(ExchangeError::UnsupportedOperation(
"Leverage not supported for Spot/Margin".to_string()
));
}
_ => {}
}
let body = json!({
"category": account_type_to_category(account_type),
"symbol": format_symbol(&symbol, account_type),
"buyLeverage": leverage.to_string(),
"sellLeverage": leverage.to_string(),
});
let response = self.post(BybitEndpoint::SetLeverage, body).await?;
self.check_response(&response)?;
Ok(())
}
PositionModification::SetMarginMode { ref symbol, margin_type, account_type } => {
let symbol = symbol.clone();
match account_type {
AccountType::Spot | AccountType::Margin => {
return Err(ExchangeError::UnsupportedOperation(
"SetMarginMode not supported for Spot/Margin".to_string()
));
}
_ => {}
}
let trade_mode = match margin_type {
MarginType::Cross => 0i32,
MarginType::Isolated => 1i32,
};
let body = json!({
"category": "linear",
"symbol": format_symbol(&symbol, account_type),
"tradeMode": trade_mode,
"buyLeverage": "1",
"sellLeverage": "1",
});
let response = self.post(BybitEndpoint::SetMarginMode, body).await?;
self.check_response(&response)?;
Ok(())
}
PositionModification::AddMargin { ref symbol, amount, account_type } => {
let symbol = symbol.clone();
match account_type {
AccountType::Spot | AccountType::Margin => {
return Err(ExchangeError::UnsupportedOperation(
"AddMargin not supported for Spot/Margin".to_string()
));
}
_ => {}
}
let body = json!({
"category": "linear",
"symbol": format_symbol(&symbol, account_type),
"margin": amount.to_string(),
"positionIdx": 0,
});
let response = self.post(BybitEndpoint::AddMargin, body).await?;
self.check_response(&response)?;
Ok(())
}
PositionModification::RemoveMargin { ref symbol, amount, account_type } => {
let symbol = symbol.clone();
match account_type {
AccountType::Spot | AccountType::Margin => {
return Err(ExchangeError::UnsupportedOperation(
"RemoveMargin not supported for Spot/Margin".to_string()
));
}
_ => {}
}
let body = json!({
"category": "linear",
"symbol": format_symbol(&symbol, account_type),
"margin": format!("-{}", amount),
"positionIdx": 0,
});
let response = self.post(BybitEndpoint::AddMargin, body).await?;
self.check_response(&response)?;
Ok(())
}
PositionModification::ClosePosition { ref symbol, account_type } => {
let symbol = symbol.clone();
match account_type {
AccountType::Spot | AccountType::Margin => {
return Err(ExchangeError::UnsupportedOperation(
"ClosePosition not supported for Spot/Margin".to_string()
));
}
_ => {}
}
let order_link_id = format!("close_{}", crate::core::timestamp_millis());
let body = json!({
"category": "linear",
"symbol": format_symbol(&symbol, account_type),
"side": "Sell", "orderType": "Market",
"qty": "0",
"reduceOnly": true,
"closeOnTrigger": true,
"orderLinkId": order_link_id,
});
let response = self.post(BybitEndpoint::PlaceOrder, body).await?;
self.check_response(&response)?;
Ok(())
}
PositionModification::SetTpSl { ref symbol, take_profit, stop_loss, account_type } => {
let symbol = symbol.clone();
match account_type {
AccountType::Spot | AccountType::Margin => {
return Err(ExchangeError::UnsupportedOperation(
"SetTpSl not supported for Spot/Margin".to_string()
));
}
_ => {}
}
let mut body = json!({
"category": "linear",
"symbol": format_symbol(&symbol, account_type),
"positionIdx": 0,
"tpslMode": "Full",
});
if let Some(tp) = take_profit {
body["takeProfit"] = json!(tp.to_string());
}
if let Some(sl) = stop_loss {
body["stopLoss"] = json!(sl.to_string());
}
let response = self.post(BybitEndpoint::TpSlMode, body).await?;
self.check_response(&response)?;
Ok(())
}
_ => Err(ExchangeError::UnsupportedOperation(
"This position modification is not supported by Bybit".to_string()
)),
}
}
}
#[async_trait]
impl CancelAll for BybitConnector {
async fn cancel_all_orders(
&self,
scope: CancelScope,
account_type: AccountType,
) -> ExchangeResult<CancelAllResponse> {
let symbol = match &scope {
CancelScope::All { symbol } => symbol.clone(),
CancelScope::BySymbol { symbol } => Some(symbol.clone()),
_ => {
return Err(ExchangeError::InvalidRequest(
"cancel_all_orders only accepts All or BySymbol scope".to_string()
));
}
};
let mut body = json!({
"category": account_type_to_category(account_type),
});
if let Some(sym) = symbol {
body["symbol"] = json!(format_symbol(&sym, account_type));
}
let response = self.post(BybitEndpoint::CancelAllOrders, body).await?;
BybitParser::parse_cancel_all_response(&response)
}
}
#[async_trait]
impl AmendOrder for BybitConnector {
async fn amend_order(&self, req: AmendRequest) -> ExchangeResult<Order> {
if req.fields.price.is_none() && req.fields.quantity.is_none() && req.fields.trigger_price.is_none() {
return Err(ExchangeError::InvalidRequest(
"At least one of price, quantity, or trigger_price must be provided for amend".to_string()
));
}
let account_type = req.account_type;
let amend_symbol_str = format_symbol(&req.symbol, account_type);
let mut body = json!({
"category": account_type_to_category(account_type),
"symbol": amend_symbol_str.clone(),
"orderId": req.order_id,
});
if let Some(price) = req.fields.price {
body["price"] = json!(self.precision.price(&amend_symbol_str, price));
}
if let Some(qty) = req.fields.quantity {
body["qty"] = json!(self.precision.qty(&amend_symbol_str, qty));
}
if let Some(trigger_price) = req.fields.trigger_price {
body["triggerPrice"] = json!(self.precision.price(&amend_symbol_str, trigger_price));
}
let response = self.post(BybitEndpoint::AmendOrder, body).await?;
BybitParser::parse_amend_order_response(&response)
}
}
#[async_trait]
impl BatchOrders for BybitConnector {
async fn place_orders_batch(
&self,
orders: Vec<OrderRequest>,
) -> ExchangeResult<Vec<OrderResult>> {
if orders.is_empty() {
return Ok(vec![]);
}
if orders.len() > self.max_batch_place_size() {
return Err(ExchangeError::InvalidRequest(
format!("Batch size {} exceeds Bybit limit of {}", orders.len(), self.max_batch_place_size())
));
}
let account_type = orders[0].account_type;
let category = account_type_to_category(account_type);
let order_list: Vec<serde_json::Value> = orders.iter().map(|req| {
let mut obj = serde_json::Map::new();
obj.insert("category".to_string(), json!(category));
obj.insert("symbol".to_string(), json!(format_symbol(&req.symbol, req.account_type)));
obj.insert("side".to_string(), json!(match req.side {
OrderSide::Buy => "Buy",
OrderSide::Sell => "Sell",
}));
let batch_sym_str = format_symbol(&req.symbol, req.account_type);
match &req.order_type {
OrderType::Market => {
obj.insert("orderType".to_string(), json!("Market"));
obj.insert("qty".to_string(), json!(self.precision.qty(&batch_sym_str, req.quantity)));
}
OrderType::Limit { price } => {
obj.insert("orderType".to_string(), json!("Limit"));
obj.insert("qty".to_string(), json!(self.precision.qty(&batch_sym_str, req.quantity)));
obj.insert("price".to_string(), json!(self.precision.price(&batch_sym_str, *price)));
obj.insert("timeInForce".to_string(), json!("GTC"));
}
_ => {
obj.insert("orderType".to_string(), json!("Market"));
obj.insert("qty".to_string(), json!(self.precision.qty(&batch_sym_str, req.quantity)));
}
}
if req.reduce_only {
obj.insert("reduceOnly".to_string(), json!(true));
}
if let Some(ref cid) = req.client_order_id {
obj.insert("orderLinkId".to_string(), json!(cid));
}
serde_json::Value::Object(obj)
}).collect();
let body = json!({
"category": category,
"request": order_list,
});
let response = self.post(BybitEndpoint::BatchPlaceOrders, body).await?;
BybitParser::parse_batch_orders_response(&response)
}
async fn cancel_orders_batch(
&self,
order_ids: Vec<String>,
symbol: Option<&str>,
account_type: AccountType,
) -> ExchangeResult<Vec<OrderResult>> {
if order_ids.is_empty() {
return Ok(vec![]);
}
if order_ids.len() > self.max_batch_cancel_size() {
return Err(ExchangeError::InvalidRequest(
format!("Batch cancel size {} exceeds Bybit limit of {}", order_ids.len(), self.max_batch_cancel_size())
));
}
let category = account_type_to_category(account_type);
let sym = symbol.ok_or_else(|| ExchangeError::InvalidRequest(
"Symbol is required for batch cancel on Bybit".to_string()
))?;
let cancel_list: Vec<serde_json::Value> = order_ids.iter().map(|id| {
json!({
"symbol": sym.replace('/', "").to_uppercase(),
"orderId": id,
})
}).collect();
let body = json!({
"category": category,
"request": cancel_list,
});
let response = self.post(BybitEndpoint::BatchCancelOrders, body).await?;
BybitParser::parse_batch_orders_response(&response)
}
fn max_batch_place_size(&self) -> usize {
10 }
fn max_batch_cancel_size(&self) -> usize {
10 }
}
impl BybitConnector {
pub async fn batch_amend_orders(
&self,
amends: Vec<serde_json::Value>,
account_type: AccountType,
) -> ExchangeResult<Value> {
if amends.is_empty() {
return Ok(serde_json::Value::Array(vec![]));
}
if amends.len() > 10 {
return Err(ExchangeError::InvalidRequest(
format!("Batch amend size {} exceeds Bybit limit of 10", amends.len())
));
}
let category = account_type_to_category(account_type);
let body = json!({
"category": category,
"request": amends,
});
self.post(BybitEndpoint::BatchAmendOrders, body).await
}
}
#[async_trait]
impl AccountTransfers for BybitConnector {
async fn transfer(&self, req: TransferRequest) -> ExchangeResult<TransferResponse> {
let transfer_id = format!("t{}", crate::core::timestamp_millis());
let body = serde_json::json!({
"transferId": transfer_id,
"coin": req.asset,
"amount": req.amount.to_string(),
"fromAccountType": account_type_to_transfer_type(req.from_account),
"toAccountType": account_type_to_transfer_type(req.to_account),
});
let response = self.post(BybitEndpoint::InterTransfer, body).await?;
let mut result = BybitParser::parse_transfer_response(&response)?;
result.asset = req.asset;
result.amount = req.amount;
Ok(result)
}
async fn get_transfer_history(
&self,
filter: TransferHistoryFilter,
) -> ExchangeResult<Vec<TransferResponse>> {
let mut params = HashMap::new();
if let Some(st) = filter.start_time {
params.insert("startTime".to_string(), st.to_string());
}
if let Some(et) = filter.end_time {
params.insert("endTime".to_string(), et.to_string());
}
if let Some(lim) = filter.limit {
params.insert("limit".to_string(), lim.to_string());
}
let response = self.get(BybitEndpoint::TransferHistory, params).await?;
BybitParser::parse_transfer_history(&response)
}
}
#[async_trait]
impl CustodialFunds for BybitConnector {
async fn get_deposit_address(
&self,
asset: &str,
network: Option<&str>,
) -> ExchangeResult<DepositAddress> {
let mut params = HashMap::new();
params.insert("coin".to_string(), asset.to_uppercase());
if let Some(net) = network {
params.insert("chainType".to_string(), net.to_string());
}
let response = self.get(BybitEndpoint::DepositAddress, params).await?;
BybitParser::parse_deposit_address(&response, asset, network)
}
async fn withdraw(&self, req: WithdrawRequest) -> ExchangeResult<WithdrawResponse> {
let mut body = serde_json::json!({
"coin": req.asset,
"amount": req.amount.to_string(),
"address": req.address,
"forceChain": 1,
});
if let Some(net) = req.network {
body["chain"] = serde_json::Value::String(net);
}
if let Some(tag) = req.tag {
body["tag"] = serde_json::Value::String(tag);
}
let response = self.post(BybitEndpoint::Withdraw, body).await?;
BybitParser::parse_withdraw_response(&response)
}
async fn get_funds_history(
&self,
filter: FundsHistoryFilter,
) -> ExchangeResult<Vec<FundsRecord>> {
match filter.record_type {
FundsRecordType::Deposit => {
let mut params = HashMap::new();
if let Some(ref asset) = filter.asset {
params.insert("coin".to_string(), asset.clone());
}
if let Some(st) = filter.start_time {
params.insert("startTime".to_string(), st.to_string());
}
if let Some(et) = filter.end_time {
params.insert("endTime".to_string(), et.to_string());
}
if let Some(lim) = filter.limit {
params.insert("limit".to_string(), lim.to_string());
}
let response = self.get(BybitEndpoint::DepositHistory, params).await?;
BybitParser::parse_deposit_history(&response)
}
FundsRecordType::Withdrawal => {
let mut params = HashMap::new();
if let Some(ref asset) = filter.asset {
params.insert("coin".to_string(), asset.clone());
}
if let Some(st) = filter.start_time {
params.insert("startTime".to_string(), st.to_string());
}
if let Some(et) = filter.end_time {
params.insert("endTime".to_string(), et.to_string());
}
if let Some(lim) = filter.limit {
params.insert("limit".to_string(), lim.to_string());
}
let response = self.get(BybitEndpoint::WithdrawHistory, params).await?;
BybitParser::parse_withdrawal_history(&response)
}
FundsRecordType::Both => {
let deposit_filter = FundsHistoryFilter {
record_type: FundsRecordType::Deposit,
..filter.clone()
};
let withdrawal_filter = FundsHistoryFilter {
record_type: FundsRecordType::Withdrawal,
..filter
};
let mut deposits = self.get_funds_history(deposit_filter).await?;
let withdrawals = self.get_funds_history(withdrawal_filter).await?;
deposits.extend(withdrawals);
Ok(deposits)
}
}
}
}
#[async_trait]
impl SubAccounts for BybitConnector {
async fn sub_account_operation(
&self,
op: SubAccountOperation,
) -> ExchangeResult<SubAccountResult> {
match op {
SubAccountOperation::Create { label } => {
let body = serde_json::json!({
"username": label,
"memberType": 1, });
let response = self.post(BybitEndpoint::CreateSubMember, body).await?;
BybitParser::parse_create_sub_member(&response)
}
SubAccountOperation::List => {
let response = self.get(BybitEndpoint::ListSubMembers, HashMap::new()).await?;
BybitParser::parse_list_sub_members(&response)
}
SubAccountOperation::Transfer { sub_account_id, asset, amount, to_sub } => {
let transfer_id = format!("u{}", crate::core::timestamp_millis());
let (from_member, to_member) = if to_sub {
("".to_string(), sub_account_id.clone())
} else {
(sub_account_id.clone(), "".to_string())
};
let body = serde_json::json!({
"transferId": transfer_id,
"coin": asset,
"amount": amount.to_string(),
"fromMemberId": from_member,
"toMemberId": to_member,
"fromAccountType": "UNIFIED",
"toAccountType": "UNIFIED",
});
let response = self.post(BybitEndpoint::UniversalTransfer, body).await?;
BybitParser::parse_universal_transfer(&response)
}
SubAccountOperation::GetBalance { sub_account_id } => {
let mut params = HashMap::new();
params.insert("memberId".to_string(), sub_account_id);
params.insert("accountType".to_string(), "UNIFIED".to_string());
let response = self.get(BybitEndpoint::SubAccountBalance, params).await?;
BybitParser::parse_sub_account_balance(&response)
}
}
}
}
#[async_trait]
impl FundingHistory for BybitConnector {
async fn get_funding_payments(
&self,
filter: FundingFilter,
account_type: AccountType,
) -> ExchangeResult<Vec<FundingPayment>> {
let mut params: HashMap<String, String> = HashMap::new();
params.insert("type".to_string(), "SETTLEMENT".to_string());
let acct_type_str = match account_type {
AccountType::Spot => "SPOT",
_ => "UNIFIED",
};
params.insert("accountType".to_string(), acct_type_str.to_string());
if let Some(symbol) = &filter.symbol {
params.insert("symbol".to_string(), symbol.to_uppercase());
}
if let Some(start) = filter.start_time {
params.insert("startTime".to_string(), start.to_string());
}
if let Some(end) = filter.end_time {
params.insert("endTime".to_string(), end.to_string());
}
if let Some(limit) = filter.limit {
params.insert("limit".to_string(), limit.min(50).to_string());
}
let response = self.get(BybitEndpoint::TransactionLog, params).await?;
BybitParser::parse_funding_payments(&response)
}
}
#[async_trait]
impl AccountLedger for BybitConnector {
async fn get_ledger(
&self,
filter: LedgerFilter,
account_type: AccountType,
) -> ExchangeResult<Vec<LedgerEntry>> {
let mut params: HashMap<String, String> = HashMap::new();
let acct_type_str = match account_type {
AccountType::Spot => "SPOT",
_ => "UNIFIED",
};
params.insert("accountType".to_string(), acct_type_str.to_string());
if let Some(asset) = &filter.asset {
params.insert("currency".to_string(), asset.to_uppercase());
}
if let Some(start) = filter.start_time {
params.insert("startTime".to_string(), start.to_string());
}
if let Some(end) = filter.end_time {
params.insert("endTime".to_string(), end.to_string());
}
if let Some(limit) = filter.limit {
params.insert("limit".to_string(), limit.min(50).to_string());
}
let response = self.get(BybitEndpoint::TransactionLog, params).await?;
let mut entries = BybitParser::parse_ledger(&response)?;
if let Some(ref type_filter) = filter.entry_type {
entries.retain(|e| &e.entry_type == type_filter);
}
Ok(entries)
}
}