Skip to main content

nautilus_bybit/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Configuration structures for the Bybit adapter.
17
18use std::collections::HashMap;
19
20use nautilus_common::config::{ConfigResult, check_unique_values};
21#[cfg(test)]
22use nautilus_core::string::secret::REDACTED;
23use nautilus_core::string::secret::SecretString;
24use nautilus_live::book::DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS;
25use nautilus_model::identifiers::AccountId;
26use nautilus_network::websocket::TransportBackend;
27use serde::{Deserialize, Serialize};
28
29use crate::common::{
30    enums::{
31        BybitEnvironment, BybitMarginMode, BybitOrderSmpType, BybitPositionMode, BybitProductType,
32    },
33    parse::deserialize_optional_smp_type,
34    urls::{bybit_http_base_url, bybit_ws_private_url, bybit_ws_public_url, bybit_ws_trade_url},
35};
36
37/// Configuration for the Bybit live data client.
38#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
39#[serde(default, deny_unknown_fields)]
40#[cfg_attr(
41    feature = "python",
42    pyo3::pyclass(module = "nautilus_trader.adapters.bybit", from_py_object)
43)]
44#[cfg_attr(
45    feature = "python",
46    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.bybit")
47)]
48pub struct BybitDataClientConfig {
49    /// Optional API key for authenticated REST/WebSocket requests.
50    pub api_key: Option<SecretString>,
51    /// Optional API secret for authenticated REST/WebSocket requests.
52    pub api_secret: Option<SecretString>,
53    /// Product types to subscribe to (e.g., Linear, Spot, Inverse, Option).
54    #[builder(default = vec![BybitProductType::Linear])]
55    pub product_types: Vec<BybitProductType>,
56    /// Environment selection (Mainnet, Testnet, Demo).
57    #[builder(default = BybitEnvironment::Mainnet)]
58    pub environment: BybitEnvironment,
59    /// Optional override for the REST base URL.
60    pub base_url_http: Option<String>,
61    /// Optional override for the public WebSocket URL.
62    pub base_url_ws_public: Option<String>,
63    /// Optional override for the private WebSocket URL.
64    pub base_url_ws_private: Option<String>,
65    /// Optional proxy URL for HTTP and WebSocket transports.
66    pub proxy_url: Option<SecretString>,
67    /// REST timeout in seconds.
68    #[builder(default = 60)]
69    pub http_timeout_secs: u64,
70    /// Maximum retry attempts for REST requests.
71    #[builder(default = 3)]
72    pub max_retries: u32,
73    /// Initial retry backoff in milliseconds.
74    #[builder(default = 1_000)]
75    pub retry_delay_initial_ms: u64,
76    /// Maximum retry backoff in milliseconds.
77    #[builder(default = 10_000)]
78    pub retry_delay_max_ms: u64,
79    /// Heartbeat interval in seconds for WebSocket clients.
80    #[builder(default = 20)]
81    pub heartbeat_interval_secs: u64,
82    /// Receive window in milliseconds for signed requests.
83    #[builder(default = 5_000)]
84    pub recv_window_ms: u64,
85    /// Interval in minutes for instrument refresh from REST.
86    /// When `None`, instrument refresh is disabled.
87    pub update_instruments_interval_mins: Option<u64>,
88    /// Interval in seconds for polling instrument definitions and status changes from REST.
89    /// When `None`, instrument/status polling is disabled.
90    pub instrument_poll_interval_secs: Option<u64>,
91    /// Maximum time to wait for an initial, post-reconnect, or recovery order book
92    /// snapshot in seconds. Set to 0 to disable.
93    #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
94    pub book_snapshot_timeout_secs: u64,
95    /// WebSocket transport backend (defaults to `Tungstenite`).
96    #[builder(default)]
97    pub transport_backend: TransportBackend,
98    /// Whether bar timestamps use the close time instead of the open time.
99    #[builder(default = true)]
100    pub bars_timestamp_on_close: bool,
101}
102
103#[cfg(feature = "python")]
104nautilus_core::impl_pyo3_config_getters!(BybitDataClientConfig {
105    product_types: Vec<BybitProductType>,
106    environment: BybitEnvironment,
107    base_url_http: Option<String>,
108    base_url_ws_public: Option<String>,
109    base_url_ws_private: Option<String>,
110    http_timeout_secs: u64,
111    max_retries: u32,
112    retry_delay_initial_ms: u64,
113    retry_delay_max_ms: u64,
114    heartbeat_interval_secs: u64,
115    recv_window_ms: u64,
116    update_instruments_interval_mins: Option<u64>,
117    book_snapshot_timeout_secs: u64,
118    transport_backend: TransportBackend,
119    bars_timestamp_on_close: bool,
120});
121
122impl Default for BybitDataClientConfig {
123    fn default() -> Self {
124        Self {
125            update_instruments_interval_mins: Some(60),
126            instrument_poll_interval_secs: Some(60),
127            ..Self::builder().build()
128        }
129    }
130}
131
132impl BybitDataClientConfig {
133    /// Creates a configuration with default values.
134    #[must_use]
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Validates that configured selections contain no duplicates.
140    ///
141    /// # Errors
142    ///
143    /// Returns an error if `product_types` repeats a value.
144    pub fn validate(&self) -> ConfigResult<()> {
145        check_unique_values("product_types", &self.product_types)
146    }
147
148    /// Returns `true` if both API key and secret are available.
149    #[must_use]
150    pub fn has_api_credentials(&self) -> bool {
151        self.api_key.is_some() && self.api_secret.is_some()
152    }
153
154    /// Returns the REST base URL, considering overrides and environment.
155    #[must_use]
156    pub fn http_base_url(&self) -> String {
157        self.base_url_http
158            .clone()
159            .unwrap_or_else(|| bybit_http_base_url(self.environment).to_string())
160    }
161
162    /// Returns the public WebSocket URL for the given product type.
163    ///
164    /// Falls back to the first product type in the config if multiple are configured.
165    #[must_use]
166    pub fn ws_public_url(&self) -> String {
167        self.base_url_ws_public.clone().unwrap_or_else(|| {
168            let product_type = self
169                .product_types
170                .first()
171                .copied()
172                .unwrap_or(BybitProductType::Linear);
173            bybit_ws_public_url(product_type, self.environment)
174        })
175    }
176
177    /// Returns the public WebSocket URL for a specific product type.
178    #[must_use]
179    pub fn ws_public_url_for(&self, product_type: BybitProductType) -> String {
180        self.base_url_ws_public
181            .clone()
182            .unwrap_or_else(|| bybit_ws_public_url(product_type, self.environment))
183    }
184
185    /// Returns the private WebSocket URL, considering overrides and environment.
186    #[must_use]
187    pub fn ws_private_url(&self) -> String {
188        self.base_url_ws_private
189            .clone()
190            .unwrap_or_else(|| bybit_ws_private_url(self.environment).to_string())
191    }
192
193    /// Returns `true` when private WebSocket connection is required.
194    #[must_use]
195    pub fn requires_private_ws(&self) -> bool {
196        self.has_api_credentials()
197    }
198}
199
200/// Configuration for the Bybit live execution client.
201#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
202#[serde(default, deny_unknown_fields)]
203#[cfg_attr(
204    feature = "python",
205    pyo3::pyclass(module = "nautilus_trader.adapters.bybit", from_py_object)
206)]
207#[cfg_attr(
208    feature = "python",
209    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.bybit")
210)]
211pub struct BybitExecutionClientConfig {
212    /// API key for authenticated requests.
213    pub api_key: Option<SecretString>,
214    /// API secret for authenticated requests.
215    pub api_secret: Option<SecretString>,
216    /// Product types to support (e.g., Linear, Spot, Inverse, Option).
217    #[builder(default = vec![BybitProductType::Linear])]
218    pub product_types: Vec<BybitProductType>,
219    /// Environment selection (Mainnet, Testnet, Demo).
220    #[builder(default = BybitEnvironment::Mainnet)]
221    pub environment: BybitEnvironment,
222    /// Optional override for the REST base URL.
223    pub base_url_http: Option<String>,
224    /// Optional override for the private WebSocket URL.
225    pub base_url_ws_private: Option<String>,
226    /// Optional override for the trade WebSocket URL.
227    pub base_url_ws_trade: Option<String>,
228    /// Optional proxy URL for HTTP and WebSocket transports.
229    pub proxy_url: Option<SecretString>,
230    /// REST timeout in seconds.
231    #[builder(default = 60)]
232    pub http_timeout_secs: u64,
233    /// Maximum retry attempts for REST requests.
234    #[builder(default = 3)]
235    pub max_retries: u32,
236    /// Initial retry backoff in milliseconds.
237    #[builder(default = 1_000)]
238    pub retry_delay_initial_ms: u64,
239    /// Maximum retry backoff in milliseconds.
240    #[builder(default = 10_000)]
241    pub retry_delay_max_ms: u64,
242    /// Heartbeat interval in seconds for WebSocket clients.
243    #[builder(default = 20)]
244    pub heartbeat_interval_secs: u64,
245    /// Optional WebSocket authentication wait timeout (seconds), defaulting to
246    /// the client default when unset.
247    pub auth_timeout_secs: Option<u64>,
248    /// Receive window in milliseconds for signed requests.
249    #[builder(default = 5_000)]
250    pub recv_window_ms: u64,
251    /// Optional account identifier to associate with the execution client.
252    pub account_id: Option<AccountId>,
253    /// Whether scoped execution-client SPOT position requests derive positions from wallet
254    /// balances. The HTTP client rejects enabled unscoped SPOT requests because balances cannot be
255    /// attributed to pairs. The execution client omits SPOT from bulk requests and reports its bulk
256    /// coverage as unavailable.
257    #[builder(default)]
258    pub use_spot_position_reports: bool,
259    /// Whether to automatically repay SPOT margin borrows after BUY orders tracked by
260    /// this client and reported on the standard `execution` channel (not `execution.fast`)
261    /// fully fill.
262    #[builder(default)]
263    pub auto_repay_spot_borrows: bool,
264    /// Leverage configuration for futures (symbol -> leverage).
265    pub futures_leverages: Option<HashMap<String, u32>>,
266    /// Position mode configuration for symbols (symbol -> mode).
267    pub position_mode: Option<HashMap<String, BybitPositionMode>>,
268    /// Unified margin mode setting.
269    pub margin_mode: Option<BybitMarginMode>,
270    /// Self-match prevention type sent on every submitted order. The `smp_type` order parameter
271    /// overrides it, and leaving both unset omits the field so the venue default applies.
272    #[serde(deserialize_with = "deserialize_optional_smp_type")]
273    pub smp_type: Option<BybitOrderSmpType>,
274    /// WebSocket transport backend (defaults to `Tungstenite`).
275    #[builder(default)]
276    pub transport_backend: TransportBackend,
277}
278
279#[cfg(feature = "python")]
280nautilus_core::impl_pyo3_config_getters!(BybitExecutionClientConfig {
281    product_types: Vec<BybitProductType>,
282    environment: BybitEnvironment,
283    base_url_http: Option<String>,
284    base_url_ws_private: Option<String>,
285    base_url_ws_trade: Option<String>,
286    http_timeout_secs: u64,
287    max_retries: u32,
288    retry_delay_initial_ms: u64,
289    retry_delay_max_ms: u64,
290    heartbeat_interval_secs: u64,
291    auth_timeout_secs: Option<u64>,
292    recv_window_ms: u64,
293    account_id: Option<AccountId>,
294    use_spot_position_reports: bool,
295    auto_repay_spot_borrows: bool,
296    margin_mode: Option<BybitMarginMode>,
297    transport_backend: TransportBackend,
298});
299
300impl Default for BybitExecutionClientConfig {
301    fn default() -> Self {
302        Self::builder().build()
303    }
304}
305
306impl BybitExecutionClientConfig {
307    /// Creates a configuration with default values.
308    #[must_use]
309    pub fn new() -> Self {
310        Self::default()
311    }
312
313    /// Validates that configured selections contain no duplicates.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error if `product_types` repeats a value.
318    pub fn validate(&self) -> ConfigResult<()> {
319        check_unique_values("product_types", &self.product_types)
320    }
321
322    /// Returns `true` if both API key and secret are available.
323    #[must_use]
324    pub fn has_api_credentials(&self) -> bool {
325        self.api_key.is_some() && self.api_secret.is_some()
326    }
327
328    /// Returns the REST base URL, considering overrides and environment.
329    #[must_use]
330    pub fn http_base_url(&self) -> String {
331        self.base_url_http
332            .clone()
333            .unwrap_or_else(|| bybit_http_base_url(self.environment).to_string())
334    }
335
336    /// Returns the private WebSocket URL, considering overrides and environment.
337    #[must_use]
338    pub fn ws_private_url(&self) -> String {
339        self.base_url_ws_private
340            .clone()
341            .unwrap_or_else(|| bybit_ws_private_url(self.environment).to_string())
342    }
343
344    /// Returns the trade WebSocket URL, considering overrides and environment.
345    #[must_use]
346    pub fn ws_trade_url(&self) -> String {
347        self.base_url_ws_trade
348            .clone()
349            .unwrap_or_else(|| bybit_ws_trade_url(self.environment).to_string())
350    }
351}
352#[cfg(test)]
353mod tests {
354    use std::{cell::RefCell, rc::Rc};
355
356    use nautilus_common::{cache::Cache, config::ConfigError};
357    use nautilus_live::ExecutionClientCore;
358    use nautilus_model::{
359        enums::{AccountType, OmsType},
360        identifiers::{ClientId, TraderId, Venue},
361    };
362    use rstest::rstest;
363
364    use super::*;
365    use crate::{data::BybitDataClient, execution::BybitExecutionClient};
366
367    #[rstest]
368    fn test_data_client_rejects_duplicate_types() {
369        let config = BybitDataClientConfig {
370            product_types: vec![BybitProductType::Linear, BybitProductType::Linear],
371            ..Default::default()
372        };
373
374        let error = BybitDataClient::new(ClientId::from("BYBIT"), config)
375            .expect_err("duplicate config must fail before client setup");
376
377        assert_eq!(
378            error.downcast_ref::<ConfigError>(),
379            Some(&ConfigError::duplicate("product_types", None)),
380        );
381    }
382
383    #[rstest]
384    #[case::empty(vec![], false)]
385    #[case::distinct(vec![BybitProductType::Spot, BybitProductType::Linear], false)]
386    #[case::adjacent(vec![BybitProductType::Linear, BybitProductType::Linear], true)]
387    #[case::separated(vec![BybitProductType::Linear, BybitProductType::Spot, BybitProductType::Linear], true)]
388    fn test_config_duplicate_types(#[case] values: Vec<BybitProductType>, #[case] duplicate: bool) {
389        let data = BybitDataClientConfig {
390            product_types: values.clone(),
391            ..Default::default()
392        };
393
394        let execution = BybitExecutionClientConfig {
395            product_types: values.clone(),
396            ..Default::default()
397        };
398
399        let expected = if duplicate {
400            Err(ConfigError::duplicate("product_types", None))
401        } else {
402            Ok(())
403        };
404
405        let data_result = data.validate();
406        let execution_result = execution.validate();
407
408        assert_eq!(data_result, expected);
409        assert_eq!(execution_result, expected);
410        assert_eq!(data.product_types, values);
411        assert_eq!(execution.product_types, values);
412    }
413
414    #[rstest]
415    fn test_config_debug_redacts_credentials() {
416        let data = BybitDataClientConfig {
417            api_key: Some("data-api-key".into()),
418            api_secret: Some("data-api-secret".into()),
419            proxy_url: Some("http://user:data-proxy@localhost".into()),
420            ..Default::default()
421        };
422        let execution = BybitExecutionClientConfig {
423            api_key: Some("exec-api-key".into()),
424            api_secret: Some("exec-api-secret".into()),
425            proxy_url: Some("http://user:exec-proxy@localhost".into()),
426            ..Default::default()
427        };
428
429        let formatted = format!("{data:?} {execution:?}");
430
431        assert_eq!(formatted.matches(REDACTED).count(), 6);
432
433        for secret in [
434            "data-api-key",
435            "data-api-secret",
436            "data-proxy",
437            "exec-api-key",
438            "exec-api-secret",
439            "exec-proxy",
440        ] {
441            assert!(!formatted.contains(secret));
442        }
443    }
444
445    #[rstest]
446    #[case("None", BybitOrderSmpType::None)]
447    #[case("CancelMaker", BybitOrderSmpType::CancelMaker)]
448    #[case("CancelTaker", BybitOrderSmpType::CancelTaker)]
449    #[case("CancelBoth", BybitOrderSmpType::CancelBoth)]
450    fn test_exec_config_deserializes_smp_type(
451        #[case] value: &str,
452        #[case] expected: BybitOrderSmpType,
453    ) {
454        let json = format!(r#"{{"smp_type": "{value}"}}"#);
455        let config: BybitExecutionClientConfig = serde_json::from_str(&json).unwrap();
456
457        assert_eq!(config.smp_type, Some(expected));
458    }
459
460    #[rstest]
461    #[case(r#"{"smp_type": "Other"}"#)]
462    #[case(r#"{"smp_type": "cancel-maker"}"#)]
463    #[case(r#"{"smp_type": ""}"#)]
464    fn test_exec_config_rejects_invalid_smp_type(#[case] json: &str) {
465        let err = serde_json::from_str::<BybitExecutionClientConfig>(json).unwrap_err();
466
467        assert!(
468            err.to_string().contains("invalid Bybit smp_type"),
469            "expected an smp_type rejection, was '{err}'"
470        );
471    }
472
473    #[rstest]
474    fn test_exec_config_smp_type_defaults_to_none() {
475        let config: BybitExecutionClientConfig = serde_json::from_str("{}").unwrap();
476
477        assert_eq!(config.smp_type, None);
478    }
479
480    #[rstest]
481    fn test_data_config_default() {
482        let config = BybitDataClientConfig::default();
483
484        assert!(config.bars_timestamp_on_close);
485        assert!(!config.has_api_credentials());
486        assert_eq!(config.product_types, vec![BybitProductType::Linear]);
487        assert_eq!(config.http_timeout_secs, 60);
488        assert_eq!(config.heartbeat_interval_secs, 20);
489        assert_eq!(config.book_snapshot_timeout_secs, 10);
490    }
491
492    #[rstest]
493    fn test_data_config_bar_timestamp_builder() {
494        assert!(
495            BybitDataClientConfig::builder()
496                .build()
497                .bars_timestamp_on_close
498        );
499        assert!(
500            serde_json::from_str::<BybitDataClientConfig>("{}")
501                .unwrap()
502                .bars_timestamp_on_close
503        );
504        let config = BybitDataClientConfig::builder()
505            .bars_timestamp_on_close(false)
506            .build();
507        assert!(!config.bars_timestamp_on_close);
508        let config: BybitDataClientConfig =
509            serde_json::from_str(r#"{"bars_timestamp_on_close":false}"#).unwrap();
510        assert!(!config.bars_timestamp_on_close);
511    }
512
513    #[rstest]
514    fn test_data_config_with_credentials() {
515        let config = BybitDataClientConfig {
516            api_key: Some("test_key".into()),
517            api_secret: Some("test_secret".into()),
518            ..Default::default()
519        };
520
521        assert!(config.has_api_credentials());
522        assert!(config.requires_private_ws());
523    }
524
525    #[rstest]
526    fn test_data_config_http_url_mainnet() {
527        let config = BybitDataClientConfig {
528            environment: BybitEnvironment::Mainnet,
529            ..Default::default()
530        };
531
532        assert_eq!(config.http_base_url(), "https://api.bybit.com");
533    }
534
535    #[rstest]
536    fn test_data_config_http_url_testnet() {
537        let config = BybitDataClientConfig {
538            environment: BybitEnvironment::Testnet,
539            ..Default::default()
540        };
541
542        assert_eq!(config.http_base_url(), "https://api-testnet.bybit.com");
543    }
544
545    #[rstest]
546    fn test_data_config_http_url_demo() {
547        let config = BybitDataClientConfig {
548            environment: BybitEnvironment::Demo,
549            ..Default::default()
550        };
551
552        assert_eq!(config.http_base_url(), "https://api-demo.bybit.com");
553    }
554
555    #[rstest]
556    fn test_data_config_http_url_override() {
557        let custom_url = "https://custom.bybit.com";
558        let config = BybitDataClientConfig {
559            base_url_http: Some(custom_url.to_string()),
560            ..Default::default()
561        };
562
563        assert_eq!(config.http_base_url(), custom_url);
564    }
565
566    #[rstest]
567    fn test_data_config_ws_public_url() {
568        let config = BybitDataClientConfig {
569            environment: BybitEnvironment::Mainnet,
570            ..Default::default()
571        };
572
573        assert_eq!(
574            config.ws_public_url(),
575            "wss://stream.bybit.com/v5/public/linear"
576        );
577    }
578
579    #[rstest]
580    fn test_data_config_ws_public_url_for_spot() {
581        let config = BybitDataClientConfig {
582            environment: BybitEnvironment::Mainnet,
583            ..Default::default()
584        };
585
586        assert_eq!(
587            config.ws_public_url_for(BybitProductType::Spot),
588            "wss://stream.bybit.com/v5/public/spot"
589        );
590    }
591
592    #[rstest]
593    fn test_data_config_ws_private_url() {
594        let config = BybitDataClientConfig {
595            environment: BybitEnvironment::Mainnet,
596            ..Default::default()
597        };
598
599        assert_eq!(config.ws_private_url(), "wss://stream.bybit.com/v5/private");
600    }
601
602    #[rstest]
603    fn test_data_config_ws_private_url_testnet() {
604        let config = BybitDataClientConfig {
605            environment: BybitEnvironment::Testnet,
606            ..Default::default()
607        };
608
609        assert_eq!(
610            config.ws_private_url(),
611            "wss://stream-testnet.bybit.com/v5/private"
612        );
613    }
614
615    #[rstest]
616    fn test_exec_config_default() {
617        let config = BybitExecutionClientConfig::default();
618
619        assert!(!config.has_api_credentials());
620        assert_eq!(config.product_types, vec![BybitProductType::Linear]);
621        assert_eq!(config.http_timeout_secs, 60);
622        assert_eq!(config.heartbeat_interval_secs, 20);
623    }
624
625    #[rstest]
626    fn test_exec_config_with_credentials() {
627        let config = BybitExecutionClientConfig {
628            api_key: Some("test_key".into()),
629            api_secret: Some("test_secret".into()),
630            ..Default::default()
631        };
632
633        assert!(config.has_api_credentials());
634    }
635
636    #[rstest]
637    fn test_exec_config_urls() {
638        let config = BybitExecutionClientConfig {
639            environment: BybitEnvironment::Mainnet,
640            ..Default::default()
641        };
642
643        assert_eq!(config.http_base_url(), "https://api.bybit.com");
644        assert_eq!(config.ws_private_url(), "wss://stream.bybit.com/v5/private");
645        assert_eq!(config.ws_trade_url(), "wss://stream.bybit.com/v5/trade");
646    }
647
648    #[rstest]
649    fn test_exec_config_urls_testnet() {
650        let config = BybitExecutionClientConfig {
651            environment: BybitEnvironment::Testnet,
652            ..Default::default()
653        };
654
655        assert_eq!(config.http_base_url(), "https://api-testnet.bybit.com");
656        assert_eq!(
657            config.ws_private_url(),
658            "wss://stream-testnet.bybit.com/v5/private"
659        );
660        assert_eq!(
661            config.ws_trade_url(),
662            "wss://stream-testnet.bybit.com/v5/trade"
663        );
664    }
665
666    #[rstest]
667    fn test_exec_config_custom_urls() {
668        let config = BybitExecutionClientConfig {
669            base_url_http: Some("https://custom-http.bybit.com".to_string()),
670            base_url_ws_private: Some("wss://custom-private.bybit.com".to_string()),
671            base_url_ws_trade: Some("wss://custom-trade.bybit.com".to_string()),
672            ..Default::default()
673        };
674
675        assert_eq!(config.http_base_url(), "https://custom-http.bybit.com");
676        assert_eq!(config.ws_private_url(), "wss://custom-private.bybit.com");
677        assert_eq!(config.ws_trade_url(), "wss://custom-trade.bybit.com");
678    }
679
680    #[rstest]
681    fn test_data_config_toml_minimal() {
682        let config: BybitDataClientConfig = toml::from_str(
683            r#"
684environment = "testnet"
685product_types = ["spot", "linear"]
686http_timeout_secs = 45
687"#,
688        )
689        .unwrap();
690
691        assert_eq!(config.environment, BybitEnvironment::Testnet);
692        assert_eq!(
693            config.product_types,
694            vec![BybitProductType::Spot, BybitProductType::Linear]
695        );
696        assert_eq!(config.http_timeout_secs, 45);
697    }
698
699    #[rstest]
700    fn test_exec_config_toml_empty_uses_defaults() {
701        let config: BybitExecutionClientConfig = toml::from_str("").unwrap();
702        let expected = BybitExecutionClientConfig::default();
703
704        assert_eq!(config.environment, expected.environment);
705        assert_eq!(config.product_types, expected.product_types);
706        assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
707        assert_eq!(
708            config.heartbeat_interval_secs,
709            expected.heartbeat_interval_secs,
710        );
711        assert_eq!(config.recv_window_ms, expected.recv_window_ms);
712        assert_eq!(config.transport_backend, expected.transport_backend);
713    }
714
715    #[rstest]
716    fn test_execution_client_rejects_duplicate_types() {
717        let core = ExecutionClientCore::new(
718            TraderId::from("TESTER-001"),
719            ClientId::from("BYBIT"),
720            Venue::from("BYBIT"),
721            OmsType::Netting,
722            AccountId::from("BYBIT-001"),
723            AccountType::Margin,
724            None,
725            Rc::new(RefCell::new(Cache::default())),
726        );
727
728        let config = BybitExecutionClientConfig {
729            product_types: vec![BybitProductType::Linear, BybitProductType::Linear],
730            ..Default::default()
731        };
732
733        let error = BybitExecutionClient::new(core, config)
734            .expect_err("duplicate config must fail before credentials or client setup");
735
736        assert_eq!(
737            error.downcast_ref::<ConfigError>(),
738            Some(&ConfigError::duplicate("product_types", None))
739        );
740    }
741}