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