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
18#[cfg(test)]
19use nautilus_core::string::secret::REDACTED;
20use nautilus_core::string::secret::SecretString;
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(Debug, 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<SecretString>,
43    pub api_secret: Option<SecretString>,
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    /// Optional proxy URL for HTTP and WebSocket transports.
54    pub proxy_url: Option<SecretString>,
55    /// Validate Kraken's CRC32 checksum on each L3 update.
56    #[builder(default = true)]
57    pub validate_l3_checksum: bool,
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 KrakenDataClientConfig {
108    /// Validates config invariants.
109    ///
110    /// # Errors
111    ///
112    /// Returns an error if the demo environment is used for Spot.
113    pub fn validate(&self) -> anyhow::Result<()> {
114        validate_product_environment(self.product_type, self.environment)
115    }
116
117    /// Returns true if both API key and secret are set.
118    pub fn has_api_credentials(&self) -> bool {
119        self.api_key.is_some() && self.api_secret.is_some()
120    }
121
122    /// Returns the HTTP base URL for the configured product type and environment.
123    pub fn http_base_url(&self) -> String {
124        self.base_url.clone().unwrap_or_else(|| {
125            get_kraken_http_base_url(self.product_type, self.environment).to_string()
126        })
127    }
128
129    /// Returns the public WebSocket URL for the configured product type and environment.
130    pub fn ws_public_url(&self) -> String {
131        self.ws_public_url.clone().unwrap_or_else(|| {
132            get_kraken_ws_public_url(self.product_type, self.environment).to_string()
133        })
134    }
135
136    /// Returns the private WebSocket URL for the configured product type and environment.
137    pub fn ws_private_url(&self) -> String {
138        self.ws_private_url.clone().unwrap_or_else(|| {
139            get_kraken_ws_private_url(self.product_type, self.environment).to_string()
140        })
141    }
142
143    /// Returns the L3 WebSocket URL for the configured environment.
144    pub fn ws_l3_url(&self) -> String {
145        self.ws_l3_url
146            .clone()
147            .unwrap_or_else(|| crate::common::consts::KRAKEN_SPOT_WS_L3_URL.to_string())
148    }
149}
150
151/// Configuration for the Kraken execution client.
152#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
153#[serde(default, deny_unknown_fields)]
154#[cfg_attr(
155    feature = "python",
156    pyo3::pyclass(module = "nautilus_trader.adapters.kraken", from_py_object)
157)]
158#[cfg_attr(
159    feature = "python",
160    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.kraken")
161)]
162pub struct KrakenExecutionClientConfig {
163    #[builder(default = AccountId::from("KRAKEN-001"))]
164    pub account_id: AccountId,
165    #[builder(default)]
166    pub api_key: SecretString,
167    #[builder(default)]
168    pub api_secret: SecretString,
169    #[builder(default = KrakenProductType::Spot)]
170    pub product_type: KrakenProductType,
171    #[builder(default = KrakenEnvironment::Live)]
172    pub environment: KrakenEnvironment,
173    pub base_url: Option<String>,
174    pub ws_url: Option<String>,
175    /// Optional proxy URL for HTTP and WebSocket transports.
176    pub proxy_url: Option<SecretString>,
177    #[builder(default = 30)]
178    pub timeout_secs: u64,
179    #[builder(default = 30)]
180    pub heartbeat_interval_secs: u64,
181    /// Optional WebSocket authentication timeout (seconds), defaulting to
182    /// `AUTHENTICATION_TIMEOUT_SECS` when unset (Kraken Futures login).
183    pub auth_timeout_secs: Option<u64>,
184    pub max_requests_per_second: Option<u32>,
185    /// Maximum retry attempts for retryable REST requests.
186    #[builder(default = 3)]
187    pub max_retries: u32,
188    #[builder(default)]
189    pub transport_backend: TransportBackend,
190
191    /// Account type for spot trading (`Cash` or `Margin`).
192    ///
193    /// When set to `Margin`, the adapter calls `TradeBalance` for margin reporting
194    /// and `OpenPositions` for position reconciliation.
195    /// Per-order leverage is set via `SubmitOrder.params["leverage"]` (u16 multiplier).
196    #[builder(default = AccountType::Cash)]
197    pub spot_account_type: AccountType,
198
199    /// Default leverage multiplier for spot margin orders when not overridden per-order.
200    ///
201    /// Sent as `"N:1"` to Kraken (e.g., `3` becomes `"3:1"`).
202    /// Valid tiers per pair are in `AssetPairInfo.leverage_buy` / `leverage_sell`.
203    /// `None` means cash orders (no leverage field sent).
204    pub default_leverage: Option<u16>,
205
206    /// Whether to generate `PositionStatusReport`s from spot wallet balances.
207    ///
208    /// Set `true` for spot-only (cash) accounts that need position tracking from
209    /// balance snapshots. For margin accounts leave `false`; positions are
210    /// reconciled via `OpenPositions` instead.
211    #[builder(default = false)]
212    pub use_spot_position_reports: bool,
213
214    /// Quote currency used for synthetic spot position reports.
215    ///
216    /// Only relevant when `use_spot_position_reports` is `true`.
217    #[builder(default = "USDT".to_string())]
218    pub spot_positions_quote_currency: String,
219
220    /// Summary-display asset for `TradeBalance` margin metrics.
221    ///
222    /// Controls the denomination of equity, free margin, used margin, and other
223    /// summary figures returned by Kraken's `TradeBalance` endpoint (e.g. `"ZUSD"`,
224    /// `"ZGBP"`, `"ZEUR"`, `"USDT"`). `None` lets Kraken default to `ZUSD`.
225    /// Display-only: Kraken converts internally; per-position figures from
226    /// `OpenPositions` remain in the traded pair's quote currency.
227    pub margin_balance_asset: Option<String>,
228
229    /// Use WebSocket v2 for order submission, modification, and cancellation.
230    ///
231    /// When `true` (default), `submit_order`, `modify_order`, `cancel_order`,
232    /// and `submit_order_list` route through the authenticated WebSocket
233    /// connection when active, falling back to REST when the WebSocket is
234    /// inactive. When `false`, all order operations use REST only.
235    #[builder(default = true)]
236    pub use_ws_trade: bool,
237
238    /// Timeout in seconds for WebSocket order responses.
239    ///
240    /// Timeouts preserve request correlation until a matching response or shutdown,
241    /// without emitting a terminal event. `submit_order` and `submit_order_list`
242    /// also send a best-effort compensating cancel.
243    #[builder(default = 5)]
244    pub ws_request_timeout_secs: u64,
245}
246
247#[cfg(feature = "python")]
248nautilus_core::impl_pyo3_config_getters!(KrakenExecutionClientConfig {
249    account_id: AccountId,
250    product_type: KrakenProductType,
251    environment: KrakenEnvironment,
252    base_url: Option<String>,
253    timeout_secs: u64,
254    heartbeat_interval_secs: u64,
255    auth_timeout_secs: Option<u64>,
256    max_requests_per_second: Option<u32>,
257    max_retries: u32,
258    spot_account_type: AccountType,
259    default_leverage: Option<u16>,
260    use_spot_position_reports: bool,
261    spot_positions_quote_currency: String,
262    margin_balance_asset: Option<String>,
263    use_ws_trade: bool,
264    ws_request_timeout_secs: u64,
265    transport_backend: TransportBackend,
266});
267
268impl Default for KrakenExecutionClientConfig {
269    fn default() -> Self {
270        Self::builder().build()
271    }
272}
273
274impl KrakenExecutionClientConfig {
275    /// Returns the HTTP base URL for the configured product type and environment.
276    pub fn http_base_url(&self) -> String {
277        self.base_url.clone().unwrap_or_else(|| {
278            get_kraken_http_base_url(self.product_type, self.environment).to_string()
279        })
280    }
281
282    /// Returns the WebSocket URL for the configured product type and environment.
283    pub fn ws_url(&self) -> String {
284        self.ws_url.clone().unwrap_or_else(|| {
285            get_kraken_ws_private_url(self.product_type, self.environment).to_string()
286        })
287    }
288
289    /// Validates config invariants.
290    ///
291    /// # Errors
292    ///
293    /// Returns an error if `default_leverage` is set on a Cash account or the demo environment is
294    /// used for Spot.
295    pub fn validate(&self) -> anyhow::Result<()> {
296        validate_product_environment(self.product_type, self.environment)?;
297
298        if self.default_leverage.is_some() && self.spot_account_type == AccountType::Cash {
299            anyhow::bail!("default_leverage requires spot_account_type=Margin");
300        }
301        Ok(())
302    }
303}
304
305fn validate_product_environment(
306    product_type: KrakenProductType,
307    environment: KrakenEnvironment,
308) -> anyhow::Result<()> {
309    if product_type == KrakenProductType::Spot && environment == KrakenEnvironment::Demo {
310        anyhow::bail!("Kraken Spot does not support the demo environment");
311    }
312    Ok(())
313}
314
315#[cfg(test)]
316mod tests {
317    use rstest::rstest;
318
319    use super::*;
320
321    const DATA_API_KEY: &str = "data-api-key-sentinel";
322    const DATA_API_SECRET: &str = "data-api-secret-sentinel";
323    const EXEC_API_KEY: &str = "exec-api-key-sentinel";
324    const EXEC_API_SECRET: &str = "exec-api-secret-sentinel";
325
326    #[rstest]
327    fn test_data_config_debug_redacts_credentials() {
328        let config = KrakenDataClientConfig {
329            api_key: Some(DATA_API_KEY.into()),
330            api_secret: Some(DATA_API_SECRET.into()),
331            product_type: KrakenProductType::Futures,
332            timeout_secs: 41,
333            ..Default::default()
334        };
335
336        let debug_output = format!("{config:?}");
337        let api_key_marker = format!("api_key: Some({REDACTED})");
338        let api_secret_marker = format!("api_secret: Some({REDACTED})");
339
340        assert!(!debug_output.contains(DATA_API_KEY));
341        assert!(!debug_output.contains(DATA_API_SECRET));
342        assert!(debug_output.contains(&api_key_marker));
343        assert!(debug_output.contains(&api_secret_marker));
344        assert!(debug_output.contains("product_type: Futures"));
345        assert!(debug_output.contains("timeout_secs: 41"));
346    }
347
348    #[rstest]
349    fn test_exec_config_debug_redacts_credentials() {
350        let config = KrakenExecutionClientConfig {
351            api_key: EXEC_API_KEY.into(),
352            api_secret: EXEC_API_SECRET.into(),
353            product_type: KrakenProductType::Futures,
354            timeout_secs: 43,
355            ..Default::default()
356        };
357
358        let debug_output = format!("{config:?}");
359        let api_key_marker = format!("api_key: {REDACTED}");
360        let api_secret_marker = format!("api_secret: {REDACTED}");
361
362        assert!(!debug_output.contains(EXEC_API_KEY));
363        assert!(!debug_output.contains(EXEC_API_SECRET));
364        assert!(debug_output.contains(&api_key_marker));
365        assert!(debug_output.contains(&api_secret_marker));
366        assert!(debug_output.contains("product_type: Futures"));
367        assert!(debug_output.contains("timeout_secs: 43"));
368    }
369
370    #[rstest]
371    fn test_exec_config_ws_trade_defaults() {
372        let cfg = KrakenExecutionClientConfig::default();
373        assert!(cfg.use_ws_trade);
374        assert_eq!(cfg.ws_request_timeout_secs, 5);
375        assert_eq!(cfg.max_retries, 3);
376    }
377
378    #[rstest]
379    fn test_exec_config_max_retries_serde_round_trip() {
380        let config = KrakenExecutionClientConfig {
381            max_retries: 0,
382            ..Default::default()
383        };
384
385        let serialized = serde_json::to_string(&config).unwrap();
386        let deserialized: KrakenExecutionClientConfig = serde_json::from_str(&serialized).unwrap();
387
388        assert_eq!(deserialized.max_retries, 0);
389    }
390
391    #[rstest]
392    fn test_data_config_toml_minimal() {
393        let config: KrakenDataClientConfig = toml::from_str(
394            r#"
395product_type = "spot"
396environment = "live"
397timeout_secs = 45
398validate_l3_checksum = false
399"#,
400        )
401        .unwrap();
402
403        assert_eq!(config.product_type, KrakenProductType::Spot);
404        assert_eq!(config.environment, KrakenEnvironment::Live);
405        assert_eq!(config.timeout_secs, 45);
406        assert!(!config.validate_l3_checksum);
407    }
408
409    #[rstest]
410    fn test_data_config_ws_idle_timeout_default() {
411        let config = KrakenDataClientConfig::default();
412        assert_eq!(config.ws_idle_timeout_ms, 10_000);
413    }
414
415    #[rstest]
416    fn test_data_config_ws_idle_timeout_override() {
417        let config: KrakenDataClientConfig = toml::from_str("ws_idle_timeout_ms = 0").unwrap();
418
419        assert_eq!(config.ws_idle_timeout_ms, 0);
420    }
421
422    #[rstest]
423    fn test_exec_config_toml_empty_uses_defaults() {
424        let config: KrakenExecutionClientConfig = toml::from_str("").unwrap();
425        let expected = KrakenExecutionClientConfig::default();
426        assert_eq!(config.account_id, expected.account_id);
427        assert_eq!(config.product_type, expected.product_type);
428        assert_eq!(config.environment, expected.environment);
429        assert_eq!(config.timeout_secs, expected.timeout_secs);
430        assert_eq!(config.max_retries, 3);
431        assert_eq!(config.spot_account_type, expected.spot_account_type);
432        assert_eq!(
433            config.use_spot_position_reports,
434            expected.use_spot_position_reports,
435        );
436        assert_eq!(config.use_ws_trade, expected.use_ws_trade);
437        assert_eq!(config.transport_backend, expected.transport_backend);
438    }
439}