1use 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#[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 pub api_key: Option<SecretString>,
49 pub api_secret: Option<SecretString>,
51 #[builder(default = vec![BybitProductType::Linear])]
53 pub product_types: Vec<BybitProductType>,
54 #[builder(default = BybitEnvironment::Mainnet)]
56 pub environment: BybitEnvironment,
57 pub base_url_http: Option<String>,
59 pub base_url_ws_public: Option<String>,
61 pub base_url_ws_private: Option<String>,
63 pub proxy_url: Option<SecretString>,
65 #[builder(default = 60)]
67 pub http_timeout_secs: u64,
68 #[builder(default = 3)]
70 pub max_retries: u32,
71 #[builder(default = 1_000)]
73 pub retry_delay_initial_ms: u64,
74 #[builder(default = 10_000)]
76 pub retry_delay_max_ms: u64,
77 #[builder(default = 20)]
79 pub heartbeat_interval_secs: u64,
80 #[builder(default = 5_000)]
82 pub recv_window_ms: u64,
83 pub update_instruments_interval_mins: Option<u64>,
86 pub instrument_poll_interval_secs: Option<u64>,
89 #[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 #[must_use]
124 pub fn new() -> Self {
125 Self::default()
126 }
127
128 #[must_use]
130 pub fn has_api_credentials(&self) -> bool {
131 self.api_key.is_some() && self.api_secret.is_some()
132 }
133
134 #[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 #[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 #[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 #[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 #[must_use]
175 pub fn requires_private_ws(&self) -> bool {
176 self.has_api_credentials()
177 }
178}
179
180#[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 pub api_key: Option<SecretString>,
194 pub api_secret: Option<SecretString>,
196 #[builder(default = vec![BybitProductType::Linear])]
198 pub product_types: Vec<BybitProductType>,
199 #[builder(default = BybitEnvironment::Mainnet)]
201 pub environment: BybitEnvironment,
202 pub base_url_http: Option<String>,
204 pub base_url_ws_private: Option<String>,
206 pub base_url_ws_trade: Option<String>,
208 pub proxy_url: Option<SecretString>,
210 #[builder(default = 60)]
212 pub http_timeout_secs: u64,
213 #[builder(default = 3)]
215 pub max_retries: u32,
216 #[builder(default = 1_000)]
218 pub retry_delay_initial_ms: u64,
219 #[builder(default = 10_000)]
221 pub retry_delay_max_ms: u64,
222 #[builder(default = 20)]
224 pub heartbeat_interval_secs: u64,
225 pub auth_timeout_secs: Option<u64>,
228 #[builder(default = 5_000)]
230 pub recv_window_ms: u64,
231 pub account_id: Option<AccountId>,
233 #[builder(default)]
238 pub use_spot_position_reports: bool,
239 #[builder(default)]
243 pub auto_repay_spot_borrows: bool,
244 pub futures_leverages: Option<HashMap<String, u32>>,
246 pub position_mode: Option<HashMap<String, BybitPositionMode>>,
248 pub margin_mode: Option<BybitMarginMode>,
250 #[serde(deserialize_with = "deserialize_optional_smp_type")]
253 pub smp_type: Option<BybitOrderSmpType>,
254 #[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 #[must_use]
289 pub fn new() -> Self {
290 Self::default()
291 }
292
293 #[must_use]
295 pub fn has_api_credentials(&self) -> bool {
296 self.api_key.is_some() && self.api_secret.is_some()
297 }
298
299 #[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 #[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 #[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}