Skip to main content

wickra_exchange_core/exchanges/
bybit.rs

1//! Bybit (v5 unified API) — the second exchange, proving the pattern scales.
2//!
3//! Like Binance it is generic over the injected [`HttpTransport`] and tested
4//! offline against recorded responses. The shape is the same; the internals are
5//! bespoke: Bybit wraps every response in a `{retCode, retMsg, result}` envelope,
6//! uses a `category` (spot/linear/inverse) query parameter, reports klines
7//! newest-first, and (in a later slice) signs with `timestamp + apiKey +
8//! recvWindow + payload` rather than a signed query string.
9//!
10//! Covered here: the public REST market data (ticker, klines, depth), the
11//! `{retCode, retMsg, result}` envelope handling, the error taxonomy,
12//! `X-BAPI-*`-header signed execution (place/cancel/query/open orders, balances),
13//! the pull-based WebSocket market streams (`op:subscribe`, topic-routed frames),
14//! and the [`Exchange`] trait — so Bybit is usable as `Box<dyn Exchange>`.
15//!
16//! [`AdvancedOrders`] is native: STP via `smpType` on order create, amend via
17//! `/v5/order/amend`, and batch place/cancel via `/v5/order/create-batch` and
18//! `/v5/order/cancel-batch`. Bybit has no standalone OCO order-list (take-profit
19//! / stop-loss attach to a position/order), so `place_oco` is a documented gap.
20
21// Bybit `retCode`s are externally-defined numeric codes; grouping their digits
22// with underscores would obscure them rather than aid reading.
23#![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
53/// The current Unix time in milliseconds, from the system clock.
54fn 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
62/// A Bybit client over an injected HTTP transport.
63pub struct Bybit {
64    http: Box<dyn HttpTransport>,
65    ws: Option<Box<dyn WsTransport>>,
66    rest_base: String,
67    category: &'static str,
68    /// The market this client was built for.
69    ///
70    /// Kept alongside `category` rather than derived from it: two market types
71    /// map to the same category, so the string cannot say which was asked for.
72    market_type: MarketType,
73    /// One-way or hedge. Bybit names the side with `positionIdx`, which is only
74    /// meaningful on a derivatives category.
75    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    /// Offset between this machine's clock and the venue's, applied to every
81    /// signed timestamp. Zero until [`sync_time`](Self::sync_time) is called.
82    clock: ServerClock,
83    connection: Option<Box<dyn WsConnection>>,
84    sub_messages: Vec<String>,
85    subscriptions: Vec<(String, Symbol)>,
86    /// Which derivatives channels were subscribed, per wire symbol. Bybit
87    /// carries funding, mark and index on the same `tickers` stream that
88    /// feeds the ordinary ticker, so the frame alone cannot say which prints
89    /// the caller wants out of it.
90    derivatives_channels: Vec<(String, DerivativesChannel)>,
91    /// The private user-data connection, opened by
92    /// [`subscribe_user_data`](Self::subscribe_user_data) and drained by
93    /// [`poll_events`](Self::poll_events) alongside the public stream.
94    private_connection: Option<Box<dyn WsConnection>>,
95    /// Set once the private stream is subscribed, so [`poll_events`](Self::poll_events)
96    /// re-subscribes it after a drop.
97    user_data_active: bool,
98    /// A dedicated connection to the WebSocket trade API (`/v5/trade`), opened and
99    /// authenticated lazily on the first [`place_order_ws`](Self::place_order_ws)
100    /// / [`cancel_order_ws`](Self::cancel_order_ws) call.
101    ws_api_connection: Option<Box<dyn WsConnection>>,
102}
103
104/// Hand-written: the client holds `Box<dyn HttpTransport>`, `Box<dyn WsTransport>`
105/// and a `Box<dyn Fn() -> i64>` clock, none of which a derive can reach. The
106/// transports are shown by whether a connection is open rather than by value, and
107/// the credentials only by whether they are set -- printing them would put secret
108/// material into every log line that formats a client.
109impl 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    /// Refuse a market this client does not route.
131    ///
132    /// Called at every seam that reaches the venue -- the HTTP helpers and each
133    /// WebSocket connect -- so an unrouted market is refused before a request
134    /// is built rather than answered by whichever market the URL happened to
135    /// name. See [`ensure_market_is_routed`](super::ensure_market_is_routed)
136    /// for what each client used to answer instead.
137    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    /// The timestamp a signed request must carry: this machine's clock plus the
173    /// offset learned from the venue.
174    ///
175    /// A venue rejects a signed request whose timestamp falls outside its own
176    /// receive window, so a machine a few seconds off has every order refused --
177    /// with a message about the window rather than about the clock. Until
178    /// [`sync_time`](Self::sync_time) is called the offset is zero and this is
179    /// the local time, which is the previous behaviour.
180    fn signed_now_ms(&self) -> i64 {
181        self.clock.server_time_ms((self.now_ms)())
182    }
183
184    /// Learn the offset between this machine's clock and the venue's, from
185    /// `GET /v5/market/time`.
186    ///
187    /// Explicit rather than automatic: it costs a request, and a client should
188    /// not make one the caller did not ask for. Call it once after connecting,
189    /// and again if the process runs long enough for drift to matter.
190    ///
191    /// Returns the new offset in milliseconds (`server - local`).
192    ///
193    /// # Errors
194    /// Returns an [`Error`] if the request fails or the response cannot be parsed.
195    pub fn sync_time(&mut self) -> Result<i64> {
196        // `get` unwraps the envelope to its `result`, so the top-level `time`
197        // is not reachable here. `timeNano` is inside `result` and is a
198        // nanosecond string; `timeSecond` beside it would drop sub-second
199        // precision, which is the precision this offset exists to recover.
200        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    /// Attach a WebSocket transport, enabling the streaming subscriptions.
213    #[must_use]
214    pub fn with_ws(mut self, ws: Box<dyn WsTransport>) -> Self {
215        self.ws = Some(ws);
216        self
217    }
218
219    /// Build a public Bybit client over the given transport and options.
220    #[must_use]
221    pub fn with_http(http: Box<dyn HttpTransport>, options: &ExchangeOptions) -> Self {
222        Self::build(http, options, None)
223    }
224
225    /// Build an authenticated Bybit client for signed endpoints.
226    #[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    /// Override the timestamp source (used for deterministic signing in tests).
236    #[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    /// Whether this client targets a futures category. Funding, mark/index and
243    /// liquidations exist only there.
244    fn is_futures(&self) -> bool {
245        self.category != "spot"
246    }
247
248    /// Refuse `reduce_only` on a spot client, on every path that takes an order.
249    ///
250    /// A spot account holds balances, not positions, so there is nothing for a
251    /// reduce-only order to reduce. Bybit's `reduceOnly` belongs to the `linear`
252    /// and `inverse` categories; the spot endpoint takes the field and does not
253    /// act on it, which is worse than rejecting it -- the caller is told the
254    /// order can only close, and it can open.
255    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    /// The Bybit product category this client targets (`spot`/`linear`/`inverse`).
267    #[must_use]
268    pub fn category(&self) -> &'static str {
269        self.category
270    }
271
272    /// The Bybit wire symbol for a canonical [`Symbol`] (`BTC/USDT` -> `BTCUSDT`).
273    #[must_use]
274    pub fn wire_symbol(symbol: &Symbol) -> String {
275        symbol.to_concatenated()
276    }
277
278    /// A ticker for `symbol`.
279    ///
280    /// # Errors
281    /// Returns an [`Error`] if the request fails or the symbol is unknown.
282    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            // Bybit's ticker entries carry no timestamp; the envelope's `time`
303            // is when the server replied, not when the quote was true.
304            timestamp: 0,
305        })
306    }
307
308    /// Up to `limit` candles for `symbol` at `interval` (unified, e.g. `"1m"`,
309    /// `"1h"`, `"1d"`). Bybit returns newest-first; the result is chronological.
310    ///
311    /// # Errors
312    /// Returns an [`Error`] if the request fails or a row cannot be parsed.
313    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    /// A depth snapshot of `symbol` up to `depth` levels per side.
333    ///
334    /// # Errors
335    /// Returns an [`Error`] if the request fails or the response cannot be parsed.
336    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    /// Subscribe to the public trade stream for `symbol`.
355    ///
356    /// # Errors
357    /// Returns [`Error::NotConnected`] if no WebSocket transport is configured,
358    /// or a transport error if the connection or subscription fails.
359    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    /// Subscribe to the order-book stream for `symbol`.
365    ///
366    /// # Errors
367    /// See [`subscribe_trades`](Self::subscribe_trades).
368    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    /// Subscribe to the ticker stream for `symbol`.
374    ///
375    /// # Errors
376    /// See [`subscribe_trades`](Self::subscribe_trades).
377    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    /// Subscribe to a pushed derivatives channel.
383    ///
384    /// Bybit publishes funding, mark and index on the **same `tickers` stream**
385    /// that carries the ordinary ticker -- one topic answering four questions --
386    /// so both channels resolve to it and the parser emits whichever the caller
387    /// subscribed to. Liquidations are `allLiquidation`, which reports the taker
388    /// side of the forced order rather than the side of the position that was
389    /// closed; that is the side this crate's [`Liquidation`] carries, so it maps
390    /// straight through.
391    ///
392    /// These streams exist only on the linear (futures) venue.
393    ///
394    /// # Errors
395    /// Returns an [`Error`] on a spot client, or if the subscription fails.
396    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    /// The current open interest (`GET /v5/market/open-interest`), most recent
420    /// point.
421    ///
422    /// Bybit does push open interest, on the `tickers` stream -- but only as one
423    /// field among many, and only when it changes. Reading it is what gives a
424    /// caller a figure at a known moment rather than whenever the venue last
425    /// happened to include it.
426    ///
427    /// # Errors
428    /// Returns an [`Error`] on a spot client, if the request fails, or if the
429    /// venue returns no data point.
430    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    /// The current long/short account ratio (`GET /v5/market/account-ratio`),
460    /// most recent point.
461    ///
462    /// Reported as account proportions summing to one, carried through as given.
463    ///
464    /// # Errors
465    /// Returns an [`Error`] on a spot client, if the request fails, or if the
466    /// venue returns no data point.
467    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    /// Open the connection if needed, send an `op:subscribe` for `topic`, and
498    /// register the symbol for wire-name resolution.
499    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    /// Drain all stream events available since the last call. Non-blocking;
522    /// frames that fail to parse are skipped.
523    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                // Parsed separately: a `tickers` frame is both an ordinary
539                // ticker and, on the linear venue, a funding and mark/index
540                // print. One frame, up to three subscriptions.
541                events.extend(parse_derivatives_frame(&frame, &resolve, &channels));
542            }
543        }
544        // Drain the private user-data stream (order/wallet topics), if open.
545        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        // A dropped private stream is re-subscribed with a fresh op:auth (the
553        // signature is time-bound, so a stale replay would be rejected).
554        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    /// Open the private user-data stream (`wss://.../v5/private`). Authenticates
578    /// with an `op:auth` frame (signature = HMAC-SHA256 over `GET/realtime<expires>`),
579    /// then subscribes to the `order` and `wallet` topics. Afterwards
580    /// [`poll_events`](Self::poll_events) also surfaces the account's own
581    /// [`Event::OrderUpdate`] and [`Event::BalanceUpdate`].
582    ///
583    /// A dropped private stream is re-subscribed automatically on the next
584    /// [`poll_events`](Self::poll_events); call
585    /// [`keepalive_user_data`](Self::keepalive_user_data) periodically to keep it
586    /// from being dropped for inactivity.
587    ///
588    /// # Errors
589    /// Returns [`Error::InvalidCredentials`] without credentials, [`Error::NotConnected`]
590    /// without a WebSocket transport, or another [`Error`] if the request fails.
591    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        // Bybit signs `GET/realtime<expires>`; the auth is valid until `expires`.
597        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    /// Send an application-level heartbeat (`op:ping`) on the private stream so it
617    /// is not dropped for inactivity. A no-op before
618    /// [`subscribe_user_data`](Self::subscribe_user_data).
619    ///
620    /// # Errors
621    /// Returns an [`Error`] if the ping cannot be sent.
622    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    /// Place an order. Validated locally, then sent signed. Bybit's create
630    /// endpoint returns only the ids, so the resulting [`Order`] carries the
631    /// request's own fields with the venue order id and a `New` status.
632    ///
633    /// # Errors
634    /// Returns an [`Error`] if the order is invalid, credentials are missing, or
635    /// the venue rejects it.
636    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    /// Cancel an open order by venue id.
686    ///
687    /// # Errors
688    /// Returns an [`Error`] if credentials are missing or the venue rejects it.
689    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    /// Place an order over the Bybit WebSocket trade API (`order.create`). Builds
700    /// the same args as the REST path and exchanges them on the lazily-opened,
701    /// authenticated `/v5/trade` connection. Bybit returns only the ids, so the
702    /// resulting [`Order`] carries the request's fields with a `New` status.
703    ///
704    /// # Errors
705    /// Returns [`Error::NotConnected`] without a WebSocket transport, or another
706    /// [`Error`] if the order is invalid or rejected.
707    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    /// Cancel an order over the Bybit WebSocket trade API (`order.cancel`).
755    ///
756    /// # Errors
757    /// Returns [`Error::NotConnected`] without a WebSocket transport, or another
758    /// [`Error`] if the order is unknown or the request fails.
759    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    /// Open and authenticate the `/v5/trade` connection if needed. Sends the
770    /// `op:auth` frame (same signature as the private stream) and consumes the
771    /// auth acknowledgement so later requests read their own responses.
772    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        // Consume the auth acknowledgement.
793        connection.recv()?;
794        self.ws_api_connection = Some(connection);
795        Ok(())
796    }
797
798    /// Send a signed trade request frame and return its `data`, mapping a non-zero
799    /// `retCode` onto the error taxonomy.
800    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    /// Query a single order by venue id.
846    ///
847    /// # Errors
848    /// Returns an [`Error`] if credentials are missing or the order is unknown.
849    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    /// All open orders, optionally filtered to one `symbol`. When unfiltered, the
866    /// venue's wire symbol is mapped back to a canonical [`Symbol`].
867    ///
868    /// # Errors
869    /// Returns an [`Error`] if credentials are missing or the request fails.
870    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    /// Unified-account balances (assets are reported as the venue lists them).
890    ///
891    /// # Errors
892    /// Returns an [`Error`] if credentials are missing or the request fails.
893    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    /// GET a public endpoint and unwrap the `{retCode, retMsg, result}` envelope.
918    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    /// Sign a request with the Bybit `X-BAPI-*` header scheme: HMAC-SHA256 over
928    /// `timestamp + apiKey + recvWindow + (query for GET, body for POST)`.
929    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
967/// The Bybit product category for a market type.
968impl Bybit {
969    /// Bybit's `positionIdx`, or `None` when the order does not need one.
970    ///
971    /// `0` is one-way and is also the venue default, so it is left off rather
972    /// than sent. In hedge mode the buy side of the account is `1` and the sell
973    /// side is `2`, and an order has to say which it acts on. Spot has no
974    /// position index at all.
975    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
996/// Map a unified interval (`1m`/`1h`/`1d`) to Bybit's format (`1`/`60`/`D`).
997fn 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    /// Verified against the live endpoint: the book result is stamped.
1054    #[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    // Bybit kline: [startTime, open, high, low, close, volume, turnover].
1076    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
1091/// Map a Bybit `retCode` onto the unified error taxonomy.
1092fn 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    // Bybit returns HTTP 200 even for API errors; the retCode carries the real
1107    // status, so parse the envelope regardless of the HTTP code.
1108    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
1116/// Pull `topic` and `data` out of a raw frame and hand them to
1117/// [`parse_derivatives_message`]. A frame that is not JSON, or carries no topic,
1118/// yields nothing.
1119fn 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
1136/// Parse one Bybit WebSocket frame into the derivatives events the caller
1137/// subscribed to.
1138///
1139/// Bybit's `tickers` frames are *deltas*: after the first snapshot only the
1140/// fields that changed are present. So a funding print is emitted only when the
1141/// frame actually carries a rate, and a mark/index print only when it carries
1142/// both prices. Filling a missing field with the last seen value would report a
1143/// figure as current that the venue did not just publish, and filling it with
1144/// zero would report a funding rate of zero, which is a number a strategy acts
1145/// on.
1146fn 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        // A missing field is an empty string here, which does not parse -- so an
1166        // absent price is absent rather than zero.
1167        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            // `S` on `allLiquidation` is the taker side of the forced order --
1204            // the side hitting the book -- which is what `Liquidation` carries.
1205            // The older `liquidation` topic reported the *position* side, the
1206            // opposite; reading one as the other inverts every liquidation-flow
1207            // figure computed downstream.
1208            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
1245/// The fields that turn a Bybit order into a conditional one.
1246///
1247/// `triggerDirection` says which way the market has to cross the trigger, and
1248/// Bybit will not infer it: 1 is "rises to", 2 is "falls to". A sell stop
1249/// protects a long and fires on the way *down*; a buy stop covers a short and
1250/// fires on the way *up*. Sending the wrong direction arms the order on the
1251/// side that never comes, so the stop simply never fires.
1252///
1253/// Spot conditional orders also need `orderFilter`, because Bybit's spot
1254/// endpoint serves plain and conditional orders through the same call and
1255/// defaults to the plain one -- a trigger sent without it is accepted and
1256/// placed immediately.
1257fn 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        // Selling to protect a long: the market has to fall to the trigger.
1266        OrderSide::Sell => 2,
1267        // Buying to cover a short: it has to rise to it.
1268        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
1287/// The Bybit `timeInForce` value for a request. `PostOnly` is one of the values
1288/// that field takes, alongside `GTC`/`IOC`/`FOK`, so setting both asks for two
1289/// things in one slot: that is refused rather than resolved by dropping the
1290/// time-in-force, which is what these builders used to do silently.
1291fn 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
1311/// The Bybit `smpType` value for a self-trade-prevention policy, or `None` to
1312/// omit it. Bybit expresses the maker/taker/both choice as `Cancel*`.
1313fn 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
1434/// Quote assets used to split a concatenated wire symbol (`BTCUSDT` -> `BTC/USDT`).
1435const KNOWN_QUOTES: &[&str] = &["USDT", "USDC", "EUR", "BTC", "ETH", "USD"];
1436
1437/// Map a concatenated Bybit wire symbol back to a canonical [`Symbol`].
1438fn 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
1449/// The public WebSocket base URL for a category and network.
1450fn 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
1459/// The private (user-data) WebSocket URL for a network.
1460fn 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
1469/// The WebSocket trade-API URL for a network.
1470fn 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
1517/// Parse one Bybit WebSocket frame into zero or more [`Event`]s, routing by the
1518/// `topic` prefix. `op` responses and unhandled topics yield an empty vector.
1519fn 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        // Private order stream: `data` is an array of order objects sharing the
1590        // REST order shape, so each deserializes into `RawOrder`.
1591        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        // Private wallet stream: `data` is an array of accounts, each with a
1605        // `coin` array; every account emits a balance-update snapshot.
1606        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    /// Open positions (`/v5/position/list`). Without a symbol filter, linear
1676    /// positions are queried by settle coin (USDT).
1677    ///
1678    /// # Errors
1679    /// Returns an [`Error`] if credentials are missing or the request fails.
1680    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    /// Set the leverage for `symbol` (`/v5/position/set-leverage`).
1691    ///
1692    /// # Errors
1693    /// Returns an [`Error`] if the leverage is rejected or the request fails.
1694    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    /// Set the margin mode for `symbol` (`/v5/position/switch-isolated`). Bybit
1712    /// couples the mode with the leverage, so the current leverage is preserved.
1713    ///
1714    /// # Errors
1715    /// Returns an [`Error`] if the change is rejected or the request fails.
1716    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    /// Flatten the open position in `symbol` with a reduce-only market order.
1742    ///
1743    /// # Errors
1744    /// Returns [`Error::NotFound`] if there is no open position, or another
1745    /// [`Error`] if the request fails.
1746    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    /// Amend a resting order's price and/or quantity in place
1761    /// (`/v5/order/amend`), then return the refreshed order.
1762    ///
1763    /// # Errors
1764    /// Returns an [`Error`] if the order is unknown or the amend is rejected.
1765    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    /// Place several orders in one request (`/v5/order/create-batch`). Each
1788    /// element's outcome is preserved: an empty returned `orderId` marks a
1789    /// rejected leg.
1790    ///
1791    /// # Errors
1792    /// Returns an [`Error`] if the batch request itself fails.
1793    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        // Resolved before the batch is built, so a request the venue cannot
1801        // express refuses the whole call rather than being sent weakened.
1802        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    /// Cancel several orders on one `symbol` in a single request
1867    /// (`/v5/order/cancel-batch`).
1868    ///
1869    /// # Errors
1870    /// Returns an [`Error`] if the request fails.
1871    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    /// Bybit has no standalone one-cancels-other order-list (take-profit/stop are
1905    /// attached to a position/order via `takeProfit`/`stopLoss`), so this returns
1906    /// an [`Error::Exchange`].
1907    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, // flat position
2001        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        // What a geo-blocked runner receives: a status of its own and a body
2055        // that was never this venue's JSON. Read as an envelope it fails at
2056        // line 2 column 5, which says nothing about why it failed.
2057        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        // Bybit returns newest-first.
2115        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        // Oldest first after reversing.
2124        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    /// Bybit will not infer which way the market must cross the trigger.
2207    ///
2208    /// `triggerDirection` is 1 for "rises to" and 2 for "falls to". A sell stop
2209    /// protects a long and fires on the way *down*; a buy stop covers a short
2210    /// and fires on the way *up*. The wrong direction arms the order on the
2211    /// side that never comes, so the stop simply never fires -- and nothing
2212    /// reports an error, because the order was accepted.
2213    #[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    /// Bybit's spot endpoint serves plain and conditional orders through one
2239    /// call and defaults to the plain one, so a trigger without `orderFilter`
2240    /// is accepted and placed **immediately** -- the stop that executes at once.
2241    #[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    /// A futures trigger needs no order filter: the category already says which
2260    /// endpoint behaviour applies.
2261    #[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        // The limit price keeps its own field; the trigger has another.
2278        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    /// Bybit's `tickers` frames are deltas: after the first snapshot only the
2379    /// changed fields are present. A funding print is emitted only when the
2380    /// frame actually carries a rate. Carrying the last seen value forward would
2381    /// report a stale figure as current, and defaulting to zero would report a
2382    /// funding rate of zero -- a number a strategy acts on.
2383    #[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        // A delta with only the mark price: no funding print, because there is
2399        // no rate in it.
2400        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        // A delta with a rate and a mark, but no index: funding only.
2406        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    /// The same frame prints nothing for a client that subscribed to neither
2419    /// channel: the `tickers` topic also feeds the ordinary ticker, so a client
2420    /// watching prices must not start receiving funding prints it never asked
2421    /// for.
2422    #[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    /// `allLiquidation` reports the taker side of the forced order -- the side
2436    /// hitting the book -- which is what `Liquidation` carries. The older
2437    /// `liquidation` topic reported the position side, the opposite one.
2438    #[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    /// A spot client refuses the derivatives channels rather than subscribing to
2467    /// a topic that will never carry a frame.
2468    #[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    /// The polled figures are read over REST, and an empty series is reported
2485    /// rather than read as a zero.
2486    #[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); // leverage lookup
2563        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        // Three paths build an order body; each spells post-only as the
2614        // `PostOnly` time-in-force and reduce-only as its own flag. A futures
2615        // client, because `reduce_only` names a position: on spot there is none
2616        // to reduce and every path refuses it.
2617        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        // positionIdx 1 is the buy side of a hedged account, 2 the sell side.
2650        // 0 is one-way and is the venue default, so it is left off entirely.
2651        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        // A buy that reduces is closing the short side.
2671        assert!(reqs[1]
2672            .body
2673            .as_deref()
2674            .unwrap()
2675            .contains(r#""positionIdx":2"#));
2676
2677        // Spot never carries one, whatever the mode says.
2678        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        // The first private connection closes on the first recv; the reconnect
2931        // target is a fresh open connection.
2932        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        // Two private connections (initial + reconnect), each re-signing op:auth.
2940        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        // Self-trade prevention was set on the request, honoured on the REST
2953        // path, and dropped on this one -- a policy that is not sent is not
2954        // applied.
2955        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        // The auth frame is sent first, then the order request.
3001        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        // 2 trades + 1 book snapshot (the op response is ignored).
3067        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); // one connection reused
3096
3097        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    /// `Debug` reports connection state, never secret material. A client is
3139    /// formatted into logs and error messages, so anything it prints is
3140    /// somewhere a credential must not be.
3141    #[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    /// The clock offset must reach the wire, not just the struct.
3153    ///
3154    /// A venue refuses a signed request whose timestamp is outside its own
3155    /// receive window, so a machine a few seconds off has every order rejected
3156    /// -- with a message about the window, not about the clock. This asserts the
3157    /// whole path: sync, then a signed request carrying the adjusted time.
3158    #[test]
3159    fn sync_time_shifts_signed_timestamps() {
3160        let (mut bybit, http) = signed_client(1_000_000);
3161
3162        // Shape verified against the live public endpoint on 2026-09-01.
3163        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    /// A rate-limited response carries the venue's advised wait.
3185    ///
3186    /// The limit itself is recognised from the body -- a code in the error
3187    /// envelope -- while the wait arrives in the `Retry-After` header, so the
3188    /// two are read in different places. Until they were joined, every
3189    /// `RateLimited` this client raised carried `retry_after: None`: a field
3190    /// the error type documents, and that nothing in the crate ever filled.
3191    #[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}