Skip to main content

nautilus_kraken/http/spot/
client.rs

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