use nautilus_core::{correctness::check_in_range_inclusive_usize, string::secret::SecretString};
use nautilus_model::identifiers::AccountId;
use nautilus_network::websocket::TransportBackend;
use serde::{Deserialize, Serialize};
use crate::common::{
consts::{BITMEX_HTTP_TESTNET_URL, BITMEX_HTTP_URL, BITMEX_WS_TESTNET_URL, BITMEX_WS_URL},
credential::credential_env_vars,
enums::BitmexEnvironment,
};
pub(crate) const MAX_BROADCASTER_POOL_SIZE: usize = 16;
pub(crate) fn validate_broadcaster_pool_size(
pool_size: usize,
parameter: &str,
) -> anyhow::Result<()> {
check_in_range_inclusive_usize(pool_size, 1, MAX_BROADCASTER_POOL_SIZE, parameter)?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
#[serde(default, deny_unknown_fields)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.adapters.bitmex", from_py_object)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.bitmex")
)]
pub struct BitmexDataClientConfig {
pub api_key: Option<SecretString>,
pub api_secret: Option<SecretString>,
pub base_url_http: Option<String>,
pub base_url_ws: Option<String>,
pub proxy_url: Option<SecretString>,
#[builder(default = 60)]
pub http_timeout_secs: u64,
#[builder(default = 3)]
pub max_retries: u32,
#[builder(default = 1_000)]
pub retry_delay_initial_ms: u64,
#[builder(default = 10_000)]
pub retry_delay_max_ms: u64,
pub heartbeat_interval_secs: Option<u64>,
pub auth_timeout_secs: Option<u64>,
#[builder(default = 10_000)]
pub recv_window_ms: u64,
#[builder(default = true)]
pub active_only: bool,
pub update_instruments_interval_mins: Option<u64>,
#[builder(default)]
pub environment: BitmexEnvironment,
#[builder(default = 10)]
pub max_requests_per_second: u32,
#[builder(default = 120)]
pub max_requests_per_minute: u32,
#[builder(default)]
pub transport_backend: TransportBackend,
}
#[cfg(feature = "python")]
nautilus_core::impl_pyo3_config_getters!(BitmexDataClientConfig {
base_url_http: Option<String>,
base_url_ws: Option<String>,
http_timeout_secs: u64,
max_retries: u32,
retry_delay_initial_ms: u64,
retry_delay_max_ms: u64,
heartbeat_interval_secs: Option<u64>,
auth_timeout_secs: Option<u64>,
recv_window_ms: u64,
active_only: bool,
update_instruments_interval_mins: Option<u64>,
environment: BitmexEnvironment,
max_requests_per_second: u32,
max_requests_per_minute: u32,
transport_backend: TransportBackend,
});
impl Default for BitmexDataClientConfig {
fn default() -> Self {
Self::builder().build()
}
}
impl BitmexDataClientConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn has_api_credentials(&self) -> bool {
let (key_var, secret_var) = credential_env_vars(self.environment);
let has_key = self.api_key.is_some() || std::env::var(key_var).is_ok();
let has_secret = self.api_secret.is_some() || std::env::var(secret_var).is_ok();
has_key && has_secret
}
#[must_use]
pub fn http_base_url(&self) -> String {
self.base_url_http
.clone()
.unwrap_or_else(|| match self.environment {
BitmexEnvironment::Testnet => BITMEX_HTTP_TESTNET_URL.to_string(),
BitmexEnvironment::Mainnet => BITMEX_HTTP_URL.to_string(),
})
}
#[must_use]
pub fn ws_url(&self) -> String {
self.base_url_ws
.clone()
.unwrap_or_else(|| match self.environment {
BitmexEnvironment::Testnet => BITMEX_WS_TESTNET_URL.to_string(),
BitmexEnvironment::Mainnet => BITMEX_WS_URL.to_string(),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
#[serde(default, deny_unknown_fields)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.adapters.bitmex", from_py_object)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.bitmex")
)]
pub struct BitmexExecutionClientConfig {
pub api_key: Option<SecretString>,
pub api_secret: Option<SecretString>,
pub base_url_http: Option<String>,
pub base_url_ws: Option<String>,
pub proxy_url: Option<SecretString>,
#[builder(default = 60)]
pub http_timeout_secs: u64,
#[builder(default = 3)]
pub max_retries: u32,
#[builder(default = 1_000)]
pub retry_delay_initial_ms: u64,
#[builder(default = 10_000)]
pub retry_delay_max_ms: u64,
#[builder(default = 5)]
pub heartbeat_interval_secs: u64,
pub auth_timeout_secs: Option<u64>,
#[builder(default = 10_000)]
pub recv_window_ms: u64,
#[builder(default = true)]
pub active_only: bool,
#[builder(default)]
pub environment: BitmexEnvironment,
pub account_id: Option<AccountId>,
#[builder(default = 10)]
pub max_requests_per_second: u32,
#[builder(default = 120)]
pub max_requests_per_minute: u32,
pub submitter_pool_size: Option<usize>,
pub canceller_pool_size: Option<usize>,
pub submitter_proxy_urls: Option<Vec<SecretString>>,
pub canceller_proxy_urls: Option<Vec<SecretString>>,
pub deadmans_switch_timeout_secs: Option<u64>,
#[builder(default)]
pub transport_backend: TransportBackend,
}
#[cfg(feature = "python")]
nautilus_core::impl_pyo3_config_getters!(BitmexExecutionClientConfig {
base_url_http: Option<String>,
base_url_ws: Option<String>,
http_timeout_secs: u64,
max_retries: u32,
retry_delay_initial_ms: u64,
retry_delay_max_ms: u64,
heartbeat_interval_secs: u64,
auth_timeout_secs: Option<u64>,
recv_window_ms: u64,
active_only: bool,
environment: BitmexEnvironment,
account_id: Option<AccountId>,
max_requests_per_second: u32,
max_requests_per_minute: u32,
submitter_pool_size: Option<usize>,
canceller_pool_size: Option<usize>,
deadmans_switch_timeout_secs: Option<u64>,
transport_backend: TransportBackend,
});
impl Default for BitmexExecutionClientConfig {
fn default() -> Self {
Self::builder().build()
}
}
impl BitmexExecutionClientConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub(crate) fn validate_broadcaster_pool_sizes(&self) -> anyhow::Result<()> {
let submitter_pool_size = self.submitter_pool_size.unwrap_or(1);
let canceller_pool_size = self.canceller_pool_size.unwrap_or(1);
validate_broadcaster_pool_size(submitter_pool_size, "submitter_pool_size")?;
validate_broadcaster_pool_size(canceller_pool_size, "canceller_pool_size")?;
let combined_pool_size = submitter_pool_size
.checked_add(canceller_pool_size)
.ok_or_else(|| anyhow::anyhow!("combined BitMEX broadcaster pool size overflow"))?;
check_in_range_inclusive_usize(
combined_pool_size,
2,
MAX_BROADCASTER_POOL_SIZE,
"combined_pool_size",
)?;
Ok(())
}
#[must_use]
pub fn has_api_credentials(&self) -> bool {
let (key_var, secret_var) = credential_env_vars(self.environment);
let has_key = self.api_key.is_some() || std::env::var(key_var).is_ok();
let has_secret = self.api_secret.is_some() || std::env::var(secret_var).is_ok();
has_key && has_secret
}
#[must_use]
pub fn http_base_url(&self) -> String {
self.base_url_http
.clone()
.unwrap_or_else(|| match self.environment {
BitmexEnvironment::Testnet => BITMEX_HTTP_TESTNET_URL.to_string(),
BitmexEnvironment::Mainnet => BITMEX_HTTP_URL.to_string(),
})
}
#[must_use]
pub fn ws_url(&self) -> String {
self.base_url_ws
.clone()
.unwrap_or_else(|| match self.environment {
BitmexEnvironment::Testnet => BITMEX_WS_TESTNET_URL.to_string(),
BitmexEnvironment::Mainnet => BITMEX_WS_URL.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case(1)]
#[case(3)]
#[case(MAX_BROADCASTER_POOL_SIZE)]
fn test_validate_broadcaster_pool_size_accepts_supported_values(#[case] pool_size: usize) {
assert!(validate_broadcaster_pool_size(pool_size, "pool_size").is_ok());
}
#[rstest]
#[case(0)]
#[case(MAX_BROADCASTER_POOL_SIZE + 1)]
#[case(usize::MAX)]
fn test_validate_broadcaster_pool_size_rejects_invalid_values(#[case] pool_size: usize) {
assert!(validate_broadcaster_pool_size(pool_size, "pool_size").is_err());
}
#[rstest]
#[case(Some(1), Some(1))]
#[case(Some(MAX_BROADCASTER_POOL_SIZE - 1), Some(1))]
#[case(Some(1), Some(MAX_BROADCASTER_POOL_SIZE - 1))]
fn test_execution_config_accepts_supported_combined_pool_size(
#[case] submitter_pool_size: Option<usize>,
#[case] canceller_pool_size: Option<usize>,
) {
let config = BitmexExecutionClientConfig {
submitter_pool_size,
canceller_pool_size,
..Default::default()
};
assert!(config.validate_broadcaster_pool_sizes().is_ok());
}
#[rstest]
#[case(Some(0), Some(1))]
#[case(Some(1), Some(0))]
#[case(Some(MAX_BROADCASTER_POOL_SIZE), Some(1))]
#[case(Some(usize::MAX), Some(1))]
fn test_execution_config_rejects_invalid_pool_sizes(
#[case] submitter_pool_size: Option<usize>,
#[case] canceller_pool_size: Option<usize>,
) {
let config = BitmexExecutionClientConfig {
submitter_pool_size,
canceller_pool_size,
..Default::default()
};
assert!(config.validate_broadcaster_pool_sizes().is_err());
}
#[rstest]
fn test_data_config_toml_minimal() {
let config: BitmexDataClientConfig = toml::from_str(
r#"
environment = "testnet"
http_timeout_secs = 30
active_only = false
max_requests_per_second = 5
"#,
)
.unwrap();
assert_eq!(config.environment, BitmexEnvironment::Testnet);
assert_eq!(config.http_timeout_secs, 30);
assert!(!config.active_only);
assert_eq!(config.max_requests_per_second, 5);
}
#[rstest]
fn test_exec_config_toml_empty_uses_defaults() {
let config: BitmexExecutionClientConfig = toml::from_str("").unwrap();
let expected = BitmexExecutionClientConfig::default();
assert_eq!(config.environment, expected.environment);
assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
assert_eq!(
config.heartbeat_interval_secs,
expected.heartbeat_interval_secs,
);
assert_eq!(config.recv_window_ms, expected.recv_window_ms);
assert_eq!(config.active_only, expected.active_only);
assert_eq!(
config.max_requests_per_second,
expected.max_requests_per_second,
);
assert_eq!(config.transport_backend, expected.transport_backend);
}
#[rstest]
fn test_config_auth_timeout_secs() {
assert_eq!(BitmexDataClientConfig::default().auth_timeout_secs, None);
assert_eq!(
BitmexExecutionClientConfig::default().auth_timeout_secs,
None
);
let data = BitmexDataClientConfig::builder()
.auth_timeout_secs(3)
.build();
assert_eq!(data.auth_timeout_secs, Some(3));
let exec = BitmexExecutionClientConfig::builder()
.auth_timeout_secs(4)
.build();
assert_eq!(exec.auth_timeout_secs, Some(4));
let data: BitmexDataClientConfig = toml::from_str("auth_timeout_secs = 7\n").unwrap();
assert_eq!(data.auth_timeout_secs, Some(7));
let exec: BitmexExecutionClientConfig = toml::from_str("auth_timeout_secs = 8\n").unwrap();
assert_eq!(exec.auth_timeout_secs, Some(8));
}
#[rstest]
fn test_config_debug_redacts_credentials() {
let data = BitmexDataClientConfig {
api_key: Some("data-api-key".into()),
api_secret: Some("data-api-secret".into()),
proxy_url: Some("http://data-user:data-password@localhost".into()),
..Default::default()
};
let execution = BitmexExecutionClientConfig {
api_key: Some("execution-api-key".into()),
api_secret: Some("execution-api-secret".into()),
proxy_url: Some("http://execution-user:execution-password@localhost".into()),
submitter_proxy_urls: Some(vec!["http://submit-user:submit-password@localhost".into()]),
canceller_proxy_urls: Some(vec!["http://cancel-user:cancel-password@localhost".into()]),
..Default::default()
};
let debug = format!("{data:?} {execution:?}");
assert!(!debug.contains("data-api-key"));
assert!(!debug.contains("data-api-secret"));
assert!(!debug.contains("data-password"));
assert!(!debug.contains("execution-api-key"));
assert!(!debug.contains("execution-api-secret"));
assert!(!debug.contains("execution-password"));
assert!(!debug.contains("submit-password"));
assert!(!debug.contains("cancel-password"));
}
}