ccxt_exchanges/bybit/
mod.rs

1//! Bybit exchange implementation.
2//!
3//! Supports spot trading and futures trading (USDT-M and Coin-M) with REST API and WebSocket support.
4//! Bybit uses V5 unified account API with HMAC-SHA256 authentication.
5
6use ccxt_core::types::default_type::{DefaultSubType, DefaultType};
7use ccxt_core::{BaseExchange, ExchangeConfig, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11pub mod auth;
12pub mod builder;
13pub mod error;
14pub mod parser;
15pub mod rest;
16pub mod symbol;
17pub mod ws;
18mod ws_exchange_impl;
19
20pub use auth::BybitAuth;
21pub use builder::BybitBuilder;
22pub use error::{BybitErrorCode, is_error_response, parse_error};
23
24/// Bybit exchange structure.
25#[derive(Debug)]
26pub struct Bybit {
27    /// Base exchange instance.
28    base: BaseExchange,
29    /// Bybit-specific options.
30    options: BybitOptions,
31}
32
33/// Bybit-specific options.
34///
35/// # Example
36///
37/// ```rust
38/// use ccxt_exchanges::bybit::BybitOptions;
39/// use ccxt_core::types::default_type::{DefaultType, DefaultSubType};
40///
41/// let options = BybitOptions {
42///     default_type: DefaultType::Swap,
43///     default_sub_type: Some(DefaultSubType::Linear),
44///     ..Default::default()
45/// };
46/// ```
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct BybitOptions {
49    /// Account type: UNIFIED, CONTRACT, SPOT.
50    ///
51    /// This is kept for backward compatibility with existing configurations.
52    pub account_type: String,
53    /// Default trading type (spot/swap/futures/option).
54    ///
55    /// This determines which category to use for API calls.
56    /// Bybit uses a unified V5 API with category-based filtering:
57    /// - `Spot` -> category=spot
58    /// - `Swap` + Linear -> category=linear
59    /// - `Swap` + Inverse -> category=inverse
60    /// - `Option` -> category=option
61    #[serde(default)]
62    pub default_type: DefaultType,
63    /// Default sub-type for contract settlement (linear/inverse).
64    ///
65    /// - `Linear`: USDT-margined contracts (category=linear)
66    /// - `Inverse`: Coin-margined contracts (category=inverse)
67    ///
68    /// Only applicable when `default_type` is `Swap` or `Futures`.
69    /// Ignored for `Spot` and `Option` types.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub default_sub_type: Option<DefaultSubType>,
72    /// Enables testnet environment.
73    pub testnet: bool,
74    /// Receive window in milliseconds.
75    pub recv_window: u64,
76}
77
78impl Default for BybitOptions {
79    fn default() -> Self {
80        Self {
81            account_type: "UNIFIED".to_string(),
82            default_type: DefaultType::default(), // Defaults to Spot
83            default_sub_type: None,
84            testnet: false,
85            recv_window: 5000,
86        }
87    }
88}
89
90impl Bybit {
91    /// Creates a new Bybit instance using the builder pattern.
92    ///
93    /// This is the recommended way to create a Bybit instance.
94    ///
95    /// # Example
96    ///
97    /// ```no_run
98    /// use ccxt_exchanges::bybit::Bybit;
99    ///
100    /// let bybit = Bybit::builder()
101    ///     .api_key("your-api-key")
102    ///     .secret("your-secret")
103    ///     .testnet(true)
104    ///     .build()
105    ///     .unwrap();
106    /// ```
107    pub fn builder() -> BybitBuilder {
108        BybitBuilder::new()
109    }
110
111    /// Creates a new Bybit instance.
112    ///
113    /// # Arguments
114    ///
115    /// * `config` - Exchange configuration.
116    pub fn new(config: ExchangeConfig) -> Result<Self> {
117        let base = BaseExchange::new(config)?;
118        let options = BybitOptions::default();
119
120        Ok(Self { base, options })
121    }
122
123    /// Creates a new Bybit instance with custom options.
124    ///
125    /// This is used internally by the builder pattern.
126    ///
127    /// # Arguments
128    ///
129    /// * `config` - Exchange configuration.
130    /// * `options` - Bybit-specific options.
131    pub fn new_with_options(config: ExchangeConfig, options: BybitOptions) -> Result<Self> {
132        let base = BaseExchange::new(config)?;
133        Ok(Self { base, options })
134    }
135
136    /// Returns a reference to the base exchange.
137    pub fn base(&self) -> &BaseExchange {
138        &self.base
139    }
140
141    /// Returns a mutable reference to the base exchange.
142    pub fn base_mut(&mut self) -> &mut BaseExchange {
143        &mut self.base
144    }
145
146    /// Returns the Bybit options.
147    pub fn options(&self) -> &BybitOptions {
148        &self.options
149    }
150
151    /// Sets the Bybit options.
152    pub fn set_options(&mut self, options: BybitOptions) {
153        self.options = options;
154    }
155
156    /// Returns the exchange ID.
157    pub fn id(&self) -> &str {
158        "bybit"
159    }
160
161    /// Returns the exchange name.
162    pub fn name(&self) -> &str {
163        "Bybit"
164    }
165
166    /// Returns the API version.
167    pub fn version(&self) -> &str {
168        "v5"
169    }
170
171    /// Returns `true` if the exchange is CCXT-certified.
172    pub fn certified(&self) -> bool {
173        false
174    }
175
176    /// Returns `true` if Pro version (WebSocket) is supported.
177    pub fn pro(&self) -> bool {
178        true
179    }
180
181    /// Returns the rate limit in requests per second.
182    pub fn rate_limit(&self) -> u32 {
183        20
184    }
185
186    /// Returns `true` if sandbox/testnet mode is enabled.
187    ///
188    /// Sandbox mode is enabled when either:
189    /// - `config.sandbox` is set to `true`
190    /// - `options.testnet` is set to `true`
191    ///
192    /// # Returns
193    ///
194    /// `true` if sandbox mode is enabled, `false` otherwise.
195    ///
196    /// # Example
197    ///
198    /// ```no_run
199    /// use ccxt_exchanges::bybit::Bybit;
200    /// use ccxt_core::ExchangeConfig;
201    ///
202    /// let config = ExchangeConfig {
203    ///     sandbox: true,
204    ///     ..Default::default()
205    /// };
206    /// let bybit = Bybit::new(config).unwrap();
207    /// assert!(bybit.is_sandbox());
208    /// ```
209    pub fn is_sandbox(&self) -> bool {
210        self.base().config.sandbox || self.options.testnet
211    }
212
213    /// Returns the supported timeframes.
214    pub fn timeframes(&self) -> HashMap<String, String> {
215        let mut timeframes = HashMap::new();
216        timeframes.insert("1m".to_string(), "1".to_string());
217        timeframes.insert("3m".to_string(), "3".to_string());
218        timeframes.insert("5m".to_string(), "5".to_string());
219        timeframes.insert("15m".to_string(), "15".to_string());
220        timeframes.insert("30m".to_string(), "30".to_string());
221        timeframes.insert("1h".to_string(), "60".to_string());
222        timeframes.insert("2h".to_string(), "120".to_string());
223        timeframes.insert("4h".to_string(), "240".to_string());
224        timeframes.insert("6h".to_string(), "360".to_string());
225        timeframes.insert("12h".to_string(), "720".to_string());
226        timeframes.insert("1d".to_string(), "D".to_string());
227        timeframes.insert("1w".to_string(), "W".to_string());
228        timeframes.insert("1M".to_string(), "M".to_string());
229        timeframes
230    }
231
232    /// Returns the API URLs.
233    pub fn urls(&self) -> BybitUrls {
234        if self.base().config.sandbox || self.options.testnet {
235            BybitUrls::testnet()
236        } else {
237            BybitUrls::production()
238        }
239    }
240
241    /// Returns the default type configuration.
242    pub fn default_type(&self) -> DefaultType {
243        self.options.default_type
244    }
245
246    /// Returns the default sub-type configuration.
247    pub fn default_sub_type(&self) -> Option<DefaultSubType> {
248        self.options.default_sub_type
249    }
250
251    /// Checks if the current default_type is a contract type (Swap, Futures, or Option).
252    ///
253    /// This is useful for determining whether contract-specific API parameters should be used.
254    ///
255    /// # Returns
256    ///
257    /// `true` if the default_type is Swap, Futures, or Option; `false` otherwise.
258    pub fn is_contract_type(&self) -> bool {
259        self.options.default_type.is_contract()
260    }
261
262    /// Checks if the current configuration uses inverse (coin-margined) contracts.
263    ///
264    /// # Returns
265    ///
266    /// `true` if default_sub_type is Inverse; `false` otherwise.
267    pub fn is_inverse(&self) -> bool {
268        matches!(self.options.default_sub_type, Some(DefaultSubType::Inverse))
269    }
270
271    /// Checks if the current configuration uses linear (USDT-margined) contracts.
272    ///
273    /// # Returns
274    ///
275    /// `true` if default_sub_type is Linear or not specified (defaults to Linear); `false` otherwise.
276    pub fn is_linear(&self) -> bool {
277        !self.is_inverse()
278    }
279
280    /// Returns the Bybit category string based on the current default_type and default_sub_type.
281    ///
282    /// Bybit V5 API uses category parameter for filtering:
283    /// - `Spot` -> "spot"
284    /// - `Swap` + Linear -> "linear"
285    /// - `Swap` + Inverse -> "inverse"
286    /// - `Futures` + Linear -> "linear"
287    /// - `Futures` + Inverse -> "inverse"
288    /// - `Option` -> "option"
289    /// - `Margin` -> "spot" (margin trading uses spot category)
290    ///
291    /// # Returns
292    ///
293    /// The category string to use for Bybit API calls.
294    pub fn category(&self) -> &'static str {
295        match self.options.default_type {
296            DefaultType::Spot | DefaultType::Margin => "spot",
297            DefaultType::Swap | DefaultType::Futures => {
298                if self.is_inverse() {
299                    "inverse"
300                } else {
301                    "linear"
302                }
303            }
304            DefaultType::Option => "option",
305        }
306    }
307
308    /// Creates a public WebSocket client.
309    ///
310    /// The WebSocket URL is selected based on the configured `default_type`:
311    /// - `Spot` -> spot public stream
312    /// - `Swap`/`Futures` + Linear -> linear public stream
313    /// - `Swap`/`Futures` + Inverse -> inverse public stream
314    /// - `Option` -> option public stream
315    ///
316    /// # Returns
317    ///
318    /// Returns a `BybitWs` instance for public data streams.
319    ///
320    /// # Example
321    /// ```no_run
322    /// use ccxt_exchanges::bybit::Bybit;
323    /// use ccxt_core::ExchangeConfig;
324    ///
325    /// # async fn example() -> ccxt_core::error::Result<()> {
326    /// let bybit = Bybit::new(ExchangeConfig::default())?;
327    /// let ws = bybit.create_ws();
328    /// ws.connect().await?;
329    /// # Ok(())
330    /// # }
331    /// ```
332    pub fn create_ws(&self) -> ws::BybitWs {
333        let urls = self.urls();
334        let category = self.category();
335        let ws_url = urls.ws_public_for_category(category);
336        ws::BybitWs::new(ws_url)
337    }
338}
339
340/// Bybit API URLs.
341#[derive(Debug, Clone)]
342pub struct BybitUrls {
343    /// REST API base URL.
344    pub rest: String,
345    /// Public WebSocket base URL (without category suffix).
346    pub ws_public_base: String,
347    /// Public WebSocket URL (default: spot).
348    pub ws_public: String,
349    /// Private WebSocket URL.
350    pub ws_private: String,
351}
352
353impl BybitUrls {
354    /// Returns production environment URLs.
355    pub fn production() -> Self {
356        Self {
357            rest: "https://api.bybit.com".to_string(),
358            ws_public_base: "wss://stream.bybit.com/v5/public".to_string(),
359            ws_public: "wss://stream.bybit.com/v5/public/spot".to_string(),
360            ws_private: "wss://stream.bybit.com/v5/private".to_string(),
361        }
362    }
363
364    /// Returns testnet environment URLs.
365    pub fn testnet() -> Self {
366        Self {
367            rest: "https://api-testnet.bybit.com".to_string(),
368            ws_public_base: "wss://stream-testnet.bybit.com/v5/public".to_string(),
369            ws_public: "wss://stream-testnet.bybit.com/v5/public/spot".to_string(),
370            ws_private: "wss://stream-testnet.bybit.com/v5/private".to_string(),
371        }
372    }
373
374    /// Returns the public WebSocket URL for a specific category.
375    ///
376    /// Bybit V5 API uses different WebSocket endpoints for different categories:
377    /// - spot: /v5/public/spot
378    /// - linear: /v5/public/linear
379    /// - inverse: /v5/public/inverse
380    /// - option: /v5/public/option
381    ///
382    /// # Arguments
383    ///
384    /// * `category` - The category string (spot, linear, inverse, option)
385    ///
386    /// # Returns
387    ///
388    /// The full WebSocket URL for the specified category.
389    pub fn ws_public_for_category(&self, category: &str) -> String {
390        format!("{}/{}", self.ws_public_base, category)
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn test_bybit_creation() {
400        let config = ExchangeConfig {
401            id: "bybit".to_string(),
402            name: "Bybit".to_string(),
403            ..Default::default()
404        };
405
406        let bybit = Bybit::new(config);
407        assert!(bybit.is_ok());
408
409        let bybit = bybit.unwrap();
410        assert_eq!(bybit.id(), "bybit");
411        assert_eq!(bybit.name(), "Bybit");
412        assert_eq!(bybit.version(), "v5");
413        assert!(!bybit.certified());
414        assert!(bybit.pro());
415    }
416
417    #[test]
418    fn test_timeframes() {
419        let config = ExchangeConfig::default();
420        let bybit = Bybit::new(config).unwrap();
421        let timeframes = bybit.timeframes();
422
423        assert!(timeframes.contains_key("1m"));
424        assert!(timeframes.contains_key("1h"));
425        assert!(timeframes.contains_key("1d"));
426        assert_eq!(timeframes.len(), 13);
427    }
428
429    #[test]
430    fn test_urls() {
431        let config = ExchangeConfig::default();
432        let bybit = Bybit::new(config).unwrap();
433        let urls = bybit.urls();
434
435        assert!(urls.rest.contains("api.bybit.com"));
436        assert!(urls.ws_public.contains("stream.bybit.com"));
437        assert!(urls.ws_public_base.contains("stream.bybit.com"));
438    }
439
440    #[test]
441    fn test_testnet_urls() {
442        let config = ExchangeConfig {
443            sandbox: true,
444            ..Default::default()
445        };
446        let bybit = Bybit::new(config).unwrap();
447        let urls = bybit.urls();
448
449        assert!(urls.rest.contains("api-testnet.bybit.com"));
450        assert!(urls.ws_public.contains("stream-testnet.bybit.com"));
451        assert!(urls.ws_public_base.contains("stream-testnet.bybit.com"));
452    }
453
454    #[test]
455    fn test_is_sandbox_with_config_sandbox() {
456        let config = ExchangeConfig {
457            sandbox: true,
458            ..Default::default()
459        };
460        let bybit = Bybit::new(config).unwrap();
461        assert!(bybit.is_sandbox());
462    }
463
464    #[test]
465    fn test_is_sandbox_with_options_testnet() {
466        let config = ExchangeConfig::default();
467        let options = BybitOptions {
468            testnet: true,
469            ..Default::default()
470        };
471        let bybit = Bybit::new_with_options(config, options).unwrap();
472        assert!(bybit.is_sandbox());
473    }
474
475    #[test]
476    fn test_is_sandbox_false_by_default() {
477        let config = ExchangeConfig::default();
478        let bybit = Bybit::new(config).unwrap();
479        assert!(!bybit.is_sandbox());
480    }
481
482    #[test]
483    fn test_ws_public_for_category() {
484        let urls = BybitUrls::production();
485
486        assert_eq!(
487            urls.ws_public_for_category("spot"),
488            "wss://stream.bybit.com/v5/public/spot"
489        );
490        assert_eq!(
491            urls.ws_public_for_category("linear"),
492            "wss://stream.bybit.com/v5/public/linear"
493        );
494        assert_eq!(
495            urls.ws_public_for_category("inverse"),
496            "wss://stream.bybit.com/v5/public/inverse"
497        );
498        assert_eq!(
499            urls.ws_public_for_category("option"),
500            "wss://stream.bybit.com/v5/public/option"
501        );
502    }
503
504    #[test]
505    fn test_ws_public_for_category_testnet() {
506        let urls = BybitUrls::testnet();
507
508        assert_eq!(
509            urls.ws_public_for_category("spot"),
510            "wss://stream-testnet.bybit.com/v5/public/spot"
511        );
512        assert_eq!(
513            urls.ws_public_for_category("linear"),
514            "wss://stream-testnet.bybit.com/v5/public/linear"
515        );
516    }
517
518    #[test]
519    fn test_default_options() {
520        let options = BybitOptions::default();
521        assert_eq!(options.account_type, "UNIFIED");
522        assert_eq!(options.default_type, DefaultType::Spot);
523        assert_eq!(options.default_sub_type, None);
524        assert!(!options.testnet);
525        assert_eq!(options.recv_window, 5000);
526    }
527
528    #[test]
529    fn test_bybit_options_with_default_type() {
530        let options = BybitOptions {
531            default_type: DefaultType::Swap,
532            default_sub_type: Some(DefaultSubType::Linear),
533            ..Default::default()
534        };
535        assert_eq!(options.default_type, DefaultType::Swap);
536        assert_eq!(options.default_sub_type, Some(DefaultSubType::Linear));
537    }
538
539    #[test]
540    fn test_bybit_options_serialization() {
541        let options = BybitOptions {
542            default_type: DefaultType::Swap,
543            default_sub_type: Some(DefaultSubType::Linear),
544            ..Default::default()
545        };
546        let json = serde_json::to_string(&options).unwrap();
547        assert!(json.contains("\"default_type\":\"swap\""));
548        assert!(json.contains("\"default_sub_type\":\"linear\""));
549    }
550
551    #[test]
552    fn test_bybit_options_deserialization() {
553        let json = r#"{
554            "account_type": "CONTRACT",
555            "default_type": "swap",
556            "default_sub_type": "inverse",
557            "testnet": true,
558            "recv_window": 10000
559        }"#;
560        let options: BybitOptions = serde_json::from_str(json).unwrap();
561        assert_eq!(options.account_type, "CONTRACT");
562        assert_eq!(options.default_type, DefaultType::Swap);
563        assert_eq!(options.default_sub_type, Some(DefaultSubType::Inverse));
564        assert!(options.testnet);
565        assert_eq!(options.recv_window, 10000);
566    }
567
568    #[test]
569    fn test_bybit_options_deserialization_without_default_type() {
570        // Test backward compatibility - default_type should default to Spot
571        let json = r#"{
572            "account_type": "UNIFIED",
573            "testnet": false,
574            "recv_window": 5000
575        }"#;
576        let options: BybitOptions = serde_json::from_str(json).unwrap();
577        assert_eq!(options.default_type, DefaultType::Spot);
578        assert_eq!(options.default_sub_type, None);
579    }
580
581    #[test]
582    fn test_bybit_category_spot() {
583        let config = ExchangeConfig::default();
584        let options = BybitOptions {
585            default_type: DefaultType::Spot,
586            ..Default::default()
587        };
588        let bybit = Bybit::new_with_options(config, options).unwrap();
589        assert_eq!(bybit.category(), "spot");
590    }
591
592    #[test]
593    fn test_bybit_category_linear() {
594        let config = ExchangeConfig::default();
595        let options = BybitOptions {
596            default_type: DefaultType::Swap,
597            default_sub_type: Some(DefaultSubType::Linear),
598            ..Default::default()
599        };
600        let bybit = Bybit::new_with_options(config, options).unwrap();
601        assert_eq!(bybit.category(), "linear");
602    }
603
604    #[test]
605    fn test_bybit_category_inverse() {
606        let config = ExchangeConfig::default();
607        let options = BybitOptions {
608            default_type: DefaultType::Swap,
609            default_sub_type: Some(DefaultSubType::Inverse),
610            ..Default::default()
611        };
612        let bybit = Bybit::new_with_options(config, options).unwrap();
613        assert_eq!(bybit.category(), "inverse");
614    }
615
616    #[test]
617    fn test_bybit_category_option() {
618        let config = ExchangeConfig::default();
619        let options = BybitOptions {
620            default_type: DefaultType::Option,
621            ..Default::default()
622        };
623        let bybit = Bybit::new_with_options(config, options).unwrap();
624        assert_eq!(bybit.category(), "option");
625    }
626
627    #[test]
628    fn test_bybit_is_contract_type() {
629        let config = ExchangeConfig::default();
630
631        // Spot is not a contract type
632        let options = BybitOptions {
633            default_type: DefaultType::Spot,
634            ..Default::default()
635        };
636        let bybit = Bybit::new_with_options(config.clone(), options).unwrap();
637        assert!(!bybit.is_contract_type());
638
639        // Swap is a contract type
640        let options = BybitOptions {
641            default_type: DefaultType::Swap,
642            ..Default::default()
643        };
644        let bybit = Bybit::new_with_options(config.clone(), options).unwrap();
645        assert!(bybit.is_contract_type());
646
647        // Futures is a contract type
648        let options = BybitOptions {
649            default_type: DefaultType::Futures,
650            ..Default::default()
651        };
652        let bybit = Bybit::new_with_options(config.clone(), options).unwrap();
653        assert!(bybit.is_contract_type());
654
655        // Option is a contract type
656        let options = BybitOptions {
657            default_type: DefaultType::Option,
658            ..Default::default()
659        };
660        let bybit = Bybit::new_with_options(config, options).unwrap();
661        assert!(bybit.is_contract_type());
662    }
663
664    // ============================================================
665    // Sandbox Mode Market Type URL Selection Tests
666    // ============================================================
667
668    #[test]
669    fn test_sandbox_market_type_spot() {
670        // Sandbox mode with Spot type should use spot testnet endpoints
671        let config = ExchangeConfig {
672            sandbox: true,
673            ..Default::default()
674        };
675        let options = BybitOptions {
676            default_type: DefaultType::Spot,
677            ..Default::default()
678        };
679        let bybit = Bybit::new_with_options(config, options).unwrap();
680
681        assert!(bybit.is_sandbox());
682        assert_eq!(bybit.category(), "spot");
683
684        let urls = bybit.urls();
685        assert!(
686            urls.rest.contains("api-testnet.bybit.com"),
687            "Spot sandbox REST URL should contain api-testnet.bybit.com, got: {}",
688            urls.rest
689        );
690
691        let ws_url = urls.ws_public_for_category("spot");
692        assert!(
693            ws_url.contains("stream-testnet.bybit.com"),
694            "Spot sandbox WS URL should contain stream-testnet.bybit.com, got: {}",
695            ws_url
696        );
697        assert!(
698            ws_url.contains("/v5/public/spot"),
699            "Spot sandbox WS URL should contain /v5/public/spot, got: {}",
700            ws_url
701        );
702    }
703
704    #[test]
705    fn test_sandbox_market_type_linear() {
706        // Sandbox mode with Swap/Linear should use linear testnet endpoints
707        let config = ExchangeConfig {
708            sandbox: true,
709            ..Default::default()
710        };
711        let options = BybitOptions {
712            default_type: DefaultType::Swap,
713            default_sub_type: Some(DefaultSubType::Linear),
714            ..Default::default()
715        };
716        let bybit = Bybit::new_with_options(config, options).unwrap();
717
718        assert!(bybit.is_sandbox());
719        assert_eq!(bybit.category(), "linear");
720
721        let urls = bybit.urls();
722        assert!(
723            urls.rest.contains("api-testnet.bybit.com"),
724            "Linear sandbox REST URL should contain api-testnet.bybit.com, got: {}",
725            urls.rest
726        );
727
728        let ws_url = urls.ws_public_for_category("linear");
729        assert!(
730            ws_url.contains("stream-testnet.bybit.com"),
731            "Linear sandbox WS URL should contain stream-testnet.bybit.com, got: {}",
732            ws_url
733        );
734        assert!(
735            ws_url.contains("/v5/public/linear"),
736            "Linear sandbox WS URL should contain /v5/public/linear, got: {}",
737            ws_url
738        );
739    }
740
741    #[test]
742    fn test_sandbox_market_type_inverse() {
743        // Sandbox mode with Swap/Inverse should use inverse testnet endpoints
744        let config = ExchangeConfig {
745            sandbox: true,
746            ..Default::default()
747        };
748        let options = BybitOptions {
749            default_type: DefaultType::Swap,
750            default_sub_type: Some(DefaultSubType::Inverse),
751            ..Default::default()
752        };
753        let bybit = Bybit::new_with_options(config, options).unwrap();
754
755        assert!(bybit.is_sandbox());
756        assert_eq!(bybit.category(), "inverse");
757
758        let urls = bybit.urls();
759        assert!(
760            urls.rest.contains("api-testnet.bybit.com"),
761            "Inverse sandbox REST URL should contain api-testnet.bybit.com, got: {}",
762            urls.rest
763        );
764
765        let ws_url = urls.ws_public_for_category("inverse");
766        assert!(
767            ws_url.contains("stream-testnet.bybit.com"),
768            "Inverse sandbox WS URL should contain stream-testnet.bybit.com, got: {}",
769            ws_url
770        );
771        assert!(
772            ws_url.contains("/v5/public/inverse"),
773            "Inverse sandbox WS URL should contain /v5/public/inverse, got: {}",
774            ws_url
775        );
776    }
777
778    #[test]
779    fn test_sandbox_market_type_option() {
780        // Sandbox mode with Option type should use option testnet endpoints
781        let config = ExchangeConfig {
782            sandbox: true,
783            ..Default::default()
784        };
785        let options = BybitOptions {
786            default_type: DefaultType::Option,
787            ..Default::default()
788        };
789        let bybit = Bybit::new_with_options(config, options).unwrap();
790
791        assert!(bybit.is_sandbox());
792        assert_eq!(bybit.category(), "option");
793
794        let urls = bybit.urls();
795        assert!(
796            urls.rest.contains("api-testnet.bybit.com"),
797            "Option sandbox REST URL should contain api-testnet.bybit.com, got: {}",
798            urls.rest
799        );
800
801        let ws_url = urls.ws_public_for_category("option");
802        assert!(
803            ws_url.contains("stream-testnet.bybit.com"),
804            "Option sandbox WS URL should contain stream-testnet.bybit.com, got: {}",
805            ws_url
806        );
807        assert!(
808            ws_url.contains("/v5/public/option"),
809            "Option sandbox WS URL should contain /v5/public/option, got: {}",
810            ws_url
811        );
812    }
813
814    #[test]
815    fn test_sandbox_private_websocket_url() {
816        // Verify private WebSocket URL in sandbox mode
817        let config = ExchangeConfig {
818            sandbox: true,
819            ..Default::default()
820        };
821        let bybit = Bybit::new(config).unwrap();
822
823        assert!(bybit.is_sandbox());
824        let urls = bybit.urls();
825        assert!(
826            urls.ws_private.contains("stream-testnet.bybit.com"),
827            "Private WS sandbox URL should contain stream-testnet.bybit.com, got: {}",
828            urls.ws_private
829        );
830        assert!(
831            urls.ws_private.contains("/v5/private"),
832            "Private WS sandbox URL should contain /v5/private, got: {}",
833            urls.ws_private
834        );
835    }
836
837    #[test]
838    fn test_sandbox_futures_linear() {
839        // Sandbox mode with Futures/Linear should use linear testnet endpoints
840        let config = ExchangeConfig {
841            sandbox: true,
842            ..Default::default()
843        };
844        let options = BybitOptions {
845            default_type: DefaultType::Futures,
846            default_sub_type: Some(DefaultSubType::Linear),
847            ..Default::default()
848        };
849        let bybit = Bybit::new_with_options(config, options).unwrap();
850
851        assert!(bybit.is_sandbox());
852        assert_eq!(bybit.category(), "linear");
853
854        let urls = bybit.urls();
855        assert!(
856            urls.rest.contains("api-testnet.bybit.com"),
857            "Futures Linear sandbox REST URL should contain api-testnet.bybit.com, got: {}",
858            urls.rest
859        );
860    }
861
862    #[test]
863    fn test_sandbox_futures_inverse() {
864        let config = ExchangeConfig {
865            sandbox: true,
866            ..Default::default()
867        };
868        let options = BybitOptions {
869            default_type: DefaultType::Futures,
870            default_sub_type: Some(DefaultSubType::Inverse),
871            ..Default::default()
872        };
873        let bybit = Bybit::new_with_options(config, options).unwrap();
874
875        assert!(bybit.is_sandbox());
876        assert_eq!(bybit.category(), "inverse");
877
878        let urls = bybit.urls();
879        assert!(
880            urls.rest.contains("api-testnet.bybit.com"),
881            "Futures Inverse sandbox REST URL should contain api-testnet.bybit.com, got: {}",
882            urls.rest
883        );
884    }
885}