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
20#[cfg(test)]
21use nautilus_core::string::secret::REDACTED;
22use nautilus_core::string::secret::SecretString;
23use nautilus_model::identifiers::AccountId;
24use nautilus_network::websocket::TransportBackend;
25use serde::{Deserialize, Serialize};
26
27use crate::common::{
28    enums::{
29        BybitEnvironment, BybitMarginMode, BybitOrderSmpType, BybitPositionMode, BybitProductType,
30    },
31    parse::deserialize_optional_smp_type,
32    urls::{bybit_http_base_url, bybit_ws_private_url, bybit_ws_public_url, bybit_ws_trade_url},
33};
34
35/// Configuration for the Bybit live data client.
36#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
37#[serde(default, deny_unknown_fields)]
38#[cfg_attr(
39    feature = "python",
40    pyo3::pyclass(module = "nautilus_trader.adapters.bybit", from_py_object)
41)]
42#[cfg_attr(
43    feature = "python",
44    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.bybit")
45)]
46pub struct BybitDataClientConfig {
47    /// Optional API key for authenticated REST/WebSocket requests.
48    pub api_key: Option<SecretString>,
49    /// Optional API secret for authenticated REST/WebSocket requests.
50    pub api_secret: Option<SecretString>,
51    /// Product types to subscribe to (e.g., Linear, Spot, Inverse, Option).
52    #[builder(default = vec![BybitProductType::Linear])]
53    pub product_types: Vec<BybitProductType>,
54    /// Environment selection (Mainnet, Testnet, Demo).
55    #[builder(default = BybitEnvironment::Mainnet)]
56    pub environment: BybitEnvironment,
57    /// Optional override for the REST base URL.
58    pub base_url_http: Option<String>,
59    /// Optional override for the public WebSocket URL.
60    pub base_url_ws_public: Option<String>,
61    /// Optional override for the private WebSocket URL.
62    pub base_url_ws_private: Option<String>,
63    /// Optional proxy URL for HTTP and WebSocket transports.
64    pub proxy_url: Option<SecretString>,
65    /// REST timeout in seconds.
66    #[builder(default = 60)]
67    pub http_timeout_secs: u64,
68    /// Maximum retry attempts for REST requests.
69    #[builder(default = 3)]
70    pub max_retries: u32,
71    /// Initial retry backoff in milliseconds.
72    #[builder(default = 1_000)]
73    pub retry_delay_initial_ms: u64,
74    /// Maximum retry backoff in milliseconds.
75    #[builder(default = 10_000)]
76    pub retry_delay_max_ms: u64,
77    /// Heartbeat interval in seconds for WebSocket clients.
78    #[builder(default = 20)]
79    pub heartbeat_interval_secs: u64,
80    /// Receive window in milliseconds for signed requests.
81    #[builder(default = 5_000)]
82    pub recv_window_ms: u64,
83    /// Interval in minutes for instrument refresh from REST.
84    /// When `None`, instrument refresh is disabled.
85    pub update_instruments_interval_mins: Option<u64>,
86    /// Interval in seconds for polling instrument definitions and status changes from REST.
87    /// When `None`, instrument/status polling is disabled.
88    pub instrument_poll_interval_secs: Option<u64>,
89    /// WebSocket transport backend (defaults to `Tungstenite`).
90    #[builder(default)]
91    pub transport_backend: TransportBackend,
92}
93
94#[cfg(feature = "python")]
95nautilus_core::impl_pyo3_config_getters!(BybitDataClientConfig {
96    product_types: Vec<BybitProductType>,
97    environment: BybitEnvironment,
98    base_url_http: Option<String>,
99    base_url_ws_public: Option<String>,
100    base_url_ws_private: Option<String>,
101    http_timeout_secs: u64,
102    max_retries: u32,
103    retry_delay_initial_ms: u64,
104    retry_delay_max_ms: u64,
105    heartbeat_interval_secs: u64,
106    recv_window_ms: u64,
107    update_instruments_interval_mins: Option<u64>,
108    transport_backend: TransportBackend,
109});
110
111impl Default for BybitDataClientConfig {
112    fn default() -> Self {
113        Self {
114            update_instruments_interval_mins: Some(60),
115            instrument_poll_interval_secs: Some(60),
116            ..Self::builder().build()
117        }
118    }
119}
120
121impl BybitDataClientConfig {
122    /// Creates a configuration with default values.
123    #[must_use]
124    pub fn new() -> Self {
125        Self::default()
126    }
127
128    /// Returns `true` if both API key and secret are available.
129    #[must_use]
130    pub fn has_api_credentials(&self) -> bool {
131        self.api_key.is_some() && self.api_secret.is_some()
132    }
133
134    /// Returns the REST base URL, considering overrides and environment.
135    #[must_use]
136    pub fn http_base_url(&self) -> String {
137        self.base_url_http
138            .clone()
139            .unwrap_or_else(|| bybit_http_base_url(self.environment).to_string())
140    }
141
142    /// Returns the public WebSocket URL for the given product type.
143    ///
144    /// Falls back to the first product type in the config if multiple are configured.
145    #[must_use]
146    pub fn ws_public_url(&self) -> String {
147        self.base_url_ws_public.clone().unwrap_or_else(|| {
148            let product_type = self
149                .product_types
150                .first()
151                .copied()
152                .unwrap_or(BybitProductType::Linear);
153            bybit_ws_public_url(product_type, self.environment)
154        })
155    }
156
157    /// Returns the public WebSocket URL for a specific product type.
158    #[must_use]
159    pub fn ws_public_url_for(&self, product_type: BybitProductType) -> String {
160        self.base_url_ws_public
161            .clone()
162            .unwrap_or_else(|| bybit_ws_public_url(product_type, self.environment))
163    }
164
165    /// Returns the private WebSocket URL, considering overrides and environment.
166    #[must_use]
167    pub fn ws_private_url(&self) -> String {
168        self.base_url_ws_private
169            .clone()
170            .unwrap_or_else(|| bybit_ws_private_url(self.environment).to_string())
171    }
172
173    /// Returns `true` when private WebSocket connection is required.
174    #[must_use]
175    pub fn requires_private_ws(&self) -> bool {
176        self.has_api_credentials()
177    }
178}
179
180/// Configuration for the Bybit live execution client.
181#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
182#[serde(default, deny_unknown_fields)]
183#[cfg_attr(
184    feature = "python",
185    pyo3::pyclass(module = "nautilus_trader.adapters.bybit", from_py_object)
186)]
187#[cfg_attr(
188    feature = "python",
189    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.bybit")
190)]
191pub struct BybitExecutionClientConfig {
192    /// API key for authenticated requests.
193    pub api_key: Option<SecretString>,
194    /// API secret for authenticated requests.
195    pub api_secret: Option<SecretString>,
196    /// Product types to support (e.g., Linear, Spot, Inverse, Option).
197    #[builder(default = vec![BybitProductType::Linear])]
198    pub product_types: Vec<BybitProductType>,
199    /// Environment selection (Mainnet, Testnet, Demo).
200    #[builder(default = BybitEnvironment::Mainnet)]
201    pub environment: BybitEnvironment,
202    /// Optional override for the REST base URL.
203    pub base_url_http: Option<String>,
204    /// Optional override for the private WebSocket URL.
205    pub base_url_ws_private: Option<String>,
206    /// Optional override for the trade WebSocket URL.
207    pub base_url_ws_trade: Option<String>,
208    /// Optional proxy URL for HTTP and WebSocket transports.
209    pub proxy_url: Option<SecretString>,
210    /// REST timeout in seconds.
211    #[builder(default = 60)]
212    pub http_timeout_secs: u64,
213    /// Maximum retry attempts for REST requests.
214    #[builder(default = 3)]
215    pub max_retries: u32,
216    /// Initial retry backoff in milliseconds.
217    #[builder(default = 1_000)]
218    pub retry_delay_initial_ms: u64,
219    /// Maximum retry backoff in milliseconds.
220    #[builder(default = 10_000)]
221    pub retry_delay_max_ms: u64,
222    /// Heartbeat interval in seconds for WebSocket clients.
223    #[builder(default = 20)]
224    pub heartbeat_interval_secs: u64,
225    /// Optional WebSocket authentication wait timeout (seconds), defaulting to
226    /// the client default when unset.
227    pub auth_timeout_secs: Option<u64>,
228    /// Receive window in milliseconds for signed requests.
229    #[builder(default = 5_000)]
230    pub recv_window_ms: u64,
231    /// Optional account identifier to associate with the execution client.
232    pub account_id: Option<AccountId>,
233    /// Whether scoped execution-client SPOT position requests derive positions from wallet
234    /// balances. The HTTP client rejects enabled unscoped SPOT requests because balances cannot be
235    /// attributed to pairs. The execution client omits SPOT from bulk requests and reports its bulk
236    /// coverage as unavailable.
237    #[builder(default)]
238    pub use_spot_position_reports: bool,
239    /// Whether to automatically repay SPOT margin borrows after BUY orders tracked by
240    /// this client and reported on the standard `execution` channel (not `execution.fast`)
241    /// fully fill.
242    #[builder(default)]
243    pub auto_repay_spot_borrows: bool,
244    /// Leverage configuration for futures (symbol -> leverage).
245    pub futures_leverages: Option<HashMap<String, u32>>,
246    /// Position mode configuration for symbols (symbol -> mode).
247    pub position_mode: Option<HashMap<String, BybitPositionMode>>,
248    /// Unified margin mode setting.
249    pub margin_mode: Option<BybitMarginMode>,
250    /// Self-match prevention type sent on every submitted order. The `smp_type` order parameter
251    /// overrides it, and leaving both unset omits the field so the venue default applies.
252    #[serde(deserialize_with = "deserialize_optional_smp_type")]
253    pub smp_type: Option<BybitOrderSmpType>,
254    /// WebSocket transport backend (defaults to `Tungstenite`).
255    #[builder(default)]
256    pub transport_backend: TransportBackend,
257}
258
259#[cfg(feature = "python")]
260nautilus_core::impl_pyo3_config_getters!(BybitExecutionClientConfig {
261    product_types: Vec<BybitProductType>,
262    environment: BybitEnvironment,
263    base_url_http: Option<String>,
264    base_url_ws_private: Option<String>,
265    base_url_ws_trade: Option<String>,
266    http_timeout_secs: u64,
267    max_retries: u32,
268    retry_delay_initial_ms: u64,
269    retry_delay_max_ms: u64,
270    heartbeat_interval_secs: u64,
271    auth_timeout_secs: Option<u64>,
272    recv_window_ms: u64,
273    account_id: Option<AccountId>,
274    use_spot_position_reports: bool,
275    auto_repay_spot_borrows: bool,
276    margin_mode: Option<BybitMarginMode>,
277    transport_backend: TransportBackend,
278});
279
280impl Default for BybitExecutionClientConfig {
281    fn default() -> Self {
282        Self::builder().build()
283    }
284}
285
286impl BybitExecutionClientConfig {
287    /// Creates a configuration with default values.
288    #[must_use]
289    pub fn new() -> Self {
290        Self::default()
291    }
292
293    /// Returns `true` if both API key and secret are available.
294    #[must_use]
295    pub fn has_api_credentials(&self) -> bool {
296        self.api_key.is_some() && self.api_secret.is_some()
297    }
298
299    /// Returns the REST base URL, considering overrides and environment.
300    #[must_use]
301    pub fn http_base_url(&self) -> String {
302        self.base_url_http
303            .clone()
304            .unwrap_or_else(|| bybit_http_base_url(self.environment).to_string())
305    }
306
307    /// Returns the private WebSocket URL, considering overrides and environment.
308    #[must_use]
309    pub fn ws_private_url(&self) -> String {
310        self.base_url_ws_private
311            .clone()
312            .unwrap_or_else(|| bybit_ws_private_url(self.environment).to_string())
313    }
314
315    /// Returns the trade WebSocket URL, considering overrides and environment.
316    #[must_use]
317    pub fn ws_trade_url(&self) -> String {
318        self.base_url_ws_trade
319            .clone()
320            .unwrap_or_else(|| bybit_ws_trade_url(self.environment).to_string())
321    }
322}
323#[cfg(test)]
324mod tests {
325    use rstest::rstest;
326
327    use super::*;
328
329    #[rstest]
330    fn test_config_debug_redacts_credentials() {
331        let data = BybitDataClientConfig {
332            api_key: Some("data-api-key".into()),
333            api_secret: Some("data-api-secret".into()),
334            proxy_url: Some("http://user:data-proxy@localhost".into()),
335            ..Default::default()
336        };
337        let execution = BybitExecutionClientConfig {
338            api_key: Some("exec-api-key".into()),
339            api_secret: Some("exec-api-secret".into()),
340            proxy_url: Some("http://user:exec-proxy@localhost".into()),
341            ..Default::default()
342        };
343
344        let formatted = format!("{data:?} {execution:?}");
345
346        assert_eq!(formatted.matches(REDACTED).count(), 6);
347
348        for secret in [
349            "data-api-key",
350            "data-api-secret",
351            "data-proxy",
352            "exec-api-key",
353            "exec-api-secret",
354            "exec-proxy",
355        ] {
356            assert!(!formatted.contains(secret));
357        }
358    }
359
360    #[rstest]
361    #[case("None", BybitOrderSmpType::None)]
362    #[case("CancelMaker", BybitOrderSmpType::CancelMaker)]
363    #[case("CancelTaker", BybitOrderSmpType::CancelTaker)]
364    #[case("CancelBoth", BybitOrderSmpType::CancelBoth)]
365    fn test_exec_config_deserializes_smp_type(
366        #[case] value: &str,
367        #[case] expected: BybitOrderSmpType,
368    ) {
369        let json = format!(r#"{{"smp_type": "{value}"}}"#);
370        let config: BybitExecutionClientConfig = serde_json::from_str(&json).unwrap();
371
372        assert_eq!(config.smp_type, Some(expected));
373    }
374
375    #[rstest]
376    #[case(r#"{"smp_type": "Other"}"#)]
377    #[case(r#"{"smp_type": "cancel-maker"}"#)]
378    #[case(r#"{"smp_type": ""}"#)]
379    fn test_exec_config_rejects_invalid_smp_type(#[case] json: &str) {
380        let err = serde_json::from_str::<BybitExecutionClientConfig>(json).unwrap_err();
381
382        assert!(
383            err.to_string().contains("invalid Bybit smp_type"),
384            "expected an smp_type rejection, was '{err}'"
385        );
386    }
387
388    #[rstest]
389    fn test_exec_config_smp_type_defaults_to_none() {
390        let config: BybitExecutionClientConfig = serde_json::from_str("{}").unwrap();
391
392        assert_eq!(config.smp_type, None);
393    }
394
395    #[rstest]
396    fn test_data_config_default() {
397        let config = BybitDataClientConfig::default();
398
399        assert!(!config.has_api_credentials());
400        assert_eq!(config.product_types, vec![BybitProductType::Linear]);
401        assert_eq!(config.http_timeout_secs, 60);
402        assert_eq!(config.heartbeat_interval_secs, 20);
403    }
404
405    #[rstest]
406    fn test_data_config_with_credentials() {
407        let config = BybitDataClientConfig {
408            api_key: Some("test_key".into()),
409            api_secret: Some("test_secret".into()),
410            ..Default::default()
411        };
412
413        assert!(config.has_api_credentials());
414        assert!(config.requires_private_ws());
415    }
416
417    #[rstest]
418    fn test_data_config_http_url_mainnet() {
419        let config = BybitDataClientConfig {
420            environment: BybitEnvironment::Mainnet,
421            ..Default::default()
422        };
423
424        assert_eq!(config.http_base_url(), "https://api.bybit.com");
425    }
426
427    #[rstest]
428    fn test_data_config_http_url_testnet() {
429        let config = BybitDataClientConfig {
430            environment: BybitEnvironment::Testnet,
431            ..Default::default()
432        };
433
434        assert_eq!(config.http_base_url(), "https://api-testnet.bybit.com");
435    }
436
437    #[rstest]
438    fn test_data_config_http_url_demo() {
439        let config = BybitDataClientConfig {
440            environment: BybitEnvironment::Demo,
441            ..Default::default()
442        };
443
444        assert_eq!(config.http_base_url(), "https://api-demo.bybit.com");
445    }
446
447    #[rstest]
448    fn test_data_config_http_url_override() {
449        let custom_url = "https://custom.bybit.com";
450        let config = BybitDataClientConfig {
451            base_url_http: Some(custom_url.to_string()),
452            ..Default::default()
453        };
454
455        assert_eq!(config.http_base_url(), custom_url);
456    }
457
458    #[rstest]
459    fn test_data_config_ws_public_url() {
460        let config = BybitDataClientConfig {
461            environment: BybitEnvironment::Mainnet,
462            ..Default::default()
463        };
464
465        assert_eq!(
466            config.ws_public_url(),
467            "wss://stream.bybit.com/v5/public/linear"
468        );
469    }
470
471    #[rstest]
472    fn test_data_config_ws_public_url_for_spot() {
473        let config = BybitDataClientConfig {
474            environment: BybitEnvironment::Mainnet,
475            ..Default::default()
476        };
477
478        assert_eq!(
479            config.ws_public_url_for(BybitProductType::Spot),
480            "wss://stream.bybit.com/v5/public/spot"
481        );
482    }
483
484    #[rstest]
485    fn test_data_config_ws_private_url() {
486        let config = BybitDataClientConfig {
487            environment: BybitEnvironment::Mainnet,
488            ..Default::default()
489        };
490
491        assert_eq!(config.ws_private_url(), "wss://stream.bybit.com/v5/private");
492    }
493
494    #[rstest]
495    fn test_data_config_ws_private_url_testnet() {
496        let config = BybitDataClientConfig {
497            environment: BybitEnvironment::Testnet,
498            ..Default::default()
499        };
500
501        assert_eq!(
502            config.ws_private_url(),
503            "wss://stream-testnet.bybit.com/v5/private"
504        );
505    }
506
507    #[rstest]
508    fn test_exec_config_default() {
509        let config = BybitExecutionClientConfig::default();
510
511        assert!(!config.has_api_credentials());
512        assert_eq!(config.product_types, vec![BybitProductType::Linear]);
513        assert_eq!(config.http_timeout_secs, 60);
514        assert_eq!(config.heartbeat_interval_secs, 20);
515    }
516
517    #[rstest]
518    fn test_exec_config_with_credentials() {
519        let config = BybitExecutionClientConfig {
520            api_key: Some("test_key".into()),
521            api_secret: Some("test_secret".into()),
522            ..Default::default()
523        };
524
525        assert!(config.has_api_credentials());
526    }
527
528    #[rstest]
529    fn test_exec_config_urls() {
530        let config = BybitExecutionClientConfig {
531            environment: BybitEnvironment::Mainnet,
532            ..Default::default()
533        };
534
535        assert_eq!(config.http_base_url(), "https://api.bybit.com");
536        assert_eq!(config.ws_private_url(), "wss://stream.bybit.com/v5/private");
537        assert_eq!(config.ws_trade_url(), "wss://stream.bybit.com/v5/trade");
538    }
539
540    #[rstest]
541    fn test_exec_config_urls_testnet() {
542        let config = BybitExecutionClientConfig {
543            environment: BybitEnvironment::Testnet,
544            ..Default::default()
545        };
546
547        assert_eq!(config.http_base_url(), "https://api-testnet.bybit.com");
548        assert_eq!(
549            config.ws_private_url(),
550            "wss://stream-testnet.bybit.com/v5/private"
551        );
552        assert_eq!(
553            config.ws_trade_url(),
554            "wss://stream-testnet.bybit.com/v5/trade"
555        );
556    }
557
558    #[rstest]
559    fn test_exec_config_custom_urls() {
560        let config = BybitExecutionClientConfig {
561            base_url_http: Some("https://custom-http.bybit.com".to_string()),
562            base_url_ws_private: Some("wss://custom-private.bybit.com".to_string()),
563            base_url_ws_trade: Some("wss://custom-trade.bybit.com".to_string()),
564            ..Default::default()
565        };
566
567        assert_eq!(config.http_base_url(), "https://custom-http.bybit.com");
568        assert_eq!(config.ws_private_url(), "wss://custom-private.bybit.com");
569        assert_eq!(config.ws_trade_url(), "wss://custom-trade.bybit.com");
570    }
571
572    #[rstest]
573    fn test_data_config_toml_minimal() {
574        let config: BybitDataClientConfig = toml::from_str(
575            r#"
576environment = "testnet"
577product_types = ["spot", "linear"]
578http_timeout_secs = 45
579"#,
580        )
581        .unwrap();
582
583        assert_eq!(config.environment, BybitEnvironment::Testnet);
584        assert_eq!(
585            config.product_types,
586            vec![BybitProductType::Spot, BybitProductType::Linear]
587        );
588        assert_eq!(config.http_timeout_secs, 45);
589    }
590
591    #[rstest]
592    fn test_exec_config_toml_empty_uses_defaults() {
593        let config: BybitExecutionClientConfig = toml::from_str("").unwrap();
594        let expected = BybitExecutionClientConfig::default();
595
596        assert_eq!(config.environment, expected.environment);
597        assert_eq!(config.product_types, expected.product_types);
598        assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
599        assert_eq!(
600            config.heartbeat_interval_secs,
601            expected.heartbeat_interval_secs,
602        );
603        assert_eq!(config.recv_window_ms, expected.recv_window_ms);
604        assert_eq!(config.transport_backend, expected.transport_backend);
605    }
606}