Skip to main content

nautilus_bitmex/
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 types for the BitMEX adapter clients.
17
18use nautilus_core::{correctness::check_in_range_inclusive_usize, string::secret::SecretString};
19use nautilus_model::identifiers::AccountId;
20use nautilus_network::websocket::TransportBackend;
21use serde::{Deserialize, Serialize};
22
23use crate::common::{
24    consts::{BITMEX_HTTP_TESTNET_URL, BITMEX_HTTP_URL, BITMEX_WS_TESTNET_URL, BITMEX_WS_URL},
25    credential::credential_env_vars,
26    enums::BitmexEnvironment,
27};
28
29pub(crate) const MAX_BROADCASTER_POOL_SIZE: usize = 16;
30
31/// Validates a BitMEX broadcaster pool size.
32///
33/// # Errors
34///
35/// Returns an error if `pool_size` is outside `[1, 16]`.
36pub(crate) fn validate_broadcaster_pool_size(
37    pool_size: usize,
38    parameter: &str,
39) -> anyhow::Result<()> {
40    check_in_range_inclusive_usize(pool_size, 1, MAX_BROADCASTER_POOL_SIZE, parameter)?;
41    Ok(())
42}
43
44/// Configuration for the BitMEX live data client.
45#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
46#[serde(default, deny_unknown_fields)]
47#[cfg_attr(
48    feature = "python",
49    pyo3::pyclass(module = "nautilus_trader.adapters.bitmex", from_py_object)
50)]
51#[cfg_attr(
52    feature = "python",
53    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.bitmex")
54)]
55pub struct BitmexDataClientConfig {
56    /// Optional API key used for authenticated REST/WebSocket requests.
57    pub api_key: Option<SecretString>,
58    /// Optional API secret used for authenticated REST/WebSocket requests.
59    pub api_secret: Option<SecretString>,
60    /// Optional override for the REST base URL.
61    pub base_url_http: Option<String>,
62    /// Optional override for the WebSocket URL.
63    pub base_url_ws: 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    /// Optional heartbeat interval (seconds) for the WebSocket client.
79    pub heartbeat_interval_secs: Option<u64>,
80    /// Optional WebSocket authentication timeout (seconds), defaulting to
81    /// `AUTHENTICATION_TIMEOUT_SECS` when unset.
82    pub auth_timeout_secs: Option<u64>,
83    /// Receive window in milliseconds for signed requests.
84    ///
85    /// This value determines how far in the future the `api-expires` timestamp will be set
86    /// for signed REST requests. BitMEX uses seconds-granularity Unix timestamps in the
87    /// `api-expires` header, calculated as: `current_timestamp + (recv_window_ms / 1000)`.
88    ///
89    /// **Note**: This parameter is specified in milliseconds for consistency with other
90    /// adapter configurations (e.g., Bybit's `recv_window_ms`), but BitMEX only supports
91    /// seconds-granularity timestamps. The value is converted via integer division, so
92    /// 10000ms becomes 10 seconds, 15500ms becomes 15 seconds, etc.
93    ///
94    /// A larger window provides more tolerance for clock skew and network latency, but
95    /// increases the replay attack window. The default of 10 seconds should be sufficient
96    /// for most deployments. Consider increasing this value (e.g., to 30_000ms = 30s) if you
97    /// experience request expiration errors due to clock drift or high network latency.
98    #[builder(default = 10_000)]
99    pub recv_window_ms: u64,
100    /// When `true`, only active instruments are requested during bootstrap.
101    #[builder(default = true)]
102    pub active_only: bool,
103    /// Optional interval (minutes) for instrument refresh from REST.
104    pub update_instruments_interval_mins: Option<u64>,
105    /// BitMEX environment (mainnet or testnet).
106    #[builder(default)]
107    pub environment: BitmexEnvironment,
108    /// Maximum number of requests per second (burst limit).
109    #[builder(default = 10)]
110    pub max_requests_per_second: u32,
111    /// Maximum number of requests per minute (rolling window).
112    #[builder(default = 120)]
113    pub max_requests_per_minute: u32,
114    /// WebSocket transport backend (defaults to `Tungstenite`).
115    #[builder(default)]
116    pub transport_backend: TransportBackend,
117}
118
119#[cfg(feature = "python")]
120nautilus_core::impl_pyo3_config_getters!(BitmexDataClientConfig {
121    base_url_http: Option<String>,
122    base_url_ws: Option<String>,
123    http_timeout_secs: u64,
124    max_retries: u32,
125    retry_delay_initial_ms: u64,
126    retry_delay_max_ms: u64,
127    heartbeat_interval_secs: Option<u64>,
128    auth_timeout_secs: Option<u64>,
129    recv_window_ms: u64,
130    active_only: bool,
131    update_instruments_interval_mins: Option<u64>,
132    environment: BitmexEnvironment,
133    max_requests_per_second: u32,
134    max_requests_per_minute: u32,
135    transport_backend: TransportBackend,
136});
137
138impl Default for BitmexDataClientConfig {
139    fn default() -> Self {
140        Self::builder().build()
141    }
142}
143
144impl BitmexDataClientConfig {
145    /// Creates a configuration with default values.
146    #[must_use]
147    pub fn new() -> Self {
148        Self::default()
149    }
150
151    /// Returns `true` if both API key and secret are available
152    /// (either explicitly set or resolvable from environment variables).
153    #[must_use]
154    pub fn has_api_credentials(&self) -> bool {
155        let (key_var, secret_var) = credential_env_vars(self.environment);
156        let has_key = self.api_key.is_some() || std::env::var(key_var).is_ok();
157        let has_secret = self.api_secret.is_some() || std::env::var(secret_var).is_ok();
158        has_key && has_secret
159    }
160
161    /// Returns the REST base URL, considering overrides and the environment.
162    #[must_use]
163    pub fn http_base_url(&self) -> String {
164        self.base_url_http
165            .clone()
166            .unwrap_or_else(|| match self.environment {
167                BitmexEnvironment::Testnet => BITMEX_HTTP_TESTNET_URL.to_string(),
168                BitmexEnvironment::Mainnet => BITMEX_HTTP_URL.to_string(),
169            })
170    }
171
172    /// Returns the WebSocket URL, considering overrides and the environment.
173    #[must_use]
174    pub fn ws_url(&self) -> String {
175        self.base_url_ws
176            .clone()
177            .unwrap_or_else(|| match self.environment {
178                BitmexEnvironment::Testnet => BITMEX_WS_TESTNET_URL.to_string(),
179                BitmexEnvironment::Mainnet => BITMEX_WS_URL.to_string(),
180            })
181    }
182}
183
184/// Configuration for the BitMEX live execution client.
185///
186/// The submit and cancel broadcaster pools must each contain `[1, 15]` clients, with a combined
187/// size in `[2, 16]`.
188#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
189#[serde(default, deny_unknown_fields)]
190#[cfg_attr(
191    feature = "python",
192    pyo3::pyclass(module = "nautilus_trader.adapters.bitmex", from_py_object)
193)]
194#[cfg_attr(
195    feature = "python",
196    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.bitmex")
197)]
198pub struct BitmexExecutionClientConfig {
199    /// API key used for authenticated requests.
200    pub api_key: Option<SecretString>,
201    /// API secret used for authenticated requests.
202    pub api_secret: Option<SecretString>,
203    /// Optional override for the REST base URL.
204    pub base_url_http: Option<String>,
205    /// Optional override for the WebSocket URL.
206    pub base_url_ws: Option<String>,
207    /// Optional proxy URL for HTTP and WebSocket transports.
208    pub proxy_url: Option<SecretString>,
209    /// REST timeout in seconds.
210    #[builder(default = 60)]
211    pub http_timeout_secs: u64,
212    /// Maximum retry attempts for REST requests.
213    #[builder(default = 3)]
214    pub max_retries: u32,
215    /// Initial retry backoff in milliseconds.
216    #[builder(default = 1_000)]
217    pub retry_delay_initial_ms: u64,
218    /// Maximum retry backoff in milliseconds.
219    #[builder(default = 10_000)]
220    pub retry_delay_max_ms: u64,
221    /// Heartbeat interval (seconds) for the WebSocket client.
222    #[builder(default = 5)]
223    pub heartbeat_interval_secs: u64,
224    /// Optional WebSocket authentication timeout (seconds), defaulting to
225    /// `AUTHENTICATION_TIMEOUT_SECS` when unset.
226    pub auth_timeout_secs: Option<u64>,
227    /// Receive window in milliseconds for signed requests.
228    ///
229    /// This value determines how far in the future the `api-expires` timestamp will be set
230    /// for signed REST requests. BitMEX uses seconds-granularity Unix timestamps in the
231    /// `api-expires` header, calculated as: `current_timestamp + (recv_window_ms / 1000)`.
232    ///
233    /// **Note**: This parameter is specified in milliseconds for consistency with other
234    /// adapter configurations (e.g., Bybit's `recv_window_ms`), but BitMEX only supports
235    /// seconds-granularity timestamps. The value is converted via integer division, so
236    /// 10000ms becomes 10 seconds, 15500ms becomes 15 seconds, etc.
237    ///
238    /// A larger window provides more tolerance for clock skew and network latency, but
239    /// increases the replay attack window. The default of 10 seconds should be sufficient
240    /// for most deployments. Consider increasing this value (e.g., to 30000ms = 30s) if you
241    /// experience request expiration errors due to clock drift or high network latency.
242    #[builder(default = 10_000)]
243    pub recv_window_ms: u64,
244    /// When `true`, only active instruments are requested during bootstrap.
245    #[builder(default = true)]
246    pub active_only: bool,
247    /// BitMEX environment (mainnet or testnet).
248    #[builder(default)]
249    pub environment: BitmexEnvironment,
250    /// Optional account identifier to associate with the execution client.
251    pub account_id: Option<AccountId>,
252    /// Maximum number of requests per second (burst limit).
253    #[builder(default = 10)]
254    pub max_requests_per_second: u32,
255    /// Maximum number of requests per minute (rolling window).
256    #[builder(default = 120)]
257    pub max_requests_per_minute: u32,
258    /// Number of HTTP clients in the submit broadcaster pool
259    /// (effective range `[1, 15]`, defaults to 1).
260    pub submitter_pool_size: Option<usize>,
261    /// Number of HTTP clients in the cancel broadcaster pool
262    /// (effective range `[1, 15]`, defaults to 1).
263    pub canceller_pool_size: Option<usize>,
264    /// Optional list of proxy URLs for submit broadcaster pool (path diversity).
265    pub submitter_proxy_urls: Option<Vec<SecretString>>,
266    /// Optional list of proxy URLs for cancel broadcaster pool (path diversity).
267    pub canceller_proxy_urls: Option<Vec<SecretString>>,
268    /// Optional dead man's switch timeout in seconds.
269    ///
270    /// When set, a background task periodically calls the BitMEX `cancelAllAfter` endpoint
271    /// to keep a server-side timer alive. If the client loses connectivity the timer expires
272    /// and BitMEX cancels all open orders. Calling with `timeout=0` disarms the switch.
273    /// The refresh interval is derived as `timeout / 4` (minimum 1 second).
274    pub deadmans_switch_timeout_secs: Option<u64>,
275    /// WebSocket transport backend (defaults to `Tungstenite`).
276    #[builder(default)]
277    pub transport_backend: TransportBackend,
278}
279
280#[cfg(feature = "python")]
281nautilus_core::impl_pyo3_config_getters!(BitmexExecutionClientConfig {
282    base_url_http: Option<String>,
283    base_url_ws: Option<String>,
284    http_timeout_secs: u64,
285    max_retries: u32,
286    retry_delay_initial_ms: u64,
287    retry_delay_max_ms: u64,
288    heartbeat_interval_secs: u64,
289    auth_timeout_secs: Option<u64>,
290    recv_window_ms: u64,
291    active_only: bool,
292    environment: BitmexEnvironment,
293    account_id: Option<AccountId>,
294    max_requests_per_second: u32,
295    max_requests_per_minute: u32,
296    submitter_pool_size: Option<usize>,
297    canceller_pool_size: Option<usize>,
298    deadmans_switch_timeout_secs: Option<u64>,
299    transport_backend: TransportBackend,
300});
301
302impl Default for BitmexExecutionClientConfig {
303    fn default() -> Self {
304        Self::builder().build()
305    }
306}
307
308impl BitmexExecutionClientConfig {
309    /// Creates a configuration with default values.
310    #[must_use]
311    pub fn new() -> Self {
312        Self::default()
313    }
314
315    /// Validates the individual and combined broadcaster pool sizes.
316    ///
317    /// # Errors
318    ///
319    /// Returns an error if either pool is outside `[1, 15]` or their combined size is outside
320    /// `[2, 16]`.
321    pub(crate) fn validate_broadcaster_pool_sizes(&self) -> anyhow::Result<()> {
322        let submitter_pool_size = self.submitter_pool_size.unwrap_or(1);
323        let canceller_pool_size = self.canceller_pool_size.unwrap_or(1);
324        validate_broadcaster_pool_size(submitter_pool_size, "submitter_pool_size")?;
325        validate_broadcaster_pool_size(canceller_pool_size, "canceller_pool_size")?;
326        let combined_pool_size = submitter_pool_size
327            .checked_add(canceller_pool_size)
328            .ok_or_else(|| anyhow::anyhow!("combined BitMEX broadcaster pool size overflow"))?;
329        check_in_range_inclusive_usize(
330            combined_pool_size,
331            2,
332            MAX_BROADCASTER_POOL_SIZE,
333            "combined_pool_size",
334        )?;
335        Ok(())
336    }
337
338    /// Returns `true` if both API key and secret are available
339    /// (either explicitly set or resolvable from environment variables).
340    #[must_use]
341    pub fn has_api_credentials(&self) -> bool {
342        let (key_var, secret_var) = credential_env_vars(self.environment);
343        let has_key = self.api_key.is_some() || std::env::var(key_var).is_ok();
344        let has_secret = self.api_secret.is_some() || std::env::var(secret_var).is_ok();
345        has_key && has_secret
346    }
347
348    /// Returns the REST base URL, considering overrides and the environment.
349    #[must_use]
350    pub fn http_base_url(&self) -> String {
351        self.base_url_http
352            .clone()
353            .unwrap_or_else(|| match self.environment {
354                BitmexEnvironment::Testnet => BITMEX_HTTP_TESTNET_URL.to_string(),
355                BitmexEnvironment::Mainnet => BITMEX_HTTP_URL.to_string(),
356            })
357    }
358
359    /// Returns the WebSocket URL, considering overrides and the environment.
360    #[must_use]
361    pub fn ws_url(&self) -> String {
362        self.base_url_ws
363            .clone()
364            .unwrap_or_else(|| match self.environment {
365                BitmexEnvironment::Testnet => BITMEX_WS_TESTNET_URL.to_string(),
366                BitmexEnvironment::Mainnet => BITMEX_WS_URL.to_string(),
367            })
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use rstest::rstest;
374
375    use super::*;
376
377    #[rstest]
378    #[case(1)]
379    #[case(3)]
380    #[case(MAX_BROADCASTER_POOL_SIZE)]
381    fn test_validate_broadcaster_pool_size_accepts_supported_values(#[case] pool_size: usize) {
382        assert!(validate_broadcaster_pool_size(pool_size, "pool_size").is_ok());
383    }
384
385    #[rstest]
386    #[case(0)]
387    #[case(MAX_BROADCASTER_POOL_SIZE + 1)]
388    #[case(usize::MAX)]
389    fn test_validate_broadcaster_pool_size_rejects_invalid_values(#[case] pool_size: usize) {
390        assert!(validate_broadcaster_pool_size(pool_size, "pool_size").is_err());
391    }
392
393    #[rstest]
394    #[case(Some(1), Some(1))]
395    #[case(Some(MAX_BROADCASTER_POOL_SIZE - 1), Some(1))]
396    #[case(Some(1), Some(MAX_BROADCASTER_POOL_SIZE - 1))]
397    fn test_execution_config_accepts_supported_combined_pool_size(
398        #[case] submitter_pool_size: Option<usize>,
399        #[case] canceller_pool_size: Option<usize>,
400    ) {
401        let config = BitmexExecutionClientConfig {
402            submitter_pool_size,
403            canceller_pool_size,
404            ..Default::default()
405        };
406
407        assert!(config.validate_broadcaster_pool_sizes().is_ok());
408    }
409
410    #[rstest]
411    #[case(Some(0), Some(1))]
412    #[case(Some(1), Some(0))]
413    #[case(Some(MAX_BROADCASTER_POOL_SIZE), Some(1))]
414    #[case(Some(usize::MAX), Some(1))]
415    fn test_execution_config_rejects_invalid_pool_sizes(
416        #[case] submitter_pool_size: Option<usize>,
417        #[case] canceller_pool_size: Option<usize>,
418    ) {
419        let config = BitmexExecutionClientConfig {
420            submitter_pool_size,
421            canceller_pool_size,
422            ..Default::default()
423        };
424
425        assert!(config.validate_broadcaster_pool_sizes().is_err());
426    }
427
428    #[rstest]
429    fn test_data_config_toml_minimal() {
430        let config: BitmexDataClientConfig = toml::from_str(
431            r#"
432environment = "testnet"
433http_timeout_secs = 30
434active_only = false
435max_requests_per_second = 5
436"#,
437        )
438        .unwrap();
439
440        assert_eq!(config.environment, BitmexEnvironment::Testnet);
441        assert_eq!(config.http_timeout_secs, 30);
442        assert!(!config.active_only);
443        assert_eq!(config.max_requests_per_second, 5);
444    }
445
446    #[rstest]
447    fn test_exec_config_toml_empty_uses_defaults() {
448        let config: BitmexExecutionClientConfig = toml::from_str("").unwrap();
449        let expected = BitmexExecutionClientConfig::default();
450
451        assert_eq!(config.environment, expected.environment);
452        assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
453        assert_eq!(
454            config.heartbeat_interval_secs,
455            expected.heartbeat_interval_secs,
456        );
457        assert_eq!(config.recv_window_ms, expected.recv_window_ms);
458        assert_eq!(config.active_only, expected.active_only);
459        assert_eq!(
460            config.max_requests_per_second,
461            expected.max_requests_per_second,
462        );
463        assert_eq!(config.transport_backend, expected.transport_backend);
464    }
465
466    #[rstest]
467    fn test_config_auth_timeout_secs() {
468        assert_eq!(BitmexDataClientConfig::default().auth_timeout_secs, None);
469        assert_eq!(
470            BitmexExecutionClientConfig::default().auth_timeout_secs,
471            None
472        );
473
474        let data = BitmexDataClientConfig::builder()
475            .auth_timeout_secs(3)
476            .build();
477        assert_eq!(data.auth_timeout_secs, Some(3));
478
479        let exec = BitmexExecutionClientConfig::builder()
480            .auth_timeout_secs(4)
481            .build();
482        assert_eq!(exec.auth_timeout_secs, Some(4));
483
484        let data: BitmexDataClientConfig = toml::from_str("auth_timeout_secs = 7\n").unwrap();
485        assert_eq!(data.auth_timeout_secs, Some(7));
486
487        let exec: BitmexExecutionClientConfig = toml::from_str("auth_timeout_secs = 8\n").unwrap();
488        assert_eq!(exec.auth_timeout_secs, Some(8));
489    }
490
491    #[rstest]
492    fn test_config_debug_redacts_credentials() {
493        let data = BitmexDataClientConfig {
494            api_key: Some("data-api-key".into()),
495            api_secret: Some("data-api-secret".into()),
496            proxy_url: Some("http://data-user:data-password@localhost".into()),
497            ..Default::default()
498        };
499        let execution = BitmexExecutionClientConfig {
500            api_key: Some("execution-api-key".into()),
501            api_secret: Some("execution-api-secret".into()),
502            proxy_url: Some("http://execution-user:execution-password@localhost".into()),
503            submitter_proxy_urls: Some(vec!["http://submit-user:submit-password@localhost".into()]),
504            canceller_proxy_urls: Some(vec!["http://cancel-user:cancel-password@localhost".into()]),
505            ..Default::default()
506        };
507
508        let debug = format!("{data:?} {execution:?}");
509
510        assert!(!debug.contains("data-api-key"));
511        assert!(!debug.contains("data-api-secret"));
512        assert!(!debug.contains("data-password"));
513        assert!(!debug.contains("execution-api-key"));
514        assert!(!debug.contains("execution-api-secret"));
515        assert!(!debug.contains("execution-password"));
516        assert!(!debug.contains("submit-password"));
517        assert!(!debug.contains("cancel-password"));
518    }
519}