Skip to main content

nautilus_kraken/
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 Kraken data and execution clients.
17
18use 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/// Configuration for the Kraken data client.
33#[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    /// Override for the L3 WebSocket URL. Defaults to `wss://ws-l3.kraken.com/v2`.
54    pub ws_l3_url: Option<String>,
55    /// Optional proxy URL for HTTP and WebSocket transports.
56    pub proxy_url: Option<SecretString>,
57    /// Validate Kraken's CRC32 checksum on each L3 update.
58    #[builder(default = true)]
59    pub validate_l3_checksum: bool,
60    /// Validate Kraken's CRC32 checksum on each Spot L2 `book` snapshot and update.
61    #[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    /// Idle timeout (milliseconds) for the spot v2 WebSocket.
68    ///
69    /// If no application data (any text or binary frame) is received within this
70    /// window, the connection is treated as dead and the client reconnects and
71    /// resubscribes. This recovers from a backend that acknowledges a
72    /// subscription but never attaches the data fan-out: the socket stays open
73    /// with no close frame or transport error, so nothing else detects it.
74    ///
75    /// Kraken sends a `heartbeat` text frame once per second while at least one
76    /// subscription is active, so a live subscribed connection resets this timer
77    /// well within the window. Note the client's keepalive `ping` is answered
78    /// with a `pong` *text* frame, which also resets the timer roughly every
79    /// `heartbeat_interval_secs`; the default below is therefore kept short
80    /// enough to rely on the 1/s heartbeats rather than the keepalive, which
81    /// assumes the connection carries at least one subscription. A connection
82    /// held open without any subscription should disable this (`0`) or raise it
83    /// above `heartbeat_interval_secs`.
84    ///
85    /// `0` disables the idle timeout.
86    #[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    /// Validates config invariants.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the demo environment is used for Spot.
148    pub fn validate(&self) -> anyhow::Result<()> {
149        validate_product_environment(self.product_type, self.environment)
150    }
151
152    /// Returns true if both API key and secret are set.
153    pub fn has_api_credentials(&self) -> bool {
154        self.api_key.is_some() && self.api_secret.is_some()
155    }
156
157    /// Returns the HTTP base URL for the configured product type and environment.
158    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    /// Returns the public WebSocket URL for the configured product type and environment.
165    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    /// Returns the private WebSocket URL for the configured product type and environment.
172    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    /// Returns the L3 WebSocket URL for the configured environment.
179    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/// Configuration for the Kraken execution client.
187#[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    /// Optional proxy URL for HTTP and WebSocket transports.
211    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    /// Optional WebSocket authentication timeout (seconds), defaulting to
217    /// `AUTHENTICATION_TIMEOUT_SECS` when unset (Kraken Futures login).
218    pub auth_timeout_secs: Option<u64>,
219    pub max_requests_per_second: Option<u32>,
220    /// Maximum retry attempts for retryable REST requests.
221    #[builder(default = 3)]
222    pub max_retries: u32,
223    #[builder(default)]
224    pub transport_backend: TransportBackend,
225
226    /// Account type for spot trading (`Cash` or `Margin`).
227    ///
228    /// When set to `Margin`, the adapter calls `TradeBalance` for margin reporting
229    /// and `OpenPositions` for position reconciliation.
230    /// Per-order leverage is set via `SubmitOrder.params["leverage"]` (u16 multiplier).
231    #[builder(default = AccountType::Cash)]
232    pub spot_account_type: AccountType,
233
234    /// Default leverage multiplier for spot margin orders when not overridden per-order.
235    ///
236    /// Sent as `"N:1"` to Kraken (e.g., `3` becomes `"3:1"`).
237    /// Valid tiers per pair are in `AssetPairInfo.leverage_buy` / `leverage_sell`.
238    /// `None` means cash orders (no leverage field sent).
239    pub default_leverage: Option<u16>,
240
241    /// Whether to generate `PositionStatusReport`s from spot wallet balances.
242    ///
243    /// Set `true` for spot-only (cash) accounts that need position tracking from
244    /// balance snapshots. For margin accounts leave `false`; positions are
245    /// reconciled via `OpenPositions` instead.
246    #[builder(default = false)]
247    pub use_spot_position_reports: bool,
248
249    /// Quote currency used for synthetic spot position reports.
250    ///
251    /// Only relevant when `use_spot_position_reports` is `true`. The bulk read reports only
252    /// instruments quoted in this currency, and the client declares bulk position coverage for
253    /// exactly those, so an instrument quoted in anything else is never reconciled to flat from
254    /// a missing report.
255    #[builder(default = "USDT".to_string())]
256    pub spot_positions_quote_currency: String,
257
258    /// Summary-display asset for `TradeBalance` margin metrics.
259    ///
260    /// Controls the denomination of equity, free margin, used margin, and other
261    /// summary figures returned by Kraken's `TradeBalance` endpoint (e.g. `"ZUSD"`,
262    /// `"ZGBP"`, `"ZEUR"`, `"USDT"`). `None` lets Kraken default to `ZUSD`.
263    /// Display-only: Kraken converts internally; per-position figures from
264    /// `OpenPositions` remain in the traded pair's quote currency.
265    pub margin_balance_asset: Option<String>,
266
267    /// Use WebSocket v2 for order submission, modification, and cancellation.
268    ///
269    /// When `true` (default), `submit_order`, `modify_order`, `cancel_order`,
270    /// and `submit_order_list` route through the authenticated WebSocket
271    /// connection when active, falling back to REST when the WebSocket is
272    /// inactive. When `false`, all order operations use REST only.
273    #[builder(default = true)]
274    pub use_ws_trade: bool,
275
276    /// Timeout in seconds for WebSocket order responses.
277    ///
278    /// Timeouts preserve request correlation until a matching response or shutdown,
279    /// without emitting a terminal event. `submit_order` and `submit_order_list`
280    /// also send a best-effort compensating cancel.
281    #[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    /// Returns the HTTP base URL for the configured product type and environment.
345    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    /// Returns the WebSocket URL for the configured product type and environment.
352    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    /// Validates config invariants.
359    ///
360    /// # Errors
361    ///
362    /// Returns an error if `default_leverage` is set on a Cash account or the demo environment is
363    /// used for Spot.
364    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 rstest::rstest;
387
388    use super::*;
389
390    const DATA_API_KEY: &str = "data-api-key-sentinel";
391    const DATA_API_SECRET: &str = "data-api-secret-sentinel";
392    const EXEC_API_KEY: &str = "exec-api-key-sentinel";
393    const EXEC_API_SECRET: &str = "exec-api-secret-sentinel";
394
395    #[rstest]
396    fn test_data_config_debug_redacts_credentials() {
397        let config = KrakenDataClientConfig {
398            api_key: Some(DATA_API_KEY.into()),
399            api_secret: Some(DATA_API_SECRET.into()),
400            product_type: KrakenProductType::Futures,
401            timeout_secs: 41,
402            ..Default::default()
403        };
404
405        let debug_output = format!("{config:?}");
406        let api_key_marker = format!("api_key: Some({REDACTED})");
407        let api_secret_marker = format!("api_secret: Some({REDACTED})");
408
409        assert!(!debug_output.contains(DATA_API_KEY));
410        assert!(!debug_output.contains(DATA_API_SECRET));
411        assert!(debug_output.contains(&api_key_marker));
412        assert!(debug_output.contains(&api_secret_marker));
413        assert!(debug_output.contains("product_type: Futures"));
414        assert!(debug_output.contains("timeout_secs: 41"));
415    }
416
417    #[rstest]
418    fn test_exec_config_debug_redacts_credentials() {
419        let config = KrakenExecutionClientConfig {
420            api_key: EXEC_API_KEY.into(),
421            api_secret: EXEC_API_SECRET.into(),
422            product_type: KrakenProductType::Futures,
423            timeout_secs: 43,
424            base_url: Some(
425                "https://SYN-user:SYN-password@api.example.com:8443/SYN-path?token=SYN-query"
426                    .to_string(),
427            ),
428            ws_url: Some(
429                "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query"
430                    .to_string(),
431            ),
432            ..Default::default()
433        };
434
435        let debug_output = format!("{config:?}");
436        let api_key_marker = format!("api_key: {REDACTED}");
437        let api_secret_marker = format!("api_secret: {REDACTED}");
438
439        assert!(!debug_output.contains(EXEC_API_KEY));
440        assert!(!debug_output.contains(EXEC_API_SECRET));
441        assert!(debug_output.contains(&api_key_marker));
442        assert!(debug_output.contains(&api_secret_marker));
443        assert!(debug_output.contains("product_type: Futures"));
444        assert!(debug_output.contains("timeout_secs: 43"));
445        assert!(debug_output.contains("base_url: Some(\"https://api.example.com:8443\")"));
446        assert!(debug_output.contains("ws_url: Some(\"wss://stream.example.com:9443\")"));
447        assert!(!debug_output.contains("SYN-"));
448    }
449
450    #[rstest]
451    fn test_exec_config_ws_trade_defaults() {
452        let cfg = KrakenExecutionClientConfig::default();
453        assert!(cfg.use_ws_trade);
454        assert_eq!(cfg.ws_request_timeout_secs, 5);
455        assert_eq!(cfg.max_retries, 3);
456    }
457
458    #[rstest]
459    fn test_exec_config_max_retries_serde_round_trip() {
460        let config = KrakenExecutionClientConfig {
461            max_retries: 0,
462            ..Default::default()
463        };
464
465        let serialized = serde_json::to_string(&config).unwrap();
466        let deserialized: KrakenExecutionClientConfig = serde_json::from_str(&serialized).unwrap();
467
468        assert_eq!(deserialized.max_retries, 0);
469    }
470
471    #[rstest]
472    fn test_data_config_toml_minimal() {
473        let config: KrakenDataClientConfig = toml::from_str(
474            r#"
475product_type = "spot"
476environment = "live"
477timeout_secs = 45
478validate_l3_checksum = false
479"#,
480        )
481        .unwrap();
482
483        assert_eq!(config.product_type, KrakenProductType::Spot);
484        assert_eq!(config.environment, KrakenEnvironment::Live);
485        assert_eq!(config.timeout_secs, 45);
486        assert!(!config.validate_l3_checksum);
487    }
488
489    #[rstest]
490    fn test_data_config_ws_idle_timeout_default() {
491        let config = KrakenDataClientConfig::default();
492        assert_eq!(config.ws_idle_timeout_ms, 10_000);
493    }
494
495    #[rstest]
496    fn test_data_config_ws_idle_timeout_override() {
497        let config: KrakenDataClientConfig = toml::from_str("ws_idle_timeout_ms = 0").unwrap();
498
499        assert_eq!(config.ws_idle_timeout_ms, 0);
500    }
501
502    #[rstest]
503    fn test_exec_config_toml_empty_uses_defaults() {
504        let config: KrakenExecutionClientConfig = toml::from_str("").unwrap();
505        let expected = KrakenExecutionClientConfig::default();
506        assert_eq!(config.account_id, expected.account_id);
507        assert_eq!(config.product_type, expected.product_type);
508        assert_eq!(config.environment, expected.environment);
509        assert_eq!(config.timeout_secs, expected.timeout_secs);
510        assert_eq!(config.max_retries, 3);
511        assert_eq!(config.spot_account_type, expected.spot_account_type);
512        assert_eq!(
513            config.use_spot_position_reports,
514            expected.use_spot_position_reports,
515        );
516        assert_eq!(config.use_ws_trade, expected.use_ws_trade);
517        assert_eq!(config.transport_backend, expected.transport_backend);
518    }
519}