Skip to main content

nautilus_kraken/http/spot/
client.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//! HTTP client for the Kraken Spot REST API.
17
18use std::{
19    collections::HashMap,
20    fmt::Debug,
21    num::NonZeroU32,
22    sync::{
23        Arc,
24        atomic::{AtomicBool, Ordering},
25    },
26};
27
28use ahash::AHashMap;
29use anyhow::Context;
30use indexmap::IndexMap;
31use jiff::Timestamp;
32use nautilus_common::cache::InstrumentLookupError;
33use nautilus_core::{
34    AtomicMap, AtomicTime, UUID4, datetime::NANOSECONDS_IN_SECOND, nanos::UnixNanos,
35    time::get_atomic_clock_realtime,
36};
37use nautilus_model::{
38    data::{Bar, BarType, BookOrder, TradeTick},
39    enums::{
40        AccountType, BookType, CurrencyType, MarketStatusAction, OrderSide, OrderType,
41        PositionSide, TimeInForce, TriggerType,
42    },
43    events::AccountState,
44    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
45    instruments::{Instrument, InstrumentAny},
46    orderbook::OrderBook,
47    reports::{FillReport, OrderStatusReport, PositionStatusReport},
48    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
49};
50use nautilus_network::{
51    http::{
52        HttpClient, HttpRedirectPolicy, HttpResponse, Method, create_standard_nautilus_headers,
53    },
54    ratelimiter::quota::Quota,
55    retry::{RetryConfig, RetryError, RetryManager},
56};
57use parking_lot::RwLock;
58use rust_decimal::Decimal;
59use rust_decimal_macros::dec;
60use serde::{Serialize, de::DeserializeOwned};
61use tokio_util::sync::CancellationToken;
62use ustr::Ustr;
63
64use super::{models::*, query::*};
65use crate::{
66    common::{
67        consts::{
68            KRAKEN_OFLAG_POST_ONLY, KRAKEN_OFLAG_QUOTE_QUANTITY, KRAKEN_VENUE,
69            NAUTILUS_KRAKEN_BROKER_ID,
70        },
71        credential::KrakenCredential,
72        enums::{
73            KrakenAssetClass, KrakenEnvironment, KrakenOrderSide, KrakenOrderType,
74            KrakenProductType,
75        },
76        parse::{
77            bar_type_to_spot_interval, normalize_currency_code, normalize_spot_symbol, parse_bar,
78            parse_fill_report, parse_order_status_report, parse_spot_instrument_with_fee_rates,
79            parse_tokenized_instrument_with_fee_rates, parse_trade_tick_from_array,
80            truncate_cl_ord_id,
81        },
82        urls::get_kraken_http_base_url,
83    },
84    http::{
85        apply_count_limit,
86        error::{
87            KrakenBatchOrderError, KrakenHttpError, KrakenModifyOrderError, KrakenSubmitOrderError,
88            kraken_http_should_retry,
89        },
90    },
91};
92
93/// Default Kraken Spot REST API rate limit (requests per second).
94pub const KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND: u32 = 5;
95
96const KRAKEN_GLOBAL_RATE_KEY: &str = "kraken:spot:global";
97
98/// Maximum orders per batch cancel request for Kraken Spot API.
99const BATCH_CANCEL_LIMIT: usize = 50;
100
101/// Maximum orders per batch submit request for Kraken Spot API.
102const BATCH_SUBMIT_LIMIT: usize = 15;
103
104/// Raw HTTP client for low-level Kraken Spot API operations.
105///
106/// This client handles request/response operations with the Kraken Spot API,
107/// returning venue-specific response types. It does not parse to Nautilus domain types.
108pub struct KrakenSpotRawHttpClient {
109    base_url: String,
110    client: HttpClient,
111    credential: Option<KrakenCredential>,
112    retry_manager: RetryManager<KrakenHttpError>,
113    cancellation_token: RwLock<CancellationToken>,
114    clock: &'static AtomicTime,
115    /// Mutex to serialize authenticated requests, ensuring nonces arrive at Kraken in order
116    auth_mutex: tokio::sync::Mutex<()>,
117}
118
119impl Default for KrakenSpotRawHttpClient {
120    fn default() -> Self {
121        Self::new(
122            KrakenEnvironment::Live,
123            None,
124            60,
125            None,
126            None,
127            None,
128            None,
129            KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
130        )
131        .expect("Failed to create default KrakenSpotRawHttpClient")
132    }
133}
134
135impl Debug for KrakenSpotRawHttpClient {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.debug_struct(stringify!(KrakenSpotRawHttpClient))
138            .field("base_url", &self.base_url)
139            .field("has_credentials", &self.credential.is_some())
140            .finish()
141    }
142}
143
144impl KrakenSpotRawHttpClient {
145    /// Creates a new [`KrakenSpotRawHttpClient`].
146    #[expect(clippy::too_many_arguments)]
147    pub fn new(
148        environment: KrakenEnvironment,
149        base_url_override: Option<String>,
150        timeout_secs: u64,
151        max_retries: Option<u32>,
152        retry_delay_ms: Option<u64>,
153        retry_delay_max_ms: Option<u64>,
154        proxy_url: Option<String>,
155        max_requests_per_second: u32,
156    ) -> anyhow::Result<Self> {
157        let retry_config = RetryConfig {
158            max_retries: max_retries.unwrap_or(3),
159            initial_delay_ms: retry_delay_ms.unwrap_or(1000),
160            max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
161            backoff_factor: 2.0,
162            jitter_ms: 1000,
163            operation_timeout_ms: Some(60_000),
164            immediate_first: false,
165            max_elapsed_ms: Some(180_000),
166        };
167
168        let retry_manager = RetryManager::new(retry_config);
169        let base_url = base_url_override.unwrap_or_else(|| {
170            get_kraken_http_base_url(KrakenProductType::Spot, environment).to_string()
171        });
172
173        Ok(Self {
174            base_url,
175            client: HttpClient::builder()
176                .headers(Self::default_headers())
177                .keyed_quotas(Self::rate_limiter_quotas(max_requests_per_second)?)
178                .default_quota(Self::default_quota(max_requests_per_second)?)
179                .timeout_secs(timeout_secs)
180                .maybe_proxy_url(proxy_url)
181                .build()
182                .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
183            credential: None,
184            retry_manager,
185            cancellation_token: RwLock::new(CancellationToken::new()),
186            clock: get_atomic_clock_realtime(),
187            auth_mutex: tokio::sync::Mutex::new(()),
188        })
189    }
190
191    /// Creates a new [`KrakenSpotRawHttpClient`] with credentials.
192    #[expect(clippy::too_many_arguments)]
193    pub fn with_credentials(
194        api_key: String,
195        api_secret: String,
196        environment: KrakenEnvironment,
197        base_url_override: Option<String>,
198        timeout_secs: u64,
199        max_retries: Option<u32>,
200        retry_delay_ms: Option<u64>,
201        retry_delay_max_ms: Option<u64>,
202        proxy_url: Option<String>,
203        max_requests_per_second: u32,
204    ) -> anyhow::Result<Self> {
205        let retry_config = RetryConfig {
206            max_retries: max_retries.unwrap_or(3),
207            initial_delay_ms: retry_delay_ms.unwrap_or(1000),
208            max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
209            backoff_factor: 2.0,
210            jitter_ms: 1000,
211            operation_timeout_ms: Some(60_000),
212            immediate_first: false,
213            max_elapsed_ms: Some(180_000),
214        };
215
216        let retry_manager = RetryManager::new(retry_config);
217        let base_url = base_url_override.unwrap_or_else(|| {
218            get_kraken_http_base_url(KrakenProductType::Spot, environment).to_string()
219        });
220
221        Ok(Self {
222            base_url,
223            client: HttpClient::builder()
224                .redirect_policy(HttpRedirectPolicy::Reject)
225                .headers(Self::default_headers())
226                .keyed_quotas(Self::rate_limiter_quotas(max_requests_per_second)?)
227                .default_quota(Self::default_quota(max_requests_per_second)?)
228                .timeout_secs(timeout_secs)
229                .maybe_proxy_url(proxy_url)
230                .build()
231                .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
232            credential: Some(KrakenCredential::new(api_key, api_secret)),
233            retry_manager,
234            cancellation_token: RwLock::new(CancellationToken::new()),
235            clock: get_atomic_clock_realtime(),
236            auth_mutex: tokio::sync::Mutex::new(()),
237        })
238    }
239
240    /// Generates a unique nonce for Kraken Spot API requests.
241    ///
242    /// Uses `AtomicTime` for strict monotonicity. The nanosecond timestamp
243    /// guarantees uniqueness even for rapid consecutive calls.
244    fn generate_nonce(&self) -> u64 {
245        self.clock.get_time_ns().as_u64()
246    }
247
248    /// Returns the base URL for this client.
249    pub fn base_url(&self) -> &str {
250        &self.base_url
251    }
252
253    /// Returns the credential for this client, if set.
254    pub fn credential(&self) -> Option<&KrakenCredential> {
255        self.credential.as_ref()
256    }
257
258    /// Cancels all pending HTTP requests.
259    pub fn cancel_all_requests(&self) {
260        self.cancellation_token.read().cancel();
261    }
262
263    /// Replaces the canceled token so requests can proceed after reconnect.
264    pub fn reset_cancellation_token(&self) {
265        *self.cancellation_token.write() = CancellationToken::new();
266    }
267
268    /// Returns a clone of the current cancellation token.
269    pub fn cancellation_token(&self) -> CancellationToken {
270        self.cancellation_token.read().clone()
271    }
272
273    fn default_headers() -> HashMap<String, String> {
274        create_standard_nautilus_headers().into_iter().collect()
275    }
276
277    fn default_quota(max_requests_per_second: u32) -> anyhow::Result<Quota> {
278        let burst = NonZeroU32::new(max_requests_per_second).unwrap_or(
279            NonZeroU32::new(KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND).expect("non-zero"),
280        );
281        Quota::per_second(burst).ok_or_else(|| {
282            anyhow::anyhow!(
283                "Invalid max_requests_per_second: {max_requests_per_second} exceeds maximum"
284            )
285        })
286    }
287
288    fn rate_limiter_quotas(max_requests_per_second: u32) -> anyhow::Result<Vec<(String, Quota)>> {
289        Ok(vec![(
290            KRAKEN_GLOBAL_RATE_KEY.to_string(),
291            Self::default_quota(max_requests_per_second)?,
292        )])
293    }
294
295    fn rate_limit_keys(endpoint: &str) -> Vec<String> {
296        let normalized = endpoint.split('?').next().unwrap_or(endpoint);
297        let route = format!("kraken:spot:{normalized}");
298        vec![KRAKEN_GLOBAL_RATE_KEY.to_string(), route]
299    }
300
301    fn sign_spot(
302        &self,
303        path: &str,
304        nonce: u64,
305        params: &HashMap<String, String>,
306    ) -> anyhow::Result<(HashMap<String, String>, String)> {
307        let credential = self
308            .credential
309            .as_ref()
310            .ok_or_else(|| anyhow::anyhow!("Missing credentials"))?;
311
312        let (signature, post_data) = credential.sign_spot(path, nonce, params)?;
313
314        let mut headers = HashMap::new();
315        headers.insert("API-Key".to_string(), credential.api_key().to_string());
316        headers.insert("API-Sign".to_string(), signature);
317
318        Ok((headers, post_data))
319    }
320
321    async fn send_request<T: DeserializeOwned>(
322        &self,
323        method: Method,
324        endpoint: &str,
325        body: Option<Vec<u8>>,
326        authenticate: bool,
327    ) -> anyhow::Result<KrakenResponse<T>, KrakenHttpError> {
328        self.send_request_with_body(method, endpoint, RequestBody::Form(body), authenticate)
329            .await
330    }
331
332    async fn send_json_request<T: DeserializeOwned, P: Serialize>(
333        &self,
334        method: Method,
335        endpoint: &str,
336        params: &P,
337        authenticate: bool,
338    ) -> anyhow::Result<KrakenResponse<T>, KrakenHttpError> {
339        let body = serde_json::to_value(params).map_err(|e| {
340            KrakenHttpError::RequestNotStarted(format!("Failed to encode request params: {e}"))
341        })?;
342        self.send_request_with_body(method, endpoint, RequestBody::Json(body), authenticate)
343            .await
344    }
345
346    async fn send_request_with_body<T: DeserializeOwned>(
347        &self,
348        method: Method,
349        endpoint: &str,
350        body: RequestBody,
351        authenticate: bool,
352    ) -> anyhow::Result<KrakenResponse<T>, KrakenHttpError> {
353        // Serialize authenticated requests to ensure nonces arrive at Kraken in order.
354        // Without this, concurrent requests can race through the network and arrive
355        // out-of-order, causing "Invalid nonce" errors.
356        let _guard = if authenticate {
357            Some(self.auth_mutex.lock().await)
358        } else {
359            None
360        };
361
362        let endpoint = endpoint.to_string();
363        let method_clone = method.clone();
364        let body_clone = body.clone();
365
366        let operation = || {
367            let method = method_clone.clone();
368            let body = body_clone.clone();
369            let endpoint = endpoint.clone();
370
371            async move {
372                self.send_request_attempt(method, &endpoint, body, authenticate, None)
373                    .await
374            }
375        };
376
377        let should_retry = kraken_http_should_retry;
378        let create_error = |error: RetryError| KrakenHttpError::NetworkError(error.to_string());
379
380        let cancellation_token = self.cancellation_token();
381
382        self.retry_manager
383            .invocation(&endpoint, operation, should_retry, create_error)
384            .cancellation_token(&cancellation_token)
385            .execute()
386            .await
387    }
388
389    async fn send_request_once<T: DeserializeOwned>(
390        &self,
391        method: Method,
392        endpoint: &str,
393        body: Option<Vec<u8>>,
394        authenticate: bool,
395    ) -> anyhow::Result<KrakenResponse<T>, KrakenHttpError> {
396        let cancellation_token = self.cancellation_token();
397        let _guard = if authenticate {
398            Some(self.lock_order_request(&cancellation_token).await?)
399        } else {
400            None
401        };
402
403        self.send_request_attempt(
404            method,
405            endpoint,
406            RequestBody::Form(body),
407            authenticate,
408            Some(&cancellation_token),
409        )
410        .await
411    }
412
413    async fn lock_order_request(
414        &self,
415        cancellation_token: &CancellationToken,
416    ) -> anyhow::Result<tokio::sync::MutexGuard<'_, ()>, KrakenHttpError> {
417        if cancellation_token.is_cancelled() {
418            return Err(KrakenHttpError::RequestNotStarted(
419                "Request canceled before transport invocation".to_string(),
420            ));
421        }
422
423        tokio::select! {
424            biased;
425            () = cancellation_token.cancelled() => Err(KrakenHttpError::RequestNotStarted(
426                "Request canceled before transport invocation".to_string(),
427            )),
428            guard = self.auth_mutex.lock() => Ok(guard),
429        }
430    }
431
432    async fn send_request_attempt<T: DeserializeOwned>(
433        &self,
434        method: Method,
435        endpoint: &str,
436        body: RequestBody,
437        authenticate: bool,
438        cancellation_token: Option<&CancellationToken>,
439    ) -> anyhow::Result<KrakenResponse<T>, KrakenHttpError> {
440        let mut headers = Self::default_headers();
441        let (final_body, content_type) = match (authenticate, body) {
442            (true, RequestBody::Form(body)) => {
443                let nonce = self.generate_nonce();
444                log::debug!("Generated nonce {nonce} for {endpoint}");
445
446                let params: HashMap<String, String> = if let Some(ref body_bytes) = body {
447                    let body_str = std::str::from_utf8(body_bytes).map_err(|e| {
448                        KrakenHttpError::RequestNotStarted(format!(
449                            "Invalid UTF-8 in request body: {e}"
450                        ))
451                    })?;
452                    serde_urlencoded::from_str(body_str).map_err(|e| {
453                        KrakenHttpError::RequestNotStarted(format!(
454                            "Failed to parse request params: {e}"
455                        ))
456                    })?
457                } else {
458                    HashMap::new()
459                };
460
461                let (auth_headers, post_data) =
462                    self.sign_spot(endpoint, nonce, &params).map_err(|e| {
463                        KrakenHttpError::RequestNotStarted(format!("Failed to sign request: {e}"))
464                    })?;
465                headers.extend(auth_headers);
466                (
467                    Some(post_data.into_bytes()),
468                    "application/x-www-form-urlencoded",
469                )
470            }
471            (true, RequestBody::Json(mut body)) => {
472                let nonce = self.generate_nonce();
473                log::debug!("Generated nonce {nonce} for {endpoint}");
474
475                let body = body.as_object_mut().ok_or_else(|| {
476                    KrakenHttpError::RequestNotStarted(
477                        "Authenticated JSON request body must be an object".to_string(),
478                    )
479                })?;
480                body.insert("nonce".to_string(), serde_json::json!(nonce.to_string()));
481                let body = serde_json::to_string(body).map_err(|e| {
482                    KrakenHttpError::RequestNotStarted(format!(
483                        "Failed to serialize request body: {e}"
484                    ))
485                })?;
486                let credential = self.credential.as_ref().ok_or_else(|| {
487                    KrakenHttpError::RequestNotStarted("Missing credentials".to_string())
488                })?;
489                let signature = credential
490                    .sign_spot_json(endpoint, nonce, &body)
491                    .map_err(|e| {
492                        KrakenHttpError::RequestNotStarted(format!("Failed to sign request: {e}"))
493                    })?;
494                headers.insert("API-Key".to_string(), credential.api_key().to_string());
495                headers.insert("API-Sign".to_string(), signature);
496                (Some(body.into_bytes()), "application/json")
497            }
498            (false, RequestBody::Form(body)) => (body, "application/x-www-form-urlencoded"),
499            (false, RequestBody::Json(body)) => {
500                let body = serde_json::to_vec(&body).map_err(|e| {
501                    KrakenHttpError::RequestNotStarted(format!(
502                        "Failed to serialize request body: {e}"
503                    ))
504                })?;
505                (Some(body), "application/json")
506            }
507        };
508
509        if method == Method::POST {
510            headers.insert("Content-Type".to_string(), content_type.to_string());
511        }
512
513        let response = if let Some(cancellation_token) = cancellation_token {
514            self.send_order_request(method, endpoint, headers, final_body, cancellation_token)
515                .await?
516        } else {
517            let url = format!("{}{endpoint}", self.base_url);
518            let rate_limit_keys = Self::rate_limit_keys(endpoint);
519            self.client
520                .request(
521                    method,
522                    url,
523                    None,
524                    Some(headers),
525                    final_body,
526                    None,
527                    Some(rate_limit_keys),
528                )
529                .await
530                .map_err(|e| KrakenHttpError::NetworkError(e.to_string()))?
531        };
532
533        let status = response.status.as_u16();
534        if status >= 400 {
535            let body = String::from_utf8_lossy(&response.body).to_string();
536            let http_error = if status == 401 || status == 403 {
537                KrakenHttpError::AuthenticationError(format!("HTTP error {status}: {body}"))
538            } else {
539                KrakenHttpError::NetworkError(format!("HTTP error {status}: {body}"))
540            };
541            return Err(http_error);
542        }
543
544        let response_text = String::from_utf8(response.body.to_vec()).map_err(|e| {
545            KrakenHttpError::ParseError(format!("Failed to parse response as UTF-8: {e}"))
546        })?;
547        let kraken_response: KrakenResponse<T> =
548            serde_json::from_str(&response_text).map_err(|e| {
549                KrakenHttpError::ParseError(format!("Failed to deserialize response: {e}"))
550            })?;
551
552        if !kraken_response.error.is_empty() {
553            return Err(KrakenHttpError::ApiError(kraken_response.error));
554        }
555
556        Ok(kraken_response)
557    }
558
559    async fn send_order_request(
560        &self,
561        method: Method,
562        endpoint: &str,
563        headers: HashMap<String, String>,
564        body: Option<Vec<u8>>,
565        cancellation_token: &CancellationToken,
566    ) -> anyhow::Result<HttpResponse, KrakenHttpError> {
567        if cancellation_token.is_cancelled() {
568            return Err(KrakenHttpError::RequestNotStarted(
569                "Request canceled before transport invocation".to_string(),
570            ));
571        }
572
573        let request_started = AtomicBool::new(false);
574        let url = format!("{}{endpoint}", self.base_url);
575        let rate_limit_keys = Self::rate_limit_keys(endpoint);
576        let request = async {
577            request_started.store(true, Ordering::Relaxed);
578            self.client
579                .request(
580                    method,
581                    url,
582                    None,
583                    Some(headers),
584                    body,
585                    None,
586                    Some(rate_limit_keys),
587                )
588                .await
589        };
590        tokio::pin!(request);
591
592        tokio::select! {
593            biased;
594            () = cancellation_token.cancelled() => {
595                if request_started.load(Ordering::Relaxed) {
596                    Err(KrakenHttpError::NetworkError(
597                        "Request canceled after transport invocation".to_string(),
598                    ))
599                } else {
600                    Err(KrakenHttpError::RequestNotStarted(
601                        "Request canceled before transport invocation".to_string(),
602                    ))
603                }
604            }
605            response = &mut request => response
606                .map_err(|e| KrakenHttpError::NetworkError(e.to_string())),
607        }
608    }
609
610    /// Requests the server time from Kraken.
611    pub async fn get_server_time(&self) -> anyhow::Result<ServerTime, KrakenHttpError> {
612        let response: KrakenResponse<ServerTime> = self
613            .send_request(Method::GET, "/0/public/Time", None, false)
614            .await?;
615
616        response.result.ok_or_else(|| {
617            KrakenHttpError::ParseError("Missing result in server time response".to_string())
618        })
619    }
620
621    /// Requests the system status from Kraken.
622    pub async fn get_system_status(&self) -> anyhow::Result<SystemStatus, KrakenHttpError> {
623        let response: KrakenResponse<SystemStatus> = self
624            .send_request(Method::GET, "/0/public/SystemStatus", None, false)
625            .await?;
626
627        response.result.ok_or_else(|| {
628            KrakenHttpError::ParseError("Missing result in system status response".to_string())
629        })
630    }
631
632    /// Requests tradable asset pairs from Kraken.
633    ///
634    /// When `aclass_base` is `None`, the Kraken API defaults to `"currency"` (crypto pairs).
635    /// Pass `"tokenized_asset"` to fetch tokenized equities (xStocks).
636    pub async fn get_asset_pairs(
637        &self,
638        pairs: Option<Vec<String>>,
639        aclass_base: Option<&str>,
640    ) -> anyhow::Result<AssetPairsResponse, KrakenHttpError> {
641        let mut params = Vec::new();
642
643        if let Some(pairs) = pairs {
644            params.push(format!("pair={}", pairs.join(",")));
645        }
646
647        if let Some(aclass) = aclass_base {
648            params.push(format!("aclass_base={aclass}"));
649        }
650
651        let endpoint = if params.is_empty() {
652            "/0/public/AssetPairs".to_string()
653        } else {
654            format!("/0/public/AssetPairs?{}", params.join("&"))
655        };
656
657        let response: KrakenResponse<AssetPairsResponse> = self
658            .send_request(Method::GET, &endpoint, None, false)
659            .await?;
660
661        response.result.ok_or_else(|| {
662            KrakenHttpError::ParseError("Missing result in asset pairs response".to_string())
663        })
664    }
665
666    /// Requests ticker information for asset pairs.
667    pub async fn get_ticker(
668        &self,
669        pairs: Vec<String>,
670        asset_class: Option<KrakenAssetClass>,
671    ) -> anyhow::Result<TickerResponse, KrakenHttpError> {
672        let mut endpoint = format!("/0/public/Ticker?pair={}", pairs.join(","));
673
674        if let Some(aclass) = asset_class {
675            endpoint.push_str(&format!("&asset_class={aclass}"));
676        }
677
678        let response: KrakenResponse<TickerResponse> = self
679            .send_request(Method::GET, &endpoint, None, false)
680            .await?;
681
682        response.result.ok_or_else(|| {
683            KrakenHttpError::ParseError("Missing result in ticker response".to_string())
684        })
685    }
686
687    /// Requests OHLC candlestick data for an asset pair.
688    pub async fn get_ohlc(
689        &self,
690        pair: &str,
691        interval: Option<u32>,
692        since: Option<i64>,
693        asset_class: Option<KrakenAssetClass>,
694    ) -> anyhow::Result<OhlcResponse, KrakenHttpError> {
695        let mut endpoint = format!("/0/public/OHLC?pair={pair}");
696
697        if let Some(aclass) = asset_class {
698            endpoint.push_str(&format!("&asset_class={aclass}"));
699        }
700
701        if let Some(interval) = interval {
702            endpoint.push_str(&format!("&interval={interval}"));
703        }
704
705        if let Some(since) = since {
706            endpoint.push_str(&format!("&since={since}"));
707        }
708
709        let response: KrakenResponse<OhlcResponse> = self
710            .send_request(Method::GET, &endpoint, None, false)
711            .await?;
712
713        response.result.ok_or_else(|| {
714            KrakenHttpError::ParseError("Missing result in OHLC response".to_string())
715        })
716    }
717
718    /// Requests order book depth for an asset pair.
719    pub async fn get_book_depth(
720        &self,
721        pair: &str,
722        count: Option<u32>,
723        asset_class: Option<KrakenAssetClass>,
724    ) -> anyhow::Result<OrderBookResponse, KrakenHttpError> {
725        let mut endpoint = format!("/0/public/Depth?pair={pair}");
726
727        if let Some(aclass) = asset_class {
728            endpoint.push_str(&format!("&asset_class={aclass}"));
729        }
730
731        if let Some(count) = count {
732            endpoint.push_str(&format!("&count={count}"));
733        }
734
735        let response: KrakenResponse<OrderBookResponse> = self
736            .send_request(Method::GET, &endpoint, None, false)
737            .await?;
738
739        response.result.ok_or_else(|| {
740            KrakenHttpError::ParseError("Missing result in book depth response".to_string())
741        })
742    }
743
744    /// Requests recent trades for an asset pair.
745    pub async fn get_trades(
746        &self,
747        pair: &str,
748        since: Option<String>,
749        asset_class: Option<KrakenAssetClass>,
750    ) -> anyhow::Result<TradesResponse, KrakenHttpError> {
751        let mut endpoint = format!("/0/public/Trades?pair={pair}");
752
753        if let Some(aclass) = asset_class {
754            endpoint.push_str(&format!("&asset_class={aclass}"));
755        }
756
757        if let Some(since) = since {
758            endpoint.push_str(&format!("&since={since}"));
759        }
760
761        let response: KrakenResponse<TradesResponse> = self
762            .send_request(Method::GET, &endpoint, None, false)
763            .await?;
764
765        response.result.ok_or_else(|| {
766            KrakenHttpError::ParseError("Missing result in trades response".to_string())
767        })
768    }
769
770    /// Requests an authentication token for WebSocket connections.
771    pub async fn get_websockets_token(&self) -> anyhow::Result<WebSocketToken, KrakenHttpError> {
772        if self.credential.is_none() {
773            return Err(KrakenHttpError::AuthenticationError(
774                "API credentials required for GetWebSocketsToken".to_string(),
775            ));
776        }
777
778        let response: KrakenResponse<WebSocketToken> = self
779            .send_request(Method::POST, "/0/private/GetWebSocketsToken", None, true)
780            .await?;
781
782        response.result.ok_or_else(|| {
783            KrakenHttpError::ParseError("Missing result in websockets token response".to_string())
784        })
785    }
786
787    /// Requests all open orders (requires authentication).
788    pub async fn get_open_orders(
789        &self,
790        trades: Option<bool>,
791        userref: Option<i64>,
792    ) -> anyhow::Result<IndexMap<String, SpotOrder>, KrakenHttpError> {
793        if self.credential.is_none() {
794            return Err(KrakenHttpError::AuthenticationError(
795                "API credentials required for OpenOrders".to_string(),
796            ));
797        }
798
799        let mut params = vec![];
800
801        if let Some(trades_flag) = trades {
802            params.push(format!("trades={trades_flag}"));
803        }
804
805        if let Some(userref_val) = userref {
806            params.push(format!("userref={userref_val}"));
807        }
808
809        let body = if params.is_empty() {
810            None
811        } else {
812            Some(params.join("&").into_bytes())
813        };
814
815        let response: KrakenResponse<SpotOpenOrdersResult> = self
816            .send_request(Method::POST, "/0/private/OpenOrders", body, true)
817            .await?;
818
819        let result = response.result.ok_or_else(|| {
820            KrakenHttpError::ParseError("Missing result in open orders response".to_string())
821        })?;
822
823        Ok(result.open)
824    }
825
826    /// Requests closed orders history (requires authentication).
827    pub async fn get_closed_orders(
828        &self,
829        trades: Option<bool>,
830        userref: Option<i64>,
831        start: Option<i64>,
832        end: Option<i64>,
833        ofs: Option<i32>,
834        closetime: Option<String>,
835    ) -> anyhow::Result<IndexMap<String, SpotOrder>, KrakenHttpError> {
836        if self.credential.is_none() {
837            return Err(KrakenHttpError::AuthenticationError(
838                "API credentials required for ClosedOrders".to_string(),
839            ));
840        }
841
842        let mut params = vec![];
843
844        if let Some(trades_flag) = trades {
845            params.push(format!("trades={trades_flag}"));
846        }
847
848        if let Some(userref_val) = userref {
849            params.push(format!("userref={userref_val}"));
850        }
851
852        if let Some(start_val) = start {
853            params.push(format!("start={start_val}"));
854        }
855
856        if let Some(end_val) = end {
857            params.push(format!("end={end_val}"));
858        }
859
860        if let Some(ofs_val) = ofs {
861            params.push(format!("ofs={ofs_val}"));
862        }
863
864        if let Some(closetime_val) = closetime {
865            params.push(format!("closetime={closetime_val}"));
866        }
867
868        let body = if params.is_empty() {
869            None
870        } else {
871            Some(params.join("&").into_bytes())
872        };
873
874        let response: KrakenResponse<SpotClosedOrdersResult> = self
875            .send_request(Method::POST, "/0/private/ClosedOrders", body, true)
876            .await?;
877
878        let result = response.result.ok_or_else(|| {
879            KrakenHttpError::ParseError("Missing result in closed orders response".to_string())
880        })?;
881
882        Ok(result.closed)
883    }
884
885    /// Requests trades history (requires authentication).
886    pub async fn get_trades_history(
887        &self,
888        trade_type: Option<String>,
889        trades: Option<bool>,
890        start: Option<i64>,
891        end: Option<i64>,
892        ofs: Option<i32>,
893    ) -> anyhow::Result<IndexMap<String, SpotTrade>, KrakenHttpError> {
894        if self.credential.is_none() {
895            return Err(KrakenHttpError::AuthenticationError(
896                "API credentials required for TradesHistory".to_string(),
897            ));
898        }
899
900        let mut params = vec![];
901
902        if let Some(type_val) = trade_type {
903            params.push(format!("type={type_val}"));
904        }
905
906        if let Some(trades_flag) = trades {
907            params.push(format!("trades={trades_flag}"));
908        }
909
910        if let Some(start_val) = start {
911            params.push(format!("start={start_val}"));
912        }
913
914        if let Some(end_val) = end {
915            params.push(format!("end={end_val}"));
916        }
917
918        if let Some(ofs_val) = ofs {
919            params.push(format!("ofs={ofs_val}"));
920        }
921
922        let body = if params.is_empty() {
923            None
924        } else {
925            Some(params.join("&").into_bytes())
926        };
927
928        let response: KrakenResponse<SpotTradesHistoryResult> = self
929            .send_request(Method::POST, "/0/private/TradesHistory", body, true)
930            .await?;
931
932        let result = response.result.ok_or_else(|| {
933            KrakenHttpError::ParseError("Missing result in trades history response".to_string())
934        })?;
935
936        Ok(result.trades)
937    }
938
939    /// Submits a new order (requires authentication).
940    pub async fn add_order(
941        &self,
942        params: &KrakenSpotAddOrderParams,
943    ) -> anyhow::Result<SpotAddOrderResponse, KrakenHttpError> {
944        if self.credential.is_none() {
945            return Err(KrakenHttpError::MissingCredentials);
946        }
947
948        let param_string = serde_urlencoded::to_string(params).map_err(|e| {
949            KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
950        })?;
951        let body = Some(param_string.into_bytes());
952
953        let response: KrakenResponse<SpotAddOrderResponse> = self
954            .send_request_once(Method::POST, "/0/private/AddOrder", body, true)
955            .await?;
956
957        response
958            .result
959            .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
960    }
961
962    /// Submits multiple orders in a single batch request (requires authentication).
963    pub async fn add_order_batch(
964        &self,
965        params: &KrakenSpotAddOrderBatchParams,
966    ) -> anyhow::Result<SpotAddOrderBatchResponse, KrakenHttpError> {
967        let credential = self
968            .credential
969            .as_ref()
970            .ok_or(KrakenHttpError::MissingCredentials)?;
971
972        let cancellation_token = self.cancellation_token();
973        let _guard = self.lock_order_request(&cancellation_token).await?;
974
975        let endpoint = "/0/private/AddOrderBatch";
976        let nonce = self.generate_nonce();
977
978        let mut json_body = serde_json::json!({
979            "nonce": nonce.to_string(),
980            "pair": params.pair,
981            "orders": params.orders,
982        });
983
984        if let Some(aclass) = &params.asset_class {
985            json_body["asset_class"] = serde_json::json!(aclass);
986        }
987        let json_str = serde_json::to_string(&json_body)
988            .map_err(|e| KrakenHttpError::RequestNotStarted(format!("Failed to serialize: {e}")))?;
989
990        let signature = credential
991            .sign_spot_json(endpoint, nonce, &json_str)
992            .map_err(|e| KrakenHttpError::RequestNotStarted(format!("Failed to sign: {e}")))?;
993
994        let mut headers = Self::default_headers();
995        headers.insert("API-Key".to_string(), credential.api_key().to_string());
996        headers.insert("API-Sign".to_string(), signature);
997        headers.insert("Content-Type".to_string(), "application/json".to_string());
998
999        let response = self
1000            .send_order_request(
1001                Method::POST,
1002                endpoint,
1003                headers,
1004                Some(json_str.into_bytes()),
1005                &cancellation_token,
1006            )
1007            .await?;
1008
1009        if !response.status.is_success() {
1010            return Err(KrakenHttpError::NetworkError(format!(
1011                "HTTP {:?} for {}",
1012                response.status, endpoint
1013            )));
1014        }
1015
1016        let parsed: KrakenResponse<SpotAddOrderBatchResponse> =
1017            serde_json::from_slice(&response.body).map_err(|e| {
1018                KrakenHttpError::ParseError(format!("Failed to parse JSON response: {e}"))
1019            })?;
1020
1021        if !parsed.error.is_empty() {
1022            return Err(KrakenHttpError::ApiError(parsed.error));
1023        }
1024
1025        parsed
1026            .result
1027            .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1028    }
1029
1030    /// Cancels an open order (requires authentication).
1031    pub async fn cancel_order(
1032        &self,
1033        params: &KrakenSpotCancelOrderParams,
1034    ) -> anyhow::Result<SpotCancelOrderResponse, KrakenHttpError> {
1035        if self.credential.is_none() {
1036            return Err(KrakenHttpError::AuthenticationError(
1037                "API credentials required for canceling orders".to_string(),
1038            ));
1039        }
1040
1041        let param_string = serde_urlencoded::to_string(params)
1042            .map_err(|e| KrakenHttpError::ParseError(format!("Failed to encode params: {e}")))?;
1043
1044        let body = Some(param_string.into_bytes());
1045
1046        let response: KrakenResponse<SpotCancelOrderResponse> = self
1047            .send_request(Method::POST, "/0/private/CancelOrder", body, true)
1048            .await?;
1049
1050        response
1051            .result
1052            .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1053    }
1054
1055    /// Cancels multiple orders in a single batch request (max 50 orders).
1056    pub async fn cancel_order_batch(
1057        &self,
1058        params: &KrakenSpotCancelOrderBatchParams,
1059    ) -> anyhow::Result<SpotCancelOrderBatchResponse, KrakenHttpError> {
1060        let credential = self.credential.as_ref().ok_or_else(|| {
1061            KrakenHttpError::AuthenticationError(
1062                "API credentials required for canceling orders".to_string(),
1063            )
1064        })?;
1065
1066        // Serialize authenticated requests to ensure nonces arrive at Kraken in order
1067        let _guard = self.auth_mutex.lock().await;
1068
1069        let endpoint = "/0/private/CancelOrderBatch";
1070        let nonce = self.generate_nonce();
1071
1072        // CancelOrderBatch uses JSON body with nonce included
1073        let json_body = serde_json::json!({
1074            "nonce": nonce.to_string(),
1075            "orders": params.orders
1076        });
1077        let json_str = serde_json::to_string(&json_body)
1078            .map_err(|e| KrakenHttpError::ParseError(format!("Failed to serialize: {e}")))?;
1079
1080        let signature = credential
1081            .sign_spot_json(endpoint, nonce, &json_str)
1082            .map_err(|e| KrakenHttpError::AuthenticationError(format!("Failed to sign: {e}")))?;
1083
1084        let mut headers = Self::default_headers();
1085        headers.insert("API-Key".to_string(), credential.api_key().to_string());
1086        headers.insert("API-Sign".to_string(), signature);
1087        headers.insert("Content-Type".to_string(), "application/json".to_string());
1088
1089        let url = format!("{}{endpoint}", self.base_url);
1090        let rate_limit_keys = Self::rate_limit_keys(endpoint);
1091
1092        let response = self
1093            .client
1094            .request(
1095                Method::POST,
1096                url,
1097                None,
1098                Some(headers),
1099                Some(json_str.into_bytes()),
1100                None,
1101                Some(rate_limit_keys),
1102            )
1103            .await
1104            .map_err(|e| KrakenHttpError::NetworkError(e.to_string()))?;
1105
1106        if response.status.as_u16() >= 400 {
1107            let status = response.status.as_u16();
1108            let body = String::from_utf8_lossy(&response.body).to_string();
1109
1110            if status == 401 || status == 403 {
1111                return Err(KrakenHttpError::AuthenticationError(format!(
1112                    "HTTP error {status}: {body}"
1113                )));
1114            }
1115            return Err(KrakenHttpError::NetworkError(format!(
1116                "HTTP error {status}: {body}"
1117            )));
1118        }
1119
1120        let response_text = String::from_utf8(response.body.to_vec())
1121            .map_err(|e| KrakenHttpError::ParseError(format!("Invalid UTF-8: {e}")))?;
1122
1123        let kraken_response: KrakenResponse<SpotCancelOrderBatchResponse> =
1124            serde_json::from_str(&response_text).map_err(|e| {
1125                KrakenHttpError::ParseError(format!("Failed to parse response: {e}"))
1126            })?;
1127
1128        if !kraken_response.error.is_empty() {
1129            return Err(KrakenHttpError::ApiError(kraken_response.error));
1130        }
1131
1132        kraken_response
1133            .result
1134            .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1135    }
1136
1137    /// Cancels all open orders (requires authentication).
1138    pub async fn cancel_all_orders(
1139        &self,
1140    ) -> anyhow::Result<SpotCancelOrderResponse, KrakenHttpError> {
1141        if self.credential.is_none() {
1142            return Err(KrakenHttpError::AuthenticationError(
1143                "API credentials required for canceling orders".to_string(),
1144            ));
1145        }
1146
1147        let response: KrakenResponse<SpotCancelOrderResponse> = self
1148            .send_request(Method::POST, "/0/private/CancelAll", None, true)
1149            .await?;
1150
1151        response
1152            .result
1153            .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1154    }
1155
1156    /// Edits an existing order (cancel and replace).
1157    pub async fn edit_order(
1158        &self,
1159        params: &KrakenSpotEditOrderParams,
1160    ) -> anyhow::Result<SpotEditOrderResponse, KrakenHttpError> {
1161        if self.credential.is_none() {
1162            return Err(KrakenHttpError::AuthenticationError(
1163                "API credentials required for editing orders".to_string(),
1164            ));
1165        }
1166
1167        let param_string = serde_urlencoded::to_string(params)
1168            .map_err(|e| KrakenHttpError::ParseError(format!("Failed to encode params: {e}")))?;
1169
1170        let body = Some(param_string.into_bytes());
1171
1172        let response: KrakenResponse<SpotEditOrderResponse> = self
1173            .send_request(Method::POST, "/0/private/EditOrder", body, true)
1174            .await?;
1175
1176        response
1177            .result
1178            .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1179    }
1180
1181    /// Amends an existing order in-place (no cancel/replace).
1182    pub async fn amend_order(
1183        &self,
1184        params: &KrakenSpotAmendOrderParams,
1185    ) -> anyhow::Result<SpotAmendOrderResponse, KrakenHttpError> {
1186        if self.credential.is_none() {
1187            return Err(KrakenHttpError::MissingCredentials);
1188        }
1189
1190        let param_string = serde_urlencoded::to_string(params).map_err(|e| {
1191            KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
1192        })?;
1193
1194        let body = Some(param_string.into_bytes());
1195
1196        let response: KrakenResponse<SpotAmendOrderResponse> = self
1197            .send_request_once(Method::POST, "/0/private/AmendOrder", body, true)
1198            .await?;
1199
1200        response
1201            .result
1202            .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1203    }
1204
1205    /// Requests account balances (requires authentication).
1206    pub async fn get_balance(&self) -> anyhow::Result<BalanceResponse, KrakenHttpError> {
1207        if self.credential.is_none() {
1208            return Err(KrakenHttpError::AuthenticationError(
1209                "API credentials required for Balance".to_string(),
1210            ));
1211        }
1212
1213        let response: KrakenResponse<BalanceResponse> = self
1214            .send_request(Method::POST, "/0/private/Balance", None, true)
1215            .await?;
1216
1217        response.result.ok_or_else(|| {
1218            KrakenHttpError::ParseError("Missing result in balance response".to_string())
1219        })
1220    }
1221
1222    /// Requests account balances including held amounts (requires authentication).
1223    ///
1224    /// Unlike [`Self::get_balance`], which reports only total wallet amounts, this additionally
1225    /// reports `hold_trade`: the portion of each balance Kraken has reserved against resting
1226    /// orders. Required to populate `locked` on cash balances.
1227    pub async fn get_balance_ex(&self) -> anyhow::Result<BalanceExResponse, KrakenHttpError> {
1228        if self.credential.is_none() {
1229            return Err(KrakenHttpError::AuthenticationError(
1230                "API credentials required for BalanceEx".to_string(),
1231            ));
1232        }
1233
1234        let response: KrakenResponse<BalanceExResponse> = self
1235            .send_request(Method::POST, "/0/private/BalanceEx", None, true)
1236            .await?;
1237
1238        response.result.ok_or_else(|| {
1239            KrakenHttpError::ParseError("Missing result in extended balance response".to_string())
1240        })
1241    }
1242
1243    /// Requests margin account summary (requires authentication).
1244    ///
1245    /// Unlike `get_balance` which returns per-currency wallet amounts, this returns margin
1246    /// accounting metrics: used margin, free margin, equity, all denominated in `asset`
1247    /// (defaults to `"ZUSD"` when `None`). Only meaningful for spot margin accounts.
1248    pub async fn get_trade_balance(
1249        &self,
1250        asset: Option<&str>,
1251    ) -> anyhow::Result<TradeBalanceResponse, KrakenHttpError> {
1252        if self.credential.is_none() {
1253            return Err(KrakenHttpError::AuthenticationError(
1254                "API credentials required for TradeBalance".to_string(),
1255            ));
1256        }
1257
1258        let params = asset.map(|a| SpotTradeBalanceParams {
1259            asset: Some(a.to_string()),
1260        });
1261
1262        let body = params
1263            .as_ref()
1264            .and_then(|p| serde_urlencoded::to_string(p).ok())
1265            .map(|s| s.into_bytes());
1266
1267        let response: KrakenResponse<TradeBalanceResponse> = self
1268            .send_request(Method::POST, "/0/private/TradeBalance", body, true)
1269            .await?;
1270
1271        response.result.ok_or_else(|| {
1272            KrakenHttpError::ParseError("Missing result in TradeBalance response".to_string())
1273        })
1274    }
1275
1276    async fn get_trade_volume(
1277        &self,
1278        params: &SpotTradeVolumeParams,
1279    ) -> anyhow::Result<SpotTradeVolumeResponse, KrakenHttpError> {
1280        if self.credential.is_none() {
1281            return Err(KrakenHttpError::MissingCredentials);
1282        }
1283
1284        let response: KrakenResponse<SpotTradeVolumeResponse> = self
1285            .send_json_request(Method::POST, "/0/private/TradeVolume", params, true)
1286            .await?;
1287
1288        response.result.ok_or_else(|| {
1289            KrakenHttpError::ParseError("Missing result in TradeVolume response".to_string())
1290        })
1291    }
1292
1293    /// Requests open spot margin positions (requires authentication).
1294    pub async fn get_open_positions(
1295        &self,
1296        params: &SpotOpenPositionsParams,
1297    ) -> anyhow::Result<SpotOpenPositionsResponse, KrakenHttpError> {
1298        if self.credential.is_none() {
1299            return Err(KrakenHttpError::AuthenticationError(
1300                "API credentials required for OpenPositions".to_string(),
1301            ));
1302        }
1303
1304        let body = serde_urlencoded::to_string(params)
1305            .ok()
1306            .filter(|s| !s.is_empty())
1307            .map(|s| s.into_bytes());
1308
1309        let response: KrakenResponse<SpotOpenPositionsResponse> = self
1310            .send_request(Method::POST, "/0/private/OpenPositions", body, true)
1311            .await?;
1312
1313        response.result.ok_or_else(|| {
1314            KrakenHttpError::ParseError("Missing result in OpenPositions response".to_string())
1315        })
1316    }
1317}
1318
1319#[derive(Clone)]
1320enum RequestBody {
1321    Form(Option<Vec<u8>>),
1322    Json(serde_json::Value),
1323}
1324
1325#[derive(Clone, Serialize)]
1326struct SpotTradeVolumeParams {
1327    pair: SpotTradeVolumePairs,
1328}
1329
1330#[derive(Clone, Serialize)]
1331#[serde(untagged)]
1332enum SpotTradeVolumePairs {
1333    Names(String),
1334    Classified(Vec<SpotTradeVolumePair>),
1335}
1336
1337#[derive(Clone, Serialize)]
1338struct SpotTradeVolumePair {
1339    asset: String,
1340    aclass: SpotTradeVolumeAssetClass,
1341}
1342
1343#[derive(Clone, Copy, Serialize)]
1344#[serde(rename_all = "snake_case")]
1345enum SpotTradeVolumeAssetClass {
1346    EquityPair,
1347}
1348
1349pub(crate) type SpotBatchOrder = (
1350    InstrumentId,
1351    ClientOrderId,
1352    OrderSide,
1353    OrderType,
1354    Quantity,
1355    TimeInForce,
1356    Option<UnixNanos>,
1357    Option<Price>,
1358    Option<Price>,
1359    Option<TriggerType>,
1360    Option<Decimal>,
1361    Option<Decimal>,
1362    bool,
1363    bool,
1364    bool,
1365    Option<Quantity>,
1366    Option<u16>,
1367);
1368
1369/// High-level HTTP client for the Kraken Spot REST API.
1370///
1371/// This client wraps the raw client and provides Nautilus domain types.
1372/// It maintains an instrument cache and uses it to parse venue responses
1373/// into Nautilus domain objects.
1374#[cfg_attr(
1375    feature = "python",
1376    pyo3::pyclass(module = "nautilus_trader.adapters.kraken", from_py_object)
1377)]
1378#[cfg_attr(
1379    feature = "python",
1380    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.kraken")
1381)]
1382pub struct KrakenSpotHttpClient {
1383    pub(crate) inner: Arc<KrakenSpotRawHttpClient>,
1384    pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
1385    /// Maps a Kraken `altname` to the `AssetPairs` key used as the instrument `raw_symbol`.
1386    ///
1387    /// Kraken spells the same pair two ways: `OpenPositions` returns the key (`XXBTZUSD`) while
1388    /// `OpenOrders` and `TradesHistory` return the altname (`XBTUSD`). The altname is not derivable
1389    /// from a cached instrument, because `normalize_spot_symbol` rewrites Kraken's currency codes,
1390    /// so it is captured from `AssetPairs` while the definitions are in hand.
1391    pair_aliases: Arc<AtomicMap<Ustr, Ustr>>,
1392    leverage_tiers_cache: LeverageTiersCache,
1393    clock: &'static AtomicTime,
1394    cache_initialized: Arc<AtomicBool>,
1395}
1396
1397impl Clone for KrakenSpotHttpClient {
1398    fn clone(&self) -> Self {
1399        Self {
1400            inner: self.inner.clone(),
1401            instruments_cache: self.instruments_cache.clone(),
1402            pair_aliases: self.pair_aliases.clone(),
1403            leverage_tiers_cache: self.leverage_tiers_cache.clone(),
1404            cache_initialized: self.cache_initialized.clone(),
1405            clock: self.clock,
1406        }
1407    }
1408}
1409
1410impl Default for KrakenSpotHttpClient {
1411    fn default() -> Self {
1412        Self::new(
1413            KrakenEnvironment::Live,
1414            None,
1415            60,
1416            None,
1417            None,
1418            None,
1419            None,
1420            KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
1421        )
1422        .expect("Failed to create default KrakenSpotHttpClient")
1423    }
1424}
1425
1426impl Debug for KrakenSpotHttpClient {
1427    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1428        f.debug_struct(stringify!(KrakenSpotHttpClient))
1429            .field("inner", &self.inner)
1430            .finish()
1431    }
1432}
1433
1434impl KrakenSpotHttpClient {
1435    /// Creates a new [`KrakenSpotHttpClient`].
1436    #[expect(clippy::too_many_arguments)]
1437    pub fn new(
1438        environment: KrakenEnvironment,
1439        base_url_override: Option<String>,
1440        timeout_secs: u64,
1441        max_retries: Option<u32>,
1442        retry_delay_ms: Option<u64>,
1443        retry_delay_max_ms: Option<u64>,
1444        proxy_url: Option<String>,
1445        max_requests_per_second: u32,
1446    ) -> anyhow::Result<Self> {
1447        Ok(Self {
1448            inner: Arc::new(KrakenSpotRawHttpClient::new(
1449                environment,
1450                base_url_override,
1451                timeout_secs,
1452                max_retries,
1453                retry_delay_ms,
1454                retry_delay_max_ms,
1455                proxy_url,
1456                max_requests_per_second,
1457            )?),
1458            instruments_cache: Arc::new(AtomicMap::new()),
1459            pair_aliases: Arc::new(AtomicMap::new()),
1460            leverage_tiers_cache: Arc::new(AtomicMap::new()),
1461            cache_initialized: Arc::new(AtomicBool::new(false)),
1462            clock: get_atomic_clock_realtime(),
1463        })
1464    }
1465
1466    /// Creates a new [`KrakenSpotHttpClient`] with credentials.
1467    #[expect(clippy::too_many_arguments)]
1468    pub fn with_credentials(
1469        api_key: String,
1470        api_secret: String,
1471        environment: KrakenEnvironment,
1472        base_url_override: Option<String>,
1473        timeout_secs: u64,
1474        max_retries: Option<u32>,
1475        retry_delay_ms: Option<u64>,
1476        retry_delay_max_ms: Option<u64>,
1477        proxy_url: Option<String>,
1478        max_requests_per_second: u32,
1479    ) -> anyhow::Result<Self> {
1480        Ok(Self {
1481            inner: Arc::new(KrakenSpotRawHttpClient::with_credentials(
1482                api_key,
1483                api_secret,
1484                environment,
1485                base_url_override,
1486                timeout_secs,
1487                max_retries,
1488                retry_delay_ms,
1489                retry_delay_max_ms,
1490                proxy_url,
1491                max_requests_per_second,
1492            )?),
1493            instruments_cache: Arc::new(AtomicMap::new()),
1494            pair_aliases: Arc::new(AtomicMap::new()),
1495            leverage_tiers_cache: Arc::new(AtomicMap::new()),
1496            cache_initialized: Arc::new(AtomicBool::new(false)),
1497            clock: get_atomic_clock_realtime(),
1498        })
1499    }
1500
1501    /// Creates a new [`KrakenSpotHttpClient`] loading credentials from environment variables.
1502    ///
1503    /// Looks for `KRAKEN_SPOT_API_KEY` and `KRAKEN_SPOT_API_SECRET`.
1504    ///
1505    /// Note: Kraken Spot does not have a testnet/demo environment.
1506    ///
1507    /// Falls back to unauthenticated client if credentials are not set.
1508    #[expect(clippy::too_many_arguments)]
1509    pub fn from_env(
1510        environment: KrakenEnvironment,
1511        base_url_override: Option<String>,
1512        timeout_secs: u64,
1513        max_retries: Option<u32>,
1514        retry_delay_ms: Option<u64>,
1515        retry_delay_max_ms: Option<u64>,
1516        proxy_url: Option<String>,
1517        max_requests_per_second: u32,
1518    ) -> anyhow::Result<Self> {
1519        if let Some(credential) = KrakenCredential::from_env_spot() {
1520            let (api_key, api_secret) = credential.into_parts();
1521            Self::with_credentials(
1522                api_key,
1523                api_secret,
1524                environment,
1525                base_url_override,
1526                timeout_secs,
1527                max_retries,
1528                retry_delay_ms,
1529                retry_delay_max_ms,
1530                proxy_url,
1531                max_requests_per_second,
1532            )
1533        } else {
1534            Self::new(
1535                environment,
1536                base_url_override,
1537                timeout_secs,
1538                max_retries,
1539                retry_delay_ms,
1540                retry_delay_max_ms,
1541                proxy_url,
1542                max_requests_per_second,
1543            )
1544        }
1545    }
1546
1547    /// Cancels all pending HTTP requests.
1548    pub fn cancel_all_requests(&self) {
1549        self.inner.cancel_all_requests();
1550    }
1551
1552    /// Replaces the canceled token so requests can proceed after reconnect.
1553    pub fn reset_cancellation_token(&self) {
1554        self.inner.reset_cancellation_token();
1555    }
1556
1557    /// Returns a clone of the current cancellation token.
1558    pub fn cancellation_token(&self) -> CancellationToken {
1559        self.inner.cancellation_token()
1560    }
1561
1562    /// Caches an instrument for symbol lookup.
1563    pub fn cache_instrument(&self, instrument: InstrumentAny) {
1564        self.instruments_cache
1565            .insert(instrument.symbol().inner(), instrument);
1566        self.cache_initialized.store(true, Ordering::Release);
1567    }
1568
1569    /// Caches multiple instruments for symbol lookup.
1570    pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1571        self.instruments_cache.rcu(|m| {
1572            for instrument in instruments {
1573                m.insert(instrument.symbol().inner(), instrument.clone());
1574            }
1575        });
1576        self.cache_initialized.store(true, Ordering::Release);
1577    }
1578
1579    /// Gets an instrument from the cache by symbol.
1580    pub fn get_cached_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1581        self.instruments_cache.get_cloned(symbol)
1582    }
1583
1584    /// Records the `altname` of each pair against the `AssetPairs` key.
1585    ///
1586    /// Only differing spellings are stored, so a key is never shadowed by another pair's altname.
1587    fn record_pair_aliases(&self, pairs: &AssetPairsResponse) {
1588        let aliases: Vec<(Ustr, Ustr)> = pairs
1589            .iter()
1590            .filter(|(pair_name, definition)| definition.altname.as_str() != pair_name.as_str())
1591            .map(|(pair_name, definition)| (definition.altname, Ustr::from(pair_name)))
1592            .collect();
1593
1594        if aliases.is_empty() {
1595            return;
1596        }
1597
1598        self.pair_aliases.rcu(|m| {
1599            for (altname, pair_name) in &aliases {
1600                m.insert(*altname, *pair_name);
1601            }
1602        });
1603    }
1604
1605    /// Resolves a cached instrument from whichever spelling Kraken used for the pair.
1606    ///
1607    /// Matches the `AssetPairs` key first so a key always wins, then falls back to the altname
1608    /// recorded by [`Self::record_pair_aliases`].
1609    fn get_instrument_by_raw_symbol(&self, raw_symbol: &str) -> Option<InstrumentAny> {
1610        let instruments = self.instruments_cache.load();
1611
1612        if let Some(instrument) = instruments
1613            .values()
1614            .find(|inst| inst.raw_symbol().as_str() == raw_symbol)
1615        {
1616            return Some(instrument.clone());
1617        }
1618
1619        let pair_name = self.pair_aliases.get_cloned(&Ustr::from(raw_symbol))?;
1620
1621        instruments
1622            .values()
1623            .find(|inst| inst.raw_symbol().inner() == pair_name)
1624            .cloned()
1625    }
1626
1627    fn generate_ts_init(&self) -> UnixNanos {
1628        self.clock.get_time_ns()
1629    }
1630
1631    // Kraken requires `asset_class=tokenized_asset` on every request that references a tokenized pair.
1632    fn asset_class_for(instrument: &InstrumentAny) -> Option<KrakenAssetClass> {
1633        if matches!(instrument, InstrumentAny::TokenizedAsset(_)) {
1634            Some(KrakenAssetClass::TokenizedAsset)
1635        } else {
1636            None
1637        }
1638    }
1639
1640    /// Requests an authentication token for WebSocket connections.
1641    pub async fn get_websockets_token(&self) -> anyhow::Result<WebSocketToken, KrakenHttpError> {
1642        self.inner.get_websockets_token().await
1643    }
1644
1645    /// Requests tradable instruments from Kraken.
1646    ///
1647    /// When `pairs` is `None` (loading all), also fetches tokenized asset pairs
1648    /// (xStocks) and merges them with the default currency pairs.
1649    /// When credentials are configured, instruments use account fee rates from `TradeVolume`;
1650    /// otherwise, they use the public base-tier rates from `AssetPairs`. When the `TradeVolume`
1651    /// request itself fails, instruments load with the public rates rather than failing, so a fee
1652    /// problem cannot take down the execution client on connect.
1653    pub async fn request_instruments(
1654        &self,
1655        pairs: Option<Vec<String>>,
1656    ) -> anyhow::Result<Vec<InstrumentAny>, KrakenHttpError> {
1657        let ts_init = self.generate_ts_init();
1658        let asset_pairs = self.inner.get_asset_pairs(pairs.clone(), None).await?;
1659        self.record_pair_aliases(&asset_pairs);
1660        let fee_rates = self.request_fee_rates(&asset_pairs, None).await?;
1661
1662        let mut instruments: Vec<InstrumentAny> = asset_pairs
1663            .iter()
1664            .filter_map(|(pair_name, definition)| {
1665                match parse_spot_instrument_with_fee_rates(
1666                    pair_name,
1667                    definition,
1668                    fee_rates.get(pair_name).copied(),
1669                    ts_init,
1670                    ts_init,
1671                ) {
1672                    Ok(instrument) => Some((instrument, definition)),
1673                    Err(e) => {
1674                        log::warn!("Failed to parse instrument {pair_name}: {e}");
1675                        None
1676                    }
1677                }
1678            })
1679            .map(|(instrument, definition)| {
1680                let key = Ustr::from(instrument.raw_symbol().as_str());
1681                let tiers = (
1682                    definition.leverage_buy.clone(),
1683                    definition.leverage_sell.clone(),
1684                );
1685                self.leverage_tiers_cache.rcu(|m| {
1686                    m.insert(key, tiers.clone());
1687                });
1688                instrument
1689            })
1690            .collect();
1691
1692        // Also fetch tokenized asset pairs (xStocks). When loading all pairs this
1693        // picks up tokenized equities; when loading specific pairs it covers the
1694        // case where the requested symbols are tokenized assets.
1695        {
1696            match self
1697                .inner
1698                .get_asset_pairs(pairs, Some("tokenized_asset"))
1699                .await
1700            {
1701                Ok(tokenized_pairs) => {
1702                    if !tokenized_pairs.is_empty() {
1703                        log::debug!("Fetched {} tokenized asset pairs", tokenized_pairs.len());
1704                    }
1705                    self.record_pair_aliases(&tokenized_pairs);
1706                    let fee_rates = self
1707                        .request_fee_rates(
1708                            &tokenized_pairs,
1709                            Some(SpotTradeVolumeAssetClass::EquityPair),
1710                        )
1711                        .await?;
1712                    let tokenized_instruments: Vec<InstrumentAny> = tokenized_pairs
1713                        .iter()
1714                        .filter_map(|(pair_name, definition)| {
1715                            match parse_tokenized_instrument_with_fee_rates(
1716                                pair_name,
1717                                definition,
1718                                fee_rates.get(pair_name).copied(),
1719                                ts_init,
1720                                ts_init,
1721                            ) {
1722                                Ok(instrument) => Some(instrument),
1723                                Err(e) => {
1724                                    log::warn!(
1725                                        "Failed to parse tokenized instrument {pair_name}: {e}"
1726                                    );
1727                                    None
1728                                }
1729                            }
1730                        })
1731                        .collect();
1732                    instruments.extend(tokenized_instruments);
1733                }
1734                Err(e) => {
1735                    log::warn!("Failed to fetch tokenized asset pairs: {e}");
1736                }
1737            }
1738        }
1739
1740        Ok(instruments)
1741    }
1742
1743    async fn request_fee_rates(
1744        &self,
1745        pairs: &AssetPairsResponse,
1746        asset_class: Option<SpotTradeVolumeAssetClass>,
1747    ) -> anyhow::Result<AHashMap<String, (Decimal, Decimal)>, KrakenHttpError> {
1748        if self.inner.credential().is_none() || pairs.is_empty() {
1749            return Ok(AHashMap::new());
1750        }
1751
1752        let (pair_ids, fee_keys) = match asset_class {
1753            Some(aclass) => {
1754                let mut assets = IndexMap::new();
1755                let mut fee_keys = AHashMap::with_capacity(pairs.len());
1756                for (pair_name, definition) in pairs {
1757                    let base = definition.base.strip_suffix('x').ok_or_else(|| {
1758                        KrakenHttpError::ParseError(format!(
1759                            "Tokenized pair {pair_name} base {} is missing the x suffix",
1760                            definition.base
1761                        ))
1762                    })?;
1763                    let quote = normalize_currency_code(definition.quote.as_str());
1764                    let asset = format!("{base}/{quote}");
1765                    let fee_key = format!("{base}{}.EQ", definition.quote);
1766                    assets.insert(asset, ());
1767                    fee_keys.insert(pair_name.clone(), fee_key);
1768                }
1769                let pairs = assets
1770                    .into_keys()
1771                    .map(|asset| SpotTradeVolumePair { asset, aclass })
1772                    .collect();
1773                (SpotTradeVolumePairs::Classified(pairs), fee_keys)
1774            }
1775            None => (
1776                SpotTradeVolumePairs::Names(pairs.keys().cloned().collect::<Vec<_>>().join(",")),
1777                pairs
1778                    .keys()
1779                    .map(|pair_name| (pair_name.clone(), pair_name.clone()))
1780                    .collect(),
1781            ),
1782        };
1783        // A failure to resolve account fee rates must not abort instrument loading: this runs on
1784        // the execution client connect path, where losing the listing costs the account state and
1785        // reconciliation. Fall back to the public base-tier rates from `AssetPairs` instead.
1786        // A malformed response is still an error, see the fee-key lookup below.
1787        let response = match self
1788            .inner
1789            .get_trade_volume(&SpotTradeVolumeParams { pair: pair_ids })
1790            .await
1791        {
1792            Ok(response) => response,
1793            Err(e) => {
1794                log::warn!(
1795                    "Failed to request Kraken account fee rates, falling back to public rates: {e}"
1796                );
1797                return Ok(AHashMap::new());
1798            }
1799        };
1800
1801        fee_keys
1802            .into_iter()
1803            .map(|(pair_name, fee_key)| {
1804                let taker = response.fees.get(&fee_key).ok_or_else(|| {
1805                    KrakenHttpError::ParseError(format!(
1806                        "TradeVolume response missing taker fee for {pair_name}"
1807                    ))
1808                })?;
1809                let maker = response.fees_maker.get(&fee_key).unwrap_or(taker);
1810                let maker_fee = maker.fee / dec!(100);
1811                let taker_fee = taker.fee / dec!(100);
1812
1813                Ok((pair_name.clone(), (maker_fee, taker_fee)))
1814            })
1815            .collect()
1816    }
1817
1818    /// Requests the current market status for Kraken Spot instruments.
1819    ///
1820    /// Fetches both regular and tokenized asset pairs. The call returns an error if
1821    /// either fetch fails so callers can avoid emitting partial snapshots that would
1822    /// otherwise cause the missing tokenized symbols to be diffed as removed.
1823    pub async fn request_instrument_statuses(
1824        &self,
1825        pairs: Option<Vec<String>>,
1826    ) -> anyhow::Result<AHashMap<InstrumentId, MarketStatusAction>, KrakenHttpError> {
1827        let asset_pairs = self.inner.get_asset_pairs(pairs.clone(), None).await?;
1828        let mut statuses = collect_spot_statuses(&asset_pairs);
1829
1830        let tokenized_pairs = self
1831            .inner
1832            .get_asset_pairs(pairs, Some("tokenized_asset"))
1833            .await?;
1834        statuses.extend(collect_spot_statuses(&tokenized_pairs));
1835
1836        Ok(statuses)
1837    }
1838
1839    /// Requests historical trades for an instrument.
1840    pub async fn request_trades(
1841        &self,
1842        instrument_id: InstrumentId,
1843        start: Option<Timestamp>,
1844        end: Option<Timestamp>,
1845        limit: Option<u64>,
1846    ) -> anyhow::Result<Vec<TradeTick>, KrakenHttpError> {
1847        let instrument = self
1848            .get_cached_instrument(&instrument_id.symbol.inner())
1849            .ok_or_else(|| {
1850                KrakenHttpError::ParseError(
1851                    InstrumentLookupError::not_found(instrument_id).to_string(),
1852                )
1853            })?;
1854
1855        let raw_symbol = instrument.raw_symbol().to_string();
1856        let asset_class = Self::asset_class_for(&instrument);
1857        let ts_init = self.generate_ts_init();
1858
1859        // Kraken trades API expects nanoseconds since epoch as string
1860        let since = start.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0).to_string());
1861        let response = self
1862            .inner
1863            .get_trades(&raw_symbol, since, asset_class)
1864            .await?;
1865
1866        let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1867        let mut trades = Vec::new();
1868
1869        for (_pair_name, trade_arrays) in &response.data {
1870            for trade_array in trade_arrays {
1871                match parse_trade_tick_from_array(trade_array, &instrument, ts_init) {
1872                    Ok(trade_tick) => {
1873                        if let Some(end_nanos) = end_ns
1874                            && trade_tick.ts_event.as_u64() > end_nanos
1875                        {
1876                            continue;
1877                        }
1878                        trades.push(trade_tick);
1879                    }
1880                    Err(e) => {
1881                        log::warn!("Failed to parse trade tick: {e}");
1882                    }
1883                }
1884            }
1885        }
1886
1887        // Count-only keeps the most recent `limit` trades, not the oldest
1888        apply_count_limit(&mut trades, start, limit);
1889
1890        Ok(trades)
1891    }
1892
1893    /// Requests historical bars/OHLC data for an instrument.
1894    pub async fn request_bars(
1895        &self,
1896        bar_type: BarType,
1897        start: Option<Timestamp>,
1898        end: Option<Timestamp>,
1899        limit: Option<u64>,
1900    ) -> anyhow::Result<Vec<Bar>, KrakenHttpError> {
1901        let instrument_id = bar_type.instrument_id();
1902        let instrument = self
1903            .get_cached_instrument(&instrument_id.symbol.inner())
1904            .ok_or_else(|| {
1905                KrakenHttpError::ParseError(
1906                    InstrumentLookupError::not_found(instrument_id).to_string(),
1907                )
1908            })?;
1909
1910        let raw_symbol = instrument.raw_symbol().to_string();
1911        let asset_class = Self::asset_class_for(&instrument);
1912        let ts_init = self.generate_ts_init();
1913
1914        let interval = Some(
1915            bar_type_to_spot_interval(bar_type)
1916                .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?,
1917        );
1918
1919        // Kraken OHLC API expects Unix timestamp in seconds
1920        let since = start.map(|dt| dt.as_second());
1921        let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1922        let response = self
1923            .inner
1924            .get_ohlc(&raw_symbol, interval, since, asset_class)
1925            .await?;
1926
1927        let mut bars = Vec::new();
1928
1929        for (_pair_name, ohlc_arrays) in &response.data {
1930            for ohlc_array in ohlc_arrays {
1931                if ohlc_array.len() < 8 {
1932                    let len = ohlc_array.len();
1933                    log::warn!("OHLC array too short: {len}");
1934                    continue;
1935                }
1936
1937                let ohlc = OhlcData {
1938                    time: ohlc_array[0].as_i64().unwrap_or(0),
1939                    open: ohlc_array[1].as_str().unwrap_or("0").to_string(),
1940                    high: ohlc_array[2].as_str().unwrap_or("0").to_string(),
1941                    low: ohlc_array[3].as_str().unwrap_or("0").to_string(),
1942                    close: ohlc_array[4].as_str().unwrap_or("0").to_string(),
1943                    vwap: ohlc_array[5].as_str().unwrap_or("0").to_string(),
1944                    volume: ohlc_array[6].as_str().unwrap_or("0").to_string(),
1945                    count: ohlc_array[7].as_i64().unwrap_or(0),
1946                };
1947
1948                match parse_bar(&ohlc, &instrument, bar_type, ts_init) {
1949                    Ok(bar) => {
1950                        if let Some(end_nanos) = end_ns
1951                            && bar.ts_event.as_u64() > end_nanos
1952                        {
1953                            continue;
1954                        }
1955                        bars.push(bar);
1956                    }
1957                    Err(e) => {
1958                        log::warn!("Failed to parse bar: {e}");
1959                    }
1960                }
1961            }
1962        }
1963
1964        // Kraken returns the page oldest-first; keep the most recent `limit`
1965        // bars for count-only requests rather than the oldest (issue #4254).
1966        apply_count_limit(&mut bars, start, limit);
1967
1968        Ok(bars)
1969    }
1970
1971    /// Requests an order book snapshot for an instrument.
1972    pub async fn request_book_snapshot(
1973        &self,
1974        instrument_id: InstrumentId,
1975        depth: Option<u32>,
1976    ) -> anyhow::Result<OrderBook, KrakenHttpError> {
1977        let instrument = self
1978            .get_cached_instrument(&instrument_id.symbol.inner())
1979            .ok_or_else(|| {
1980                KrakenHttpError::ParseError(
1981                    InstrumentLookupError::not_found(instrument_id).to_string(),
1982                )
1983            })?;
1984
1985        let raw_symbol = instrument.raw_symbol().to_string();
1986        let asset_class = Self::asset_class_for(&instrument);
1987        let price_precision = instrument.price_precision();
1988        let size_precision = instrument.size_precision();
1989        let ts_event = self.generate_ts_init();
1990
1991        let response = self
1992            .inner
1993            .get_book_depth(&raw_symbol, depth, asset_class)
1994            .await?;
1995
1996        let book_data = response.values().next().ok_or_else(|| {
1997            KrakenHttpError::ParseError(format!("No book data returned for {instrument_id}"))
1998        })?;
1999
2000        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
2001
2002        // Pass sequence=0 so the snapshot does not advance the book's high-water sequence,
2003        // the WS subscription owns sequencing once it starts streaming deltas.
2004        for (i, level) in book_data.bids.iter().enumerate() {
2005            let price_str = level.first().and_then(|v| v.as_str()).unwrap_or("0");
2006            let size_str = level.get(1).and_then(|v| v.as_str()).unwrap_or("0");
2007            let price = Price::from_decimal_dp(
2008                Decimal::from_str_exact(price_str).unwrap_or(Decimal::ZERO),
2009                price_precision,
2010            )
2011            .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
2012            let size = Quantity::from_decimal_dp(
2013                Decimal::from_str_exact(size_str).unwrap_or(Decimal::ZERO),
2014                size_precision,
2015            )
2016            .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
2017            let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
2018            book.add(order, 0, 0, ts_event);
2019        }
2020
2021        let bids_len = book_data.bids.len();
2022
2023        for (i, level) in book_data.asks.iter().enumerate() {
2024            let price_str = level.first().and_then(|v| v.as_str()).unwrap_or("0");
2025            let size_str = level.get(1).and_then(|v| v.as_str()).unwrap_or("0");
2026            let price = Price::from_decimal_dp(
2027                Decimal::from_str_exact(price_str).unwrap_or(Decimal::ZERO),
2028                price_precision,
2029            )
2030            .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
2031            let size = Quantity::from_decimal_dp(
2032                Decimal::from_str_exact(size_str).unwrap_or(Decimal::ZERO),
2033                size_precision,
2034            )
2035            .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
2036            let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
2037            book.add(order, 0, 0, ts_event);
2038        }
2039
2040        Ok(book)
2041    }
2042
2043    /// Requests account state (balances) from Kraken.
2044    ///
2045    /// In cash mode returns wallet balances only.
2046    /// In margin mode additionally calls `TradeBalance` to build [`MarginBalance`] entries.
2047    /// `margin_balance_asset` selects the summary-display denomination for `TradeBalance`
2048    /// (e.g. `"ZUSD"`, `"ZGBP"`); `None` lets Kraken default to `ZUSD`.
2049    ///
2050    /// Callers that also need the `TradeBalance` metrics dictionary should use
2051    /// [`Self::request_account_state_with_metrics`] to avoid issuing two `TradeBalance`
2052    /// HTTP requests per account update.
2053    pub async fn request_account_state(
2054        &self,
2055        account_id: AccountId,
2056        account_type: AccountType,
2057        margin_balance_asset: Option<&str>,
2058    ) -> anyhow::Result<AccountState> {
2059        self.request_account_state_with_metrics(account_id, account_type, margin_balance_asset)
2060            .await
2061            .map(|(state, _)| state)
2062    }
2063
2064    /// Requests the full margin account snapshot in a single round-trip.
2065    ///
2066    /// Returns the [`AccountState`] (including `MarginBalance` entries when
2067    /// `account_type` is `Margin`) and the `TradeBalance` metrics dictionary that
2068    /// callers attach to `AccountState.info`. In cash mode the metrics map is empty
2069    /// and `TradeBalance` is not called.
2070    ///
2071    /// In margin mode, replaces the raw `margin_balance_asset` wallet with a
2072    /// synthetic [`AccountBalance`] using `total = e` and `free = mf` from
2073    /// `TradeBalance`. Kraken reports these values across all collateral, which
2074    /// avoids clamping free margin to one wallet bucket in multi-asset accounts.
2075    ///
2076    /// Wallet balances come from `BalanceEx`, whose per-asset `hold_trade` gives the amount
2077    /// Kraken has reserved against resting orders and populates `locked`. Net credit
2078    /// (`credit - credit_used`, returned only for credit-line accounts) is included in `total`,
2079    /// so `free` derives to Kraken's available balance of
2080    /// `balance + credit - credit_used - hold_trade`.
2081    ///
2082    /// The single shared fetch keeps Kraken rate-limit usage symmetric with `BalanceEx`
2083    /// (one request per account update), instead of two as if `request_account_state`
2084    /// and `request_margin_metrics` were called in sequence.
2085    pub async fn request_account_state_with_metrics(
2086        &self,
2087        account_id: AccountId,
2088        account_type: AccountType,
2089        margin_balance_asset: Option<&str>,
2090    ) -> anyhow::Result<(AccountState, IndexMap<String, String>)> {
2091        let balances_raw = self.inner.get_balance_ex().await?;
2092        let ts_init = self.generate_ts_init();
2093
2094        let (margins, metrics, margin_entry, target_code) = if account_type == AccountType::Margin {
2095            let snapshot = self
2096                .fetch_trade_balance_snapshot(margin_balance_asset)
2097                .await?;
2098            let target_code = normalize_currency_code(margin_balance_asset.unwrap_or("ZUSD"));
2099            let currency = Currency::new(target_code, 8, 0, "0", CurrencyType::Crypto);
2100            let margin_entry = AccountBalance::from_total_and_free(
2101                snapshot.equity,
2102                snapshot.free_margin,
2103                currency,
2104            )
2105            .context("Failed to build synthetic margin AccountBalance from TradeBalance")?;
2106
2107            (
2108                snapshot.margins,
2109                snapshot.metrics,
2110                Some(margin_entry),
2111                target_code,
2112            )
2113        } else {
2114            (Vec::new(), IndexMap::new(), None, "")
2115        };
2116
2117        let skip_margin_wallet = margin_entry.is_some();
2118
2119        let balances: Vec<AccountBalance> = balances_raw
2120            .iter()
2121            .filter_map(|(currency_code, entry)| {
2122                let balance = Decimal::from_str_exact(&entry.balance).ok()?;
2123                let credit = optional_credit_amount(entry.credit.as_deref())?;
2124                let credit_used = optional_credit_amount(entry.credit_used.as_deref())?;
2125
2126                // Kraken defines available funds as `balance + credit - credit_used -
2127                // hold_trade`, so net credit belongs in `total` for `free` to derive to it.
2128                let total = balance + credit - credit_used;
2129                if total.is_zero() {
2130                    return None;
2131                }
2132
2133                let normalized_code = currency_code
2134                    .strip_prefix("X")
2135                    .or_else(|| currency_code.strip_prefix("Z"))
2136                    .unwrap_or(currency_code);
2137
2138                if skip_margin_wallet && normalized_code == target_code {
2139                    return None;
2140                }
2141
2142                let locked = Decimal::from_str_exact(&entry.hold_trade).ok()?;
2143                let currency = Currency::new(normalized_code, 8, 0, "0", CurrencyType::Crypto);
2144                AccountBalance::from_total_and_locked(total, locked, currency).ok()
2145            })
2146            .chain(margin_entry)
2147            .collect();
2148
2149        let state = AccountState::new(
2150            account_id,
2151            account_type,
2152            balances,
2153            margins,
2154            true,
2155            UUID4::new(),
2156            ts_init,
2157            ts_init,
2158            None,
2159        );
2160
2161        Ok((state, metrics))
2162    }
2163
2164    /// Fetches `TradeBalance` once and returns both the parsed [`MarginBalance`] entries
2165    /// and the metrics dictionary surfaced through `AccountState.info`.
2166    ///
2167    /// # Margin mapping rationale
2168    ///
2169    /// Kraken's `TradeBalance` returns a single used-margin value `m` ("margin amount
2170    /// of open positions") and does not split into separate initial- and maintenance-
2171    /// margin figures. Kraken's "maintenance margin" is a liquidation-level percentage
2172    /// threshold (around 80% margin level), not a collateral money figure.
2173    ///
2174    /// [`nautilus_model::accounts::MarginAccount::recalculate_balance`] sums
2175    /// `initial + maintenance` to compute `locked`, so duplicating `m` into both fields
2176    /// would double-lock equity and diverge from Kraken's reported `mf = e - m`.
2177    /// `m` is therefore mapped to `initial`, with `maintenance = Money::zero(currency)`.
2178    async fn fetch_trade_balance_snapshot(
2179        &self,
2180        asset: Option<&str>,
2181    ) -> anyhow::Result<TradeBalanceSnapshot> {
2182        let tb = self.inner.get_trade_balance(asset).await?;
2183
2184        let used_margin = Decimal::from_str_exact(&tb.m)
2185            .with_context(|| format!("Failed to parse TradeBalance 'm' field {:?}", tb.m))?;
2186        let free_margin = Decimal::from_str_exact(&tb.mf)
2187            .with_context(|| format!("Failed to parse TradeBalance 'mf' field {:?}", tb.mf))?;
2188        let equity = Decimal::from_str_exact(&tb.e)
2189            .with_context(|| format!("Failed to parse TradeBalance 'e' field {:?}", tb.e))?;
2190
2191        let margins = if used_margin.is_zero() {
2192            Vec::new()
2193        } else {
2194            let currency = trade_balance_currency(asset);
2195            let initial = Money::from_decimal(used_margin, currency)
2196                .context("Failed to build initial margin from TradeBalance 'm'")?;
2197            let maintenance = Money::zero(currency);
2198            vec![MarginBalance::new(initial, maintenance, None)]
2199        };
2200
2201        let mut metrics = IndexMap::new();
2202        metrics.insert("equivalent_balance".to_string(), tb.eb);
2203        metrics.insert("trade_balance".to_string(), tb.tb);
2204        metrics.insert("used_margin".to_string(), tb.m);
2205        metrics.insert("unexecuted_value".to_string(), tb.uv);
2206        metrics.insert("unrealized_pnl".to_string(), tb.n);
2207        metrics.insert("cost_basis".to_string(), tb.c);
2208        metrics.insert("valuation".to_string(), tb.v);
2209        metrics.insert("equity".to_string(), tb.e);
2210        metrics.insert("free_margin".to_string(), tb.mf);
2211        if let Some(ml) = tb.ml {
2212            metrics.insert("margin_level".to_string(), ml);
2213        }
2214        metrics.insert(
2215            "asset".to_string(),
2216            normalize_currency_code(asset.unwrap_or("ZUSD")).to_string(),
2217        );
2218
2219        Ok(TradeBalanceSnapshot {
2220            margins,
2221            metrics,
2222            free_margin,
2223            equity,
2224        })
2225    }
2226
2227    /// Returns a flattened snapshot of Kraken's `TradeBalance` margin metrics.
2228    ///
2229    /// Caller is expected to invoke this only when operating in margin mode; consumers
2230    /// surface the values via `AccountState.info` (Python-side) for strategy access.
2231    /// Strings preserve venue precision exactly. Keys: `equivalent_balance`,
2232    /// `trade_balance`, `used_margin`, `unexecuted_value`, `unrealized_pnl`,
2233    /// `cost_basis`, `valuation`, `equity`, `free_margin`, `margin_level` (omitted
2234    /// when Kraken returns no value, i.e. no open positions), `asset`.
2235    ///
2236    /// When the metrics are needed alongside the [`AccountState`], prefer
2237    /// [`Self::request_account_state_with_metrics`] to share a single `TradeBalance`
2238    /// HTTP request between both.
2239    pub async fn request_margin_metrics(
2240        &self,
2241        asset: Option<&str>,
2242    ) -> anyhow::Result<IndexMap<String, String>> {
2243        self.fetch_trade_balance_snapshot(asset)
2244            .await
2245            .map(|snapshot| snapshot.metrics)
2246    }
2247
2248    /// Requests order status reports from Kraken.
2249    pub async fn request_order_status_reports(
2250        &self,
2251        account_id: AccountId,
2252        instrument_id: Option<InstrumentId>,
2253        start: Option<Timestamp>,
2254        end: Option<Timestamp>,
2255        open_only: bool,
2256    ) -> anyhow::Result<Vec<OrderStatusReport>> {
2257        const PAGE_SIZE: i32 = 50;
2258
2259        let ts_init = self.generate_ts_init();
2260        let mut all_reports = Vec::new();
2261
2262        let open_orders = self.inner.get_open_orders(Some(true), None).await?;
2263
2264        for (order_id, order) in &open_orders {
2265            if let Some(ref target_id) = instrument_id {
2266                let instrument = self.get_cached_instrument(&target_id.symbol.inner());
2267                if let Some(inst) = instrument
2268                    && inst.raw_symbol().as_str() != order.descr.pair
2269                {
2270                    continue;
2271                }
2272            }
2273
2274            let instrument = self
2275                .get_instrument_by_raw_symbol(order.descr.pair.as_str())
2276                .ok_or_else(|| {
2277                    anyhow::anyhow!(
2278                        "OpenOrders: instrument not in cache for pair {}",
2279                        order.descr.pair
2280                    )
2281                })?;
2282
2283            match parse_order_status_report(order_id, order, &instrument, account_id, ts_init) {
2284                Ok(report) => all_reports.push(report),
2285                Err(e) => {
2286                    log::warn!("Failed to parse order {order_id}: {e}");
2287                }
2288            }
2289        }
2290
2291        if open_only {
2292            return Ok(all_reports);
2293        }
2294
2295        // Kraken API expects Unix timestamps in seconds
2296        let start_ts = start.map(|dt| dt.as_second());
2297        let end_ts = end.map(|dt| dt.as_second());
2298
2299        let mut offset = 0;
2300
2301        loop {
2302            let closed_orders = self
2303                .inner
2304                .get_closed_orders(Some(true), None, start_ts, end_ts, Some(offset), None)
2305                .await?;
2306
2307            if closed_orders.is_empty() {
2308                break;
2309            }
2310
2311            for (order_id, order) in &closed_orders {
2312                if let Some(ref target_id) = instrument_id {
2313                    let instrument = self.get_cached_instrument(&target_id.symbol.inner());
2314                    if let Some(inst) = instrument
2315                        && inst.raw_symbol().as_str() != order.descr.pair
2316                    {
2317                        continue;
2318                    }
2319                }
2320
2321                // A historical record can reference an instrument absent from the current
2322                // listing, so warn and keep the rest rather than withholding the whole read.
2323                let Some(instrument) = self.get_instrument_by_raw_symbol(order.descr.pair.as_str())
2324                else {
2325                    log::warn!(
2326                        "ClosedOrders: instrument not in cache for pair {}, skipping order {order_id}",
2327                        order.descr.pair
2328                    );
2329                    continue;
2330                };
2331
2332                match parse_order_status_report(order_id, order, &instrument, account_id, ts_init) {
2333                    Ok(report) => all_reports.push(report),
2334                    Err(e) => {
2335                        log::warn!("Failed to parse order {order_id}: {e}");
2336                    }
2337                }
2338            }
2339
2340            offset += PAGE_SIZE;
2341        }
2342
2343        Ok(all_reports)
2344    }
2345
2346    /// Requests fill/trade reports from Kraken.
2347    pub async fn request_fill_reports(
2348        &self,
2349        account_id: AccountId,
2350        instrument_id: Option<InstrumentId>,
2351        start: Option<Timestamp>,
2352        end: Option<Timestamp>,
2353    ) -> anyhow::Result<Vec<FillReport>> {
2354        const PAGE_SIZE: i32 = 50;
2355
2356        let ts_init = self.generate_ts_init();
2357        let mut all_reports = Vec::new();
2358
2359        // Kraken API expects Unix timestamps in seconds
2360        let start_ts = start.map(|dt| dt.as_second());
2361        let end_ts = end.map(|dt| dt.as_second());
2362
2363        let mut offset = 0;
2364
2365        loop {
2366            let trades = self
2367                .inner
2368                .get_trades_history(None, Some(true), start_ts, end_ts, Some(offset))
2369                .await?;
2370
2371            if trades.is_empty() {
2372                break;
2373            }
2374
2375            for (trade_id, trade) in &trades {
2376                if let Some(ref target_id) = instrument_id {
2377                    let instrument = self.get_cached_instrument(&target_id.symbol.inner());
2378                    if let Some(inst) = instrument
2379                        && inst.raw_symbol().as_str() != trade.pair
2380                    {
2381                        continue;
2382                    }
2383                }
2384
2385                // As above: historical fills outlive the listing, so preserve the usable rows.
2386                let Some(instrument) = self.get_instrument_by_raw_symbol(trade.pair.as_str())
2387                else {
2388                    log::warn!(
2389                        "TradesHistory: instrument not in cache for pair {}, skipping trade {trade_id}",
2390                        trade.pair
2391                    );
2392                    continue;
2393                };
2394
2395                match parse_fill_report(trade_id, trade, &instrument, account_id, ts_init) {
2396                    Ok(report) => all_reports.push(report),
2397                    Err(e) => {
2398                        log::warn!("Failed to parse trade {trade_id}: {e}");
2399                    }
2400                }
2401            }
2402
2403            offset += PAGE_SIZE;
2404        }
2405
2406        Ok(all_reports)
2407    }
2408
2409    /// Requests position status reports for SPOT instruments.
2410    ///
2411    /// In margin mode: calls `OpenPositions` and returns reports for each open leveraged position.
2412    /// When `use_spot_position_reports` is enabled (cash mode): derives reports from wallet balances.
2413    /// Otherwise returns an empty vector.
2414    pub async fn request_position_status_reports(
2415        &self,
2416        account_id: AccountId,
2417        instrument_id: Option<InstrumentId>,
2418        account_type: AccountType,
2419        use_spot_position_reports: bool,
2420        quote_currency: Ustr,
2421    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2422        if account_type == AccountType::Margin {
2423            self.generate_margin_position_reports(account_id, instrument_id)
2424                .await
2425        } else if use_spot_position_reports {
2426            self.generate_spot_position_reports_from_wallet(
2427                account_id,
2428                instrument_id,
2429                quote_currency,
2430            )
2431            .await
2432        } else {
2433            Ok(Vec::new())
2434        }
2435    }
2436
2437    /// Generates position reports from Kraken `OpenPositions` (margin mode).
2438    async fn generate_margin_position_reports(
2439        &self,
2440        account_id: AccountId,
2441        instrument_id: Option<InstrumentId>,
2442    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2443        let open_positions = self
2444            .inner
2445            .get_open_positions(&SpotOpenPositionsParams::default())
2446            .await?;
2447
2448        let ts_init = self.generate_ts_init();
2449
2450        // Aggregate individual lot entries by pair into a signed net quantity.
2451        // Kraken returns one entry per order lot (keyed by ordertxid); buy lots add to net
2452        // quantity and sell lots subtract. A single signed value per pair is correct for a
2453        // NETTING account and avoids emitting conflicting long+short reports for the same
2454        // instrument when opposing lots exist on the same pair. Aggregation uses `Decimal`
2455        // so opposing lots cancel exactly and partial-close noise does not leave residual
2456        // float dust in the reported quantity.
2457        let mut agg: IndexMap<String, (Decimal, InstrumentId)> = IndexMap::new();
2458
2459        let target_pair: Option<Ustr> = match &instrument_id {
2460            Some(target_id) => match self.get_cached_instrument(&target_id.symbol.inner()) {
2461                Some(inst) => Some(Ustr::from(inst.raw_symbol().as_str())),
2462                None => return Ok(Vec::new()),
2463            },
2464            None => None,
2465        };
2466
2467        for (_pos_id, pos) in open_positions.iter() {
2468            if let Some(pair) = target_pair
2469                && pair.as_str() != pos.pair
2470            {
2471                continue;
2472            }
2473
2474            if let Some(status) = pos.posstatus.as_deref()
2475                && status != "open"
2476            {
2477                log::debug!(
2478                    "Skipping non-open OpenPositions entry for {}: posstatus={status}",
2479                    pos.pair,
2480                );
2481                continue;
2482            }
2483
2484            let instrument = self
2485                .get_instrument_by_raw_symbol(pos.pair.as_str())
2486                .ok_or_else(|| {
2487                    anyhow::anyhow!(
2488                        "OpenPositions: instrument not in cache for pair {}",
2489                        pos.pair
2490                    )
2491                })?;
2492
2493            let vol = Decimal::from_str_exact(&pos.vol)
2494                .with_context(|| format!("OpenPositions: failed to parse vol for {}", pos.pair))?;
2495            let vol_closed = Decimal::from_str_exact(&pos.vol_closed).with_context(|| {
2496                format!("OpenPositions: failed to parse vol_closed for {}", pos.pair)
2497            })?;
2498
2499            let lot_net = (vol - vol_closed).max(Decimal::ZERO);
2500            let signed_lot = match pos.side {
2501                KrakenOrderSide::Buy => lot_net,
2502                KrakenOrderSide::Sell => -lot_net,
2503            };
2504
2505            let entry = agg
2506                .entry(pos.pair.clone())
2507                .or_insert((Decimal::ZERO, instrument.id()));
2508            entry.0 += signed_lot;
2509        }
2510
2511        let mut reports = Vec::new();
2512
2513        for (_, (signed_qty, inst_id)) in agg {
2514            let instrument = self
2515                .get_cached_instrument(&inst_id.symbol.inner())
2516                .ok_or_else(|| InstrumentLookupError::not_found(inst_id))?;
2517
2518            let side = if signed_qty.is_sign_positive() && !signed_qty.is_zero() {
2519                PositionSide::Long
2520            } else if signed_qty.is_sign_negative() && !signed_qty.is_zero() {
2521                PositionSide::Short
2522            } else {
2523                PositionSide::Flat
2524            };
2525            let quantity = Quantity::from_decimal_dp(signed_qty.abs(), instrument.size_precision())
2526                .map_err(|e| {
2527                    anyhow::anyhow!("OpenPositions: failed to build Quantity for {inst_id}: {e:?}")
2528                })?;
2529            let report = PositionStatusReport::new(
2530                account_id, inst_id, side, quantity, ts_init, ts_init, None, None, None,
2531            );
2532            reports.push(report);
2533        }
2534
2535        // If a specific instrument was requested but no open position exists for it, emit
2536        // a FLAT report so the engine can reconcile a previously-open position to closed.
2537        // (Kraken omits fully-closed positions from OpenPositions entirely.)
2538        // Only emit for instruments known to this spot client; a missing cache entry means
2539        // the target belongs to a different product type (e.g. futures) and must not receive
2540        // a spurious FLAT from the spot reconciliation path.
2541        if let Some(target_id) = instrument_id {
2542            let already_reported = reports.iter().any(|r| r.instrument_id == target_id);
2543
2544            if !already_reported
2545                && let Some(instrument) = self.get_cached_instrument(&target_id.symbol.inner())
2546            {
2547                let precision = instrument.size_precision();
2548                reports.push(PositionStatusReport::new(
2549                    account_id,
2550                    target_id,
2551                    PositionSide::Flat,
2552                    Quantity::zero(precision),
2553                    ts_init,
2554                    ts_init,
2555                    None,
2556                    None,
2557                    None,
2558                ));
2559            }
2560        }
2561
2562        Ok(reports)
2563    }
2564
2565    /// Generates SPOT position reports from wallet balances.
2566    ///
2567    /// Kraken spot balances are simple totals (no borrowing concept).
2568    /// Positive balances are reported as LONG positions.
2569    /// Zero balances are reported as FLAT.
2570    async fn generate_spot_position_reports_from_wallet(
2571        &self,
2572        account_id: AccountId,
2573        instrument_id: Option<InstrumentId>,
2574        quote_currency: Ustr,
2575    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2576        let balances_raw = self.inner.get_balance().await?;
2577        let ts_init = self.generate_ts_init();
2578        let mut wallet_by_coin: HashMap<Ustr, Decimal> = HashMap::new();
2579
2580        for (currency_code, amount_str) in &balances_raw {
2581            let balance = match Decimal::from_str_exact(amount_str) {
2582                Ok(b) => b,
2583                Err(_) => continue,
2584            };
2585
2586            if balance.is_zero() {
2587                continue;
2588            }
2589
2590            wallet_by_coin.insert(Ustr::from(normalize_currency_code(currency_code)), balance);
2591        }
2592
2593        let mut reports = Vec::new();
2594
2595        if let Some(instrument_id) = instrument_id {
2596            if let Some(instrument) = self.get_cached_instrument(&instrument_id.symbol.inner()) {
2597                let base_currency = match instrument.base_currency() {
2598                    Some(currency) => currency,
2599                    None => return Ok(reports),
2600                };
2601
2602                let coin = Ustr::from(normalize_currency_code(base_currency.code.as_str()));
2603                let wallet_balance = wallet_by_coin.get(&coin).copied().unwrap_or(Decimal::ZERO);
2604
2605                let side = if wallet_balance > Decimal::ZERO {
2606                    PositionSide::Long
2607                } else {
2608                    PositionSide::Flat
2609                };
2610
2611                let abs_balance = wallet_balance.abs();
2612                let quantity = Quantity::from_decimal_dp(abs_balance, instrument.size_precision())?;
2613
2614                let report = PositionStatusReport::new(
2615                    account_id,
2616                    instrument_id,
2617                    side,
2618                    quantity,
2619                    ts_init,
2620                    ts_init,
2621                    None,
2622                    None,
2623                    None,
2624                );
2625
2626                reports.push(report);
2627            }
2628        } else {
2629            let quote_filter = quote_currency;
2630
2631            let instruments_guard = self.instruments_cache.load();
2632            for instrument in instruments_guard.values() {
2633                let quote_currency = match instrument.quote_currency() {
2634                    currency if currency.code == quote_filter => currency,
2635                    _ => continue,
2636                };
2637
2638                let base_currency = match instrument.base_currency() {
2639                    Some(currency) => currency,
2640                    None => continue,
2641                };
2642
2643                let coin = Ustr::from(normalize_currency_code(base_currency.code.as_str()));
2644                let wallet_balance = wallet_by_coin.get(&coin).copied().unwrap_or(Decimal::ZERO);
2645
2646                if wallet_balance.is_zero() {
2647                    continue;
2648                }
2649
2650                let side = PositionSide::Long;
2651                let quantity =
2652                    Quantity::from_decimal_dp(wallet_balance, instrument.size_precision())?;
2653
2654                if quantity.is_zero() {
2655                    continue;
2656                }
2657
2658                log::debug!(
2659                    "Spot position: {} {} (quote: {})",
2660                    quantity,
2661                    base_currency.code,
2662                    quote_currency.code
2663                );
2664
2665                let report = PositionStatusReport::new(
2666                    account_id,
2667                    instrument.id(),
2668                    side,
2669                    quantity,
2670                    ts_init,
2671                    ts_init,
2672                    None,
2673                    None,
2674                    None,
2675                );
2676
2677                reports.push(report);
2678            }
2679        }
2680
2681        Ok(reports)
2682    }
2683
2684    /// Submits a new order to the Kraken Spot exchange.
2685    ///
2686    /// Returns the venue order ID on success. WebSocket handles all execution events.
2687    ///
2688    /// # Errors
2689    ///
2690    /// Returns an error if:
2691    /// - Credentials are missing.
2692    /// - The instrument is not found in cache.
2693    /// - The order type or time in force is not supported.
2694    /// - The request fails.
2695    /// - The order is rejected.
2696    #[expect(clippy::too_many_arguments)]
2697    pub async fn submit_order(
2698        &self,
2699        _account_id: AccountId,
2700        instrument_id: InstrumentId,
2701        client_order_id: ClientOrderId,
2702        order_side: OrderSide,
2703        order_type: OrderType,
2704        quantity: Quantity,
2705        time_in_force: TimeInForce,
2706        expire_time: Option<UnixNanos>,
2707        price: Option<Price>,
2708        trigger_price: Option<Price>,
2709        trigger_type: Option<TriggerType>,
2710        trailing_offset: Option<Decimal>,
2711        limit_offset: Option<Decimal>,
2712        reduce_only: bool,
2713        post_only: bool,
2714        quote_quantity: bool,
2715        display_qty: Option<Quantity>,
2716        leverage: Option<u16>,
2717        account_type: AccountType,
2718    ) -> anyhow::Result<VenueOrderId> {
2719        let params = self.build_add_order_params(
2720            instrument_id,
2721            client_order_id,
2722            order_side,
2723            order_type,
2724            quantity,
2725            time_in_force,
2726            expire_time,
2727            price,
2728            trigger_price,
2729            trigger_type,
2730            trailing_offset,
2731            limit_offset,
2732            reduce_only,
2733            post_only,
2734            quote_quantity,
2735            display_qty,
2736            leverage,
2737            account_type,
2738        )?;
2739        let response = self.inner.add_order(&params).await?;
2740
2741        let venue_order_id =
2742            response
2743                .txid
2744                .first()
2745                .ok_or_else(|| KrakenSubmitOrderError::MissingOrderId {
2746                    detail: "transaction ID was absent".to_string(),
2747                })?;
2748
2749        Ok(VenueOrderId::new(venue_order_id))
2750    }
2751
2752    /// Submits multiple orders to the Kraken Spot exchange.
2753    ///
2754    /// Automatically groups orders by pair and chunks batch requests at the venue
2755    /// limit. Single-order groups fall back to `AddOrder`.
2756    #[expect(clippy::type_complexity)]
2757    pub async fn submit_orders_batch(
2758        &self,
2759        orders: Vec<(
2760            InstrumentId,
2761            ClientOrderId,
2762            OrderSide,
2763            OrderType,
2764            Quantity,
2765            TimeInForce,
2766            Option<UnixNanos>,
2767            Option<Price>,
2768            Option<Price>,
2769            Option<TriggerType>,
2770            Option<Decimal>,
2771            Option<Decimal>,
2772            bool,
2773            bool,
2774            bool,
2775            Option<Quantity>,
2776            Option<u16>,
2777        )>,
2778        account_type: AccountType,
2779    ) -> anyhow::Result<Vec<String>> {
2780        Ok(self
2781            .send_order_batches(orders, account_type)
2782            .await
2783            .into_iter()
2784            .map(|result| match result {
2785                Ok(order) if order.txid.is_some() => "placed".to_string(),
2786                Ok(order) => order.error.unwrap_or_else(|| "Unknown error".to_string()),
2787                Err(e)
2788                    if matches!(
2789                        e.downcast_ref::<KrakenBatchOrderError>(),
2790                        Some(KrakenBatchOrderError::Validation { .. })
2791                    ) =>
2792                {
2793                    format!("validation_error: {e}")
2794                }
2795                Err(e) => format!("batch_error: {e}"),
2796            })
2797            .collect())
2798    }
2799
2800    pub(crate) async fn send_order_batches(
2801        &self,
2802        orders: Vec<SpotBatchOrder>,
2803        account_type: AccountType,
2804    ) -> Vec<anyhow::Result<SpotBatchOrderResponse>> {
2805        let count = orders.len();
2806        if count == 0 {
2807            return Vec::new();
2808        }
2809
2810        let mut results: Vec<Option<anyhow::Result<SpotBatchOrderResponse>>> =
2811            (0..count).map(|_| None).collect();
2812        let mut grouped: AHashMap<Ustr, Vec<(usize, KrakenSpotAddOrderParams)>> = AHashMap::new();
2813
2814        for (
2815            idx,
2816            (
2817                instrument_id,
2818                client_order_id,
2819                order_side,
2820                order_type,
2821                quantity,
2822                time_in_force,
2823                expire_time,
2824                price,
2825                trigger_price,
2826                trigger_type,
2827                trailing_offset,
2828                limit_offset,
2829                reduce_only,
2830                post_only,
2831                quote_quantity,
2832                display_qty,
2833                leverage,
2834            ),
2835        ) in orders.into_iter().enumerate()
2836        {
2837            match self.build_add_order_params(
2838                instrument_id,
2839                client_order_id,
2840                order_side,
2841                order_type,
2842                quantity,
2843                time_in_force,
2844                expire_time,
2845                price,
2846                trigger_price,
2847                trigger_type,
2848                trailing_offset,
2849                limit_offset,
2850                reduce_only,
2851                post_only,
2852                quote_quantity,
2853                display_qty,
2854                leverage,
2855                account_type,
2856            ) {
2857                Ok(params) => {
2858                    grouped.entry(params.pair).or_default().push((idx, params));
2859                }
2860                Err(e) => {
2861                    results[idx] = Some(Err(KrakenBatchOrderError::Validation {
2862                        reason: e.to_string(),
2863                    }
2864                    .into()));
2865                }
2866            }
2867        }
2868
2869        let mut batches = Vec::new();
2870        let mut grouped_orders: Vec<_> = grouped.into_values().collect();
2871        grouped_orders.sort_by_key(|group| group.first().map_or(usize::MAX, |(idx, _)| *idx));
2872        for group in grouped_orders {
2873            batches.extend(group.chunks(BATCH_SUBMIT_LIMIT).map(|chunk| chunk.to_vec()));
2874        }
2875
2876        for (batch_index, batch) in batches.iter().enumerate() {
2877            let response = if batch.len() == 1 {
2878                self.inner
2879                    .add_order(&batch[0].1)
2880                    .await
2881                    .map(|response| SpotAddOrderBatchResponse {
2882                        orders: vec![SpotBatchOrderResponse {
2883                            descr: response.descr,
2884                            error: None,
2885                            txid: response.txid.into_iter().next(),
2886                        }],
2887                    })
2888            } else {
2889                let params = KrakenSpotAddOrderBatchParams {
2890                    pair: batch[0].1.pair,
2891                    orders: batch
2892                        .iter()
2893                        .map(|(_, params)| params.clone().into())
2894                        .collect(),
2895                    asset_class: batch[0].1.asset_class,
2896                };
2897                self.inner.add_order_batch(&params).await
2898            };
2899
2900            match response {
2901                // AddOrderBatch returns result items in request order, so exact cardinality makes
2902                // positional correlation unambiguous.
2903                Ok(response) if response.orders.len() == batch.len() => {
2904                    for ((idx, _), order) in batch.iter().zip(response.orders) {
2905                        results[*idx] = Some(Ok(order));
2906                    }
2907                }
2908                Ok(response) => {
2909                    for (idx, _) in batch {
2910                        results[*idx] = Some(Err(KrakenBatchOrderError::ResponseCount {
2911                            expected: batch.len(),
2912                            actual: response.orders.len(),
2913                        }
2914                        .into()));
2915                    }
2916                }
2917                Err(e) => {
2918                    for (idx, _) in batch {
2919                        results[*idx] = Some(Err(anyhow::Error::new(e.clone())));
2920                    }
2921
2922                    for later_batch in &batches[batch_index + 1..] {
2923                        for (idx, _) in later_batch {
2924                            results[*idx] = Some(Err(KrakenBatchOrderError::NotAttempted.into()));
2925                        }
2926                    }
2927                    break;
2928                }
2929            }
2930        }
2931
2932        results
2933            .into_iter()
2934            .map(|result| result.unwrap_or_else(|| Err(KrakenBatchOrderError::NotAttempted.into())))
2935            .collect()
2936    }
2937
2938    /// Modifies an existing order on the Kraken Spot exchange using atomic amend.
2939    ///
2940    /// Uses the AmendOrder endpoint which modifies the order in-place,
2941    /// keeping the same order ID and queue position.
2942    ///
2943    /// # Errors
2944    ///
2945    /// Returns an error if:
2946    /// - Neither `client_order_id` nor `venue_order_id` is provided.
2947    /// - The instrument is not found in cache.
2948    /// - The request fails.
2949    pub async fn modify_order(
2950        &self,
2951        instrument_id: InstrumentId,
2952        client_order_id: Option<ClientOrderId>,
2953        venue_order_id: Option<VenueOrderId>,
2954        quantity: Option<Quantity>,
2955        price: Option<Price>,
2956        trigger_price: Option<Price>,
2957    ) -> anyhow::Result<VenueOrderId> {
2958        let _ = self
2959            .get_cached_instrument(&instrument_id.symbol.inner())
2960            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2961
2962        let txid = venue_order_id.as_ref().map(|id| id.to_string());
2963        let cl_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
2964
2965        if txid.is_none() && cl_ord_id.is_none() {
2966            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
2967        }
2968
2969        let mut builder = KrakenSpotAmendOrderParamsBuilder::default();
2970        if let Some(ref id) = txid {
2971            builder.txid(id.clone());
2972        } else if let Some(ref id) = cl_ord_id {
2973            builder.cl_ord_id(id.clone());
2974        }
2975
2976        if let Some(qty) = quantity {
2977            builder.order_qty(qty.to_string());
2978        }
2979
2980        if let Some(p) = price {
2981            builder.limit_price(p.to_string());
2982        }
2983
2984        if let Some(tp) = trigger_price {
2985            builder.trigger_price(tp.to_string());
2986        }
2987
2988        let params = builder
2989            .build()
2990            .map_err(|e| anyhow::anyhow!("Failed to build amend order params: {e}"))?;
2991
2992        let _response = self.inner.amend_order(&params).await?;
2993
2994        // AmendOrder modifies in-place, so the order keeps its original ID
2995        let order_id = venue_order_id.ok_or(KrakenModifyOrderError::MissingOrderId)?;
2996
2997        Ok(order_id)
2998    }
2999
3000    /// Cancels an order on the Kraken Spot exchange.
3001    ///
3002    /// # Errors
3003    ///
3004    /// Returns an error if:
3005    /// - Credentials are missing.
3006    /// - Neither client_order_id nor venue_order_id is provided.
3007    /// - The request fails.
3008    /// - The order cancellation is rejected.
3009    pub async fn cancel_order(
3010        &self,
3011        _account_id: AccountId,
3012        instrument_id: InstrumentId,
3013        client_order_id: Option<ClientOrderId>,
3014        venue_order_id: Option<VenueOrderId>,
3015    ) -> anyhow::Result<()> {
3016        let _ = self
3017            .get_cached_instrument(&instrument_id.symbol.inner())
3018            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
3019
3020        let txid = venue_order_id.as_ref().map(|id| id.to_string());
3021        let cl_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
3022
3023        if txid.is_none() && cl_ord_id.is_none() {
3024            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
3025        }
3026
3027        // Prefer txid (venue identifier) since Kraken always knows it.
3028        // cl_ord_id may not be known to Kraken for reconciled orders.
3029        let mut builder = KrakenSpotCancelOrderParamsBuilder::default();
3030
3031        if let Some(ref id) = txid {
3032            builder.txid(id.clone());
3033        } else if let Some(ref id) = cl_ord_id {
3034            builder.cl_ord_id(id.clone());
3035        }
3036        let params = builder
3037            .build()
3038            .map_err(|e| anyhow::anyhow!("Failed to build cancel params: {e}"))?;
3039
3040        self.inner.cancel_order(&params).await?;
3041
3042        Ok(())
3043    }
3044
3045    /// Cancels multiple orders on the Kraken Spot exchange (batched, max 50 per request).
3046    pub async fn cancel_orders_batch(
3047        &self,
3048        venue_order_ids: Vec<VenueOrderId>,
3049    ) -> anyhow::Result<i32> {
3050        if venue_order_ids.is_empty() {
3051            return Ok(0);
3052        }
3053
3054        let mut total_cancelled = 0;
3055
3056        for chunk in venue_order_ids.chunks(BATCH_CANCEL_LIMIT) {
3057            let orders: Vec<String> = chunk.iter().map(|id| id.to_string()).collect();
3058            let params = KrakenSpotCancelOrderBatchParams { orders };
3059
3060            let response = self.inner.cancel_order_batch(&params).await?;
3061            total_cancelled += response.count;
3062        }
3063
3064        Ok(total_cancelled)
3065    }
3066
3067    #[expect(clippy::too_many_arguments)]
3068    fn build_add_order_params(
3069        &self,
3070        instrument_id: InstrumentId,
3071        client_order_id: ClientOrderId,
3072        order_side: OrderSide,
3073        order_type: OrderType,
3074        quantity: Quantity,
3075        time_in_force: TimeInForce,
3076        expire_time: Option<UnixNanos>,
3077        price: Option<Price>,
3078        trigger_price: Option<Price>,
3079        trigger_type: Option<TriggerType>,
3080        trailing_offset: Option<Decimal>,
3081        limit_offset: Option<Decimal>,
3082        reduce_only: bool,
3083        post_only: bool,
3084        quote_quantity: bool,
3085        display_qty: Option<Quantity>,
3086        leverage: Option<u16>,
3087        account_type: AccountType,
3088    ) -> anyhow::Result<KrakenSpotAddOrderParams> {
3089        let instrument = self
3090            .get_cached_instrument(&instrument_id.symbol.inner())
3091            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
3092
3093        let raw_symbol = instrument.raw_symbol().inner();
3094        let asset_class = Self::asset_class_for(&instrument);
3095
3096        let kraken_side = match order_side {
3097            OrderSide::Buy => KrakenOrderSide::Buy,
3098            OrderSide::Sell => KrakenOrderSide::Sell,
3099        };
3100
3101        let kraken_order_type = match order_type {
3102            OrderType::Market => KrakenOrderType::Market,
3103            OrderType::Limit => KrakenOrderType::Limit,
3104            OrderType::StopMarket => KrakenOrderType::StopLoss,
3105            OrderType::StopLimit => KrakenOrderType::StopLossLimit,
3106            OrderType::MarketIfTouched => KrakenOrderType::TakeProfit,
3107            OrderType::LimitIfTouched => KrakenOrderType::TakeProfitLimit,
3108            OrderType::TrailingStopMarket => KrakenOrderType::TrailingStop,
3109            OrderType::TrailingStopLimit => KrakenOrderType::TrailingStopLimit,
3110            _ => anyhow::bail!("Unsupported order type: {order_type:?}"),
3111        };
3112
3113        let mut oflags = Vec::new();
3114        let is_limit_order = matches!(
3115            order_type,
3116            OrderType::Limit
3117                | OrderType::StopLimit
3118                | OrderType::LimitIfTouched
3119                | OrderType::TrailingStopLimit
3120        );
3121
3122        if time_in_force == TimeInForce::Fok && order_type != OrderType::Limit {
3123            anyhow::bail!("FOK time in force only supported for LIMIT orders on Kraken Spot");
3124        }
3125
3126        let (timeinforce, expiretm) =
3127            compute_time_in_force(is_limit_order, time_in_force, expire_time)?;
3128
3129        if post_only {
3130            oflags.push(KRAKEN_OFLAG_POST_ONLY);
3131        }
3132
3133        if quote_quantity {
3134            oflags.push(KRAKEN_OFLAG_QUOTE_QUANTITY);
3135        }
3136
3137        let mut builder = KrakenSpotAddOrderParamsBuilder::default();
3138        builder
3139            .cl_ord_id(truncate_cl_ord_id(&client_order_id))
3140            .broker(NAUTILUS_KRAKEN_BROKER_ID)
3141            .pair(raw_symbol)
3142            .side(kraken_side)
3143            .volume(quantity.to_string())
3144            .order_type(kraken_order_type);
3145
3146        let is_conditional = matches!(
3147            order_type,
3148            OrderType::StopMarket
3149                | OrderType::StopLimit
3150                | OrderType::MarketIfTouched
3151                | OrderType::LimitIfTouched
3152                | OrderType::TrailingStopMarket
3153                | OrderType::TrailingStopLimit
3154        );
3155
3156        let is_trailing = matches!(
3157            order_type,
3158            OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
3159        );
3160
3161        if is_trailing {
3162            if trigger_price.is_some() {
3163                anyhow::bail!(
3164                    "Kraken Spot trailing stops do not support activation trigger prices"
3165                );
3166            }
3167
3168            if let Some(offset) = trailing_offset {
3169                builder.price(offset.to_string());
3170            }
3171
3172            if let Some(offset) = limit_offset {
3173                builder.price2(offset.to_string());
3174            }
3175        } else if is_conditional {
3176            if let Some(trigger) = trigger_price {
3177                builder.price(trigger.to_string());
3178            }
3179
3180            if let Some(limit) = price {
3181                builder.price2(limit.to_string());
3182            }
3183        } else if let Some(limit) = price {
3184            builder.price(limit.to_string());
3185        }
3186
3187        if is_conditional {
3188            match trigger_type {
3189                Some(TriggerType::IndexPrice) => {
3190                    builder.trigger("index".to_string());
3191                }
3192                Some(TriggerType::LastPrice | TriggerType::Default) | None => {}
3193                Some(other) => {
3194                    anyhow::bail!(
3195                        "Unsupported trigger type for Kraken Spot: {other:?} (only LastPrice and IndexPrice supported)"
3196                    );
3197                }
3198            }
3199        }
3200
3201        if !oflags.is_empty() {
3202            builder.oflags(oflags.join(","));
3203        }
3204
3205        if let Some(tif) = timeinforce {
3206            builder.timeinforce(tif);
3207        }
3208
3209        if let Some(expire) = expiretm {
3210            builder.expiretm(expire);
3211        }
3212
3213        if let Some(dq) = display_qty {
3214            builder.displayvol(dq.to_string());
3215        }
3216
3217        if let Some(ac) = asset_class {
3218            builder.asset_class(ac);
3219        }
3220
3221        if leverage.is_some() && account_type != AccountType::Margin {
3222            anyhow::bail!("leverage requires spot_account_type=Margin (current: Cash)");
3223        }
3224
3225        if let Some(n) = leverage {
3226            let tiers = self.leverage_tiers_cache.get_cloned(&raw_symbol);
3227            let (buy_tiers, sell_tiers) = tiers.ok_or_else(|| {
3228                anyhow::anyhow!(
3229                    "Leverage tiers not loaded for {raw_symbol}; cannot validate leverage {n}:1 (instruments must be initialized before submitting margin orders)"
3230                )
3231            })?;
3232            let valid_tiers = match order_side {
3233                OrderSide::Buy => buy_tiers,
3234                _ => sell_tiers,
3235            };
3236            let side_label = match order_side {
3237                OrderSide::Buy => "buy",
3238                _ => "sell",
3239            };
3240
3241            if valid_tiers.is_empty() {
3242                anyhow::bail!("Leverage not supported for {raw_symbol} on {side_label} side");
3243            }
3244
3245            if !valid_tiers.contains(&(n as i32)) {
3246                anyhow::bail!(
3247                    "Leverage {n}:1 not supported for {raw_symbol} on {side_label} side (valid: {valid_tiers:?})"
3248                );
3249            }
3250            builder.leverage(format!("{n}:1"));
3251        }
3252
3253        if reduce_only {
3254            if account_type != AccountType::Margin {
3255                anyhow::bail!("reduce_only requires spot_account_type=Margin (current: Cash)");
3256            }
3257            builder.reduce_only(true);
3258        }
3259
3260        builder
3261            .build()
3262            .map_err(|e| anyhow::anyhow!("Failed to build order params: {e}"))
3263    }
3264}
3265
3266fn collect_spot_statuses(
3267    asset_pairs: &AssetPairsResponse,
3268) -> AHashMap<InstrumentId, MarketStatusAction> {
3269    asset_pairs
3270        .iter()
3271        .map(|(_, definition)| {
3272            let symbol_str = definition.wsname.as_ref().unwrap_or(&definition.altname);
3273            let normalized_symbol = normalize_spot_symbol(symbol_str.as_str());
3274            let instrument_id = InstrumentId::new(Symbol::new(&normalized_symbol), *KRAKEN_VENUE);
3275            let action = definition
3276                .status
3277                .map_or(MarketStatusAction::Trading, MarketStatusAction::from);
3278
3279            (instrument_id, action)
3280        })
3281        .collect()
3282}
3283
3284/// Maps raw symbol (altname, e.g. "XBTUSD") to leverage tiers.
3285type LeverageTiersCache = Arc<AtomicMap<Ustr, (Vec<i32>, Vec<i32>)>>;
3286
3287struct TradeBalanceSnapshot {
3288    margins: Vec<MarginBalance>,
3289    metrics: IndexMap<String, String>,
3290    free_margin: Decimal,
3291    equity: Decimal,
3292}
3293
3294/// Parses an optional `BalanceEx` credit amount, treating an absent field as zero.
3295///
3296/// Kraken only includes `credit` and `credit_used` for accounts holding a credit line, so their
3297/// absence means no credit rather than an unknown amount. Returns `None` when the field is present
3298/// but unparsable, so the caller can skip the balance rather than understate it.
3299fn optional_credit_amount(value: Option<&str>) -> Option<Decimal> {
3300    match value {
3301        Some(amount) => Decimal::from_str_exact(amount).ok(),
3302        None => Some(Decimal::ZERO),
3303    }
3304}
3305
3306/// Resolves the Nautilus [`Currency`] used to denominate `TradeBalance` margin metrics.
3307///
3308/// Kraken's `TradeBalance` defaults to `ZUSD` when no asset is supplied. This strips
3309/// Kraken's legacy `X`/`Z` prefixes and falls back to a 2dp fiat currency for unknown
3310/// codes so unusual collateral assets still produce a tagged `MarginBalance`.
3311fn trade_balance_currency(asset: Option<&str>) -> Currency {
3312    let raw = asset.unwrap_or("ZUSD");
3313    let normalized = normalize_currency_code(raw);
3314    Currency::try_from_str(normalized)
3315        .unwrap_or_else(|| Currency::new(normalized, 2, 0, normalized, CurrencyType::Fiat))
3316}
3317
3318fn compute_time_in_force(
3319    is_limit_order: bool,
3320    time_in_force: TimeInForce,
3321    expire_time: Option<UnixNanos>,
3322) -> anyhow::Result<(Option<String>, Option<String>)> {
3323    if !is_limit_order {
3324        return Ok((None, None));
3325    }
3326
3327    match time_in_force {
3328        TimeInForce::Gtc => Ok((None, None)),
3329        TimeInForce::Ioc => Ok((Some("IOC".to_string()), None)),
3330        TimeInForce::Fok => Ok((Some("FOK".to_string()), None)),
3331        TimeInForce::Gtd => {
3332            let expire = expire_time.ok_or_else(|| {
3333                anyhow::anyhow!("GTD time in force requires expire_time parameter")
3334            })?;
3335            let expire_secs = expire.as_u64() / NANOSECONDS_IN_SECOND;
3336            Ok((Some("GTD".to_string()), Some(expire_secs.to_string())))
3337        }
3338        _ => anyhow::bail!("Unsupported time in force: {time_in_force:?}"),
3339    }
3340}
3341
3342#[cfg(test)]
3343mod tests {
3344    use std::{sync::Arc, time::Duration};
3345
3346    use nautilus_model::instruments::CurrencyPair;
3347    use nautilus_testkit::http::assert_http_redirect_rejected;
3348    use rstest::rstest;
3349
3350    use super::*;
3351
3352    #[tokio::test]
3353    async fn test_authenticated_client_rejects_redirects() {
3354        let client = KrakenSpotRawHttpClient::with_credentials(
3355            "key".into(),
3356            "secret".into(),
3357            KrakenEnvironment::Live,
3358            None,
3359            3,
3360            Some(0),
3361            None,
3362            None,
3363            None,
3364            10,
3365        )
3366        .unwrap()
3367        .client;
3368        assert_http_redirect_rejected(|url| async move {
3369            client
3370                .get(url, None, None, Some(3), None)
3371                .await
3372                .unwrap()
3373                .status
3374                .as_u16()
3375        })
3376        .await;
3377    }
3378
3379    #[rstest]
3380    fn test_raw_client_creation() {
3381        let client = KrakenSpotRawHttpClient::default();
3382        assert!(client.credential.is_none());
3383    }
3384
3385    #[rstest]
3386    fn test_raw_client_with_credentials() {
3387        let client = KrakenSpotRawHttpClient::with_credentials(
3388            "test_key".to_string(),
3389            "test_secret".to_string(),
3390            KrakenEnvironment::Live,
3391            None,
3392            60,
3393            None,
3394            None,
3395            None,
3396            None,
3397            KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3398        )
3399        .unwrap();
3400        assert!(client.credential.is_some());
3401    }
3402
3403    #[rstest]
3404    #[tokio::test]
3405    async fn test_order_request_cancellation_before_transport() {
3406        let client = Arc::new(KrakenSpotRawHttpClient::default());
3407        let guard = client.auth_mutex.lock().await;
3408        let cancellation_token = client.cancellation_token();
3409        let waiting_token = cancellation_token.clone();
3410        let waiting_client = Arc::clone(&client);
3411        let waiting = tokio::spawn(async move {
3412            waiting_client
3413                .lock_order_request(&waiting_token)
3414                .await
3415                .map(drop)
3416        });
3417
3418        tokio::task::yield_now().await;
3419        client.cancel_all_requests();
3420        let waiting_result = tokio::time::timeout(Duration::from_secs(1), waiting)
3421            .await
3422            .expect("auth lock wait should stop on cancellation")
3423            .expect("auth lock task should complete");
3424        let request_result = client
3425            .send_order_request(
3426                Method::POST,
3427                "/0/private/AddOrder",
3428                KrakenSpotRawHttpClient::default_headers(),
3429                None,
3430                &cancellation_token,
3431            )
3432            .await;
3433        drop(guard);
3434        client.reset_cancellation_token();
3435        let reset_token = client.cancellation_token();
3436
3437        assert!(matches!(
3438            waiting_result,
3439            Err(KrakenHttpError::RequestNotStarted(ref message))
3440                if message == "Request canceled before transport invocation"
3441        ));
3442        assert!(matches!(
3443            request_result,
3444            Err(KrakenHttpError::RequestNotStarted(ref message))
3445                if message == "Request canceled before transport invocation"
3446        ));
3447        assert!(!reset_token.is_cancelled());
3448    }
3449
3450    #[rstest]
3451    #[tokio::test]
3452    async fn test_trade_volume_request_cancellation_before_transport() {
3453        let client = KrakenSpotRawHttpClient::with_credentials(
3454            "test_key".to_string(),
3455            "dGVzdF9hcGlfc2VjcmV0X2Jhc2U2NA==".to_string(),
3456            KrakenEnvironment::Live,
3457            Some("http://127.0.0.1:1".to_string()),
3458            1,
3459            None,
3460            None,
3461            None,
3462            None,
3463            KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3464        )
3465        .unwrap();
3466        let params = SpotTradeVolumeParams {
3467            pair: SpotTradeVolumePairs::Names("XBTUSDT".to_string()),
3468        };
3469        client.cancel_all_requests();
3470
3471        let result = client.get_trade_volume(&params).await;
3472
3473        assert!(matches!(
3474            result,
3475            Err(KrakenHttpError::NetworkError(ref message))
3476                if message.contains("canceled") || message.contains("cancelled")
3477        ));
3478    }
3479
3480    #[rstest]
3481    fn test_client_creation() {
3482        let client = KrakenSpotHttpClient::default();
3483        assert!(client.instruments_cache.is_empty());
3484    }
3485
3486    #[rstest]
3487    fn test_client_with_credentials() {
3488        let client = KrakenSpotHttpClient::with_credentials(
3489            "test_key".to_string(),
3490            "test_secret".to_string(),
3491            KrakenEnvironment::Live,
3492            None,
3493            60,
3494            None,
3495            None,
3496            None,
3497            None,
3498            KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3499        )
3500        .unwrap();
3501        assert!(client.instruments_cache.is_empty());
3502    }
3503
3504    #[rstest]
3505    fn test_nonce_generation_strictly_increasing() {
3506        let client = KrakenSpotRawHttpClient::default();
3507
3508        let nonce1 = client.generate_nonce();
3509        let nonce2 = client.generate_nonce();
3510        let nonce3 = client.generate_nonce();
3511
3512        assert!(
3513            nonce2 > nonce1,
3514            "nonce2 ({nonce2}) should be > nonce1 ({nonce1})"
3515        );
3516        assert!(
3517            nonce3 > nonce2,
3518            "nonce3 ({nonce3}) should be > nonce2 ({nonce2})"
3519        );
3520    }
3521
3522    #[rstest]
3523    fn test_nonce_is_nanosecond_timestamp() {
3524        let client = KrakenSpotRawHttpClient::default();
3525
3526        let nonce = client.generate_nonce();
3527
3528        // Nonce should be a nanosecond timestamp (roughly 1.7e18 for Dec 2025)
3529        // Verify it's in a reasonable range (> 1.5e18, which is ~2017)
3530        assert!(
3531            nonce > 1_500_000_000_000_000_000,
3532            "Nonce should be nanosecond timestamp"
3533        );
3534    }
3535
3536    #[rstest]
3537    #[case::gtc_limit(true, TimeInForce::Gtc, None, None, None)]
3538    #[case::ioc_limit(true, TimeInForce::Ioc, None, Some("IOC"), None)]
3539    #[case::fok_limit(true, TimeInForce::Fok, None, Some("FOK"), None)]
3540    #[case::gtd_limit_with_expire(
3541        true,
3542        TimeInForce::Gtd,
3543        Some(1_704_067_200_000_000_000u64),
3544        Some("GTD"),
3545        Some("1704067200")
3546    )]
3547    #[case::gtc_market(false, TimeInForce::Gtc, None, None, None)]
3548    #[case::ioc_market(false, TimeInForce::Ioc, None, None, None)]
3549    fn test_compute_time_in_force_success(
3550        #[case] is_limit: bool,
3551        #[case] tif: TimeInForce,
3552        #[case] expire_nanos: Option<u64>,
3553        #[case] expected_tif: Option<&str>,
3554        #[case] expected_expire: Option<&str>,
3555    ) {
3556        let expire_time = expire_nanos.map(UnixNanos::from);
3557        let result = compute_time_in_force(is_limit, tif, expire_time).unwrap();
3558        assert_eq!(result.0, expected_tif.map(String::from));
3559        assert_eq!(result.1, expected_expire.map(String::from));
3560    }
3561
3562    #[rstest]
3563    #[case::gtd_missing_expire(TimeInForce::Gtd, None, "expire_time")]
3564    fn test_compute_time_in_force_errors(
3565        #[case] tif: TimeInForce,
3566        #[case] expire_nanos: Option<u64>,
3567        #[case] expected_error: &str,
3568    ) {
3569        let expire_time = expire_nanos.map(UnixNanos::from);
3570        let result = compute_time_in_force(true, tif, expire_time);
3571        assert!(result.is_err());
3572        assert!(result.unwrap_err().to_string().contains(expected_error));
3573    }
3574
3575    #[rstest]
3576    fn test_build_add_order_params_sets_index_trigger_for_conditional_orders() {
3577        let client = KrakenSpotHttpClient::default();
3578        let instrument_id = cache_test_spot_instrument(&client);
3579
3580        let params = client
3581            .build_add_order_params(
3582                instrument_id,
3583                ClientOrderId::new("spot-trigger-index"),
3584                OrderSide::Buy,
3585                OrderType::StopMarket,
3586                Quantity::from("0.01"),
3587                TimeInForce::Gtc,
3588                None,
3589                None,
3590                Some(Price::from("50000")),
3591                Some(TriggerType::IndexPrice),
3592                None,
3593                None,
3594                false,
3595                false,
3596                false,
3597                None,
3598                None,
3599                AccountType::Cash,
3600            )
3601            .unwrap();
3602
3603        assert_eq!(params.trigger, Some("index".to_string()));
3604        assert_eq!(params.price, Some("50000".to_string()));
3605    }
3606
3607    #[rstest]
3608    fn test_build_add_order_params_sets_trailing_offsets() {
3609        let client = KrakenSpotHttpClient::default();
3610        let instrument_id = cache_test_spot_instrument(&client);
3611
3612        let params = client
3613            .build_add_order_params(
3614                instrument_id,
3615                ClientOrderId::new("spot-trailing"),
3616                OrderSide::Sell,
3617                OrderType::TrailingStopLimit,
3618                Quantity::from("0.01"),
3619                TimeInForce::Gtc,
3620                None,
3621                Some(Price::from("49900")),
3622                None,
3623                Some(TriggerType::LastPrice),
3624                Some(Decimal::from(50)),
3625                Some(Decimal::from(25)),
3626                false,
3627                false,
3628                false,
3629                Some(Quantity::from("0.005")),
3630                None,
3631                AccountType::Cash,
3632            )
3633            .unwrap();
3634
3635        assert_eq!(params.price, Some("50".to_string()));
3636        assert_eq!(params.price2, Some("25".to_string()));
3637        assert_eq!(params.trigger, None);
3638        assert_eq!(params.displayvol, Some("0.005".to_string()));
3639    }
3640
3641    #[rstest]
3642    fn test_build_add_order_params_rejects_unsupported_trigger_type() {
3643        let client = KrakenSpotHttpClient::default();
3644        let instrument_id = cache_test_spot_instrument(&client);
3645
3646        let error = client
3647            .build_add_order_params(
3648                instrument_id,
3649                ClientOrderId::new("spot-trigger-invalid"),
3650                OrderSide::Buy,
3651                OrderType::StopMarket,
3652                Quantity::from("0.01"),
3653                TimeInForce::Gtc,
3654                None,
3655                None,
3656                Some(Price::from("50000")),
3657                Some(TriggerType::MarkPrice),
3658                None,
3659                None,
3660                false,
3661                false,
3662                false,
3663                None,
3664                None,
3665                AccountType::Cash,
3666            )
3667            .unwrap_err();
3668
3669        assert!(
3670            error
3671                .to_string()
3672                .contains("Unsupported trigger type for Kraken Spot")
3673        );
3674    }
3675
3676    fn cache_test_spot_instrument(client: &KrakenSpotHttpClient) -> InstrumentId {
3677        let instrument_id = InstrumentId::from("XBT/USD.KRAKEN");
3678
3679        client.cache_instrument(InstrumentAny::CurrencyPair(
3680            CurrencyPair::builder()
3681                .instrument_id(instrument_id)
3682                .raw_symbol(Symbol::new("XBTUSD"))
3683                .base_currency(Currency::BTC())
3684                .quote_currency(Currency::USD())
3685                .price_precision(1)
3686                .size_precision(8)
3687                .price_increment(Price::from("0.1"))
3688                .size_increment(Quantity::from("0.00000001"))
3689                .ts_event(0.into())
3690                .ts_init(0.into())
3691                .build()
3692                .unwrap(),
3693        ));
3694
3695        instrument_id
3696    }
3697
3698    fn cache_test_spot_instrument_with_leverage(
3699        client: &KrakenSpotHttpClient,
3700        leverage_buy: &[i32],
3701        leverage_sell: &[i32],
3702    ) -> InstrumentId {
3703        let instrument_id = cache_test_spot_instrument(client);
3704        let raw_symbol = Ustr::from("XBTUSD");
3705        client.leverage_tiers_cache.rcu(|m| {
3706            m.insert(raw_symbol, (leverage_buy.to_vec(), leverage_sell.to_vec()));
3707        });
3708        instrument_id
3709    }
3710
3711    #[rstest]
3712    fn test_build_add_order_params_leverage_serialized_as_ratio() {
3713        let client = KrakenSpotHttpClient::default();
3714        let instrument_id =
3715            cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
3716
3717        let params = client
3718            .build_add_order_params(
3719                instrument_id,
3720                ClientOrderId::new("spot-margin-buy"),
3721                OrderSide::Buy,
3722                OrderType::Limit,
3723                Quantity::from("0.01"),
3724                TimeInForce::Gtc,
3725                None,
3726                Some(Price::from("50000")),
3727                None,
3728                None,
3729                None,
3730                None,
3731                false,
3732                false,
3733                false,
3734                None,
3735                Some(3),
3736                AccountType::Margin,
3737            )
3738            .unwrap();
3739
3740        assert_eq!(params.leverage, Some("3:1".to_string()));
3741    }
3742
3743    #[rstest]
3744    fn test_build_add_order_params_invalid_leverage_rejected() {
3745        let client = KrakenSpotHttpClient::default();
3746        let instrument_id =
3747            cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
3748
3749        let err = client
3750            .build_add_order_params(
3751                instrument_id,
3752                ClientOrderId::new("spot-margin-bad"),
3753                OrderSide::Buy,
3754                OrderType::Limit,
3755                Quantity::from("0.01"),
3756                TimeInForce::Gtc,
3757                None,
3758                Some(Price::from("50000")),
3759                None,
3760                None,
3761                None,
3762                None,
3763                false,
3764                false,
3765                false,
3766                None,
3767                Some(7),
3768                AccountType::Margin,
3769            )
3770            .unwrap_err();
3771
3772        assert!(
3773            err.to_string().contains("not supported"),
3774            "Expected tier-validation error: {err}"
3775        );
3776    }
3777
3778    #[rstest]
3779    fn test_build_add_order_params_no_leverage_is_cash() {
3780        let client = KrakenSpotHttpClient::default();
3781        let instrument_id =
3782            cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
3783
3784        let params = client
3785            .build_add_order_params(
3786                instrument_id,
3787                ClientOrderId::new("spot-cash"),
3788                OrderSide::Buy,
3789                OrderType::Limit,
3790                Quantity::from("0.01"),
3791                TimeInForce::Gtc,
3792                None,
3793                Some(Price::from("50000")),
3794                None,
3795                None,
3796                None,
3797                None,
3798                false,
3799                false,
3800                false,
3801                None,
3802                None,
3803                AccountType::Cash,
3804            )
3805            .unwrap();
3806
3807        assert_eq!(
3808            params.leverage, None,
3809            "Cash order should not have leverage field"
3810        );
3811    }
3812
3813    #[rstest]
3814    fn test_build_add_order_params_rejects_per_order_leverage_in_cash_mode() {
3815        let client = KrakenSpotHttpClient::default();
3816        let instrument_id = cache_test_spot_instrument(&client);
3817
3818        let err = client
3819            .build_add_order_params(
3820                instrument_id,
3821                ClientOrderId::new("cash-with-leverage"),
3822                OrderSide::Buy,
3823                OrderType::Limit,
3824                Quantity::from("0.01"),
3825                TimeInForce::Gtc,
3826                None,
3827                Some(Price::from("50000")),
3828                None,
3829                None,
3830                None,
3831                None,
3832                false,
3833                false,
3834                false,
3835                None,
3836                Some(3),
3837                AccountType::Cash,
3838            )
3839            .unwrap_err();
3840
3841        assert!(
3842            err.to_string().contains("Margin"),
3843            "Expected Margin mode rejection: {err}"
3844        );
3845    }
3846
3847    #[rstest]
3848    fn test_build_add_order_params_reduce_only_forwarded_in_margin_mode() {
3849        let client = KrakenSpotHttpClient::default();
3850        let instrument_id = cache_test_spot_instrument(&client);
3851
3852        let params = client
3853            .build_add_order_params(
3854                instrument_id,
3855                ClientOrderId::new("margin-reduce-only"),
3856                OrderSide::Sell,
3857                OrderType::Limit,
3858                Quantity::from("0.01"),
3859                TimeInForce::Gtc,
3860                None,
3861                Some(Price::from("50000")),
3862                None,
3863                None,
3864                None,
3865                None,
3866                true,
3867                false,
3868                false,
3869                None,
3870                None,
3871                AccountType::Margin,
3872            )
3873            .unwrap();
3874
3875        assert_eq!(params.reduce_only, Some(true));
3876    }
3877
3878    #[rstest]
3879    fn test_build_add_order_params_rejects_reduce_only_in_cash_mode() {
3880        let client = KrakenSpotHttpClient::default();
3881        let instrument_id = cache_test_spot_instrument(&client);
3882
3883        let err = client
3884            .build_add_order_params(
3885                instrument_id,
3886                ClientOrderId::new("cash-reduce-only"),
3887                OrderSide::Sell,
3888                OrderType::Limit,
3889                Quantity::from("0.01"),
3890                TimeInForce::Gtc,
3891                None,
3892                Some(Price::from("50000")),
3893                None,
3894                None,
3895                None,
3896                None,
3897                true,
3898                false,
3899                false,
3900                None,
3901                None,
3902                AccountType::Cash,
3903            )
3904            .unwrap_err();
3905
3906        assert!(
3907            err.to_string().contains("reduce_only requires"),
3908            "expected reduce_only Margin rejection: {err}"
3909        );
3910    }
3911
3912    #[rstest]
3913    fn test_build_add_order_params_rejects_leverage_when_tiers_not_loaded() {
3914        let client = KrakenSpotHttpClient::default();
3915        let instrument_id = cache_test_spot_instrument(&client);
3916
3917        let err = client
3918            .build_add_order_params(
3919                instrument_id,
3920                ClientOrderId::new("missing-tiers"),
3921                OrderSide::Buy,
3922                OrderType::Limit,
3923                Quantity::from("0.01"),
3924                TimeInForce::Gtc,
3925                None,
3926                Some(Price::from("50000")),
3927                None,
3928                None,
3929                None,
3930                None,
3931                false,
3932                false,
3933                false,
3934                None,
3935                Some(3),
3936                AccountType::Margin,
3937            )
3938            .unwrap_err();
3939
3940        assert!(
3941            err.to_string().contains("Leverage tiers not loaded"),
3942            "expected cache-miss rejection: {err}"
3943        );
3944    }
3945}