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