1#![allow(clippy::unreadable_literal)]
24
25use crate::clock::ServerClock;
26use crate::credentials::Credentials;
27use crate::error::{Error, Result};
28use crate::events::{BookDelta, BookLevel, Event, OrderBookSnapshot, TradePrint};
29use crate::feeds::{
30 DerivativesChannel, DerivativesFeed, FundingRate, Liquidation, LongShortRatio, MarkIndex,
31 OpenInterest,
32};
33use crate::normalize::{format_decimal, parse_decimal};
34use crate::options::{ExchangeOptions, MarginMode, MarketType, PositionMode, SelfTradePrevention};
35use crate::positions::{Position, PositionSide};
36use crate::signing::hmac_sha256_hex;
37use crate::symbol::Symbol;
38use crate::traits::{
39 AdvancedOrders, Derivatives, DerivativesStream, Exchange, Execution, MarketData, WsExecution,
40 WsUserData,
41};
42use crate::transport::{HttpMethod, HttpRequest, HttpTransport, WsConnection, WsTransport};
43use crate::types::{
44 Balance, OcoRequest, Order, OrderRequest, OrderSide, OrderStatus, OrderType, Ticker,
45 TimeInForce,
46};
47use rust_decimal::Decimal;
48use serde::Deserialize;
49use std::collections::HashMap;
50use std::fmt;
51use wickra_core::Candle;
52
53fn system_now_ms() -> i64 {
55 use std::time::{SystemTime, UNIX_EPOCH};
56 SystemTime::now()
57 .duration_since(UNIX_EPOCH)
58 .expect("system clock is before the Unix epoch")
59 .as_millis() as i64
60}
61
62pub struct Bybit {
64 http: Box<dyn HttpTransport>,
65 ws: Option<Box<dyn WsTransport>>,
66 rest_base: String,
67 category: &'static str,
68 market_type: MarketType,
73 position_mode: PositionMode,
76 testnet: bool,
77 credentials: Option<Credentials>,
78 recv_window_ms: u64,
79 now_ms: Box<dyn Fn() -> i64 + Send + Sync>,
80 clock: ServerClock,
83 connection: Option<Box<dyn WsConnection>>,
84 sub_messages: Vec<String>,
85 subscriptions: Vec<(String, Symbol)>,
86 derivatives_channels: Vec<(String, DerivativesChannel)>,
91 private_connection: Option<Box<dyn WsConnection>>,
95 user_data_active: bool,
98 ws_api_connection: Option<Box<dyn WsConnection>>,
102}
103
104impl fmt::Debug for Bybit {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 f.debug_struct("Bybit")
112 .field("ws", &self.ws.is_some())
113 .field("rest_base", &self.rest_base)
114 .field("category", &self.category)
115 .field("testnet", &self.testnet)
116 .field("authenticated", &self.credentials.is_some())
117 .field("clock_offset_ms", &self.clock.offset_ms())
118 .field("recv_window_ms", &self.recv_window_ms)
119 .field("connection", &self.connection.is_some())
120 .field("sub_messages", &self.sub_messages.len())
121 .field("subscriptions", &self.subscriptions.len())
122 .field("private_connection", &self.private_connection.is_some())
123 .field("user_data_active", &self.user_data_active)
124 .field("ws_api_connection", &self.ws_api_connection.is_some())
125 .finish_non_exhaustive()
126 }
127}
128
129impl Bybit {
130 fn ensure_market_is_routed(&self) -> Result<()> {
138 super::ensure_market_is_routed("Bybit", self.market_type, super::SPOT_AND_LINEAR)
139 }
140
141 fn build(
142 http: Box<dyn HttpTransport>,
143 options: &ExchangeOptions,
144 credentials: Option<Credentials>,
145 ) -> Self {
146 Self {
147 http,
148 ws: None,
149 rest_base: if options.testnet {
150 "https://api-testnet.bybit.com".to_string()
151 } else {
152 "https://api.bybit.com".to_string()
153 },
154 category: category(options.market_type),
155 market_type: options.market_type,
156 position_mode: options.position_mode,
157 testnet: options.testnet,
158 credentials,
159 recv_window_ms: options.recv_window_ms,
160 now_ms: Box::new(system_now_ms),
161 clock: ServerClock::new(),
162 connection: None,
163 sub_messages: Vec::new(),
164 subscriptions: Vec::new(),
165 derivatives_channels: Vec::new(),
166 private_connection: None,
167 user_data_active: false,
168 ws_api_connection: None,
169 }
170 }
171
172 fn signed_now_ms(&self) -> i64 {
181 self.clock.server_time_ms((self.now_ms)())
182 }
183
184 pub fn sync_time(&mut self) -> Result<i64> {
196 let local_before = (self.now_ms)();
201 let value = self.get("/v5/market/time", "")?;
202 let nanos: i64 = value
203 .get("timeNano")
204 .and_then(serde_json::Value::as_str)
205 .ok_or_else(|| Error::Deserialization("market/time: no timeNano".into()))?
206 .parse()
207 .map_err(|_| Error::Deserialization("market/time: timeNano not an integer".into()))?;
208 self.clock.sync(local_before, nanos / 1_000_000);
209 Ok(self.clock.offset_ms())
210 }
211
212 #[must_use]
214 pub fn with_ws(mut self, ws: Box<dyn WsTransport>) -> Self {
215 self.ws = Some(ws);
216 self
217 }
218
219 #[must_use]
221 pub fn with_http(http: Box<dyn HttpTransport>, options: &ExchangeOptions) -> Self {
222 Self::build(http, options, None)
223 }
224
225 #[must_use]
227 pub fn with_credentials(
228 http: Box<dyn HttpTransport>,
229 options: &ExchangeOptions,
230 credentials: Credentials,
231 ) -> Self {
232 Self::build(http, options, Some(credentials))
233 }
234
235 #[must_use]
237 pub fn with_clock(mut self, now_ms: Box<dyn Fn() -> i64 + Send + Sync>) -> Self {
238 self.now_ms = now_ms;
239 self
240 }
241
242 fn is_futures(&self) -> bool {
245 self.category != "spot"
246 }
247
248 fn ensure_reduce_only_is_reducible(&self, request: &OrderRequest) -> Result<()> {
256 if request.reduce_only && !self.is_futures() {
257 return Err(Error::unsupported_field(
258 "Bybit",
259 "reduce_only on a spot order",
260 "spot holds balances, not positions, and has none to reduce",
261 ));
262 }
263 Ok(())
264 }
265
266 #[must_use]
268 pub fn category(&self) -> &'static str {
269 self.category
270 }
271
272 #[must_use]
274 pub fn wire_symbol(symbol: &Symbol) -> String {
275 symbol.to_concatenated()
276 }
277
278 pub fn ticker(&self, symbol: &Symbol) -> Result<Ticker> {
283 let query = format!(
284 "category={}&symbol={}",
285 self.category,
286 Self::wire_symbol(symbol)
287 );
288 let result = self.get("/v5/market/tickers", &query)?;
289 let raw: TickerList =
290 serde_json::from_value(result).map_err(|e| Error::Deserialization(e.to_string()))?;
291 let entry = raw
292 .list
293 .into_iter()
294 .next()
295 .ok_or_else(|| Error::NotFound(format!("no ticker for {symbol}")))?;
296 Ok(Ticker {
297 symbol: symbol.clone(),
298 last: parse_decimal(&entry.last_price)?,
299 bid: parse_decimal(&entry.bid1_price)?,
300 ask: parse_decimal(&entry.ask1_price)?,
301 volume: parse_decimal(&entry.volume24h)?,
302 timestamp: 0,
305 })
306 }
307
308 pub fn klines(&self, symbol: &Symbol, interval: &str, limit: u32) -> Result<Vec<Candle>> {
314 let query = format!(
315 "category={}&symbol={}&interval={}&limit={limit}",
316 self.category,
317 Self::wire_symbol(symbol),
318 map_interval(interval),
319 );
320 let result = self.get("/v5/market/kline", &query)?;
321 let raw: KlineList =
322 serde_json::from_value(result).map_err(|e| Error::Deserialization(e.to_string()))?;
323 let mut candles = raw
324 .list
325 .iter()
326 .map(|row| parse_kline_row(row))
327 .collect::<Result<Vec<_>>>()?;
328 candles.reverse();
329 Ok(candles)
330 }
331
332 pub fn order_book(&self, symbol: &Symbol, depth: u32) -> Result<OrderBookSnapshot> {
337 let query = format!(
338 "category={}&symbol={}&limit={depth}",
339 self.category,
340 Self::wire_symbol(symbol)
341 );
342 let result = self.get("/v5/market/orderbook", &query)?;
343 let raw: RawDepth =
344 serde_json::from_value(result).map_err(|e| Error::Deserialization(e.to_string()))?;
345 Ok(OrderBookSnapshot {
346 symbol: symbol.clone(),
347 last_update_id: raw.update_id,
348 bids: parse_levels(&raw.bids)?,
349 asks: parse_levels(&raw.asks)?,
350 timestamp: raw.ts,
351 })
352 }
353
354 pub fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
360 let topic = format!("publicTrade.{}", Self::wire_symbol(symbol));
361 self.subscribe(symbol, &topic)
362 }
363
364 pub fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
369 let topic = format!("orderbook.50.{}", Self::wire_symbol(symbol));
370 self.subscribe(symbol, &topic)
371 }
372
373 pub fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
378 let topic = format!("tickers.{}", Self::wire_symbol(symbol));
379 self.subscribe(symbol, &topic)
380 }
381
382 pub fn subscribe_derivatives(
397 &mut self,
398 symbol: &Symbol,
399 channel: DerivativesChannel,
400 ) -> Result<()> {
401 if !self.is_futures() {
402 return Err(Error::unsupported_field(
403 "Bybit",
404 "a derivatives channel on a spot client",
405 "funding, mark/index and liquidations exist only on the linear venue",
406 ));
407 }
408 let wire = Self::wire_symbol(symbol);
409 let topic = match channel {
410 DerivativesChannel::Funding | DerivativesChannel::MarkIndex => {
411 format!("tickers.{wire}")
412 }
413 DerivativesChannel::Liquidations => format!("allLiquidation.{wire}"),
414 };
415 self.derivatives_channels.push((wire, channel));
416 self.subscribe(symbol, &topic)
417 }
418
419 pub fn open_interest(&self, symbol: &Symbol) -> Result<OpenInterest> {
431 if !self.is_futures() {
432 return Err(Error::unsupported_field(
433 "Bybit",
434 "open interest on a spot client",
435 "open interest is a futures figure",
436 ));
437 }
438 let query = format!(
439 "category=linear&symbol={}&intervalTime=5min&limit=1",
440 Self::wire_symbol(symbol)
441 );
442 let value = self.get("/v5/market/open-interest", &query)?;
443 let result = parse_result::<serde_json::Value>(value)?;
444 let point = result
445 .get("list")
446 .and_then(serde_json::Value::as_array)
447 .and_then(|list| list.first())
448 .ok_or_else(|| Error::NotFound("no open-interest data point".to_string()))?;
449 Ok(OpenInterest {
450 symbol: symbol.clone(),
451 open_interest: parse_decimal(field_str(point, "openInterest")?)?,
452 timestamp: field_str(point, "timestamp")
453 .ok()
454 .and_then(|t| t.parse().ok())
455 .unwrap_or(0),
456 })
457 }
458
459 pub fn long_short_ratio(&self, symbol: &Symbol) -> Result<LongShortRatio> {
468 if !self.is_futures() {
469 return Err(Error::unsupported_field(
470 "Bybit",
471 "long/short positioning on a spot client",
472 "positioning is a futures figure",
473 ));
474 }
475 let query = format!(
476 "category=linear&symbol={}&period=5min&limit=1",
477 Self::wire_symbol(symbol)
478 );
479 let value = self.get("/v5/market/account-ratio", &query)?;
480 let result = parse_result::<serde_json::Value>(value)?;
481 let point = result
482 .get("list")
483 .and_then(serde_json::Value::as_array)
484 .and_then(|list| list.first())
485 .ok_or_else(|| Error::NotFound("no long/short data point".to_string()))?;
486 Ok(LongShortRatio {
487 symbol: symbol.clone(),
488 long_size: parse_decimal(field_str(point, "buyRatio")?)?,
489 short_size: parse_decimal(field_str(point, "sellRatio")?)?,
490 timestamp: field_str(point, "timestamp")
491 .ok()
492 .and_then(|t| t.parse().ok())
493 .unwrap_or(0),
494 })
495 }
496
497 fn subscribe(&mut self, symbol: &Symbol, topic: &str) -> Result<()> {
500 self.ensure_market_is_routed()?;
501 let wire = Self::wire_symbol(symbol);
502 if self.connection.is_none() {
503 let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
504 let connection = ws.connect(&ws_base_url(self.category, self.testnet))?;
505 self.connection = Some(connection);
506 }
507 let message = format!(r#"{{"op":"subscribe","args":["{topic}"]}}"#);
508 self.connection
509 .as_mut()
510 .expect("connection just ensured")
511 .send(&message)?;
512 if !self.sub_messages.contains(&message) {
513 self.sub_messages.push(message.clone());
514 }
515 if !self.subscriptions.iter().any(|(w, _)| w == &wire) {
516 self.subscriptions.push((wire, symbol.clone()));
517 }
518 Ok(())
519 }
520
521 pub fn poll_events(&mut self) -> Vec<Event> {
524 let subscriptions: HashMap<String, Symbol> = self.subscriptions.iter().cloned().collect();
525 let resolve = |wire: &str| {
526 subscriptions
527 .get(wire)
528 .cloned()
529 .unwrap_or_else(|| Symbol::new(wire, ""))
530 };
531 let channels = self.derivatives_channels.clone();
532 let mut events = Vec::new();
533 if let Some(connection) = self.connection.as_mut() {
534 while let Ok(Some(frame)) = connection.recv() {
535 if let Ok(mut parsed) = parse_ws_message(&frame, &resolve) {
536 events.append(&mut parsed);
537 }
538 events.extend(parse_derivatives_frame(&frame, &resolve, &channels));
542 }
543 }
544 if let Some(connection) = self.private_connection.as_mut() {
546 while let Ok(Some(frame)) = connection.recv() {
547 if let Ok(mut parsed) = parse_ws_message(&frame, &resolve) {
548 events.append(&mut parsed);
549 }
550 }
551 }
552 if self.user_data_active
555 && self
556 .private_connection
557 .as_ref()
558 .is_some_and(|c| !c.is_connected())
559 {
560 events.push(Event::Disconnected);
561 self.private_connection = None;
562 if self.subscribe_user_data().is_ok() {
563 events.push(Event::Reconnected);
564 }
565 }
566 let url = ws_base_url(self.category, self.testnet);
567 crate::wsutil::reconnect_if_dropped(
568 self.ws.as_deref(),
569 &url,
570 &mut self.connection,
571 &self.sub_messages,
572 &mut events,
573 );
574 events
575 }
576
577 pub fn subscribe_user_data(&mut self) -> Result<()> {
592 self.ensure_market_is_routed()?;
593 let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
594 "user-data stream requires credentials",
595 ))?;
596 let expires = self.signed_now_ms() + i64::try_from(self.recv_window_ms).unwrap_or(5000);
598 let signature = hmac_sha256_hex(
599 creds.api_secret.as_bytes(),
600 format!("GET/realtime{expires}").as_bytes(),
601 );
602 let auth = format!(
603 r#"{{"op":"auth","args":["{}",{expires},"{signature}"]}}"#,
604 creds.api_key
605 );
606 let subscribe = r#"{"op":"subscribe","args":["order","wallet"]}"#.to_string();
607 let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
608 let mut connection = ws.connect(&ws_private_url(self.testnet))?;
609 connection.send(&auth)?;
610 connection.send(&subscribe)?;
611 self.private_connection = Some(connection);
612 self.user_data_active = true;
613 Ok(())
614 }
615
616 pub fn keepalive_user_data(&mut self) -> Result<()> {
623 if let Some(connection) = self.private_connection.as_mut() {
624 connection.send(r#"{"op":"ping"}"#)?;
625 }
626 Ok(())
627 }
628
629 pub fn place_order(&self, request: &OrderRequest) -> Result<Order> {
637 self.ensure_reduce_only_is_reducible(request)?;
638 request.validate()?;
639 let time_in_force = tif_for(request)?;
640 let mut body = serde_json::json!({
641 "category": self.category,
642 "symbol": Self::wire_symbol(&request.symbol),
643 "side": side_str(request.side),
644 "orderType": order_type_str(request.order_type),
645 "qty": format_decimal(request.quantity),
646 "timeInForce": time_in_force,
647 });
648 if let Some(price) = request.price {
649 body["price"] = serde_json::json!(format_decimal(price));
650 }
651 if let Some(id) = &request.client_order_id {
652 body["orderLinkId"] = serde_json::json!(id.clone());
653 }
654 if request.reduce_only {
655 body["reduceOnly"] = serde_json::json!(true);
656 }
657 if let Some(idx) = self.position_idx(request) {
658 body["positionIdx"] = serde_json::json!(idx);
659 }
660 if let Some(smp) = smp_str(request.stp) {
661 body["smpType"] = serde_json::json!(smp);
662 }
663 if request.order_type.is_trigger() {
664 for (key, value) in trigger_fields(request, self.category)? {
665 body[key] = value;
666 }
667 }
668 let result =
669 self.signed_request(HttpMethod::Post, "/v5/order/create", "", &body.to_string())?;
670 let created: CreateResult = parse_result(result)?;
671 Ok(Order {
672 id: created.order_id,
673 client_order_id: (!created.order_link_id.is_empty()).then_some(created.order_link_id),
674 symbol: request.symbol.clone(),
675 side: request.side,
676 order_type: request.order_type,
677 status: OrderStatus::New,
678 quantity: request.quantity,
679 filled_quantity: Decimal::ZERO,
680 price: request.price,
681 average_price: None,
682 })
683 }
684
685 pub fn cancel_order(&self, symbol: &Symbol, order_id: &str) -> Result<()> {
690 let body = serde_json::json!({
691 "category": self.category,
692 "symbol": Self::wire_symbol(symbol),
693 "orderId": order_id,
694 });
695 self.signed_request(HttpMethod::Post, "/v5/order/cancel", "", &body.to_string())?;
696 Ok(())
697 }
698
699 pub fn place_order_ws(&mut self, request: &OrderRequest) -> Result<Order> {
708 if request.order_type.is_trigger() {
709 return Err(Error::unsupported_trigger("Bybit"));
710 }
711 self.ensure_reduce_only_is_reducible(request)?;
712 request.validate()?;
713 let time_in_force = tif_for(request)?;
714 let mut arg = serde_json::json!({
715 "category": self.category,
716 "symbol": Self::wire_symbol(&request.symbol),
717 "side": side_str(request.side),
718 "orderType": order_type_str(request.order_type),
719 "qty": format_decimal(request.quantity),
720 "timeInForce": time_in_force,
721 });
722 if let Some(price) = request.price {
723 arg["price"] = serde_json::json!(format_decimal(price));
724 }
725 if let Some(id) = &request.client_order_id {
726 arg["orderLinkId"] = serde_json::json!(id.clone());
727 }
728 if request.reduce_only {
729 arg["reduceOnly"] = serde_json::json!(true);
730 }
731 if let Some(smp) = smp_str(request.stp) {
732 arg["smpType"] = serde_json::json!(smp);
733 }
734 if let Some(idx) = self.position_idx(request) {
735 arg["positionIdx"] = serde_json::json!(idx);
736 }
737 let data = self.ws_trade_request("order.create", &arg)?;
738 let created: CreateResult =
739 serde_json::from_value(data).map_err(|e| Error::Deserialization(e.to_string()))?;
740 Ok(Order {
741 id: created.order_id,
742 client_order_id: (!created.order_link_id.is_empty()).then_some(created.order_link_id),
743 symbol: request.symbol.clone(),
744 side: request.side,
745 order_type: request.order_type,
746 status: OrderStatus::New,
747 quantity: request.quantity,
748 filled_quantity: Decimal::ZERO,
749 price: request.price,
750 average_price: None,
751 })
752 }
753
754 pub fn cancel_order_ws(&mut self, symbol: &Symbol, order_id: &str) -> Result<()> {
760 let arg = serde_json::json!({
761 "category": self.category,
762 "symbol": Self::wire_symbol(symbol),
763 "orderId": order_id,
764 });
765 self.ws_trade_request("order.cancel", &arg)?;
766 Ok(())
767 }
768
769 fn ensure_ws_trade(&mut self) -> Result<()> {
773 self.ensure_market_is_routed()?;
774 if self.ws_api_connection.is_some() {
775 return Ok(());
776 }
777 let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
778 "WebSocket trade requires credentials",
779 ))?;
780 let expires = self.signed_now_ms() + i64::try_from(self.recv_window_ms).unwrap_or(5000);
781 let signature = hmac_sha256_hex(
782 creds.api_secret.as_bytes(),
783 format!("GET/realtime{expires}").as_bytes(),
784 );
785 let auth = format!(
786 r#"{{"op":"auth","args":["{}",{expires},"{signature}"]}}"#,
787 creds.api_key
788 );
789 let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
790 let mut connection = ws.connect(&ws_trade_url(self.testnet))?;
791 connection.send(&auth)?;
792 connection.recv()?;
794 self.ws_api_connection = Some(connection);
795 Ok(())
796 }
797
798 fn ws_trade_request(&mut self, op: &str, arg: &serde_json::Value) -> Result<serde_json::Value> {
801 self.ensure_ws_trade()?;
802 let now = (self.now_ms)();
803 let frame = serde_json::json!({
804 "reqId": now.to_string(),
805 "header": {
806 "X-BAPI-TIMESTAMP": now.to_string(),
807 "X-BAPI-RECV-WINDOW": self.recv_window_ms.to_string(),
808 },
809 "op": op,
810 "args": [arg],
811 })
812 .to_string();
813 let connection = self
814 .ws_api_connection
815 .as_mut()
816 .expect("ws-trade connection just ensured");
817 connection.send(&frame)?;
818 let Some(response) = connection.recv()? else {
819 return Err(Error::NotConnected);
820 };
821 let value: serde_json::Value =
822 serde_json::from_str(&response).map_err(|e| Error::Deserialization(e.to_string()))?;
823 let ret_code = value
824 .get("retCode")
825 .and_then(serde_json::Value::as_i64)
826 .unwrap_or(-1);
827 if ret_code == 0 {
828 Ok(value
829 .get("data")
830 .cloned()
831 .unwrap_or(serde_json::Value::Null))
832 } else {
833 let message = value
834 .get("retMsg")
835 .and_then(serde_json::Value::as_str)
836 .unwrap_or("")
837 .to_string();
838 Err(Error::OrderRejected {
839 code: ret_code.to_string(),
840 message,
841 })
842 }
843 }
844
845 pub fn query_order(&self, symbol: &Symbol, order_id: &str) -> Result<Order> {
850 let query = format!(
851 "category={}&symbol={}&orderId={order_id}",
852 self.category,
853 Self::wire_symbol(symbol)
854 );
855 let result = self.signed_request(HttpMethod::Get, "/v5/order/realtime", &query, "")?;
856 let list: OrderList = parse_result(result)?;
857 let raw = list
858 .list
859 .into_iter()
860 .next()
861 .ok_or_else(|| Error::NotFound(format!("order {order_id}")))?;
862 order_from_raw(symbol.clone(), &raw)
863 }
864
865 pub fn open_orders(&self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
871 let mut query = format!("category={}", self.category);
872 if let Some(s) = symbol {
873 query.push_str("&symbol=");
874 query.push_str(&Self::wire_symbol(s));
875 }
876 let result = self.signed_request(HttpMethod::Get, "/v5/order/realtime", &query, "")?;
877 let list: OrderList = parse_result(result)?;
878 list.list
879 .iter()
880 .map(|raw| {
881 let sym = symbol
882 .cloned()
883 .unwrap_or_else(|| split_wire_symbol(&raw.symbol));
884 order_from_raw(sym, raw)
885 })
886 .collect()
887 }
888
889 pub fn balances(&self) -> Result<Vec<Balance>> {
894 let result = self.signed_request(
895 HttpMethod::Get,
896 "/v5/account/wallet-balance",
897 "accountType=UNIFIED",
898 "",
899 )?;
900 let wallet: WalletBalance = parse_result(result)?;
901 let account = wallet
902 .list
903 .into_iter()
904 .next()
905 .ok_or_else(|| Error::NotFound("no wallet account".to_string()))?;
906 Ok(account
907 .coin
908 .iter()
909 .map(|c| Balance {
910 asset: c.coin.clone(),
911 free: dec_or_zero(&c.available_to_withdraw),
912 locked: dec_or_zero(&c.locked),
913 })
914 .collect())
915 }
916
917 fn get(&self, path: &str, query: &str) -> Result<serde_json::Value> {
919 self.ensure_market_is_routed()?;
920 let url = format!("{}{path}?{query}", self.rest_base);
921 let response = self.http.execute(&HttpRequest::get(url))?;
922 unwrap_envelope(&response.body).map_err(|e| {
923 super::attribute_to_http_status(&response, e).with_retry_after(response.retry_after())
924 })
925 }
926
927 fn signed_request(
930 &self,
931 method: HttpMethod,
932 path: &str,
933 query: &str,
934 body: &str,
935 ) -> Result<serde_json::Value> {
936 self.ensure_market_is_routed()?;
937 let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
938 "signed endpoint requires credentials",
939 ))?;
940 let timestamp = self.signed_now_ms().to_string();
941 let recv_window = self.recv_window_ms.to_string();
942 let payload = if body.is_empty() { query } else { body };
943 let sign_input = format!("{timestamp}{}{recv_window}{payload}", creds.api_key);
944 let signature = hmac_sha256_hex(creds.api_secret.as_bytes(), sign_input.as_bytes());
945 let url = if query.is_empty() {
946 format!("{}{path}", self.rest_base)
947 } else {
948 format!("{}{path}?{query}", self.rest_base)
949 };
950 let mut request = HttpRequest::new(method, url)
951 .with_header("X-BAPI-API-KEY", creds.api_key.clone())
952 .with_header("X-BAPI-TIMESTAMP", timestamp)
953 .with_header("X-BAPI-RECV-WINDOW", recv_window)
954 .with_header("X-BAPI-SIGN", signature);
955 if !body.is_empty() {
956 request = request
957 .with_header("Content-Type", "application/json")
958 .with_body(body.to_string());
959 }
960 let response = self.http.execute(&request)?;
961 unwrap_envelope(&response.body).map_err(|e| {
962 super::attribute_to_http_status(&response, e).with_retry_after(response.retry_after())
963 })
964 }
965}
966
967impl Bybit {
969 fn position_idx(&self, request: &OrderRequest) -> Option<u8> {
976 if self.category == "spot" || self.position_mode == PositionMode::OneWay {
977 return None;
978 }
979 Some(
980 match PositionSide::for_order(request.side, request.reduce_only) {
981 PositionSide::Long => 1,
982 PositionSide::Short => 2,
983 },
984 )
985 }
986}
987
988fn category(market_type: MarketType) -> &'static str {
989 match market_type {
990 MarketType::Spot | MarketType::Margin => "spot",
991 MarketType::UsdMFutures => "linear",
992 MarketType::CoinMFutures => "inverse",
993 }
994}
995
996fn map_interval(interval: &str) -> String {
998 match interval {
999 "1m" => "1",
1000 "3m" => "3",
1001 "5m" => "5",
1002 "15m" => "15",
1003 "30m" => "30",
1004 "1h" => "60",
1005 "2h" => "120",
1006 "4h" => "240",
1007 "6h" => "360",
1008 "12h" => "720",
1009 "1d" => "D",
1010 "1w" => "W",
1011 "1M" => "M",
1012 other => other,
1013 }
1014 .to_string()
1015}
1016
1017#[derive(Deserialize)]
1018struct Envelope {
1019 #[serde(rename = "retCode")]
1020 ret_code: i64,
1021 #[serde(rename = "retMsg", default)]
1022 ret_msg: String,
1023 #[serde(default)]
1024 result: serde_json::Value,
1025}
1026
1027#[derive(Deserialize)]
1028struct TickerList {
1029 list: Vec<RawTicker>,
1030}
1031
1032#[derive(Deserialize)]
1033struct RawTicker {
1034 #[serde(rename = "lastPrice")]
1035 last_price: String,
1036 #[serde(rename = "bid1Price")]
1037 bid1_price: String,
1038 #[serde(rename = "ask1Price")]
1039 ask1_price: String,
1040 #[serde(rename = "volume24h")]
1041 volume24h: String,
1042}
1043
1044#[derive(Deserialize)]
1045struct KlineList {
1046 list: Vec<Vec<String>>,
1047}
1048
1049#[derive(Deserialize)]
1050struct RawDepth {
1051 #[serde(rename = "u")]
1052 update_id: u64,
1053 #[serde(default)]
1055 ts: i64,
1056 #[serde(rename = "b")]
1057 bids: Vec<[String; 2]>,
1058 #[serde(rename = "a")]
1059 asks: Vec<[String; 2]>,
1060}
1061
1062fn parse_levels(levels: &[[String; 2]]) -> Result<Vec<BookLevel>> {
1063 levels
1064 .iter()
1065 .map(|[price, qty]| {
1066 Ok(BookLevel {
1067 price: parse_decimal(price)?,
1068 quantity: parse_decimal(qty)?,
1069 })
1070 })
1071 .collect()
1072}
1073
1074fn parse_kline_row(row: &[String]) -> Result<Candle> {
1075 if row.len() < 6 {
1077 return Err(Error::Deserialization("kline row too short".to_string()));
1078 }
1079 let start = row[0]
1080 .parse::<i64>()
1081 .map_err(|e| Error::Deserialization(format!("kline start not an integer: {e}")))?;
1082 let f = |i: usize| -> Result<f64> {
1083 row[i]
1084 .parse::<f64>()
1085 .map_err(|e| Error::Deserialization(format!("kline field not a number: {e}")))
1086 };
1087 Candle::new(f(1)?, f(2)?, f(3)?, f(4)?, f(5)?, start)
1088 .map_err(|e| Error::Deserialization(e.to_string()))
1089}
1090
1091fn map_error(ret_code: i64, ret_msg: &str) -> Error {
1093 match ret_code {
1094 10001 | 10004 | 10005 | 33004 => Error::Auth(ret_msg.to_string()),
1095 10006 | 10018 => Error::RateLimited { retry_after: None },
1096 110004 | 110007 | 170131 => Error::InsufficientBalance,
1097 110001 | 170213 => Error::NotFound(ret_msg.to_string()),
1098 _ => Error::Exchange {
1099 code: ret_code.to_string(),
1100 message: ret_msg.to_string(),
1101 },
1102 }
1103}
1104
1105fn unwrap_envelope(body: &str) -> Result<serde_json::Value> {
1106 let envelope: Envelope =
1109 serde_json::from_str(body).map_err(|e| Error::Deserialization(e.to_string()))?;
1110 if envelope.ret_code != 0 {
1111 return Err(map_error(envelope.ret_code, &envelope.ret_msg));
1112 }
1113 Ok(envelope.result)
1114}
1115
1116fn parse_derivatives_frame(
1120 text: &str,
1121 resolve: &impl Fn(&str) -> Symbol,
1122 channels: &[(String, DerivativesChannel)],
1123) -> Vec<Event> {
1124 let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
1125 return Vec::new();
1126 };
1127 let Some(topic) = value.get("topic").and_then(serde_json::Value::as_str) else {
1128 return Vec::new();
1129 };
1130 let Some(data) = value.get("data") else {
1131 return Vec::new();
1132 };
1133 parse_derivatives_message(topic, data, resolve, channels)
1134}
1135
1136fn parse_derivatives_message(
1147 topic: &str,
1148 data: &serde_json::Value,
1149 resolve: &impl Fn(&str) -> Symbol,
1150 channels: &[(String, DerivativesChannel)],
1151) -> Vec<Event> {
1152 let subscribed = |wire: &str, channel: DerivativesChannel| {
1153 channels.iter().any(|(w, c)| w == wire && *c == channel)
1154 };
1155 let mut out = Vec::new();
1156 if topic.starts_with("tickers.") {
1157 let Ok(wire) = field_str(data, "symbol") else {
1158 return out;
1159 };
1160 let symbol = resolve(wire);
1161 let timestamp = data
1162 .get("ts")
1163 .and_then(serde_json::Value::as_i64)
1164 .unwrap_or(0);
1165 let mark = parse_decimal(opt_str(data, "markPrice")).ok();
1168 if subscribed(wire, DerivativesChannel::Funding) {
1169 if let (Some(rate), Some(mark_price)) =
1170 (parse_decimal(opt_str(data, "fundingRate")).ok(), mark)
1171 {
1172 out.push(Event::Derivatives(DerivativesFeed::Funding(FundingRate {
1173 symbol: symbol.clone(),
1174 rate,
1175 mark_price,
1176 timestamp,
1177 })));
1178 }
1179 }
1180 if subscribed(wire, DerivativesChannel::MarkIndex) {
1181 if let (Some(mark_price), Some(index_price)) =
1182 (mark, parse_decimal(opt_str(data, "indexPrice")).ok())
1183 {
1184 out.push(Event::Derivatives(DerivativesFeed::MarkIndex(MarkIndex {
1185 symbol,
1186 mark_price,
1187 index_price,
1188 timestamp,
1189 })));
1190 }
1191 }
1192 } else if topic.starts_with("allLiquidation.") {
1193 let Some(prints) = data.as_array() else {
1194 return out;
1195 };
1196 for print in prints {
1197 let Ok(wire) = field_str(print, "s") else {
1198 continue;
1199 };
1200 if !subscribed(wire, DerivativesChannel::Liquidations) {
1201 continue;
1202 }
1203 let Ok(side) = parse_side(field_str(print, "S").unwrap_or("")) else {
1209 continue;
1210 };
1211 let (Ok(price), Ok(quantity)) = (
1212 field_str(print, "p").and_then(parse_decimal),
1213 field_str(print, "v").and_then(parse_decimal),
1214 ) else {
1215 continue;
1216 };
1217 out.push(Event::Derivatives(DerivativesFeed::Liquidation(
1218 Liquidation {
1219 symbol: resolve(wire),
1220 side,
1221 price,
1222 quantity,
1223 timestamp: print
1224 .get("T")
1225 .and_then(serde_json::Value::as_i64)
1226 .unwrap_or(0),
1227 },
1228 )));
1229 }
1230 }
1231 out
1232}
1233
1234fn parse_result<T: for<'de> Deserialize<'de>>(value: serde_json::Value) -> Result<T> {
1235 serde_json::from_value(value).map_err(|e| Error::Deserialization(e.to_string()))
1236}
1237
1238fn side_str(side: OrderSide) -> &'static str {
1239 match side {
1240 OrderSide::Buy => "Buy",
1241 OrderSide::Sell => "Sell",
1242 }
1243}
1244
1245fn trigger_fields(
1258 request: &OrderRequest,
1259 category: &str,
1260) -> Result<Vec<(&'static str, serde_json::Value)>> {
1261 let stop = request
1262 .stop_price
1263 .ok_or(Error::InvalidOrder("a trigger order requires a stop price"))?;
1264 let direction = match request.side {
1265 OrderSide::Sell => 2,
1267 OrderSide::Buy => 1,
1269 };
1270 let mut fields = vec![
1271 ("triggerPrice", serde_json::json!(format_decimal(stop))),
1272 ("triggerDirection", serde_json::json!(direction)),
1273 ];
1274 if category == "spot" {
1275 fields.push(("orderFilter", serde_json::json!("StopOrder")));
1276 }
1277 Ok(fields)
1278}
1279
1280fn order_type_str(order_type: OrderType) -> &'static str {
1281 match order_type {
1282 OrderType::Market | OrderType::StopMarket => "Market",
1283 OrderType::Limit | OrderType::StopLimit => "Limit",
1284 }
1285}
1286
1287fn tif_for(request: &OrderRequest) -> Result<&'static str> {
1292 match (request.post_only, request.time_in_force) {
1293 (true, TimeInForce::Gtc) => Ok("PostOnly"),
1294 (true, _) => Err(Error::unsupported_field(
1295 "Bybit",
1296 "post_only together with a non-GTC time-in-force",
1297 "`PostOnly` is one of the values Bybit's `timeInForce` field takes",
1298 )),
1299 (false, tif) => Ok(tif_str(tif)),
1300 }
1301}
1302
1303fn tif_str(tif: TimeInForce) -> &'static str {
1304 match tif {
1305 TimeInForce::Gtc => "GTC",
1306 TimeInForce::Ioc => "IOC",
1307 TimeInForce::Fok => "FOK",
1308 }
1309}
1310
1311fn smp_str(stp: SelfTradePrevention) -> Option<&'static str> {
1314 match stp {
1315 SelfTradePrevention::None => None,
1316 SelfTradePrevention::ExpireMaker => Some("CancelMaker"),
1317 SelfTradePrevention::ExpireTaker => Some("CancelTaker"),
1318 SelfTradePrevention::ExpireBoth => Some("CancelBoth"),
1319 }
1320}
1321
1322fn parse_side(raw: &str) -> Result<OrderSide> {
1323 match raw {
1324 "Buy" => Ok(OrderSide::Buy),
1325 "Sell" => Ok(OrderSide::Sell),
1326 other => Err(Error::Deserialization(format!("unknown side {other:?}"))),
1327 }
1328}
1329
1330fn parse_order_type(raw: &str) -> Result<OrderType> {
1331 match raw {
1332 "Market" => Ok(OrderType::Market),
1333 "Limit" => Ok(OrderType::Limit),
1334 other => Err(Error::Deserialization(format!(
1335 "unknown order type {other:?}"
1336 ))),
1337 }
1338}
1339
1340fn parse_status(raw: &str) -> Result<OrderStatus> {
1341 match raw {
1342 "New" | "Untriggered" | "Triggered" => Ok(OrderStatus::New),
1343 "PartiallyFilled" => Ok(OrderStatus::PartiallyFilled),
1344 "Filled" => Ok(OrderStatus::Filled),
1345 "Cancelled" | "PartiallyFilledCanceled" | "Deactivated" => Ok(OrderStatus::Canceled),
1346 "Rejected" => Ok(OrderStatus::Rejected),
1347 other => Err(Error::Deserialization(format!("unknown status {other:?}"))),
1348 }
1349}
1350
1351fn dec_or_zero(raw: &str) -> Decimal {
1352 crate::normalize::parse_opt_decimal(Some(raw))
1353 .ok()
1354 .flatten()
1355 .unwrap_or(Decimal::ZERO)
1356}
1357
1358fn nonzero_decimal(raw: &str) -> Option<Decimal> {
1359 crate::normalize::parse_opt_decimal(Some(raw))
1360 .ok()
1361 .flatten()
1362 .filter(|d| *d > Decimal::ZERO)
1363}
1364
1365fn order_from_raw(symbol: Symbol, raw: &RawOrder) -> Result<Order> {
1366 Ok(Order {
1367 id: raw.order_id.clone(),
1368 client_order_id: (!raw.order_link_id.is_empty()).then(|| raw.order_link_id.clone()),
1369 symbol,
1370 side: parse_side(&raw.side)?,
1371 order_type: parse_order_type(&raw.order_type)?,
1372 status: parse_status(&raw.order_status)?,
1373 quantity: parse_decimal(&raw.qty)?,
1374 filled_quantity: dec_or_zero(&raw.cum_exec_qty),
1375 price: nonzero_decimal(&raw.price),
1376 average_price: nonzero_decimal(&raw.avg_price),
1377 })
1378}
1379
1380#[derive(Deserialize)]
1381struct CreateResult {
1382 #[serde(rename = "orderId")]
1383 order_id: String,
1384 #[serde(rename = "orderLinkId", default)]
1385 order_link_id: String,
1386}
1387
1388#[derive(Deserialize)]
1389struct OrderList {
1390 list: Vec<RawOrder>,
1391}
1392
1393#[derive(Deserialize)]
1394struct RawOrder {
1395 #[serde(default)]
1396 symbol: String,
1397 #[serde(rename = "orderId")]
1398 order_id: String,
1399 #[serde(rename = "orderLinkId", default)]
1400 order_link_id: String,
1401 side: String,
1402 #[serde(rename = "orderType")]
1403 order_type: String,
1404 #[serde(rename = "orderStatus")]
1405 order_status: String,
1406 qty: String,
1407 #[serde(rename = "cumExecQty", default)]
1408 cum_exec_qty: String,
1409 #[serde(default)]
1410 price: String,
1411 #[serde(rename = "avgPrice", default)]
1412 avg_price: String,
1413}
1414
1415#[derive(Deserialize)]
1416struct WalletBalance {
1417 list: Vec<WalletAccount>,
1418}
1419
1420#[derive(Deserialize)]
1421struct WalletAccount {
1422 coin: Vec<CoinBalance>,
1423}
1424
1425#[derive(Deserialize)]
1426struct CoinBalance {
1427 coin: String,
1428 #[serde(rename = "availableToWithdraw", default)]
1429 available_to_withdraw: String,
1430 #[serde(default)]
1431 locked: String,
1432}
1433
1434const KNOWN_QUOTES: &[&str] = &["USDT", "USDC", "EUR", "BTC", "ETH", "USD"];
1436
1437fn split_wire_symbol(wire: &str) -> Symbol {
1439 for quote in KNOWN_QUOTES {
1440 if let Some(base) = wire.strip_suffix(quote) {
1441 if !base.is_empty() {
1442 return Symbol::new(base, *quote);
1443 }
1444 }
1445 }
1446 Symbol::new(wire, "")
1447}
1448
1449fn ws_base_url(category: &str, testnet: bool) -> String {
1451 let host = if testnet {
1452 "wss://stream-testnet.bybit.com"
1453 } else {
1454 "wss://stream.bybit.com"
1455 };
1456 format!("{host}/v5/public/{category}")
1457}
1458
1459fn ws_private_url(testnet: bool) -> String {
1461 let host = if testnet {
1462 "wss://stream-testnet.bybit.com"
1463 } else {
1464 "wss://stream.bybit.com"
1465 };
1466 format!("{host}/v5/private")
1467}
1468
1469fn ws_trade_url(testnet: bool) -> String {
1471 let host = if testnet {
1472 "wss://stream-testnet.bybit.com"
1473 } else {
1474 "wss://stream.bybit.com"
1475 };
1476 format!("{host}/v5/trade")
1477}
1478
1479fn field_str<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
1480 value
1481 .get(key)
1482 .and_then(serde_json::Value::as_str)
1483 .ok_or_else(|| Error::Deserialization(format!("missing string field {key:?}")))
1484}
1485
1486fn opt_str<'a>(value: &'a serde_json::Value, key: &str) -> &'a str {
1487 value
1488 .get(key)
1489 .and_then(serde_json::Value::as_str)
1490 .unwrap_or("")
1491}
1492
1493fn parse_ws_levels(value: Option<&serde_json::Value>) -> Result<Vec<BookLevel>> {
1494 let array = value
1495 .and_then(serde_json::Value::as_array)
1496 .ok_or_else(|| Error::Deserialization("missing depth levels".to_string()))?;
1497 array
1498 .iter()
1499 .map(|level| {
1500 let pair = level
1501 .as_array()
1502 .ok_or_else(|| Error::Deserialization("depth level not an array".to_string()))?;
1503 let price = parse_decimal(
1504 pair.first()
1505 .and_then(serde_json::Value::as_str)
1506 .ok_or_else(|| Error::Deserialization("depth price missing".to_string()))?,
1507 )?;
1508 let quantity =
1509 parse_decimal(pair.get(1).and_then(serde_json::Value::as_str).ok_or_else(
1510 || Error::Deserialization("depth quantity missing".to_string()),
1511 )?)?;
1512 Ok(BookLevel { price, quantity })
1513 })
1514 .collect()
1515}
1516
1517fn parse_ws_message(text: &str, resolve: &impl Fn(&str) -> Symbol) -> Result<Vec<Event>> {
1520 let value: serde_json::Value =
1521 serde_json::from_str(text).map_err(|e| Error::Deserialization(e.to_string()))?;
1522 let Some(topic) = value.get("topic").and_then(serde_json::Value::as_str) else {
1523 return Ok(Vec::new());
1524 };
1525 let null = serde_json::Value::Null;
1526 let data = value.get("data").unwrap_or(&null);
1527
1528 if topic.starts_with("publicTrade.") {
1529 let trades = data
1530 .as_array()
1531 .ok_or_else(|| Error::Deserialization("trade data not an array".to_string()))?;
1532 trades
1533 .iter()
1534 .map(|t| {
1535 Ok(Event::Trade(TradePrint {
1536 symbol: resolve(field_str(t, "s")?),
1537 price: parse_decimal(field_str(t, "p")?)?,
1538 quantity: parse_decimal(field_str(t, "v")?)?,
1539 aggressor: parse_side(field_str(t, "S")?)?,
1540 timestamp: t.get("T").and_then(serde_json::Value::as_i64).unwrap_or(0),
1541 }))
1542 })
1543 .collect()
1544 } else if topic.starts_with("tickers.") {
1545 Ok(vec![Event::Ticker(Ticker {
1546 symbol: resolve(field_str(data, "symbol")?),
1547 last: parse_decimal(field_str(data, "lastPrice")?)?,
1548 bid: dec_or_zero(opt_str(data, "bid1Price")),
1549 ask: dec_or_zero(opt_str(data, "ask1Price")),
1550 volume: dec_or_zero(opt_str(data, "volume24h")),
1551 timestamp: value
1552 .get("ts")
1553 .and_then(serde_json::Value::as_i64)
1554 .unwrap_or(0),
1555 })])
1556 } else if topic.starts_with("orderbook.") {
1557 let symbol = resolve(field_str(data, "s")?);
1558 let update_id = data
1559 .get("u")
1560 .and_then(serde_json::Value::as_u64)
1561 .unwrap_or(0);
1562 let bids = parse_ws_levels(data.get("b"))?;
1563 let asks = parse_ws_levels(data.get("a"))?;
1564 if value.get("type").and_then(serde_json::Value::as_str) == Some("snapshot") {
1565 Ok(vec![Event::BookSnapshot(OrderBookSnapshot {
1566 symbol,
1567 last_update_id: update_id,
1568 bids,
1569 asks,
1570 timestamp: value
1571 .get("ts")
1572 .and_then(serde_json::Value::as_i64)
1573 .unwrap_or(0),
1574 })])
1575 } else {
1576 Ok(vec![Event::BookDelta(BookDelta {
1577 symbol,
1578 first_update_id: update_id,
1579 final_update_id: update_id,
1580 bids,
1581 asks,
1582 timestamp: value
1583 .get("ts")
1584 .and_then(serde_json::Value::as_i64)
1585 .unwrap_or(0),
1586 })])
1587 }
1588 } else if topic == "order" {
1589 let orders = data
1592 .as_array()
1593 .ok_or_else(|| Error::Deserialization("order data not an array".to_string()))?;
1594 orders
1595 .iter()
1596 .map(|raw| {
1597 let order: RawOrder = serde_json::from_value(raw.clone())
1598 .map_err(|e| Error::Deserialization(e.to_string()))?;
1599 let symbol = split_wire_symbol(&order.symbol);
1600 Ok(Event::OrderUpdate(order_from_raw(symbol, &order)?))
1601 })
1602 .collect()
1603 } else if topic == "wallet" {
1604 let accounts = data
1607 .as_array()
1608 .ok_or_else(|| Error::Deserialization("wallet data not an array".to_string()))?;
1609 accounts
1610 .iter()
1611 .map(|raw| {
1612 let account: WalletAccount = serde_json::from_value(raw.clone())
1613 .map_err(|e| Error::Deserialization(e.to_string()))?;
1614 Ok(Event::BalanceUpdate(
1615 account
1616 .coin
1617 .iter()
1618 .map(|c| Balance {
1619 asset: c.coin.clone(),
1620 free: dec_or_zero(&c.available_to_withdraw),
1621 locked: dec_or_zero(&c.locked),
1622 })
1623 .collect(),
1624 ))
1625 })
1626 .collect()
1627 } else {
1628 Ok(Vec::new())
1629 }
1630}
1631
1632impl MarketData for Bybit {
1633 fn ticker(&mut self, symbol: &Symbol) -> Result<Ticker> {
1634 Bybit::ticker(self, symbol)
1635 }
1636 fn klines(&mut self, symbol: &Symbol, interval: &str, limit: u32) -> Result<Vec<Candle>> {
1637 Bybit::klines(self, symbol, interval, limit)
1638 }
1639 fn order_book(&mut self, symbol: &Symbol, depth: u32) -> Result<OrderBookSnapshot> {
1640 Bybit::order_book(self, symbol, depth)
1641 }
1642 fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
1643 Bybit::subscribe_trades(self, symbol)
1644 }
1645 fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
1646 Bybit::subscribe_book(self, symbol)
1647 }
1648 fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
1649 Bybit::subscribe_ticker(self, symbol)
1650 }
1651 fn poll_events(&mut self) -> Vec<Event> {
1652 Bybit::poll_events(self)
1653 }
1654}
1655
1656impl Execution for Bybit {
1657 fn place_order(&mut self, request: &OrderRequest) -> Result<Order> {
1658 Bybit::place_order(self, request)
1659 }
1660 fn cancel_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<()> {
1661 Bybit::cancel_order(self, symbol, order_id)
1662 }
1663 fn query_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<Order> {
1664 Bybit::query_order(self, symbol, order_id)
1665 }
1666 fn open_orders(&mut self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
1667 Bybit::open_orders(self, symbol)
1668 }
1669 fn balances(&mut self) -> Result<Vec<Balance>> {
1670 Bybit::balances(self)
1671 }
1672}
1673
1674impl Bybit {
1675 pub fn positions(&self, symbol: Option<&Symbol>) -> Result<Vec<Position>> {
1681 let query = match symbol {
1682 Some(s) => format!("category={}&symbol={}", self.category, Self::wire_symbol(s)),
1683 None => format!("category={}&settleCoin=USDT", self.category),
1684 };
1685 let result = self.signed_request(HttpMethod::Get, "/v5/position/list", &query, "")?;
1686 let list: PositionList = parse_result(result)?;
1687 list.list.iter().filter_map(parse_bybit_position).collect()
1688 }
1689
1690 pub fn set_leverage(&self, symbol: &Symbol, leverage: u32) -> Result<()> {
1695 let lev = leverage.to_string();
1696 let body = serde_json::json!({
1697 "category": self.category,
1698 "symbol": Self::wire_symbol(symbol),
1699 "buyLeverage": lev,
1700 "sellLeverage": lev,
1701 });
1702 self.signed_request(
1703 HttpMethod::Post,
1704 "/v5/position/set-leverage",
1705 "",
1706 &body.to_string(),
1707 )?;
1708 Ok(())
1709 }
1710
1711 pub fn set_margin_mode(&self, symbol: &Symbol, mode: MarginMode) -> Result<()> {
1717 let leverage = self
1718 .positions(Some(symbol))?
1719 .first()
1720 .map_or_else(|| "10".to_string(), |p| p.leverage.normalize().to_string());
1721 let trade_mode = match mode {
1722 MarginMode::Cross => 0,
1723 MarginMode::Isolated => 1,
1724 };
1725 let body = serde_json::json!({
1726 "category": self.category,
1727 "symbol": Self::wire_symbol(symbol),
1728 "tradeMode": trade_mode,
1729 "buyLeverage": leverage,
1730 "sellLeverage": leverage,
1731 });
1732 self.signed_request(
1733 HttpMethod::Post,
1734 "/v5/position/switch-isolated",
1735 "",
1736 &body.to_string(),
1737 )?;
1738 Ok(())
1739 }
1740
1741 pub fn close_position(&self, symbol: &Symbol) -> Result<Order> {
1747 let position = self
1748 .positions(Some(symbol))?
1749 .into_iter()
1750 .find(|p| &p.symbol == symbol)
1751 .ok_or_else(|| Error::NotFound(format!("no open position for {symbol}")))?;
1752 let request = match position.side {
1753 PositionSide::Long => OrderRequest::market_sell(symbol.clone(), position.quantity),
1754 PositionSide::Short => OrderRequest::market_buy(symbol.clone(), position.quantity),
1755 }
1756 .reduce_only();
1757 self.place_order(&request)
1758 }
1759
1760 pub fn amend_order(
1766 &self,
1767 symbol: &Symbol,
1768 order_id: &str,
1769 new_price: Option<Decimal>,
1770 new_quantity: Option<Decimal>,
1771 ) -> Result<Order> {
1772 let mut body = serde_json::json!({
1773 "category": self.category,
1774 "symbol": Self::wire_symbol(symbol),
1775 "orderId": order_id,
1776 });
1777 if let Some(q) = new_quantity {
1778 body["qty"] = serde_json::json!(format_decimal(q));
1779 }
1780 if let Some(p) = new_price {
1781 body["price"] = serde_json::json!(format_decimal(p));
1782 }
1783 self.signed_request(HttpMethod::Post, "/v5/order/amend", "", &body.to_string())?;
1784 self.query_order(symbol, order_id)
1785 }
1786
1787 pub fn place_batch(&self, requests: &[OrderRequest]) -> Result<Vec<Result<Order>>> {
1794 if requests.iter().any(|r| r.order_type.is_trigger()) {
1795 return Err(Error::unsupported_trigger("Bybit"));
1796 }
1797 for request in requests {
1798 self.ensure_reduce_only_is_reducible(request)?;
1799 }
1800 let tifs = requests.iter().map(tif_for).collect::<Result<Vec<_>>>()?;
1803 let items: Vec<serde_json::Value> = requests
1804 .iter()
1805 .zip(&tifs)
1806 .map(|(r, tif)| {
1807 let mut o = serde_json::json!({
1808 "symbol": Self::wire_symbol(&r.symbol),
1809 "side": side_str(r.side),
1810 "orderType": order_type_str(r.order_type),
1811 "qty": format_decimal(r.quantity),
1812 "timeInForce": tif,
1813 });
1814 if let Some(smp) = smp_str(r.stp) {
1815 o["smpType"] = serde_json::json!(smp);
1816 }
1817 if let Some(price) = r.price {
1818 o["price"] = serde_json::json!(format_decimal(price));
1819 }
1820 if let Some(id) = &r.client_order_id {
1821 o["orderLinkId"] = serde_json::json!(id.clone());
1822 }
1823 if r.reduce_only {
1824 o["reduceOnly"] = serde_json::json!(true);
1825 }
1826 if let Some(idx) = self.position_idx(r) {
1827 o["positionIdx"] = serde_json::json!(idx);
1828 }
1829 o
1830 })
1831 .collect();
1832 let body = serde_json::json!({ "category": self.category, "request": items });
1833 let result = self.signed_request(
1834 HttpMethod::Post,
1835 "/v5/order/create-batch",
1836 "",
1837 &body.to_string(),
1838 )?;
1839 let created: BatchCreateResult = parse_result(result)?;
1840 Ok(requests
1841 .iter()
1842 .zip(created.list)
1843 .map(|(req, out)| {
1844 if out.order_id.is_empty() {
1845 return Err(Error::OrderRejected {
1846 code: "batch".to_string(),
1847 message: "order rejected in batch".to_string(),
1848 });
1849 }
1850 Ok(Order {
1851 id: out.order_id,
1852 client_order_id: (!out.order_link_id.is_empty()).then_some(out.order_link_id),
1853 symbol: req.symbol.clone(),
1854 side: req.side,
1855 order_type: req.order_type,
1856 status: OrderStatus::New,
1857 quantity: req.quantity,
1858 filled_quantity: Decimal::ZERO,
1859 price: req.price,
1860 average_price: None,
1861 })
1862 })
1863 .collect())
1864 }
1865
1866 pub fn cancel_batch(&self, symbol: &Symbol, order_ids: &[String]) -> Result<()> {
1872 let wire = Self::wire_symbol(symbol);
1873 let items: Vec<serde_json::Value> = order_ids
1874 .iter()
1875 .map(|id| serde_json::json!({ "symbol": wire, "orderId": id }))
1876 .collect();
1877 let body = serde_json::json!({ "category": self.category, "request": items });
1878 self.signed_request(
1879 HttpMethod::Post,
1880 "/v5/order/cancel-batch",
1881 "",
1882 &body.to_string(),
1883 )?;
1884 Ok(())
1885 }
1886}
1887
1888impl AdvancedOrders for Bybit {
1889 fn amend_order(
1890 &mut self,
1891 symbol: &Symbol,
1892 order_id: &str,
1893 new_price: Option<Decimal>,
1894 new_quantity: Option<Decimal>,
1895 ) -> Result<Order> {
1896 Bybit::amend_order(self, symbol, order_id, new_price, new_quantity)
1897 }
1898 fn place_batch(&mut self, requests: &[OrderRequest]) -> Result<Vec<Result<Order>>> {
1899 Bybit::place_batch(self, requests)
1900 }
1901 fn cancel_batch(&mut self, symbol: &Symbol, order_ids: &[String]) -> Result<()> {
1902 Bybit::cancel_batch(self, symbol, order_ids)
1903 }
1904 fn place_oco(&mut self, _request: &OcoRequest) -> Result<Vec<Order>> {
1908 Err(Error::Exchange {
1909 code: "unsupported".to_string(),
1910 message: "Bybit has no OCO order-list; attach takeProfit/stopLoss to the order"
1911 .to_string(),
1912 })
1913 }
1914}
1915
1916impl Exchange for Bybit {
1917 fn name(&self) -> &'static str {
1918 "bybit"
1919 }
1920}
1921
1922impl WsUserData for Bybit {
1923 fn subscribe_user_data(&mut self) -> Result<()> {
1924 Bybit::subscribe_user_data(self)
1925 }
1926 fn keepalive_user_data(&mut self) -> Result<()> {
1927 Bybit::keepalive_user_data(self)
1928 }
1929}
1930
1931impl DerivativesStream for Bybit {
1932 fn subscribe_derivatives(
1933 &mut self,
1934 symbol: &Symbol,
1935 channel: DerivativesChannel,
1936 ) -> Result<()> {
1937 Bybit::subscribe_derivatives(self, symbol, channel)
1938 }
1939 fn open_interest(&mut self, symbol: &Symbol) -> Result<OpenInterest> {
1940 Bybit::open_interest(self, symbol)
1941 }
1942 fn long_short_ratio(&mut self, symbol: &Symbol) -> Result<LongShortRatio> {
1943 Bybit::long_short_ratio(self, symbol)
1944 }
1945}
1946
1947impl WsExecution for Bybit {
1948 fn place_order_ws(&mut self, request: &OrderRequest) -> Result<Order> {
1949 Bybit::place_order_ws(self, request)
1950 }
1951 fn cancel_order_ws(&mut self, symbol: &Symbol, order_id: &str) -> Result<()> {
1952 Bybit::cancel_order_ws(self, symbol, order_id)
1953 }
1954}
1955
1956#[derive(Deserialize)]
1957struct BatchCreateResult {
1958 list: Vec<CreateResult>,
1959}
1960
1961impl Derivatives for Bybit {
1962 fn positions(&mut self, symbol: Option<&Symbol>) -> Result<Vec<Position>> {
1963 Bybit::positions(self, symbol)
1964 }
1965 fn set_leverage(&mut self, symbol: &Symbol, leverage: u32) -> Result<()> {
1966 Bybit::set_leverage(self, symbol, leverage)
1967 }
1968 fn set_margin_mode(&mut self, symbol: &Symbol, mode: MarginMode) -> Result<()> {
1969 Bybit::set_margin_mode(self, symbol, mode)
1970 }
1971 fn close_position(&mut self, symbol: &Symbol) -> Result<Order> {
1972 Bybit::close_position(self, symbol)
1973 }
1974}
1975
1976#[derive(Deserialize)]
1977struct PositionList {
1978 list: Vec<RawBybitPosition>,
1979}
1980
1981#[derive(Deserialize)]
1982struct RawBybitPosition {
1983 symbol: String,
1984 side: String,
1985 size: String,
1986 #[serde(rename = "avgPrice")]
1987 avg_price: String,
1988 #[serde(rename = "markPrice")]
1989 mark_price: String,
1990 leverage: String,
1991 #[serde(rename = "unrealisedPnl")]
1992 unrealised_pnl: String,
1993 #[serde(rename = "tradeMode", default)]
1994 trade_mode: i64,
1995}
1996
1997fn parse_bybit_position(raw: &RawBybitPosition) -> Option<Result<Position>> {
1998 let size = match parse_decimal(&raw.size) {
1999 Ok(size) if !size.is_zero() => size,
2000 Ok(_) => return None, Err(e) => return Some(Err(e)),
2002 };
2003 let side = match raw.side.as_str() {
2004 "Sell" => PositionSide::Short,
2005 _ => PositionSide::Long,
2006 };
2007 let build = || -> Result<Position> {
2008 Ok(Position {
2009 symbol: split_wire_symbol(&raw.symbol),
2010 side,
2011 quantity: size,
2012 entry_price: parse_decimal(&raw.avg_price)?,
2013 mark_price: parse_decimal(&raw.mark_price)?,
2014 leverage: parse_decimal(&raw.leverage)?,
2015 unrealized_pnl: parse_decimal(&raw.unrealised_pnl)?,
2016 margin_mode: if raw.trade_mode == 1 {
2017 MarginMode::Isolated
2018 } else {
2019 MarginMode::Cross
2020 },
2021 })
2022 };
2023 Some(build())
2024}
2025
2026#[cfg(test)]
2027mod tests {
2028 use super::*;
2029 use crate::transport::{HttpResponse, MockHttpTransport, MockWsTransport};
2030 use rust_decimal_macros::dec;
2031 use std::sync::Arc;
2032
2033 struct ArcTransport(Arc<MockHttpTransport>);
2034 impl HttpTransport for ArcTransport {
2035 fn execute(&self, request: &HttpRequest) -> Result<HttpResponse> {
2036 self.0.execute(request)
2037 }
2038 }
2039
2040 struct ArcWs(Arc<MockWsTransport>);
2041 impl WsTransport for ArcWs {
2042 fn connect(&self, url: &str) -> Result<Box<dyn WsConnection>> {
2043 self.0.connect(url)
2044 }
2045 }
2046
2047 fn symbol() -> Symbol {
2048 Symbol::new("BTC", "USDT")
2049 }
2050
2051 #[test]
2052 fn a_blocked_reply_is_reported_as_the_block_not_as_drift() {
2053 let (bybit, mock) = client(MarketType::Spot, false);
2054 mock.push_json(403, "{\n error: 'unavailable in your region'\n}");
2058 let error = bybit.ticker(&symbol()).unwrap_err();
2059 let named = matches!(&error, Error::Exchange { code, .. } if code == "403");
2060 assert!(named, "a blocked reply must not be reported as drift");
2061 }
2062
2063 fn client(market_type: MarketType, testnet: bool) -> (Bybit, Arc<MockHttpTransport>) {
2064 let mock = Arc::new(MockHttpTransport::new());
2065 let opts = if testnet {
2066 ExchangeOptions::testnet(market_type)
2067 } else {
2068 ExchangeOptions::mainnet(market_type)
2069 };
2070 let bybit = Bybit::with_http(Box::new(ArcTransport(Arc::clone(&mock))), &opts);
2071 (bybit, mock)
2072 }
2073
2074 #[test]
2075 fn category_by_market_type() {
2076 assert_eq!(category(MarketType::Spot), "spot");
2077 assert_eq!(category(MarketType::UsdMFutures), "linear");
2078 assert_eq!(category(MarketType::CoinMFutures), "inverse");
2079 assert_eq!(category(MarketType::Margin), "spot");
2080 }
2081
2082 #[test]
2083 fn interval_mapping() {
2084 assert_eq!(map_interval("1m"), "1");
2085 assert_eq!(map_interval("1h"), "60");
2086 assert_eq!(map_interval("4h"), "240");
2087 assert_eq!(map_interval("1d"), "D");
2088 assert_eq!(map_interval("weird"), "weird");
2089 }
2090
2091 #[test]
2092 fn ticker_unwraps_envelope_and_targets_url() {
2093 let (bybit, mock) = client(MarketType::Spot, false);
2094 assert_eq!(bybit.category(), "spot");
2095 mock.push_json(
2096 200,
2097 r#"{"retCode":0,"retMsg":"OK","result":{"list":[{"symbol":"BTCUSDT",
2098 "lastPrice":"20000.5","bid1Price":"20000.0","ask1Price":"20001.0","volume24h":"1234.5"}]}}"#,
2099 );
2100 let ticker = bybit.ticker(&symbol()).unwrap();
2101 assert_eq!(ticker.last, dec!(20000.5));
2102 assert_eq!(ticker.bid, dec!(20000.0));
2103 assert_eq!(ticker.ask, dec!(20001.0));
2104 let req = &mock.recorded_requests()[0];
2105 assert_eq!(
2106 req.url,
2107 "https://api.bybit.com/v5/market/tickers?category=spot&symbol=BTCUSDT"
2108 );
2109 }
2110
2111 #[test]
2112 fn klines_are_reversed_to_chronological() {
2113 let (bybit, mock) = client(MarketType::Spot, false);
2114 mock.push_json(
2116 200,
2117 r#"{"retCode":0,"result":{"list":[
2118 ["1700000060000","105","106","104","105.5","2","0"],
2119 ["1700000000000","100","110","95","105","12.5","0"]]}}"#,
2120 );
2121 let candles = bybit.klines(&symbol(), "1m", 2).unwrap();
2122 assert_eq!(candles.len(), 2);
2123 assert_eq!(candles[0].timestamp, 1_700_000_000_000);
2125 assert_eq!(candles[1].timestamp, 1_700_000_060_000);
2126 }
2127
2128 #[test]
2129 fn order_book_parses_levels() {
2130 let (bybit, mock) = client(MarketType::UsdMFutures, true);
2131 assert_eq!(bybit.category(), "linear");
2132 mock.push_json(
2133 200,
2134 r#"{"retCode":0,"result":{"s":"BTCUSDT","u":77,
2135 "b":[["100.0","1.5"]],"a":[["101.0","2.0"]]}}"#,
2136 );
2137 let book = bybit.order_book(&symbol(), 5).unwrap();
2138 assert_eq!(book.last_update_id, 77);
2139 assert_eq!(book.bids[0], BookLevel::new(dec!(100.0), dec!(1.5)));
2140 assert_eq!(book.asks[0], BookLevel::new(dec!(101.0), dec!(2.0)));
2141 assert!(mock.recorded_requests()[0]
2142 .url
2143 .starts_with("https://api-testnet.bybit.com/v5/market/orderbook"));
2144 }
2145
2146 #[test]
2147 fn error_envelope_maps_to_taxonomy() {
2148 let cases = [
2149 (10004, "sign"),
2150 (10006, "rate"),
2151 (170131, "balance"),
2152 (110001, "notfound"),
2153 (99999, "exchange"),
2154 ];
2155 for (code, kind) in cases {
2156 let (bybit, mock) = client(MarketType::Spot, false);
2157 mock.push_json(
2158 200,
2159 format!(r#"{{"retCode":{code},"retMsg":"x","result":{{}}}}"#),
2160 );
2161 let err = bybit.ticker(&symbol()).unwrap_err();
2162 match kind {
2163 "sign" => assert!(matches!(err, Error::Auth(_))),
2164 "rate" => assert!(matches!(err, Error::RateLimited { .. })),
2165 "balance" => assert!(matches!(err, Error::InsufficientBalance)),
2166 "notfound" => assert!(matches!(err, Error::NotFound(_))),
2167 _ => assert!(matches!(err, Error::Exchange { .. })),
2168 }
2169 }
2170 }
2171
2172 #[test]
2173 fn empty_ticker_list_is_not_found() {
2174 let (bybit, mock) = client(MarketType::Spot, false);
2175 mock.push_json(200, r#"{"retCode":0,"result":{"list":[]}}"#);
2176 assert!(matches!(
2177 bybit.ticker(&symbol()).unwrap_err(),
2178 Error::NotFound(_)
2179 ));
2180 }
2181
2182 fn signed_client(now_ms: i64) -> (Bybit, Arc<MockHttpTransport>) {
2183 let mock = Arc::new(MockHttpTransport::new());
2184 let opts = ExchangeOptions::mainnet(MarketType::Spot);
2185 let bybit = Bybit::with_credentials(
2186 Box::new(ArcTransport(Arc::clone(&mock))),
2187 &opts,
2188 Credentials::new("APIKEY", "SECRET"),
2189 )
2190 .with_clock(Box::new(move || now_ms));
2191 (bybit, mock)
2192 }
2193
2194 fn signed_futures_client(now_ms: i64) -> (Bybit, Arc<MockHttpTransport>) {
2195 let mock = Arc::new(MockHttpTransport::new());
2196 let opts = ExchangeOptions::mainnet(MarketType::UsdMFutures);
2197 let bybit = Bybit::with_credentials(
2198 Box::new(ArcTransport(Arc::clone(&mock))),
2199 &opts,
2200 Credentials::new("APIKEY", "SECRET"),
2201 )
2202 .with_clock(Box::new(move || now_ms));
2203 (bybit, mock)
2204 }
2205
2206 #[test]
2214 fn a_trigger_carries_the_direction_the_market_must_cross() {
2215 for (side, expected) in [(OrderSide::Sell, 2), (OrderSide::Buy, 1)] {
2216 let (bybit, mock) = signed_futures_client(1000);
2217 mock.push_json(
2218 200,
2219 r#"{"retCode":0,"result":{"orderId":"a","orderLinkId":""}}"#,
2220 );
2221 let request = OrderRequest {
2222 order_type: OrderType::StopMarket,
2223 stop_price: Some(dec!(19000)),
2224 side,
2225 ..OrderRequest::market_sell(symbol(), dec!(1))
2226 };
2227 Bybit::place_order(&bybit, &request).unwrap();
2228
2229 let body = mock.recorded_requests()[0].body.clone().unwrap();
2230 assert!(body.contains(r#""triggerPrice":"19000""#), "{body}");
2231 assert!(
2232 body.contains(&format!(r#""triggerDirection":{expected}"#)),
2233 "a {side:?} stop must fire on the other side: {body}"
2234 );
2235 }
2236 }
2237
2238 #[test]
2242 fn a_spot_trigger_names_the_order_filter() {
2243 let (bybit, mock) = signed_client(1000);
2244 mock.push_json(
2245 200,
2246 r#"{"retCode":0,"result":{"orderId":"a","orderLinkId":""}}"#,
2247 );
2248 let request = OrderRequest {
2249 order_type: OrderType::StopMarket,
2250 stop_price: Some(dec!(19000)),
2251 ..OrderRequest::market_sell(symbol(), dec!(1))
2252 };
2253 Bybit::place_order(&bybit, &request).unwrap();
2254
2255 let body = mock.recorded_requests()[0].body.clone().unwrap();
2256 assert!(body.contains(r#""orderFilter":"StopOrder""#), "{body}");
2257 }
2258
2259 #[test]
2262 fn a_futures_trigger_needs_no_order_filter() {
2263 let (bybit, mock) = signed_futures_client(1000);
2264 mock.push_json(
2265 200,
2266 r#"{"retCode":0,"result":{"orderId":"a","orderLinkId":""}}"#,
2267 );
2268 let request = OrderRequest {
2269 order_type: OrderType::StopLimit,
2270 stop_price: Some(dec!(19000)),
2271 ..OrderRequest::limit_sell(symbol(), dec!(1), dec!(18900))
2272 };
2273 Bybit::place_order(&bybit, &request).unwrap();
2274
2275 let body = mock.recorded_requests()[0].body.clone().unwrap();
2276 assert!(!body.contains("orderFilter"), "{body}");
2277 assert!(body.contains(r#""price":"18900""#), "{body}");
2279 assert!(body.contains(r#""triggerPrice":"19000""#), "{body}");
2280 }
2281
2282 #[test]
2283 fn a_bybit_trigger_without_a_stop_price_is_refused() {
2284 let (bybit, mock) = signed_client(1000);
2285 let request = OrderRequest {
2286 order_type: OrderType::StopMarket,
2287 stop_price: None,
2288 ..OrderRequest::market_sell(symbol(), dec!(1))
2289 };
2290 assert!(Bybit::place_order(&bybit, &request).is_err());
2291 assert_eq!(mock.recorded_requests(), Vec::new());
2292 }
2293
2294 #[test]
2295 fn stp_maps_to_smp_type() {
2296 let (bybit, mock) = signed_client(1000);
2297 mock.push_json(
2298 200,
2299 r#"{"retCode":0,"result":{"orderId":"a","orderLinkId":""}}"#,
2300 );
2301 bybit
2302 .place_order(
2303 &OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
2304 .with_stp(SelfTradePrevention::ExpireBoth),
2305 )
2306 .unwrap();
2307 let reqs = mock.recorded_requests();
2308 let body = reqs[0].body.as_ref().unwrap();
2309 assert!(body.contains(r#""smpType":"CancelBoth""#));
2310 }
2311
2312 #[test]
2313 fn amend_order_amends_then_reads_back() {
2314 let (bybit, mock) = signed_client(1000);
2315 mock.push_json(
2316 200,
2317 r#"{"retCode":0,"result":{"orderId":"a","orderLinkId":""}}"#,
2318 );
2319 mock.push_json(
2320 200,
2321 r#"{"retCode":0,"result":{"list":[{"symbol":"BTCUSDT","orderId":"a","side":"Buy",
2322 "orderType":"Limit","orderStatus":"New","qty":"2","price":"101"}]}}"#,
2323 );
2324 let order = bybit
2325 .amend_order(&symbol(), "a", Some(dec!(101)), Some(dec!(2)))
2326 .unwrap();
2327 assert_eq!(order.quantity, dec!(2));
2328 assert_eq!(order.price, Some(dec!(101)));
2329 let reqs = mock.recorded_requests();
2330 assert!(reqs[0].url.contains("/v5/order/amend"));
2331 let body = reqs[0].body.as_ref().unwrap();
2332 assert!(body.contains(r#""qty":"2""#));
2333 assert!(body.contains(r#""price":"101""#));
2334 assert!(reqs[1].url.contains("/v5/order/realtime"));
2335 }
2336
2337 #[test]
2338 fn place_batch_returns_per_order_results() {
2339 let (bybit, mock) = signed_client(1000);
2340 mock.push_json(
2341 200,
2342 r#"{"retCode":0,"result":{"list":[
2343 {"orderId":"o1","orderLinkId":""},
2344 {"orderId":"","orderLinkId":""}]}}"#,
2345 );
2346 let results = bybit
2347 .place_batch(&[
2348 OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)),
2349 OrderRequest::limit_buy(symbol(), dec!(1), dec!(101)),
2350 ])
2351 .unwrap();
2352 assert_eq!(results.len(), 2);
2353 assert_eq!(results[0].as_ref().unwrap().id, "o1");
2354 assert!(matches!(
2355 results[1].as_ref().unwrap_err(),
2356 Error::OrderRejected { .. }
2357 ));
2358 assert!(mock.recorded_requests()[0]
2359 .url
2360 .contains("/v5/order/create-batch"));
2361 }
2362
2363 #[test]
2364 fn cancel_batch_is_one_call() {
2365 let (bybit, mock) = signed_client(1000);
2366 mock.push_json(200, r#"{"retCode":0,"result":{"list":[]}}"#);
2367 bybit
2368 .cancel_batch(&symbol(), &["1".to_string(), "2".to_string()])
2369 .unwrap();
2370 let reqs = mock.recorded_requests();
2371 assert_eq!(reqs.len(), 1);
2372 assert!(reqs[0].url.contains("/v5/order/cancel-batch"));
2373 let body = reqs[0].body.as_ref().unwrap();
2374 assert!(body.contains(r#""orderId":"1""#));
2375 assert!(body.contains(r#""orderId":"2""#));
2376 }
2377
2378 #[test]
2384 fn a_delta_ticker_frame_prints_only_what_it_carries() {
2385 let resolve = |_: &str| symbol();
2386 let both = [
2387 ("BTCUSDT".to_string(), DerivativesChannel::Funding),
2388 ("BTCUSDT".to_string(), DerivativesChannel::MarkIndex),
2389 ];
2390
2391 let full: serde_json::Value = serde_json::from_str(
2392 r#"{"symbol":"BTCUSDT","markPrice":"20000.5","indexPrice":"19998.25","fundingRate":"0.0001","ts":1700000000000}"#,
2393 )
2394 .unwrap();
2395 let events = parse_derivatives_message("tickers.BTCUSDT", &full, &resolve, &both);
2396 assert_eq!(events.len(), 2);
2397
2398 let delta: serde_json::Value =
2401 serde_json::from_str(r#"{"symbol":"BTCUSDT","markPrice":"20001.0"}"#).unwrap();
2402 let events = parse_derivatives_message("tickers.BTCUSDT", &delta, &resolve, &both);
2403 assert_eq!(events, Vec::new());
2404
2405 let delta: serde_json::Value = serde_json::from_str(
2407 r#"{"symbol":"BTCUSDT","markPrice":"20001.0","fundingRate":"0.0002"}"#,
2408 )
2409 .unwrap();
2410 let events = parse_derivatives_message("tickers.BTCUSDT", &delta, &resolve, &both);
2411 assert_eq!(events.len(), 1);
2412 let Event::Derivatives(DerivativesFeed::Funding(funding)) = &events[0] else {
2413 panic!("expected a funding print");
2414 };
2415 assert_eq!(funding.rate, dec!(0.0002));
2416 }
2417
2418 #[test]
2423 fn a_ticker_subscription_alone_yields_no_derivatives_prints() {
2424 let resolve = |_: &str| symbol();
2425 let full: serde_json::Value = serde_json::from_str(
2426 r#"{"symbol":"BTCUSDT","markPrice":"20000.5","indexPrice":"19998.25","fundingRate":"0.0001"}"#,
2427 )
2428 .unwrap();
2429 assert_eq!(
2430 parse_derivatives_message("tickers.BTCUSDT", &full, &resolve, &[]),
2431 Vec::new()
2432 );
2433 }
2434
2435 #[test]
2439 fn all_liquidation_carries_the_side_hitting_the_book() {
2440 let resolve = |_: &str| symbol();
2441 let data: serde_json::Value = serde_json::from_str(
2442 r#"[{"T":1700000000123,"s":"BTCUSDT","S":"Sell","v":"2.5","p":"19000"}]"#,
2443 )
2444 .unwrap();
2445 let events = parse_derivatives_message(
2446 "allLiquidation.BTCUSDT",
2447 &data,
2448 &resolve,
2449 &[("BTCUSDT".to_string(), DerivativesChannel::Liquidations)],
2450 );
2451 assert_eq!(events.len(), 1);
2452 let Event::Derivatives(DerivativesFeed::Liquidation(liq)) = &events[0] else {
2453 panic!("expected a liquidation print");
2454 };
2455 assert_eq!(liq.side, OrderSide::Sell);
2456 assert_eq!(liq.price, dec!(19000));
2457 assert_eq!(liq.quantity, dec!(2.5));
2458 assert_eq!(liq.timestamp, 1_700_000_000_123);
2459
2460 assert_eq!(
2461 parse_derivatives_message("allLiquidation.BTCUSDT", &data, &resolve, &[]),
2462 Vec::new()
2463 );
2464 }
2465
2466 #[test]
2469 fn a_spot_client_refuses_the_derivatives_channels() {
2470 let (mut spot, _mock) = signed_client(1000);
2471 let err = DerivativesStream::subscribe_derivatives(
2472 &mut spot,
2473 &symbol(),
2474 DerivativesChannel::Funding,
2475 )
2476 .unwrap_err();
2477 assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
2478 let err = DerivativesStream::open_interest(&mut spot, &symbol()).unwrap_err();
2479 assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
2480 let err = DerivativesStream::long_short_ratio(&mut spot, &symbol()).unwrap_err();
2481 assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
2482 }
2483
2484 #[test]
2487 fn the_polled_figures_are_read_over_rest() {
2488 let (futures, mock) = signed_futures_client(1000);
2489 mock.push_json(
2490 200,
2491 r#"{"retCode":0,"retMsg":"OK","result":{"list":[{"openInterest":"12345.67","timestamp":"1700000000000"}]}}"#,
2492 );
2493 let oi = Bybit::open_interest(&futures, &symbol()).unwrap();
2494 assert_eq!(oi.open_interest, dec!(12345.67));
2495 assert_eq!(oi.timestamp, 1_700_000_000_000);
2496
2497 let (futures, mock) = signed_futures_client(1000);
2498 mock.push_json(
2499 200,
2500 r#"{"retCode":0,"retMsg":"OK","result":{"list":[{"buyRatio":"0.6","sellRatio":"0.4","timestamp":"1700000000000"}]}}"#,
2501 );
2502 let ratio = Bybit::long_short_ratio(&futures, &symbol()).unwrap();
2503 assert_eq!(ratio.long_size, dec!(0.6));
2504 assert_eq!(ratio.short_size, dec!(0.4));
2505
2506 let (futures, mock) = signed_futures_client(1000);
2507 mock.push_json(200, r#"{"retCode":0,"retMsg":"OK","result":{"list":[]}}"#);
2508 assert!(Bybit::open_interest(&futures, &symbol()).is_err());
2509 }
2510
2511 #[test]
2512 fn place_oco_is_unsupported() {
2513 let (mut bybit, _mock) = signed_client(1000);
2514 assert!(matches!(
2515 AdvancedOrders::place_oco(
2516 &mut bybit,
2517 &OcoRequest::new(symbol(), OrderSide::Sell, dec!(1), dec!(110), dec!(95))
2518 )
2519 .unwrap_err(),
2520 Error::Exchange { .. }
2521 ));
2522 }
2523
2524 const POSITION_ENVELOPE: &str = r#"{"retCode":0,"retMsg":"OK","result":{"list":[
2525 {"symbol":"BTCUSDT","side":"Buy","size":"0.5","avgPrice":"20000","markPrice":"20100","leverage":"10","unrealisedPnl":"50","tradeMode":1}
2526 ],"category":"linear"}}"#;
2527
2528 #[test]
2529 fn derivatives_positions_parse() {
2530 let (mut bybit, mock) = signed_futures_client(1000);
2531 mock.push_json(200, POSITION_ENVELOPE);
2532 let positions = Derivatives::positions(&mut bybit, Some(&symbol())).unwrap();
2533 assert_eq!(positions.len(), 1);
2534 assert_eq!(positions[0].symbol, Symbol::new("BTC", "USDT"));
2535 assert_eq!(positions[0].side, PositionSide::Long);
2536 assert_eq!(positions[0].quantity, dec!(0.5));
2537 assert_eq!(positions[0].leverage, dec!(10));
2538 assert_eq!(positions[0].margin_mode, MarginMode::Isolated);
2539 assert!(mock.recorded_requests()[0]
2540 .url
2541 .contains("/v5/position/list"));
2542 assert!(mock.recorded_requests()[0].url.contains("category=linear"));
2543 }
2544
2545 #[test]
2546 fn derivatives_set_leverage_hits_endpoint() {
2547 let (mut bybit, mock) = signed_futures_client(1000);
2548 mock.push_json(200, r#"{"retCode":0,"retMsg":"OK","result":{}}"#);
2549 Derivatives::set_leverage(&mut bybit, &symbol(), 20).unwrap();
2550 let req = &mock.recorded_requests()[0];
2551 assert!(req.url.contains("/v5/position/set-leverage"));
2552 assert!(req
2553 .body
2554 .as_deref()
2555 .unwrap()
2556 .contains(r#""buyLeverage":"20""#));
2557 }
2558
2559 #[test]
2560 fn derivatives_set_margin_mode_switches_isolated() {
2561 let (mut bybit, mock) = signed_futures_client(1000);
2562 mock.push_json(200, POSITION_ENVELOPE); mock.push_json(200, r#"{"retCode":0,"retMsg":"OK","result":{}}"#);
2564 Derivatives::set_margin_mode(&mut bybit, &symbol(), MarginMode::Isolated).unwrap();
2565 let reqs = mock.recorded_requests();
2566 assert!(reqs[1].url.contains("/v5/position/switch-isolated"));
2567 assert!(reqs[1]
2568 .body
2569 .as_deref()
2570 .unwrap()
2571 .contains(r#""tradeMode":1"#));
2572 }
2573
2574 #[test]
2575 fn derivatives_close_position_reduce_only() {
2576 let (mut bybit, mock) = signed_futures_client(1000);
2577 mock.push_json(200, POSITION_ENVELOPE);
2578 mock.push_json(
2579 200,
2580 r#"{"retCode":0,"retMsg":"OK","result":{"orderId":"9","orderLinkId":""}}"#,
2581 );
2582 Derivatives::close_position(&mut bybit, &symbol()).unwrap();
2583 let reqs = mock.recorded_requests();
2584 assert!(reqs[1].url.contains("/v5/order/create"));
2585 let body = reqs[1].body.as_deref().unwrap();
2586 assert!(body.contains(r#""side":"Sell""#));
2587 assert!(body.contains(r#""reduceOnly":true"#));
2588 }
2589
2590 fn header<'a>(req: &'a HttpRequest, name: &str) -> &'a str {
2591 req.headers
2592 .iter()
2593 .find(|(k, _)| k == name)
2594 .map(|(_, v)| v.as_str())
2595 .unwrap()
2596 }
2597
2598 fn hedged_futures_client(now_ms: i64) -> (Bybit, Arc<MockHttpTransport>) {
2599 let mock = Arc::new(MockHttpTransport::new());
2600 let mut opts = ExchangeOptions::mainnet(MarketType::UsdMFutures);
2601 opts.position_mode = PositionMode::Hedge;
2602 let bybit = Bybit::with_credentials(
2603 Box::new(ArcTransport(Arc::clone(&mock))),
2604 &opts,
2605 Credentials::new("APIKEY", "SECRET"),
2606 )
2607 .with_clock(Box::new(move || now_ms));
2608 (bybit, mock)
2609 }
2610
2611 #[test]
2612 fn post_only_and_reduce_only_reach_every_order_path() {
2613 let (bybit, mock) = signed_futures_client(1000);
2618 mock.push_json(
2619 200,
2620 r#"{"retCode":0,"result":{"orderId":"a","orderLinkId":""}}"#,
2621 );
2622 bybit
2623 .place_order(
2624 &OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
2625 .post_only()
2626 .reduce_only(),
2627 )
2628 .unwrap();
2629 let body = mock.recorded_requests()[0].body.clone().unwrap();
2630 assert!(body.contains(r#""timeInForce":"PostOnly""#));
2631 assert!(body.contains(r#""reduceOnly":true"#));
2632
2633 mock.push_json(
2634 200,
2635 r#"{"retCode":0,"result":{"list":[{"orderId":"o1","orderLinkId":""}]}}"#,
2636 );
2637 bybit
2638 .place_batch(&[OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).reduce_only()])
2639 .unwrap();
2640 assert!(mock.recorded_requests()[1]
2641 .body
2642 .as_deref()
2643 .unwrap()
2644 .contains(r#""reduceOnly":true"#));
2645 }
2646
2647 #[test]
2648 fn hedge_mode_sends_the_position_index() {
2649 let (hedged, mock) = hedged_futures_client(1000);
2652 for _ in 0..2 {
2653 mock.push_json(
2654 200,
2655 r#"{"retCode":0,"result":{"orderId":"a","orderLinkId":""}}"#,
2656 );
2657 }
2658 hedged
2659 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2660 .unwrap();
2661 hedged
2662 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).reduce_only())
2663 .unwrap();
2664 let reqs = mock.recorded_requests();
2665 assert!(reqs[0]
2666 .body
2667 .as_deref()
2668 .unwrap()
2669 .contains(r#""positionIdx":1"#));
2670 assert!(reqs[1]
2672 .body
2673 .as_deref()
2674 .unwrap()
2675 .contains(r#""positionIdx":2"#));
2676
2677 let (spot, spot_mock) = signed_client(1000);
2679 spot_mock.push_json(
2680 200,
2681 r#"{"retCode":0,"result":{"orderId":"a","orderLinkId":""}}"#,
2682 );
2683 spot.place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2684 .unwrap();
2685 assert!(!spot_mock.recorded_requests()[0]
2686 .body
2687 .as_deref()
2688 .unwrap()
2689 .contains("positionIdx"));
2690 }
2691
2692 #[test]
2693 fn place_order_signs_with_bapi_headers() {
2694 let (bybit, mock) = signed_client(1000);
2695 mock.push_json(
2696 200,
2697 r#"{"retCode":0,"result":{"orderId":"1739","orderLinkId":"abc"}}"#,
2698 );
2699 let order = bybit
2700 .place_order(
2701 &OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).with_client_order_id("abc"),
2702 )
2703 .unwrap();
2704 assert_eq!(order.id, "1739");
2705 assert_eq!(order.client_order_id.as_deref(), Some("abc"));
2706 assert_eq!(order.status, OrderStatus::New);
2707 assert_eq!(order.symbol, symbol());
2708
2709 let req = &mock.recorded_requests()[0];
2710 assert_eq!(req.method, HttpMethod::Post);
2711 let body = req.body.as_ref().unwrap();
2712 let ts = header(req, "X-BAPI-TIMESTAMP");
2713 let recv = header(req, "X-BAPI-RECV-WINDOW");
2714 assert_eq!(ts, "1000");
2715 let expected = hmac_sha256_hex(b"SECRET", format!("{ts}APIKEY{recv}{body}").as_bytes());
2716 assert_eq!(header(req, "X-BAPI-SIGN"), expected);
2717 assert_eq!(header(req, "X-BAPI-API-KEY"), "APIKEY");
2718 assert!(body.contains(r#""side":"Buy""#));
2719 assert!(body.contains(r#""orderLinkId":"abc""#));
2720 }
2721
2722 #[test]
2723 fn cancel_order_posts_signed() {
2724 let (bybit, mock) = signed_client(1000);
2725 mock.push_json(200, r#"{"retCode":0,"result":{"orderId":"1739"}}"#);
2726 bybit.cancel_order(&symbol(), "1739").unwrap();
2727 let req = &mock.recorded_requests()[0];
2728 assert_eq!(req.method, HttpMethod::Post);
2729 assert!(req.url.ends_with("/v5/order/cancel"));
2730 assert!(req.body.as_ref().unwrap().contains(r#""orderId":"1739""#));
2731 }
2732
2733 #[test]
2734 fn query_order_parses_realtime_list() {
2735 let (bybit, mock) = signed_client(1000);
2736 mock.push_json(
2737 200,
2738 r#"{"retCode":0,"result":{"list":[{"orderId":"1739","orderLinkId":"",
2739 "symbol":"BTCUSDT","side":"Sell","orderType":"Market","orderStatus":"Filled",
2740 "qty":"2","cumExecQty":"2","price":"0","avgPrice":"100"}]}}"#,
2741 );
2742 let order = bybit.query_order(&symbol(), "1739").unwrap();
2743 assert_eq!(order.status, OrderStatus::Filled);
2744 assert_eq!(order.side, OrderSide::Sell);
2745 assert_eq!(order.order_type, OrderType::Market);
2746 assert_eq!(order.filled_quantity, dec!(2));
2747 assert_eq!(order.average_price, Some(dec!(100)));
2748 assert_eq!(order.price, None);
2749 assert_eq!(order.client_order_id, None);
2750 let req = &mock.recorded_requests()[0];
2751 assert_eq!(req.method, HttpMethod::Get);
2752 assert!(req.url.contains("orderId=1739"));
2753 assert!(req.headers.iter().any(|(k, _)| k == "X-BAPI-SIGN"));
2754 }
2755
2756 #[test]
2757 fn query_missing_order_is_not_found() {
2758 let (bybit, mock) = signed_client(1000);
2759 mock.push_json(200, r#"{"retCode":0,"result":{"list":[]}}"#);
2760 assert!(matches!(
2761 bybit.query_order(&symbol(), "x").unwrap_err(),
2762 Error::NotFound(_)
2763 ));
2764 }
2765
2766 #[test]
2767 fn signed_without_credentials_errors() {
2768 let (bybit, _) = client(MarketType::Spot, false);
2769 assert!(matches!(
2770 bybit
2771 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2772 .unwrap_err(),
2773 Error::InvalidCredentials(_)
2774 ));
2775 }
2776
2777 #[test]
2778 fn system_clock_is_sane() {
2779 assert!(system_now_ms() > 1_600_000_000_000);
2780 }
2781
2782 #[test]
2783 fn split_wire_symbol_uses_known_quotes() {
2784 assert_eq!(split_wire_symbol("BTCUSDT"), Symbol::new("BTC", "USDT"));
2785 assert_eq!(split_wire_symbol("ETHBTC"), Symbol::new("ETH", "BTC"));
2786 assert_eq!(split_wire_symbol("WEIRD"), Symbol::new("WEIRD", ""));
2787 }
2788
2789 #[test]
2790 fn open_orders_filtered_and_unfiltered() {
2791 let (bybit, mock) = signed_client(1000);
2792 mock.push_json(
2793 200,
2794 r#"{"retCode":0,"result":{"list":[{"symbol":"BTCUSDT","orderId":"1","orderLinkId":"a",
2795 "side":"Buy","orderType":"Limit","orderStatus":"New","qty":"1","cumExecQty":"0",
2796 "price":"100","avgPrice":"0"}]}}"#,
2797 );
2798 let orders = bybit.open_orders(Some(&symbol())).unwrap();
2799 assert_eq!(orders.len(), 1);
2800 assert_eq!(orders[0].symbol, symbol());
2801
2802 mock.push_json(
2803 200,
2804 r#"{"retCode":0,"result":{"list":[{"symbol":"ETHUSDT","orderId":"2","orderLinkId":"",
2805 "side":"Sell","orderType":"Market","orderStatus":"New","qty":"2","cumExecQty":"0",
2806 "price":"0","avgPrice":"0"}]}}"#,
2807 );
2808 let orders = bybit.open_orders(None).unwrap();
2809 assert_eq!(orders[0].symbol, Symbol::new("ETH", "USDT"));
2810 assert!(!mock.recorded_requests()[1].url.contains("symbol="));
2811 }
2812
2813 #[test]
2814 fn balances_parse_unified_wallet() {
2815 let (bybit, mock) = signed_client(1000);
2816 mock.push_json(
2817 200,
2818 r#"{"retCode":0,"result":{"list":[{"coin":[
2819 {"coin":"USDT","availableToWithdraw":"100.5","locked":"25.5"},
2820 {"coin":"BTC","availableToWithdraw":"0.1","locked":"0"}]}]}}"#,
2821 );
2822 let bals = bybit.balances().unwrap();
2823 assert_eq!(bals.len(), 2);
2824 assert_eq!(bals[0].asset, "USDT");
2825 assert_eq!(bals[0].free, dec!(100.5));
2826 assert_eq!(bals[0].locked, dec!(25.5));
2827 assert_eq!(bals[0].total(), dec!(126));
2828 let req = &mock.recorded_requests()[0];
2829 assert!(req.url.contains("accountType=UNIFIED"));
2830 assert!(req.headers.iter().any(|(k, _)| k == "X-BAPI-SIGN"));
2831 }
2832
2833 fn streaming_client(ws: &Arc<MockWsTransport>) -> Bybit {
2834 let http = Arc::new(MockHttpTransport::new());
2835 let opts = ExchangeOptions::mainnet(MarketType::Spot);
2836 Bybit::with_http(Box::new(ArcTransport(http)), &opts)
2837 .with_ws(Box::new(ArcWs(Arc::clone(ws))))
2838 }
2839
2840 fn signed_ws_client(now_ms: i64) -> (Bybit, Arc<MockWsTransport>) {
2841 let http = Arc::new(MockHttpTransport::new());
2842 let ws = Arc::new(MockWsTransport::new());
2843 let opts = ExchangeOptions::mainnet(MarketType::Spot);
2844 let bybit = Bybit::with_credentials(
2845 Box::new(ArcTransport(http)),
2846 &opts,
2847 Credentials::new("APIKEY", "SECRET"),
2848 )
2849 .with_ws(Box::new(ArcWs(Arc::clone(&ws))))
2850 .with_clock(Box::new(move || now_ms));
2851 (bybit, ws)
2852 }
2853
2854 #[test]
2855 fn subscribe_user_data_authenticates_and_streams_orders_and_wallet() {
2856 let (mut bybit, ws) = signed_ws_client(1000);
2857 ws.push_connection(vec![
2858 Ok(Some(r#"{"success":true,"op":"auth"}"#.to_string())),
2859 Ok(Some(r#"{"success":true,"op":"subscribe"}"#.to_string())),
2860 Ok(Some(
2861 r#"{"topic":"order","data":[{"symbol":"BTCUSDT","orderId":"55","orderLinkId":"my",
2862 "side":"Buy","orderType":"Limit","orderStatus":"Filled","qty":"1","cumExecQty":"1",
2863 "price":"100","avgPrice":"100"}]}"#
2864 .to_string(),
2865 )),
2866 Ok(Some(
2867 r#"{"topic":"wallet","data":[{"coin":[{"coin":"USDT",
2868 "availableToWithdraw":"900","locked":"50"}]}]}"#
2869 .to_string(),
2870 )),
2871 ]);
2872 bybit.subscribe_user_data().unwrap();
2873 assert_eq!(ws.connected_urls()[0], "wss://stream.bybit.com/v5/private");
2874 assert!(ws.sent()[0].contains(r#""op":"auth""#));
2875 assert!(ws.sent()[0].contains(r#""APIKEY""#));
2876 assert!(ws.sent()[1].contains(r#""op":"subscribe""#));
2877 assert!(ws.sent()[1].contains("order"));
2878 assert!(ws.sent()[1].contains("wallet"));
2879
2880 let events = bybit.poll_events();
2881 assert_eq!(events.len(), 2);
2882 let Event::OrderUpdate(order) = &events[0] else {
2883 panic!("first event must be an order update");
2884 };
2885 assert_eq!(order.id, "55");
2886 assert_eq!(order.client_order_id.as_deref(), Some("my"));
2887 assert_eq!(order.symbol, symbol());
2888 assert_eq!(order.side, OrderSide::Buy);
2889 assert_eq!(order.status, OrderStatus::Filled);
2890 assert_eq!(order.filled_quantity, dec!(1));
2891 assert_eq!(order.average_price, Some(dec!(100)));
2892 let Event::BalanceUpdate(balances) = &events[1] else {
2893 panic!("second event must be a balance update");
2894 };
2895 assert_eq!(balances.len(), 1);
2896 assert_eq!(balances[0].asset, "USDT");
2897 assert_eq!(balances[0].free, dec!(900));
2898 assert_eq!(balances[0].locked, dec!(50));
2899 }
2900
2901 #[test]
2902 fn subscribe_user_data_requires_credentials() {
2903 let ws = Arc::new(MockWsTransport::new());
2904 let mut bybit = streaming_client(&ws);
2905 assert!(matches!(
2906 bybit.subscribe_user_data().unwrap_err(),
2907 Error::InvalidCredentials(_)
2908 ));
2909 }
2910
2911 #[test]
2912 fn keepalive_user_data_pings_the_private_stream() {
2913 let (mut bybit, ws) = signed_ws_client(1000);
2914 ws.push_connection(vec![]);
2915 bybit.subscribe_user_data().unwrap();
2916 bybit.keepalive_user_data().unwrap();
2917 assert!(ws.sent().iter().any(|f| f == r#"{"op":"ping"}"#));
2918 }
2919
2920 #[test]
2921 fn keepalive_user_data_is_a_noop_before_subscribe() {
2922 let (mut bybit, ws) = signed_ws_client(1000);
2923 bybit.keepalive_user_data().unwrap();
2924 assert_eq!(ws.sent(), Vec::<String>::new());
2925 }
2926
2927 #[test]
2928 fn dropped_user_data_stream_reconnects_with_a_fresh_auth() {
2929 let (mut bybit, ws) = signed_ws_client(1000);
2930 ws.push_connection(vec![Ok(None)]);
2933 ws.push_connection(vec![]);
2934 bybit.subscribe_user_data().unwrap();
2935
2936 let events = bybit.poll_events();
2937 assert!(events.contains(&Event::Disconnected));
2938 assert!(events.contains(&Event::Reconnected));
2939 let auth_frames = ws
2941 .sent()
2942 .into_iter()
2943 .filter(|f| f.contains(r#""op":"auth""#))
2944 .count();
2945 assert_eq!(auth_frames, 2);
2946 assert_eq!(ws.connected_urls().len(), 2);
2947 assert_eq!(ws.connected_urls()[1], "wss://stream.bybit.com/v5/private");
2948 }
2949
2950 #[test]
2951 fn the_websocket_frame_carries_the_stp_policy() {
2952 let (mut bybit, ws) = signed_ws_client(1000);
2956 ws.push_connection(vec![
2957 Ok(Some(
2958 r#"{"op":"auth","retCode":0,"retMsg":"OK"}"#.to_string(),
2959 )),
2960 Ok(Some(
2961 r#"{"reqId":"1000","retCode":0,"retMsg":"OK","op":"order.create",
2962 "data":{"orderId":"55","orderLinkId":""}}"#
2963 .to_string(),
2964 )),
2965 ]);
2966 bybit
2967 .place_order_ws(
2968 &OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
2969 .with_stp(SelfTradePrevention::ExpireTaker),
2970 )
2971 .unwrap();
2972 assert!(ws.sent()[1].contains(r#""smpType":"CancelTaker""#));
2973 }
2974
2975 #[test]
2976 fn place_and_cancel_order_over_ws_trade() {
2977 let (mut bybit, ws) = signed_ws_client(1000);
2978 ws.push_connection(vec![
2979 Ok(Some(
2980 r#"{"op":"auth","retCode":0,"retMsg":"OK"}"#.to_string(),
2981 )),
2982 Ok(Some(
2983 r#"{"reqId":"1000","retCode":0,"retMsg":"OK","op":"order.create",
2984 "data":{"orderId":"55","orderLinkId":"my"}}"#
2985 .to_string(),
2986 )),
2987 Ok(Some(
2988 r#"{"reqId":"1000","retCode":0,"retMsg":"OK","op":"order.cancel",
2989 "data":{"orderId":"55"}}"#
2990 .to_string(),
2991 )),
2992 ]);
2993 let order = bybit
2994 .place_order_ws(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2995 .unwrap();
2996 assert_eq!(order.id, "55");
2997 assert_eq!(order.client_order_id.as_deref(), Some("my"));
2998 assert_eq!(order.status, OrderStatus::New);
2999 assert_eq!(ws.connected_urls()[0], "wss://stream.bybit.com/v5/trade");
3000 assert!(ws.sent()[0].contains(r#""op":"auth""#));
3002 assert!(ws.sent()[1].contains(r#""op":"order.create""#));
3003 assert!(ws.sent()[1].contains(r#""symbol":"BTCUSDT""#));
3004
3005 bybit.cancel_order_ws(&symbol(), "55").unwrap();
3006 assert!(ws.sent()[2].contains(r#""op":"order.cancel""#));
3007 assert!(ws.sent()[2].contains(r#""orderId":"55""#));
3008 }
3009
3010 #[test]
3011 fn ws_trade_surfaces_rejection() {
3012 let (mut bybit, ws) = signed_ws_client(1000);
3013 ws.push_connection(vec![
3014 Ok(Some(r#"{"op":"auth","retCode":0}"#.to_string())),
3015 Ok(Some(
3016 r#"{"reqId":"1000","retCode":10001,"retMsg":"insufficient balance"}"#.to_string(),
3017 )),
3018 ]);
3019 assert!(matches!(
3020 bybit
3021 .place_order_ws(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
3022 .unwrap_err(),
3023 Error::OrderRejected { .. }
3024 ));
3025 }
3026
3027 #[test]
3028 fn ws_trade_requires_credentials() {
3029 let ws = Arc::new(MockWsTransport::new());
3030 let mut bybit = streaming_client(&ws);
3031 assert!(matches!(
3032 bybit
3033 .place_order_ws(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
3034 .unwrap_err(),
3035 Error::InvalidCredentials(_)
3036 ));
3037 }
3038
3039 #[test]
3040 fn subscribe_sends_op_and_poll_parses_trades_and_book() {
3041 let ws = Arc::new(MockWsTransport::new());
3042 ws.push_connection(vec![
3043 Ok(Some(
3044 r#"{"topic":"publicTrade.BTCUSDT","type":"snapshot","data":[
3045 {"T":1,"s":"BTCUSDT","S":"Buy","v":"0.5","p":"100"},
3046 {"T":2,"s":"BTCUSDT","S":"Sell","v":"1","p":"101"}]}"#
3047 .to_string(),
3048 )),
3049 Ok(Some(
3050 r#"{"topic":"orderbook.50.BTCUSDT","type":"snapshot","data":{"s":"BTCUSDT","u":10,
3051 "b":[["100","1"]],"a":[["101","2"]]}}"#
3052 .to_string(),
3053 )),
3054 Ok(Some(r#"{"success":true,"op":"subscribe"}"#.to_string())),
3055 ]);
3056 let mut bybit = streaming_client(&ws);
3057 bybit.subscribe_trades(&symbol()).unwrap();
3058 assert_eq!(
3059 ws.connected_urls(),
3060 vec!["wss://stream.bybit.com/v5/public/spot".to_string()]
3061 );
3062 assert!(ws.sent()[0].contains(r#""op":"subscribe""#));
3063 assert!(ws.sent()[0].contains("publicTrade.BTCUSDT"));
3064
3065 let events = bybit.poll_events();
3066 assert_eq!(events.len(), 3);
3068 let Event::Trade(t) = &events[0] else {
3069 panic!("expected trade")
3070 };
3071 assert_eq!(t.aggressor, OrderSide::Buy);
3072 assert_eq!(t.price, dec!(100));
3073 assert!(matches!(events[1], Event::Trade(_)));
3074 assert!(matches!(events[2], Event::BookSnapshot(_)));
3075 }
3076
3077 #[test]
3078 fn ws_ticker_and_book_delta_parse() {
3079 let ws = Arc::new(MockWsTransport::new());
3080 ws.push_connection(vec![
3081 Ok(Some(
3082 r#"{"topic":"tickers.BTCUSDT","data":{"symbol":"BTCUSDT","lastPrice":"100",
3083 "bid1Price":"99","ask1Price":"101","volume24h":"5"}}"#
3084 .to_string(),
3085 )),
3086 Ok(Some(
3087 r#"{"topic":"orderbook.50.BTCUSDT","type":"delta","data":{"s":"BTCUSDT","u":11,
3088 "b":[["100","0"]],"a":[]}}"#
3089 .to_string(),
3090 )),
3091 ]);
3092 let mut bybit = streaming_client(&ws);
3093 bybit.subscribe_ticker(&symbol()).unwrap();
3094 bybit.subscribe_book(&symbol()).unwrap();
3095 assert_eq!(ws.connected_urls().len(), 1); let events = bybit.poll_events();
3098 assert_eq!(events.len(), 2);
3099 let Event::Ticker(ticker) = &events[0] else {
3100 panic!("expected ticker")
3101 };
3102 assert_eq!(ticker.last, dec!(100));
3103 assert_eq!(ticker.bid, dec!(99));
3104 let Event::BookDelta(delta) = &events[1] else {
3105 panic!("expected book delta")
3106 };
3107 assert_eq!(delta.final_update_id, 11);
3108 assert_eq!(delta.bids[0].quantity, dec!(0));
3109 }
3110
3111 #[test]
3112 fn subscribe_without_ws_errors_and_poll_empty() {
3113 let http = Arc::new(MockHttpTransport::new());
3114 let opts = ExchangeOptions::mainnet(MarketType::Spot);
3115 let mut bybit = Bybit::with_http(Box::new(ArcTransport(http)), &opts);
3116 assert!(matches!(
3117 bybit.subscribe_trades(&symbol()).unwrap_err(),
3118 Error::NotConnected
3119 ));
3120 assert_eq!(bybit.poll_events(), Vec::new());
3121 }
3122
3123 #[test]
3124 fn works_as_a_boxed_exchange() {
3125 let (bybit, mock) = signed_client(1000);
3126 mock.push_json(
3127 200,
3128 r#"{"retCode":0,"result":{"orderId":"1","orderLinkId":""}}"#,
3129 );
3130 let mut exchange: Box<dyn Exchange> = Box::new(bybit);
3131 assert_eq!(exchange.name(), "bybit");
3132 let order = exchange
3133 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
3134 .unwrap();
3135 assert_eq!(order.id, "1");
3136 }
3137
3138 #[test]
3142 fn debug_reports_state_without_credentials() {
3143 let (client, _http) = signed_client(1_700_000_000_000);
3144 let rendered = format!("{client:?}");
3145
3146 assert!(rendered.starts_with("Bybit {"));
3147 assert!(rendered.contains("authenticated: true"));
3148 assert!(!rendered.contains("SECRET"));
3149 assert!(!rendered.contains("APIKEY"));
3150 }
3151
3152 #[test]
3159 fn sync_time_shifts_signed_timestamps() {
3160 let (mut bybit, http) = signed_client(1_000_000);
3161
3162 http.push_json(200, r#"{"retCode":0,"retMsg":"OK","result":{"timeSecond":"1004","timeNano":"1004500000000"}}"#);
3164 let offset = bybit.sync_time().expect("sync must succeed");
3165 assert_eq!(offset, 4_500, "the venue is 4.5 s ahead of this machine");
3166
3167 http.push_json(
3168 200,
3169 r#"{"retCode":0,"retMsg":"OK","result":{"list":[{"coin":[]}]}}"#,
3170 );
3171 bybit.balances().expect("balances must succeed");
3172
3173 let requests = http.recorded_requests();
3174 let signed = requests.last().expect("a signed request was recorded");
3175 let header = signed
3176 .headers
3177 .iter()
3178 .find(|(name, _)| name == "X-BAPI-TIMESTAMP")
3179 .map(|(_, value)| value.as_str())
3180 .expect("signed request carries X-BAPI-TIMESTAMP");
3181 assert_eq!(header, "1004500", "the venue's time, not ours");
3182 }
3183
3184 #[test]
3192 fn rate_limit_carries_the_venues_advised_wait() {
3193 let (bybit, mock) = client(MarketType::Spot, false);
3194 mock.push_response(
3195 crate::transport::HttpResponse::new(
3196 200,
3197 r#"{"retCode":10006,"retMsg":"rate","result":{}}"#,
3198 )
3199 .with_header("Retry-After", "2.5"),
3200 );
3201 let err = bybit.ticker(&symbol()).unwrap_err();
3202 let wait = std::time::Duration::from_millis(2500);
3203 assert!(matches!(err, Error::RateLimited { retry_after: Some(d) } if d == wait));
3204 }
3205}