1use 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#[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 pub api_key: Option<SecretString>,
51 pub api_secret: Option<SecretString>,
53 #[builder(default = vec![BybitProductType::Linear])]
55 pub product_types: Vec<BybitProductType>,
56 #[builder(default = BybitEnvironment::Mainnet)]
58 pub environment: BybitEnvironment,
59 pub base_url_http: Option<String>,
61 pub base_url_ws_public: Option<String>,
63 pub base_url_ws_private: Option<String>,
65 pub proxy_url: Option<SecretString>,
67 #[builder(default = 60)]
69 pub http_timeout_secs: u64,
70 #[builder(default = 3)]
72 pub max_retries: u32,
73 #[builder(default = 1_000)]
75 pub retry_delay_initial_ms: u64,
76 #[builder(default = 10_000)]
78 pub retry_delay_max_ms: u64,
79 #[builder(default = 20)]
81 pub heartbeat_interval_secs: u64,
82 #[builder(default = 5_000)]
84 pub recv_window_ms: u64,
85 pub update_instruments_interval_mins: Option<u64>,
88 pub instrument_poll_interval_secs: Option<u64>,
91 #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
94 pub book_snapshot_timeout_secs: u64,
95 #[builder(default)]
97 pub transport_backend: TransportBackend,
98 #[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 #[must_use]
135 pub fn new() -> Self {
136 Self::default()
137 }
138
139 pub fn validate(&self) -> ConfigResult<()> {
145 check_unique_values("product_types", &self.product_types)
146 }
147
148 #[must_use]
150 pub fn has_api_credentials(&self) -> bool {
151 self.api_key.is_some() && self.api_secret.is_some()
152 }
153
154 #[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 #[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 #[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 #[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 #[must_use]
195 pub fn requires_private_ws(&self) -> bool {
196 self.has_api_credentials()
197 }
198}
199
200#[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 pub api_key: Option<SecretString>,
214 pub api_secret: Option<SecretString>,
216 #[builder(default = vec![BybitProductType::Linear])]
218 pub product_types: Vec<BybitProductType>,
219 #[builder(default = BybitEnvironment::Mainnet)]
221 pub environment: BybitEnvironment,
222 pub base_url_http: Option<String>,
224 pub base_url_ws_private: Option<String>,
226 pub base_url_ws_trade: Option<String>,
228 pub proxy_url: Option<SecretString>,
230 #[builder(default = 60)]
232 pub http_timeout_secs: u64,
233 #[builder(default = 3)]
235 pub max_retries: u32,
236 #[builder(default = 1_000)]
238 pub retry_delay_initial_ms: u64,
239 #[builder(default = 10_000)]
241 pub retry_delay_max_ms: u64,
242 #[builder(default = 20)]
244 pub heartbeat_interval_secs: u64,
245 pub auth_timeout_secs: Option<u64>,
248 #[builder(default = 5_000)]
250 pub recv_window_ms: u64,
251 pub account_id: Option<AccountId>,
253 #[builder(default)]
258 pub use_spot_position_reports: bool,
259 #[builder(default)]
263 pub auto_repay_spot_borrows: bool,
264 pub futures_leverages: Option<HashMap<String, u32>>,
266 pub position_mode: Option<HashMap<String, BybitPositionMode>>,
268 pub margin_mode: Option<BybitMarginMode>,
270 #[serde(deserialize_with = "deserialize_optional_smp_type")]
273 pub smp_type: Option<BybitOrderSmpType>,
274 #[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 #[must_use]
309 pub fn new() -> Self {
310 Self::default()
311 }
312
313 pub fn validate(&self) -> ConfigResult<()> {
319 check_unique_values("product_types", &self.product_types)
320 }
321
322 #[must_use]
324 pub fn has_api_credentials(&self) -> bool {
325 self.api_key.is_some() && self.api_secret.is_some()
326 }
327
328 #[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 #[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 #[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}