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
20use nautilus_core::string::secret::REDACTED;
21use nautilus_model::{enums::AccountType, identifiers::AccountId};
22use nautilus_network::websocket::TransportBackend;
23use serde::{Deserialize, Serialize};
24
25use crate::common::{
26    enums::{KrakenEnvironment, KrakenProductType},
27    urls::{get_kraken_http_base_url, get_kraken_ws_private_url, get_kraken_ws_public_url},
28};
29
30/// Configuration for the Kraken data client.
31#[derive(Clone, Serialize, Deserialize, bon::Builder)]
32#[serde(default, deny_unknown_fields)]
33#[cfg_attr(
34    feature = "python",
35    pyo3::pyclass(module = "nautilus_trader.adapters.kraken", from_py_object)
36)]
37#[cfg_attr(
38    feature = "python",
39    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.kraken")
40)]
41pub struct KrakenDataClientConfig {
42    pub api_key: Option<String>,
43    pub api_secret: Option<String>,
44    #[builder(default = KrakenProductType::Spot)]
45    pub product_type: KrakenProductType,
46    #[builder(default = KrakenEnvironment::Live)]
47    pub environment: KrakenEnvironment,
48    pub base_url: Option<String>,
49    pub ws_public_url: Option<String>,
50    pub ws_private_url: Option<String>,
51    /// Override for the L3 WebSocket URL. Defaults to `wss://ws-l3.kraken.com/v2`.
52    pub ws_l3_url: Option<String>,
53    /// Validate Kraken's CRC32 checksum on each L3 update.
54    #[builder(default = true)]
55    pub validate_l3_checksum: bool,
56    /// Optional proxy URL for HTTP and WebSocket transports.
57    pub proxy_url: Option<String>,
58    #[builder(default = 30)]
59    pub timeout_secs: u64,
60    #[builder(default = 30)]
61    pub heartbeat_interval_secs: u64,
62    /// Idle timeout (milliseconds) for the spot v2 WebSocket.
63    ///
64    /// If no application data (any text or binary frame) is received within this
65    /// window, the connection is treated as dead and the client reconnects and
66    /// resubscribes. This recovers from a backend that acknowledges a
67    /// subscription but never attaches the data fan-out: the socket stays open
68    /// with no close frame or transport error, so nothing else detects it.
69    ///
70    /// Kraken sends a `heartbeat` text frame once per second while at least one
71    /// subscription is active, so a live subscribed connection resets this timer
72    /// well within the window. Note the client's keepalive `ping` is answered
73    /// with a `pong` *text* frame, which also resets the timer roughly every
74    /// `heartbeat_interval_secs`; the default below is therefore kept short
75    /// enough to rely on the 1/s heartbeats rather than the keepalive, which
76    /// assumes the connection carries at least one subscription. A connection
77    /// held open without any subscription should disable this (`0`) or raise it
78    /// above `heartbeat_interval_secs`.
79    ///
80    /// `0` disables the idle timeout.
81    #[builder(default = 10_000)]
82    pub ws_idle_timeout_ms: u64,
83    pub max_requests_per_second: Option<u32>,
84    #[builder(default)]
85    pub transport_backend: TransportBackend,
86}
87
88#[cfg(feature = "python")]
89nautilus_core::impl_pyo3_config_getters!(KrakenDataClientConfig {
90    product_type: KrakenProductType,
91    environment: KrakenEnvironment,
92    base_url: Option<String>,
93    validate_l3_checksum: bool,
94    timeout_secs: u64,
95    heartbeat_interval_secs: u64,
96    ws_idle_timeout_ms: u64,
97    max_requests_per_second: Option<u32>,
98    transport_backend: TransportBackend,
99});
100
101impl Default for KrakenDataClientConfig {
102    fn default() -> Self {
103        Self::builder().build()
104    }
105}
106
107impl Debug for KrakenDataClientConfig {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.debug_struct(stringify!(KrakenDataClientConfig))
110            .field("api_key", &self.api_key.as_ref().map(|_| REDACTED))
111            .field("api_secret", &self.api_secret.as_ref().map(|_| REDACTED))
112            .field("product_type", &self.product_type)
113            .field("environment", &self.environment)
114            .field("base_url", &self.base_url)
115            .field("ws_public_url", &self.ws_public_url)
116            .field("ws_private_url", &self.ws_private_url)
117            .field("ws_l3_url", &self.ws_l3_url)
118            .field("validate_l3_checksum", &self.validate_l3_checksum)
119            .field("proxy_url", &self.proxy_url)
120            .field("timeout_secs", &self.timeout_secs)
121            .field("heartbeat_interval_secs", &self.heartbeat_interval_secs)
122            .field("ws_idle_timeout_ms", &self.ws_idle_timeout_ms)
123            .field("max_requests_per_second", &self.max_requests_per_second)
124            .field("transport_backend", &self.transport_backend)
125            .finish()
126    }
127}
128
129impl KrakenDataClientConfig {
130    /// Validates config invariants.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the demo environment is used for Spot.
135    pub fn validate(&self) -> anyhow::Result<()> {
136        validate_product_environment(self.product_type, self.environment)
137    }
138
139    /// Returns true if both API key and secret are set.
140    pub fn has_api_credentials(&self) -> bool {
141        self.api_key.is_some() && self.api_secret.is_some()
142    }
143
144    /// Returns the HTTP base URL for the configured product type and environment.
145    pub fn http_base_url(&self) -> String {
146        self.base_url.clone().unwrap_or_else(|| {
147            get_kraken_http_base_url(self.product_type, self.environment).to_string()
148        })
149    }
150
151    /// Returns the public WebSocket URL for the configured product type and environment.
152    pub fn ws_public_url(&self) -> String {
153        self.ws_public_url.clone().unwrap_or_else(|| {
154            get_kraken_ws_public_url(self.product_type, self.environment).to_string()
155        })
156    }
157
158    /// Returns the private WebSocket URL for the configured product type and environment.
159    pub fn ws_private_url(&self) -> String {
160        self.ws_private_url.clone().unwrap_or_else(|| {
161            get_kraken_ws_private_url(self.product_type, self.environment).to_string()
162        })
163    }
164
165    /// Returns the L3 WebSocket URL for the configured environment.
166    pub fn ws_l3_url(&self) -> String {
167        self.ws_l3_url
168            .clone()
169            .unwrap_or_else(|| crate::common::consts::KRAKEN_SPOT_WS_L3_URL.to_string())
170    }
171}
172
173/// Configuration for the Kraken execution client.
174#[derive(Clone, Serialize, Deserialize, bon::Builder)]
175#[serde(default, deny_unknown_fields)]
176#[cfg_attr(
177    feature = "python",
178    pyo3::pyclass(module = "nautilus_trader.adapters.kraken", from_py_object)
179)]
180#[cfg_attr(
181    feature = "python",
182    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.kraken")
183)]
184pub struct KrakenExecutionClientConfig {
185    #[builder(default = AccountId::from("KRAKEN-001"))]
186    pub account_id: AccountId,
187    #[builder(default)]
188    pub api_key: String,
189    #[builder(default)]
190    pub api_secret: String,
191    #[builder(default = KrakenProductType::Spot)]
192    pub product_type: KrakenProductType,
193    #[builder(default = KrakenEnvironment::Live)]
194    pub environment: KrakenEnvironment,
195    pub base_url: Option<String>,
196    pub ws_url: Option<String>,
197    /// Optional proxy URL for HTTP and WebSocket transports.
198    pub proxy_url: Option<String>,
199    #[builder(default = 30)]
200    pub timeout_secs: u64,
201    #[builder(default = 30)]
202    pub heartbeat_interval_secs: u64,
203    /// Optional WebSocket authentication timeout (seconds), defaulting to
204    /// `AUTHENTICATION_TIMEOUT_SECS` when unset (Kraken Futures login).
205    pub auth_timeout_secs: Option<u64>,
206    pub max_requests_per_second: Option<u32>,
207    #[builder(default)]
208    pub transport_backend: TransportBackend,
209
210    /// Account type for spot trading (`Cash` or `Margin`).
211    ///
212    /// When set to `Margin`, the adapter calls `TradeBalance` for margin reporting
213    /// and `OpenPositions` for position reconciliation.
214    /// Per-order leverage is set via `SubmitOrder.params["leverage"]` (u16 multiplier).
215    #[builder(default = AccountType::Cash)]
216    pub spot_account_type: AccountType,
217
218    /// Default leverage multiplier for spot margin orders when not overridden per-order.
219    ///
220    /// Sent as `"N:1"` to Kraken (e.g., `3` becomes `"3:1"`).
221    /// Valid tiers per pair are in `AssetPairInfo.leverage_buy` / `leverage_sell`.
222    /// `None` means cash orders (no leverage field sent).
223    pub default_leverage: Option<u16>,
224
225    /// Whether to generate `PositionStatusReport`s from spot wallet balances.
226    ///
227    /// Set `true` for spot-only (cash) accounts that need position tracking from
228    /// balance snapshots. For margin accounts leave `false`; positions are
229    /// reconciled via `OpenPositions` instead.
230    #[builder(default = false)]
231    pub use_spot_position_reports: bool,
232
233    /// Quote currency used for synthetic spot position reports.
234    ///
235    /// Only relevant when `use_spot_position_reports` is `true`.
236    #[builder(default = "USDT".to_string())]
237    pub spot_positions_quote_currency: String,
238
239    /// Summary-display asset for `TradeBalance` margin metrics.
240    ///
241    /// Controls the denomination of equity, free margin, used margin, and other
242    /// summary figures returned by Kraken's `TradeBalance` endpoint (e.g. `"ZUSD"`,
243    /// `"ZGBP"`, `"ZEUR"`, `"USDT"`). `None` lets Kraken default to `ZUSD`.
244    /// Display-only: Kraken converts internally; per-position figures from
245    /// `OpenPositions` remain in the traded pair's quote currency.
246    pub margin_balance_asset: Option<String>,
247
248    /// Use WebSocket v2 for order submission, modification, and cancellation.
249    ///
250    /// When `true` (default), `submit_order`, `modify_order`, `cancel_order`,
251    /// and `submit_order_list` route through the authenticated WebSocket
252    /// connection when active, falling back to REST when the WebSocket is
253    /// inactive. When `false`, all order operations use REST only.
254    #[builder(default = true)]
255    pub use_ws_trade: bool,
256
257    /// Timeout in seconds for WebSocket order responses.
258    ///
259    /// Timeouts preserve request correlation until a matching response or shutdown,
260    /// without emitting a terminal event. `submit_order` and `submit_order_list`
261    /// also send a best-effort compensating cancel.
262    #[builder(default = 5)]
263    pub ws_request_timeout_secs: u64,
264}
265
266#[cfg(feature = "python")]
267nautilus_core::impl_pyo3_config_getters!(KrakenExecutionClientConfig {
268    account_id: AccountId,
269    product_type: KrakenProductType,
270    environment: KrakenEnvironment,
271    base_url: Option<String>,
272    timeout_secs: u64,
273    heartbeat_interval_secs: u64,
274    auth_timeout_secs: Option<u64>,
275    max_requests_per_second: Option<u32>,
276    spot_account_type: AccountType,
277    default_leverage: Option<u16>,
278    use_spot_position_reports: bool,
279    spot_positions_quote_currency: String,
280    margin_balance_asset: Option<String>,
281    use_ws_trade: bool,
282    ws_request_timeout_secs: u64,
283    transport_backend: TransportBackend,
284});
285
286impl Default for KrakenExecutionClientConfig {
287    fn default() -> Self {
288        Self::builder().build()
289    }
290}
291
292impl Debug for KrakenExecutionClientConfig {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        f.debug_struct(stringify!(KrakenExecutionClientConfig))
295            .field("account_id", &self.account_id)
296            .field("api_key", &REDACTED)
297            .field("api_secret", &REDACTED)
298            .field("product_type", &self.product_type)
299            .field("environment", &self.environment)
300            .field("base_url", &self.base_url)
301            .field("ws_url", &self.ws_url)
302            .field("proxy_url", &self.proxy_url)
303            .field("timeout_secs", &self.timeout_secs)
304            .field("heartbeat_interval_secs", &self.heartbeat_interval_secs)
305            .field("auth_timeout_secs", &self.auth_timeout_secs)
306            .field("max_requests_per_second", &self.max_requests_per_second)
307            .field("transport_backend", &self.transport_backend)
308            .field("spot_account_type", &self.spot_account_type)
309            .field("default_leverage", &self.default_leverage)
310            .field("use_spot_position_reports", &self.use_spot_position_reports)
311            .field(
312                "spot_positions_quote_currency",
313                &self.spot_positions_quote_currency,
314            )
315            .field("margin_balance_asset", &self.margin_balance_asset)
316            .field("use_ws_trade", &self.use_ws_trade)
317            .field("ws_request_timeout_secs", &self.ws_request_timeout_secs)
318            .finish()
319    }
320}
321
322impl KrakenExecutionClientConfig {
323    /// Returns the HTTP base URL for the configured product type and environment.
324    pub fn http_base_url(&self) -> String {
325        self.base_url.clone().unwrap_or_else(|| {
326            get_kraken_http_base_url(self.product_type, self.environment).to_string()
327        })
328    }
329
330    /// Returns the WebSocket URL for the configured product type and environment.
331    pub fn ws_url(&self) -> String {
332        self.ws_url.clone().unwrap_or_else(|| {
333            get_kraken_ws_private_url(self.product_type, self.environment).to_string()
334        })
335    }
336
337    /// Validates config invariants.
338    ///
339    /// # Errors
340    ///
341    /// Returns an error if `default_leverage` is set on a Cash account or the demo environment is
342    /// used for Spot.
343    pub fn validate(&self) -> anyhow::Result<()> {
344        validate_product_environment(self.product_type, self.environment)?;
345
346        if self.default_leverage.is_some() && self.spot_account_type == AccountType::Cash {
347            anyhow::bail!("default_leverage requires spot_account_type=Margin");
348        }
349        Ok(())
350    }
351}
352
353fn validate_product_environment(
354    product_type: KrakenProductType,
355    environment: KrakenEnvironment,
356) -> anyhow::Result<()> {
357    if product_type == KrakenProductType::Spot && environment == KrakenEnvironment::Demo {
358        anyhow::bail!("Kraken Spot does not support the demo environment");
359    }
360    Ok(())
361}
362
363#[cfg(test)]
364mod tests {
365    use rstest::rstest;
366
367    use super::*;
368
369    const DATA_API_KEY: &str = "data-api-key-sentinel";
370    const DATA_API_SECRET: &str = "data-api-secret-sentinel";
371    const EXEC_API_KEY: &str = "exec-api-key-sentinel";
372    const EXEC_API_SECRET: &str = "exec-api-secret-sentinel";
373
374    #[rstest]
375    fn test_data_config_debug_redacts_credentials() {
376        let config = KrakenDataClientConfig {
377            api_key: Some(DATA_API_KEY.to_string()),
378            api_secret: Some(DATA_API_SECRET.to_string()),
379            product_type: KrakenProductType::Futures,
380            timeout_secs: 41,
381            ..Default::default()
382        };
383
384        let debug_output = format!("{config:?}");
385        let api_key_marker = format!("api_key: Some({REDACTED:?})");
386        let api_secret_marker = format!("api_secret: Some({REDACTED:?})");
387
388        assert!(!debug_output.contains(DATA_API_KEY));
389        assert!(!debug_output.contains(DATA_API_SECRET));
390        assert!(debug_output.contains(&api_key_marker));
391        assert!(debug_output.contains(&api_secret_marker));
392        assert!(debug_output.contains("product_type: Futures"));
393        assert!(debug_output.contains("timeout_secs: 41"));
394    }
395
396    #[rstest]
397    fn test_exec_config_debug_redacts_credentials() {
398        let config = KrakenExecutionClientConfig {
399            api_key: EXEC_API_KEY.to_string(),
400            api_secret: EXEC_API_SECRET.to_string(),
401            product_type: KrakenProductType::Futures,
402            timeout_secs: 43,
403            ..Default::default()
404        };
405
406        let debug_output = format!("{config:?}");
407        let api_key_marker = format!("api_key: {REDACTED:?}");
408        let api_secret_marker = format!("api_secret: {REDACTED:?}");
409
410        assert!(!debug_output.contains(EXEC_API_KEY));
411        assert!(!debug_output.contains(EXEC_API_SECRET));
412        assert!(debug_output.contains(&api_key_marker));
413        assert!(debug_output.contains(&api_secret_marker));
414        assert!(debug_output.contains("product_type: Futures"));
415        assert!(debug_output.contains("timeout_secs: 43"));
416    }
417
418    #[rstest]
419    fn test_exec_config_ws_trade_defaults() {
420        let cfg = KrakenExecutionClientConfig::default();
421        assert!(cfg.use_ws_trade);
422        assert_eq!(cfg.ws_request_timeout_secs, 5);
423    }
424
425    #[rstest]
426    fn test_data_config_toml_minimal() {
427        let config: KrakenDataClientConfig = toml::from_str(
428            r#"
429product_type = "spot"
430environment = "live"
431timeout_secs = 45
432validate_l3_checksum = false
433"#,
434        )
435        .unwrap();
436
437        assert_eq!(config.product_type, KrakenProductType::Spot);
438        assert_eq!(config.environment, KrakenEnvironment::Live);
439        assert_eq!(config.timeout_secs, 45);
440        assert!(!config.validate_l3_checksum);
441    }
442
443    #[rstest]
444    fn test_data_config_ws_idle_timeout_default() {
445        let config = KrakenDataClientConfig::default();
446        assert_eq!(config.ws_idle_timeout_ms, 10_000);
447    }
448
449    #[rstest]
450    fn test_data_config_ws_idle_timeout_override() {
451        let config: KrakenDataClientConfig = toml::from_str("ws_idle_timeout_ms = 0").unwrap();
452
453        assert_eq!(config.ws_idle_timeout_ms, 0);
454    }
455
456    #[rstest]
457    fn test_exec_config_toml_empty_uses_defaults() {
458        let config: KrakenExecutionClientConfig = toml::from_str("").unwrap();
459        let expected = KrakenExecutionClientConfig::default();
460        assert_eq!(config.account_id, expected.account_id);
461        assert_eq!(config.product_type, expected.product_type);
462        assert_eq!(config.environment, expected.environment);
463        assert_eq!(config.timeout_secs, expected.timeout_secs);
464        assert_eq!(config.spot_account_type, expected.spot_account_type);
465        assert_eq!(
466            config.use_spot_position_reports,
467            expected.use_spot_position_reports,
468        );
469        assert_eq!(config.use_ws_trade, expected.use_ws_trade);
470        assert_eq!(config.transport_backend, expected.transport_backend);
471    }
472}