1use std::fmt::Debug;
19
20#[cfg(test)]
21use nautilus_core::string::secret::REDACTED;
22use nautilus_core::string::secret::SecretString;
23use nautilus_model::{enums::AccountType, identifiers::AccountId};
24use nautilus_network::{url_origin, websocket::TransportBackend};
25use serde::{Deserialize, Serialize};
26
27use crate::common::{
28 enums::{KrakenEnvironment, KrakenProductType},
29 urls::{get_kraken_http_base_url, get_kraken_ws_private_url, get_kraken_ws_public_url},
30};
31
32#[derive(Clone, Serialize, Deserialize, bon::Builder)]
34#[serde(default, deny_unknown_fields)]
35#[cfg_attr(
36 feature = "python",
37 pyo3::pyclass(module = "nautilus_trader.adapters.kraken", from_py_object)
38)]
39#[cfg_attr(
40 feature = "python",
41 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.kraken")
42)]
43pub struct KrakenDataClientConfig {
44 pub api_key: Option<SecretString>,
45 pub api_secret: Option<SecretString>,
46 #[builder(default = KrakenProductType::Spot)]
47 pub product_type: KrakenProductType,
48 #[builder(default = KrakenEnvironment::Live)]
49 pub environment: KrakenEnvironment,
50 pub base_url: Option<String>,
51 pub ws_public_url: Option<String>,
52 pub ws_private_url: Option<String>,
53 pub ws_l3_url: Option<String>,
55 pub proxy_url: Option<SecretString>,
57 #[builder(default = true)]
59 pub validate_l3_checksum: bool,
60 #[builder(default = true)]
62 pub validate_l2_checksum: bool,
63 #[builder(default = 30)]
64 pub timeout_secs: u64,
65 #[builder(default = 30)]
66 pub heartbeat_interval_secs: u64,
67 #[builder(default = 10_000)]
87 pub ws_idle_timeout_ms: u64,
88 pub max_requests_per_second: Option<u32>,
89 #[builder(default)]
90 pub transport_backend: TransportBackend,
91}
92
93impl Debug for KrakenDataClientConfig {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.debug_struct(stringify!(KrakenDataClientConfig))
96 .field("api_key", &self.api_key)
97 .field("api_secret", &self.api_secret)
98 .field("product_type", &self.product_type)
99 .field("environment", &self.environment)
100 .field("base_url", &self.base_url.as_deref().map(url_origin))
101 .field(
102 "ws_public_url",
103 &self.ws_public_url.as_deref().map(url_origin),
104 )
105 .field(
106 "ws_private_url",
107 &self.ws_private_url.as_deref().map(url_origin),
108 )
109 .field("ws_l3_url", &self.ws_l3_url.as_deref().map(url_origin))
110 .field("proxy_url", &self.proxy_url)
111 .field("validate_l3_checksum", &self.validate_l3_checksum)
112 .field("validate_l2_checksum", &self.validate_l2_checksum)
113 .field("timeout_secs", &self.timeout_secs)
114 .field("heartbeat_interval_secs", &self.heartbeat_interval_secs)
115 .field("ws_idle_timeout_ms", &self.ws_idle_timeout_ms)
116 .field("max_requests_per_second", &self.max_requests_per_second)
117 .field("transport_backend", &self.transport_backend)
118 .finish()
119 }
120}
121
122#[cfg(feature = "python")]
123nautilus_core::impl_pyo3_config_getters!(KrakenDataClientConfig {
124 product_type: KrakenProductType,
125 environment: KrakenEnvironment,
126 base_url: Option<String>,
127 validate_l3_checksum: bool,
128 validate_l2_checksum: bool,
129 timeout_secs: u64,
130 heartbeat_interval_secs: u64,
131 ws_idle_timeout_ms: u64,
132 max_requests_per_second: Option<u32>,
133 transport_backend: TransportBackend,
134});
135
136impl Default for KrakenDataClientConfig {
137 fn default() -> Self {
138 Self::builder().build()
139 }
140}
141
142impl KrakenDataClientConfig {
143 pub fn validate(&self) -> anyhow::Result<()> {
149 validate_product_environment(self.product_type, self.environment)
150 }
151
152 pub fn has_api_credentials(&self) -> bool {
154 self.api_key.is_some() && self.api_secret.is_some()
155 }
156
157 pub fn http_base_url(&self) -> String {
159 self.base_url.clone().unwrap_or_else(|| {
160 get_kraken_http_base_url(self.product_type, self.environment).to_string()
161 })
162 }
163
164 pub fn ws_public_url(&self) -> String {
166 self.ws_public_url.clone().unwrap_or_else(|| {
167 get_kraken_ws_public_url(self.product_type, self.environment).to_string()
168 })
169 }
170
171 pub fn ws_private_url(&self) -> String {
173 self.ws_private_url.clone().unwrap_or_else(|| {
174 get_kraken_ws_private_url(self.product_type, self.environment).to_string()
175 })
176 }
177
178 pub fn ws_l3_url(&self) -> String {
180 self.ws_l3_url
181 .clone()
182 .unwrap_or_else(|| crate::common::consts::KRAKEN_SPOT_WS_L3_URL.to_string())
183 }
184}
185
186#[derive(Clone, Serialize, Deserialize, bon::Builder)]
188#[serde(default, deny_unknown_fields)]
189#[cfg_attr(
190 feature = "python",
191 pyo3::pyclass(module = "nautilus_trader.adapters.kraken", from_py_object)
192)]
193#[cfg_attr(
194 feature = "python",
195 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.kraken")
196)]
197pub struct KrakenExecutionClientConfig {
198 #[builder(default = AccountId::from("KRAKEN-001"))]
199 pub account_id: AccountId,
200 #[builder(default)]
201 pub api_key: SecretString,
202 #[builder(default)]
203 pub api_secret: SecretString,
204 #[builder(default = KrakenProductType::Spot)]
205 pub product_type: KrakenProductType,
206 #[builder(default = KrakenEnvironment::Live)]
207 pub environment: KrakenEnvironment,
208 pub base_url: Option<String>,
209 pub ws_url: Option<String>,
210 pub proxy_url: Option<SecretString>,
212 #[builder(default = 30)]
213 pub timeout_secs: u64,
214 #[builder(default = 30)]
215 pub heartbeat_interval_secs: u64,
216 pub auth_timeout_secs: Option<u64>,
219 pub max_requests_per_second: Option<u32>,
220 #[builder(default = 3)]
222 pub max_retries: u32,
223 #[builder(default)]
224 pub transport_backend: TransportBackend,
225
226 #[builder(default = AccountType::Cash)]
232 pub spot_account_type: AccountType,
233
234 pub default_leverage: Option<u16>,
240
241 #[builder(default = false)]
247 pub use_spot_position_reports: bool,
248
249 #[builder(default = "USDT".to_string())]
256 pub spot_positions_quote_currency: String,
257
258 pub margin_balance_asset: Option<String>,
266
267 #[builder(default = true)]
274 pub use_ws_trade: bool,
275
276 #[builder(default = 5)]
282 pub ws_request_timeout_secs: u64,
283}
284
285impl Debug for KrakenExecutionClientConfig {
286 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287 f.debug_struct(stringify!(KrakenExecutionClientConfig))
288 .field("account_id", &self.account_id)
289 .field("api_key", &self.api_key)
290 .field("api_secret", &self.api_secret)
291 .field("product_type", &self.product_type)
292 .field("environment", &self.environment)
293 .field("base_url", &self.base_url.as_deref().map(url_origin))
294 .field("ws_url", &self.ws_url.as_deref().map(url_origin))
295 .field("proxy_url", &self.proxy_url)
296 .field("timeout_secs", &self.timeout_secs)
297 .field("heartbeat_interval_secs", &self.heartbeat_interval_secs)
298 .field("auth_timeout_secs", &self.auth_timeout_secs)
299 .field("max_requests_per_second", &self.max_requests_per_second)
300 .field("max_retries", &self.max_retries)
301 .field("transport_backend", &self.transport_backend)
302 .field("spot_account_type", &self.spot_account_type)
303 .field("default_leverage", &self.default_leverage)
304 .field("use_spot_position_reports", &self.use_spot_position_reports)
305 .field(
306 "spot_positions_quote_currency",
307 &self.spot_positions_quote_currency,
308 )
309 .field("margin_balance_asset", &self.margin_balance_asset)
310 .field("use_ws_trade", &self.use_ws_trade)
311 .field("ws_request_timeout_secs", &self.ws_request_timeout_secs)
312 .finish()
313 }
314}
315
316#[cfg(feature = "python")]
317nautilus_core::impl_pyo3_config_getters!(KrakenExecutionClientConfig {
318 account_id: AccountId,
319 product_type: KrakenProductType,
320 environment: KrakenEnvironment,
321 base_url: Option<String>,
322 timeout_secs: u64,
323 heartbeat_interval_secs: u64,
324 auth_timeout_secs: Option<u64>,
325 max_requests_per_second: Option<u32>,
326 max_retries: u32,
327 spot_account_type: AccountType,
328 default_leverage: Option<u16>,
329 use_spot_position_reports: bool,
330 spot_positions_quote_currency: String,
331 margin_balance_asset: Option<String>,
332 use_ws_trade: bool,
333 ws_request_timeout_secs: u64,
334 transport_backend: TransportBackend,
335});
336
337impl Default for KrakenExecutionClientConfig {
338 fn default() -> Self {
339 Self::builder().build()
340 }
341}
342
343impl KrakenExecutionClientConfig {
344 pub fn http_base_url(&self) -> String {
346 self.base_url.clone().unwrap_or_else(|| {
347 get_kraken_http_base_url(self.product_type, self.environment).to_string()
348 })
349 }
350
351 pub fn ws_url(&self) -> String {
353 self.ws_url.clone().unwrap_or_else(|| {
354 get_kraken_ws_private_url(self.product_type, self.environment).to_string()
355 })
356 }
357
358 pub fn validate(&self) -> anyhow::Result<()> {
365 validate_product_environment(self.product_type, self.environment)?;
366
367 if self.default_leverage.is_some() && self.spot_account_type == AccountType::Cash {
368 anyhow::bail!("default_leverage requires spot_account_type=Margin");
369 }
370 Ok(())
371 }
372}
373
374fn validate_product_environment(
375 product_type: KrakenProductType,
376 environment: KrakenEnvironment,
377) -> anyhow::Result<()> {
378 if product_type == KrakenProductType::Spot && environment == KrakenEnvironment::Demo {
379 anyhow::bail!("Kraken Spot does not support the demo environment");
380 }
381 Ok(())
382}
383
384#[cfg(test)]
385mod tests {
386 use std::{cell::RefCell, rc::Rc};
387
388 use nautilus_common::cache::Cache;
389 use nautilus_live::ExecutionClientCore;
390 use nautilus_model::{
391 enums::OmsType,
392 identifiers::{ClientId, TraderId, Venue},
393 };
394 use rstest::rstest;
395
396 use super::*;
397 use crate::{
398 data::{KrakenFuturesDataClient, KrakenSpotDataClient},
399 execution::{KrakenFuturesExecutionClient, KrakenSpotExecutionClient},
400 websocket::spot_v2::client::KrakenSpotWebSocketClient,
401 };
402
403 const DATA_API_KEY: &str = "data-api-key-sentinel";
404 const DATA_API_SECRET: &str = "data-api-secret-sentinel";
405 const EXEC_API_KEY: &str = "exec-api-key-sentinel";
406 const EXEC_API_SECRET: &str = "exec-api-secret-sentinel";
407
408 #[rstest]
409 fn test_data_config_debug_redacts_credentials() {
410 let config = KrakenDataClientConfig {
411 api_key: Some(DATA_API_KEY.into()),
412 api_secret: Some(DATA_API_SECRET.into()),
413 product_type: KrakenProductType::Futures,
414 timeout_secs: 41,
415 ..Default::default()
416 };
417
418 let debug_output = format!("{config:?}");
419 let api_key_marker = format!("api_key: Some({REDACTED})");
420 let api_secret_marker = format!("api_secret: Some({REDACTED})");
421
422 assert!(!debug_output.contains(DATA_API_KEY));
423 assert!(!debug_output.contains(DATA_API_SECRET));
424 assert!(debug_output.contains(&api_key_marker));
425 assert!(debug_output.contains(&api_secret_marker));
426 assert!(debug_output.contains("product_type: Futures"));
427 assert!(debug_output.contains("timeout_secs: 41"));
428 }
429
430 #[rstest]
431 fn test_exec_config_debug_redacts_credentials() {
432 let config = KrakenExecutionClientConfig {
433 api_key: EXEC_API_KEY.into(),
434 api_secret: EXEC_API_SECRET.into(),
435 product_type: KrakenProductType::Futures,
436 timeout_secs: 43,
437 base_url: Some(
438 "https://SYN-user:SYN-password@api.example.com:8443/SYN-path?token=SYN-query"
439 .to_string(),
440 ),
441 ws_url: Some(
442 "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query"
443 .to_string(),
444 ),
445 ..Default::default()
446 };
447
448 let debug_output = format!("{config:?}");
449 let api_key_marker = format!("api_key: {REDACTED}");
450 let api_secret_marker = format!("api_secret: {REDACTED}");
451
452 assert!(!debug_output.contains(EXEC_API_KEY));
453 assert!(!debug_output.contains(EXEC_API_SECRET));
454 assert!(debug_output.contains(&api_key_marker));
455 assert!(debug_output.contains(&api_secret_marker));
456 assert!(debug_output.contains("product_type: Futures"));
457 assert!(debug_output.contains("timeout_secs: 43"));
458 assert!(debug_output.contains("base_url: Some(\"https://api.example.com:8443\")"));
459 assert!(debug_output.contains("ws_url: Some(\"wss://stream.example.com:9443\")"));
460 assert!(!debug_output.contains("SYN-"));
461 }
462
463 #[rstest]
464 fn test_exec_config_ws_trade_defaults() {
465 let cfg = KrakenExecutionClientConfig::default();
466 assert!(cfg.use_ws_trade);
467 assert_eq!(cfg.ws_request_timeout_secs, 5);
468 assert_eq!(cfg.max_retries, 3);
469 }
470
471 #[rstest]
472 fn test_exec_config_max_retries_serde_round_trip() {
473 let config = KrakenExecutionClientConfig {
474 max_retries: 0,
475 ..Default::default()
476 };
477
478 let serialized = serde_json::to_string(&config).unwrap();
479 let deserialized: KrakenExecutionClientConfig = serde_json::from_str(&serialized).unwrap();
480
481 assert_eq!(deserialized.max_retries, 0);
482 }
483
484 #[rstest]
485 fn test_data_config_toml_minimal() {
486 let config: KrakenDataClientConfig = toml::from_str(
487 r#"
488product_type = "spot"
489environment = "live"
490timeout_secs = 45
491validate_l3_checksum = false
492"#,
493 )
494 .unwrap();
495
496 assert_eq!(config.product_type, KrakenProductType::Spot);
497 assert_eq!(config.environment, KrakenEnvironment::Live);
498 assert_eq!(config.timeout_secs, 45);
499 assert!(!config.validate_l3_checksum);
500 }
501
502 #[rstest]
503 fn test_data_config_ws_idle_timeout_default() {
504 let config = KrakenDataClientConfig::default();
505 assert_eq!(config.ws_idle_timeout_ms, 10_000);
506 }
507
508 #[rstest]
509 fn test_data_config_ws_idle_timeout_override() {
510 let config: KrakenDataClientConfig = toml::from_str("ws_idle_timeout_ms = 0").unwrap();
511
512 assert_eq!(config.ws_idle_timeout_ms, 0);
513 }
514
515 #[rstest]
516 fn test_exec_config_toml_empty_uses_defaults() {
517 let config: KrakenExecutionClientConfig = toml::from_str("").unwrap();
518 let expected = KrakenExecutionClientConfig::default();
519 assert_eq!(config.account_id, expected.account_id);
520 assert_eq!(config.product_type, expected.product_type);
521 assert_eq!(config.environment, expected.environment);
522 assert_eq!(config.timeout_secs, expected.timeout_secs);
523 assert_eq!(config.max_retries, 3);
524 assert_eq!(config.spot_account_type, expected.spot_account_type);
525 assert_eq!(
526 config.use_spot_position_reports,
527 expected.use_spot_position_reports,
528 );
529 assert_eq!(config.use_ws_trade, expected.use_ws_trade);
530 assert_eq!(config.transport_backend, expected.transport_backend);
531 }
532
533 #[rstest]
534 #[case::spot(false)]
535 #[case::futures(true)]
536 fn test_data_constructor_rejects_invalid_config(#[case] futures: bool) {
537 let config = KrakenDataClientConfig {
538 product_type: KrakenProductType::Spot,
539 environment: KrakenEnvironment::Demo,
540 ..Default::default()
541 };
542
543 let expected = config.validate().unwrap_err().to_string();
544 let client_id = ClientId::from("KRAKEN");
545 let error = if futures {
546 KrakenFuturesDataClient::new(client_id, config).err()
547 } else {
548 KrakenSpotDataClient::new(client_id, config).err()
549 }
550 .expect("invalid config must fail before client setup");
551
552 assert_eq!(error.to_string(), expected);
553 }
554
555 #[rstest]
556 #[case::plain(false)]
557 #[case::level3(true)]
558 fn test_websocket_constructor_rejects_invalid_config(#[case] level3: bool) {
559 let config = KrakenDataClientConfig {
560 product_type: KrakenProductType::Spot,
561 environment: KrakenEnvironment::Demo,
562 ..Default::default()
563 };
564
565 let expected = config.validate().unwrap_err().to_string();
566 let cancellation_token = tokio_util::sync::CancellationToken::new();
567 let result = if level3 {
568 KrakenSpotWebSocketClient::l3(config, cancellation_token, None)
569 } else {
570 KrakenSpotWebSocketClient::new(config, cancellation_token, None)
571 };
572
573 let error = result.unwrap_err();
574
575 assert_eq!(error.to_string(), expected);
576 }
577
578 #[rstest]
579 #[case::spot(false)]
580 #[case::futures(true)]
581 fn test_execution_constructor_rejects_invalid_config(#[case] futures: bool) {
582 let config = KrakenExecutionClientConfig {
583 product_type: KrakenProductType::Spot,
584 environment: KrakenEnvironment::Demo,
585 ..Default::default()
586 };
587
588 let expected = config.validate().unwrap_err().to_string();
589
590 let core = ExecutionClientCore::new(
591 TraderId::from("TESTER-001"),
592 ClientId::from("KRAKEN"),
593 Venue::from("KRAKEN"),
594 OmsType::Netting,
595 config.account_id,
596 AccountType::Margin,
597 None,
598 Rc::new(RefCell::new(Cache::default())),
599 );
600
601 let error = if futures {
602 KrakenFuturesExecutionClient::new(core, config).err()
603 } else {
604 KrakenSpotExecutionClient::new(core, config).err()
605 }
606 .expect("invalid config must fail before credentials or client setup");
607
608 assert_eq!(error.to_string(), expected);
609 }
610}