use serde::Deserialize;
use serde_json::Value;
use crate::actors::{CandleData, FundingData};
use crate::error::{ExchangeError, Result};
use crate::http::PublicRestClient;
const BASE_URL: &str = "https://api.bybit.com";
const EXCHANGE_NAME: &str = "bybit";
pub(super) mod str_f64 {
use serde::{Deserialize, Deserializer};
pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum SF {
S(String),
F(f64),
}
match SF::deserialize(d)? {
SF::S(s) if s.is_empty() => Ok(0.0),
SF::S(s) => s.parse().map_err(serde::de::Error::custom),
SF::F(f) => Ok(f),
}
}
}
pub(super) mod str_i64 {
use serde::{Deserialize, Deserializer};
pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum SI {
S(String),
I(i64),
}
match SI::deserialize(d)? {
SI::S(s) => s.parse().map_err(serde::de::Error::custom),
SI::I(i) => Ok(i),
}
}
}
pub(super) mod opt_str_f64 {
use serde::{Deserialize, Deserializer};
pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum W {
None,
S(String),
F(f64),
}
match Option::<W>::deserialize(d)? {
None | Some(W::None) => Ok(None),
Some(W::S(s)) if s.is_empty() => Ok(None),
Some(W::S(s)) => s.parse().map(Some).map_err(serde::de::Error::custom),
Some(W::F(f)) => Ok(Some(f)),
}
}
}
pub fn unwrap_bybit_envelope<T: serde::de::DeserializeOwned>(raw: Value) -> Result<T> {
let ret_code = raw.get("retCode").and_then(Value::as_i64).unwrap_or(-1);
if ret_code != 0 {
let message = raw
.get("retMsg")
.and_then(Value::as_str)
.unwrap_or("no message")
.to_string();
return Err(ExchangeError::Api {
code: ret_code.to_string(),
message,
});
}
let result = raw.get("result").cloned().unwrap_or(Value::Null);
serde_json::from_value(result).map_err(ExchangeError::Json)
}
fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BybitCategory {
Spot,
Linear,
Inverse,
}
impl BybitCategory {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Spot => "spot",
Self::Linear => "linear",
Self::Inverse => "inverse",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct BybitListResult<T> {
pub category: String,
pub list: Vec<T>,
}
#[derive(Debug, Clone)]
pub struct BybitKline {
pub start_time: i64,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
pub turnover: f64,
}
impl<'de> Deserialize<'de> for BybitKline {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
type Raw = (String, String, String, String, String, String, String);
let (start_s, open_s, high_s, low_s, close_s, vol_s, turn_s) = Raw::deserialize(d)?;
let parse_f = |s: String| s.parse::<f64>().map_err(serde::de::Error::custom);
let parse_i = |s: String| s.parse::<i64>().map_err(serde::de::Error::custom);
Ok(Self {
start_time: parse_i(start_s)?,
open: parse_f(open_s)?,
high: parse_f(high_s)?,
low: parse_f(low_s)?,
close: parse_f(close_s)?,
volume: parse_f(vol_s)?,
turnover: parse_f(turn_s)?,
})
}
}
impl BybitKline {
#[must_use]
pub fn into_candle_data(
self,
symbol: impl Into<String>,
interval: impl Into<String>,
) -> CandleData {
CandleData {
symbol: symbol.into(),
exchange: EXCHANGE_NAME.into(),
interval: interval.into(),
open_ts: self.start_time,
open: self.open,
high: self.high,
low: self.low,
close: self.close,
volume: self.volume,
is_closed: true,
receipt_ts: now_ms(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct BybitOrderBook {
#[serde(rename = "s")]
pub symbol: String,
#[serde(rename = "b")]
pub bids: Vec<[String; 2]>,
#[serde(rename = "a")]
pub asks: Vec<[String; 2]>,
pub ts: i64,
#[serde(rename = "u")]
pub update_id: i64,
}
impl BybitOrderBook {
#[must_use]
pub fn bids_f64(&self) -> Vec<[f64; 2]> {
Self::parse_levels(&self.bids)
}
#[must_use]
pub fn asks_f64(&self) -> Vec<[f64; 2]> {
Self::parse_levels(&self.asks)
}
fn parse_levels(rows: &[[String; 2]]) -> Vec<[f64; 2]> {
rows.iter()
.filter_map(|[p, q]| Some([p.parse().ok()?, q.parse().ok()?]))
.collect()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BybitTicker {
pub symbol: String,
#[serde(with = "str_f64")]
pub last_price: f64,
#[serde(default, with = "opt_str_f64", rename = "bid1Price")]
pub bid1_price: Option<f64>,
#[serde(default, with = "opt_str_f64", rename = "bid1Size")]
pub bid1_size: Option<f64>,
#[serde(default, with = "opt_str_f64", rename = "ask1Price")]
pub ask1_price: Option<f64>,
#[serde(default, with = "opt_str_f64", rename = "ask1Size")]
pub ask1_size: Option<f64>,
#[serde(default, with = "opt_str_f64")]
pub high_price_24h: Option<f64>,
#[serde(default, with = "opt_str_f64")]
pub low_price_24h: Option<f64>,
#[serde(default, with = "opt_str_f64")]
pub volume_24h: Option<f64>,
#[serde(default, with = "opt_str_f64")]
pub turnover_24h: Option<f64>,
#[serde(default, with = "opt_str_f64")]
pub mark_price: Option<f64>,
#[serde(default, with = "opt_str_f64")]
pub index_price: Option<f64>,
#[serde(default, with = "opt_str_f64")]
pub funding_rate: Option<f64>,
#[serde(default, rename = "nextFundingTime")]
pub next_funding_time: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BybitTrade {
pub exec_id: String,
pub symbol: String,
#[serde(with = "str_f64")]
pub price: f64,
#[serde(with = "str_f64")]
pub size: f64,
pub side: String,
#[serde(with = "str_i64")]
pub time: i64,
#[serde(default)]
pub is_block_trade: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BybitFundingRate {
pub symbol: String,
#[serde(with = "str_f64")]
pub funding_rate: f64,
#[serde(with = "str_i64")]
pub funding_rate_timestamp: i64,
}
impl BybitFundingRate {
#[must_use]
pub fn into_funding_data(self) -> FundingData {
FundingData {
symbol: self.symbol,
exchange: EXCHANGE_NAME.into(),
funding_rate: self.funding_rate,
next_funding_time: self.funding_rate_timestamp,
mark_price: None,
index_price: None,
exchange_ts: self.funding_rate_timestamp,
receipt_ts: now_ms(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BybitOpenInterest {
#[serde(with = "str_f64")]
pub open_interest: f64,
#[serde(with = "str_i64")]
pub timestamp: i64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BybitLongShortRatio {
pub symbol: String,
#[serde(with = "str_f64")]
pub buy_ratio: f64,
#[serde(with = "str_f64")]
pub sell_ratio: f64,
#[serde(with = "str_i64")]
pub timestamp: i64,
}
#[derive(Clone)]
pub struct BybitRestClient {
http: PublicRestClient,
}
impl BybitRestClient {
pub fn new() -> Result<Self> {
Self::with_base_url(BASE_URL)
}
pub fn with_base_url(base_url: impl Into<String>) -> Result<Self> {
Ok(Self {
http: PublicRestClient::new(base_url)?,
})
}
pub async fn get_klines(
&self,
category: BybitCategory,
symbol: &str,
interval: &str,
limit: u32,
) -> Result<BybitListResult<BybitKline>> {
let limit_s = limit.to_string();
let raw: Value = self
.http
.get(
"/v5/market/kline",
&[
("category", category.as_str()),
("symbol", symbol),
("interval", interval),
("limit", &limit_s),
],
)
.await?;
unwrap_bybit_envelope(raw)
}
pub async fn get_orderbook(
&self,
category: BybitCategory,
symbol: &str,
limit: u32,
) -> Result<BybitOrderBook> {
let limit_s = limit.to_string();
let raw: Value = self
.http
.get(
"/v5/market/orderbook",
&[
("category", category.as_str()),
("symbol", symbol),
("limit", &limit_s),
],
)
.await?;
unwrap_bybit_envelope(raw)
}
pub async fn get_tickers(
&self,
category: BybitCategory,
symbol: Option<&str>,
) -> Result<BybitListResult<BybitTicker>> {
let mut params: Vec<(&str, &str)> = vec![("category", category.as_str())];
if let Some(s) = symbol {
params.push(("symbol", s));
}
let raw: Value = self.http.get("/v5/market/tickers", ¶ms).await?;
unwrap_bybit_envelope(raw)
}
pub async fn get_recent_trades(
&self,
category: BybitCategory,
symbol: &str,
limit: u32,
) -> Result<BybitListResult<BybitTrade>> {
let limit_s = limit.to_string();
let raw: Value = self
.http
.get(
"/v5/market/recent-trade",
&[
("category", category.as_str()),
("symbol", symbol),
("limit", &limit_s),
],
)
.await?;
unwrap_bybit_envelope(raw)
}
pub async fn get_instruments(&self, category: BybitCategory) -> Result<Value> {
let raw: Value = self
.http
.get(
"/v5/market/instruments-info",
&[("category", category.as_str())],
)
.await?;
unwrap_bybit_envelope(raw)
}
pub async fn get_funding_rate(
&self,
category: BybitCategory,
symbol: &str,
limit: u32,
) -> Result<BybitListResult<BybitFundingRate>> {
let limit_s = limit.to_string();
let raw: Value = self
.http
.get(
"/v5/market/funding/history",
&[
("category", category.as_str()),
("symbol", symbol),
("limit", &limit_s),
],
)
.await?;
unwrap_bybit_envelope(raw)
}
pub async fn get_open_interest(
&self,
category: BybitCategory,
symbol: &str,
interval_time: &str,
limit: u32,
) -> Result<BybitListResult<BybitOpenInterest>> {
let limit_s = limit.to_string();
let raw: Value = self
.http
.get(
"/v5/market/open-interest",
&[
("category", category.as_str()),
("symbol", symbol),
("intervalTime", interval_time),
("limit", &limit_s),
],
)
.await?;
unwrap_bybit_envelope(raw)
}
pub async fn get_long_short_ratio(
&self,
category: BybitCategory,
symbol: &str,
period: &str,
limit: u32,
) -> Result<BybitListResult<BybitLongShortRatio>> {
let limit_s = limit.to_string();
let raw: Value = self
.http
.get(
"/v5/market/account-ratio",
&[
("category", category.as_str()),
("symbol", symbol),
("period", period),
("limit", &limit_s),
],
)
.await?;
unwrap_bybit_envelope(raw)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_unwraps_success() {
let raw = serde_json::json!({
"retCode": 0,
"retMsg": "OK",
"result": {"hello": "world"},
"time": 1_700_000_000_000_u64,
});
let v: Value = unwrap_bybit_envelope(raw).expect("unwrap success");
assert_eq!(v["hello"], "world");
}
#[test]
fn envelope_surfaces_nonzero_as_api_error() {
let raw = serde_json::json!({
"retCode": 10001,
"retMsg": "invalid symbol",
"result": {},
"time": 1_700_000_000_000_u64,
});
let r: Result<Value> = unwrap_bybit_envelope(raw);
match r {
Err(ExchangeError::Api { code, message }) => {
assert_eq!(code, "10001");
assert!(message.contains("invalid symbol"));
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn kline_deserializes_from_array_shape() {
let raw = r#"["1672041600000", "16513.00", "16550.00", "16500.00", "16540.00", "0.000076", "1.255988"]"#;
let k: BybitKline = serde_json::from_str(raw).expect("kline deserialize");
assert_eq!(k.start_time, 1_672_041_600_000);
assert!((k.open - 16_513.0).abs() < 1e-6);
assert!((k.high - 16_550.0).abs() < 1e-6);
assert!((k.close - 16_540.0).abs() < 1e-6);
assert!((k.turnover - 1.255_988).abs() < 1e-6);
}
#[test]
fn kline_into_candle_data_marks_rest_bars_closed() {
let k = BybitKline {
start_time: 100,
open: 1.0,
high: 2.0,
low: 0.5,
close: 1.5,
volume: 10.0,
turnover: 15.0,
};
let c = k.into_candle_data("BTCUSDT", "1");
assert_eq!(c.exchange, "bybit");
assert!(c.is_closed, "REST klines are always finalised");
}
#[test]
fn orderbook_parses_short_keys() {
let raw = r#"{
"s": "BTCUSDT",
"b": [["96000.0", "1.5"]],
"a": [["96001.0", "0.8"]],
"ts": 1700000000000,
"u": 42
}"#;
let book: BybitOrderBook = serde_json::from_str(raw).expect("orderbook deserialize");
assert_eq!(book.symbol, "BTCUSDT");
assert_eq!(book.update_id, 42);
assert!((book.bids_f64()[0][0] - 96_000.0).abs() < 1e-9);
}
#[test]
fn category_wire_format() {
assert_eq!(BybitCategory::Spot.as_str(), "spot");
assert_eq!(BybitCategory::Linear.as_str(), "linear");
assert_eq!(BybitCategory::Inverse.as_str(), "inverse");
}
#[test]
fn funding_rate_into_funding_data() {
let r = BybitFundingRate {
symbol: "BTCUSDT".into(),
funding_rate: 0.000_1,
funding_rate_timestamp: 1_700_028_800_000,
};
let f = r.into_funding_data();
assert_eq!(f.exchange, "bybit");
assert!(f.mark_price.is_none()); assert_eq!(f.next_funding_time, 1_700_028_800_000);
}
}