Skip to main content

nautilus_kraken/http/spot/
client.rs

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