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