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 leverage_tiers = AHashMap::new();
1728
1729        let mut instruments: Vec<InstrumentAny> = asset_pairs
1730            .iter()
1731            .filter_map(|(pair_name, definition)| {
1732                match parse_spot_instrument(pair_name, definition, ts_init, ts_init) {
1733                    Ok(instrument) => Some((instrument, definition)),
1734                    Err(e) => {
1735                        log::warn!("Failed to parse instrument {pair_name}: {e}");
1736                        None
1737                    }
1738                }
1739            })
1740            .map(|(instrument, definition)| {
1741                let key = Ustr::from(instrument.raw_symbol().as_str());
1742                let tiers = (
1743                    definition.leverage_buy.clone(),
1744                    definition.leverage_sell.clone(),
1745                );
1746                leverage_tiers.insert(key, tiers);
1747                instrument
1748            })
1749            .collect();
1750
1751        if !leverage_tiers.is_empty() {
1752            self.leverage_tiers_cache.rcu(|cache| {
1753                cache.extend(
1754                    leverage_tiers
1755                        .iter()
1756                        .map(|(key, tiers)| (*key, tiers.clone())),
1757                );
1758            });
1759        }
1760
1761        // Also fetch tokenized asset pairs (xStocks). When loading all pairs this
1762        // picks up tokenized equities; when loading specific pairs it covers the
1763        // case where the requested symbols are tokenized assets.
1764        {
1765            match self
1766                .inner
1767                .get_asset_pairs(pairs, Some("tokenized_asset"))
1768                .await
1769            {
1770                Ok(tokenized_pairs) => {
1771                    if !tokenized_pairs.is_empty() {
1772                        log::debug!("Fetched {} tokenized asset pairs", tokenized_pairs.len());
1773                    }
1774                    self.record_pair_aliases(&tokenized_pairs);
1775                    let tokenized_instruments: Vec<InstrumentAny> =
1776                        tokenized_pairs
1777                            .iter()
1778                            .filter_map(|(pair_name, definition)| match parse_tokenized_instrument(
1779                                pair_name, definition, ts_init, ts_init,
1780                            ) {
1781                                Ok(instrument) => Some(instrument),
1782                                Err(e) => {
1783                                    log::warn!(
1784                                        "Failed to parse tokenized instrument {pair_name}: {e}"
1785                                    );
1786                                    None
1787                                }
1788                            })
1789                            .collect();
1790                    instruments.extend(tokenized_instruments);
1791                }
1792                Err(e) => {
1793                    log::warn!("Failed to fetch tokenized asset pairs: {e}");
1794                }
1795            }
1796        }
1797
1798        Ok(instruments)
1799    }
1800
1801    /// Requests the current market status for Kraken Spot instruments.
1802    ///
1803    /// Fetches both regular and tokenized asset pairs. The call returns an error if
1804    /// either fetch fails so callers can avoid emitting partial snapshots that would
1805    /// otherwise cause the missing tokenized symbols to be diffed as removed.
1806    pub async fn request_instrument_statuses(
1807        &self,
1808        pairs: Option<Vec<String>>,
1809    ) -> anyhow::Result<AHashMap<InstrumentId, MarketStatusAction>, KrakenHttpError> {
1810        let asset_pairs = self.inner.get_asset_pairs(pairs.clone(), None).await?;
1811        let mut statuses = collect_spot_statuses(&asset_pairs);
1812
1813        let tokenized_pairs = self
1814            .inner
1815            .get_asset_pairs(pairs, Some("tokenized_asset"))
1816            .await?;
1817        statuses.extend(collect_spot_statuses(&tokenized_pairs));
1818
1819        Ok(statuses)
1820    }
1821
1822    /// Requests historical trades for an instrument.
1823    pub async fn request_trades(
1824        &self,
1825        instrument_id: InstrumentId,
1826        start: Option<Timestamp>,
1827        end: Option<Timestamp>,
1828        limit: Option<u64>,
1829    ) -> anyhow::Result<Vec<TradeTick>, KrakenHttpError> {
1830        let instrument = self
1831            .get_cached_instrument(&instrument_id.symbol.inner())
1832            .ok_or_else(|| {
1833                KrakenHttpError::ParseError(
1834                    InstrumentLookupError::not_found(instrument_id).to_string(),
1835                )
1836            })?;
1837
1838        let raw_symbol = instrument.raw_symbol().to_string();
1839        let asset_class = Self::asset_class_for(&instrument);
1840        let ts_init = self.generate_ts_init();
1841
1842        // Kraken trades API expects nanoseconds since epoch as string
1843        let since = start.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0).to_string());
1844        let response = self
1845            .inner
1846            .get_trades(&raw_symbol, since, asset_class)
1847            .await?;
1848
1849        let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1850        let mut trades = Vec::new();
1851
1852        for (_pair_name, trade_arrays) in &response.data {
1853            for trade_array in trade_arrays {
1854                match parse_trade_tick_from_array(trade_array, &instrument, ts_init) {
1855                    Ok(trade_tick) => {
1856                        if let Some(end_nanos) = end_ns
1857                            && trade_tick.ts_event.as_u64() > end_nanos
1858                        {
1859                            continue;
1860                        }
1861                        trades.push(trade_tick);
1862                    }
1863                    Err(e) => {
1864                        log::warn!("Failed to parse trade tick: {e}");
1865                    }
1866                }
1867            }
1868        }
1869
1870        // Count-only keeps the most recent `limit` trades, not the oldest
1871        apply_count_limit(&mut trades, start, limit);
1872
1873        Ok(trades)
1874    }
1875
1876    /// Requests historical bars/OHLC data for an instrument.
1877    pub async fn request_bars(
1878        &self,
1879        bar_type: BarType,
1880        start: Option<Timestamp>,
1881        end: Option<Timestamp>,
1882        limit: Option<u64>,
1883    ) -> anyhow::Result<Vec<Bar>, KrakenHttpError> {
1884        let instrument_id = bar_type.instrument_id();
1885        let instrument = self
1886            .get_cached_instrument(&instrument_id.symbol.inner())
1887            .ok_or_else(|| {
1888                KrakenHttpError::ParseError(
1889                    InstrumentLookupError::not_found(instrument_id).to_string(),
1890                )
1891            })?;
1892
1893        let raw_symbol = instrument.raw_symbol().to_string();
1894        let asset_class = Self::asset_class_for(&instrument);
1895        let ts_init = self.generate_ts_init();
1896
1897        let interval = Some(
1898            bar_type_to_spot_interval(bar_type)
1899                .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?,
1900        );
1901
1902        // Kraken OHLC API expects Unix timestamp in seconds
1903        let since = start.map(|dt| dt.as_second());
1904        let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1905        let response = self
1906            .inner
1907            .get_ohlc(&raw_symbol, interval, since, asset_class)
1908            .await?;
1909
1910        let mut bars = Vec::new();
1911
1912        for (_pair_name, ohlc_arrays) in &response.data {
1913            for ohlc_array in ohlc_arrays {
1914                if ohlc_array.len() < 8 {
1915                    let len = ohlc_array.len();
1916                    log::warn!("OHLC array too short: {len}");
1917                    continue;
1918                }
1919
1920                let ohlc = OhlcData {
1921                    time: ohlc_array[0].as_i64().unwrap_or(0),
1922                    open: ohlc_array[1].as_str().unwrap_or("0").to_string(),
1923                    high: ohlc_array[2].as_str().unwrap_or("0").to_string(),
1924                    low: ohlc_array[3].as_str().unwrap_or("0").to_string(),
1925                    close: ohlc_array[4].as_str().unwrap_or("0").to_string(),
1926                    vwap: ohlc_array[5].as_str().unwrap_or("0").to_string(),
1927                    volume: ohlc_array[6].as_str().unwrap_or("0").to_string(),
1928                    count: ohlc_array[7].as_i64().unwrap_or(0),
1929                };
1930
1931                match parse_bar(&ohlc, &instrument, bar_type, ts_init) {
1932                    Ok(bar) => {
1933                        if let Some(end_nanos) = end_ns
1934                            && bar.ts_event.as_u64() > end_nanos
1935                        {
1936                            continue;
1937                        }
1938                        bars.push(bar);
1939                    }
1940                    Err(e) => {
1941                        log::warn!("Failed to parse bar: {e}");
1942                    }
1943                }
1944            }
1945        }
1946
1947        // Kraken returns the page oldest-first; keep the most recent `limit`
1948        // bars for count-only requests rather than the oldest (issue #4254).
1949        apply_count_limit(&mut bars, start, limit);
1950
1951        Ok(bars)
1952    }
1953
1954    /// Requests an order book snapshot for an instrument.
1955    pub async fn request_book_snapshot(
1956        &self,
1957        instrument_id: InstrumentId,
1958        depth: Option<u32>,
1959    ) -> anyhow::Result<OrderBook, KrakenHttpError> {
1960        let instrument = self
1961            .get_cached_instrument(&instrument_id.symbol.inner())
1962            .ok_or_else(|| {
1963                KrakenHttpError::ParseError(
1964                    InstrumentLookupError::not_found(instrument_id).to_string(),
1965                )
1966            })?;
1967
1968        let raw_symbol = instrument.raw_symbol().to_string();
1969        let asset_class = Self::asset_class_for(&instrument);
1970        let price_precision = instrument.price_precision();
1971        let size_precision = instrument.size_precision();
1972        let ts_event = self.generate_ts_init();
1973
1974        let response = self
1975            .inner
1976            .get_book_depth(&raw_symbol, depth, asset_class)
1977            .await?;
1978
1979        let book_data = response.values().next().ok_or_else(|| {
1980            KrakenHttpError::ParseError(format!("No book data returned for {instrument_id}"))
1981        })?;
1982
1983        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
1984
1985        // Pass sequence=0 so the snapshot does not advance the book's high-water sequence,
1986        // the WS subscription owns sequencing once it starts streaming deltas.
1987        for (i, level) in book_data.bids.iter().enumerate() {
1988            let price_str = level.first().and_then(|v| v.as_str()).unwrap_or("0");
1989            let size_str = level.get(1).and_then(|v| v.as_str()).unwrap_or("0");
1990            let price = Price::from_decimal_dp(
1991                Decimal::from_str_exact(price_str).unwrap_or(Decimal::ZERO),
1992                price_precision,
1993            )
1994            .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1995            let size = Quantity::from_decimal_dp(
1996                Decimal::from_str_exact(size_str).unwrap_or(Decimal::ZERO),
1997                size_precision,
1998            )
1999            .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
2000            let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
2001            book.add(order, 0, 0, ts_event);
2002        }
2003
2004        let bids_len = book_data.bids.len();
2005
2006        for (i, level) in book_data.asks.iter().enumerate() {
2007            let price_str = level.first().and_then(|v| v.as_str()).unwrap_or("0");
2008            let size_str = level.get(1).and_then(|v| v.as_str()).unwrap_or("0");
2009            let price = Price::from_decimal_dp(
2010                Decimal::from_str_exact(price_str).unwrap_or(Decimal::ZERO),
2011                price_precision,
2012            )
2013            .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
2014            let size = Quantity::from_decimal_dp(
2015                Decimal::from_str_exact(size_str).unwrap_or(Decimal::ZERO),
2016                size_precision,
2017            )
2018            .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
2019            let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
2020            book.add(order, 0, 0, ts_event);
2021        }
2022
2023        Ok(book)
2024    }
2025
2026    /// Requests account state (balances) from Kraken.
2027    ///
2028    /// In cash mode returns wallet balances only.
2029    /// In margin mode additionally calls `TradeBalance` to build [`MarginBalance`] entries.
2030    /// `margin_balance_asset` selects the summary-display denomination for `TradeBalance`
2031    /// (e.g. `"ZUSD"`, `"ZGBP"`); `None` lets Kraken default to `ZUSD`.
2032    ///
2033    /// Callers that also need the `TradeBalance` metrics dictionary should use
2034    /// [`Self::request_account_state_with_metrics`] to avoid issuing two `TradeBalance`
2035    /// HTTP requests per account update.
2036    pub async fn request_account_state(
2037        &self,
2038        account_id: AccountId,
2039        account_type: AccountType,
2040        margin_balance_asset: Option<&str>,
2041    ) -> anyhow::Result<AccountState> {
2042        self.request_account_state_with_metrics(account_id, account_type, margin_balance_asset)
2043            .await
2044            .map(|(state, _)| state)
2045    }
2046
2047    /// Requests the full margin account snapshot in a single round-trip.
2048    ///
2049    /// Returns the [`AccountState`] (including `MarginBalance` entries when
2050    /// `account_type` is `Margin`) and the `TradeBalance` metrics dictionary that
2051    /// callers attach to `AccountState.info`. In cash mode the metrics map is empty
2052    /// and `TradeBalance` is not called.
2053    ///
2054    /// In margin mode, replaces the raw `margin_balance_asset` wallet with a
2055    /// synthetic [`AccountBalance`] using `total = e` and `free = mf` from
2056    /// `TradeBalance`. Kraken reports these values across all collateral, which
2057    /// avoids clamping free margin to one wallet bucket in multi-asset accounts.
2058    ///
2059    /// Wallet balances come from `BalanceEx`, whose per-asset `hold_trade` gives the amount
2060    /// Kraken has reserved against resting orders and populates `locked`. Net credit
2061    /// (`credit - credit_used`, returned only for credit-line accounts) is included in `total`,
2062    /// so `free` derives to Kraken's available balance of
2063    /// `balance + credit - credit_used - hold_trade`.
2064    ///
2065    /// The single shared fetch keeps Kraken rate-limit usage symmetric with `BalanceEx`
2066    /// (one request per account update), instead of two as if `request_account_state`
2067    /// and `request_margin_metrics` were called in sequence.
2068    pub async fn request_account_state_with_metrics(
2069        &self,
2070        account_id: AccountId,
2071        account_type: AccountType,
2072        margin_balance_asset: Option<&str>,
2073    ) -> anyhow::Result<(AccountState, IndexMap<String, String>)> {
2074        let balances_raw = self.inner.get_balance_ex().await?;
2075        let ts_init = self.generate_ts_init();
2076
2077        let (margins, metrics, margin_entry, target_code) = if account_type == AccountType::Margin {
2078            let snapshot = self
2079                .fetch_trade_balance_snapshot(margin_balance_asset)
2080                .await?;
2081            let target_code = normalize_currency_code(margin_balance_asset.unwrap_or("ZUSD"));
2082            let currency = Currency::new(target_code, 8, 0, "0", CurrencyType::Crypto);
2083            let margin_entry = AccountBalance::from_total_and_free(
2084                snapshot.equity,
2085                snapshot.free_margin,
2086                currency,
2087            )
2088            .context("Failed to build synthetic margin AccountBalance from TradeBalance")?;
2089
2090            (
2091                snapshot.margins,
2092                snapshot.metrics,
2093                Some(margin_entry),
2094                target_code,
2095            )
2096        } else {
2097            (Vec::new(), IndexMap::new(), None, "")
2098        };
2099
2100        let skip_margin_wallet = margin_entry.is_some();
2101
2102        let balances: Vec<AccountBalance> = balances_raw
2103            .iter()
2104            .filter_map(|(currency_code, entry)| {
2105                let balance = Decimal::from_str_exact(&entry.balance).ok()?;
2106                let credit = optional_credit_amount(entry.credit.as_deref())?;
2107                let credit_used = optional_credit_amount(entry.credit_used.as_deref())?;
2108
2109                // Kraken defines available funds as `balance + credit - credit_used -
2110                // hold_trade`, so net credit belongs in `total` for `free` to derive to it.
2111                let total = balance + credit - credit_used;
2112
2113                let normalized_code = normalize_currency_code(currency_code);
2114
2115                if skip_margin_wallet && normalized_code == target_code {
2116                    return None;
2117                }
2118
2119                let locked = Decimal::from_str_exact(&entry.hold_trade).ok()?;
2120                let currency =
2121                    Currency::new(normalized_code, 8, 0, normalized_code, CurrencyType::Crypto);
2122                AccountBalance::from_total_and_locked(total, locked, currency).ok()
2123            })
2124            .chain(margin_entry)
2125            .collect();
2126
2127        let state = AccountState::new(
2128            account_id,
2129            account_type,
2130            balances,
2131            margins,
2132            true,
2133            UUID4::new(),
2134            ts_init,
2135            ts_init,
2136            None,
2137        );
2138
2139        Ok((state, metrics))
2140    }
2141
2142    /// Fetches `TradeBalance` once and returns both the parsed [`MarginBalance`] entries
2143    /// and the metrics dictionary surfaced through `AccountState.info`.
2144    ///
2145    /// # Margin mapping rationale
2146    ///
2147    /// Kraken's `TradeBalance` returns a single used-margin value `m` ("margin amount
2148    /// of open positions") and does not split into separate initial- and maintenance-
2149    /// margin figures. Kraken's "maintenance margin" is a liquidation-level percentage
2150    /// threshold (around 80% margin level), not a collateral money figure.
2151    ///
2152    /// [`nautilus_model::accounts::MarginAccount::recalculate_balance`] sums
2153    /// `initial + maintenance` to compute `locked`, so duplicating `m` into both fields
2154    /// would double-lock equity and diverge from Kraken's reported `mf = e - m`.
2155    /// `m` is therefore mapped to `initial`, with `maintenance = Money::zero(currency)`.
2156    async fn fetch_trade_balance_snapshot(
2157        &self,
2158        asset: Option<&str>,
2159    ) -> anyhow::Result<TradeBalanceSnapshot> {
2160        let tb = self.inner.get_trade_balance(asset).await?;
2161
2162        let used_margin = Decimal::from_str_exact(&tb.m)
2163            .with_context(|| format!("Failed to parse TradeBalance 'm' field {:?}", tb.m))?;
2164        let free_margin = Decimal::from_str_exact(&tb.mf)
2165            .with_context(|| format!("Failed to parse TradeBalance 'mf' field {:?}", tb.mf))?;
2166        let equity = Decimal::from_str_exact(&tb.e)
2167            .with_context(|| format!("Failed to parse TradeBalance 'e' field {:?}", tb.e))?;
2168
2169        let margins = if used_margin.is_zero() {
2170            Vec::new()
2171        } else {
2172            let currency = trade_balance_currency(asset);
2173            let initial = Money::from_decimal(used_margin, currency)
2174                .context("Failed to build initial margin from TradeBalance 'm'")?;
2175            let maintenance = Money::zero(currency);
2176            vec![MarginBalance::new(initial, maintenance, None)]
2177        };
2178
2179        let mut metrics = IndexMap::new();
2180        metrics.insert("equivalent_balance".to_string(), tb.eb);
2181        metrics.insert("trade_balance".to_string(), tb.tb);
2182        metrics.insert("used_margin".to_string(), tb.m);
2183        metrics.insert("unexecuted_value".to_string(), tb.uv);
2184        metrics.insert("unrealized_pnl".to_string(), tb.n);
2185        metrics.insert("cost_basis".to_string(), tb.c);
2186        metrics.insert("valuation".to_string(), tb.v);
2187        metrics.insert("equity".to_string(), tb.e);
2188        metrics.insert("free_margin".to_string(), tb.mf);
2189        if let Some(ml) = tb.ml {
2190            metrics.insert("margin_level".to_string(), ml);
2191        }
2192        metrics.insert(
2193            "asset".to_string(),
2194            normalize_currency_code(asset.unwrap_or("ZUSD")).to_string(),
2195        );
2196
2197        Ok(TradeBalanceSnapshot {
2198            margins,
2199            metrics,
2200            free_margin,
2201            equity,
2202        })
2203    }
2204
2205    /// Returns a flattened snapshot of Kraken's `TradeBalance` margin metrics.
2206    ///
2207    /// Caller is expected to invoke this only when operating in margin mode; consumers
2208    /// surface the values via `AccountState.info` (Python-side) for strategy access.
2209    /// Strings preserve venue precision exactly. Keys: `equivalent_balance`,
2210    /// `trade_balance`, `used_margin`, `unexecuted_value`, `unrealized_pnl`,
2211    /// `cost_basis`, `valuation`, `equity`, `free_margin`, `margin_level` (omitted
2212    /// when Kraken returns no value, i.e. no open positions), `asset`.
2213    ///
2214    /// When the metrics are needed alongside the [`AccountState`], prefer
2215    /// [`Self::request_account_state_with_metrics`] to share a single `TradeBalance`
2216    /// HTTP request between both.
2217    pub async fn request_margin_metrics(
2218        &self,
2219        asset: Option<&str>,
2220    ) -> anyhow::Result<IndexMap<String, String>> {
2221        self.fetch_trade_balance_snapshot(asset)
2222            .await
2223            .map(|snapshot| snapshot.metrics)
2224    }
2225
2226    /// Requests order status reports from Kraken.
2227    pub async fn request_order_status_reports(
2228        &self,
2229        account_id: AccountId,
2230        instrument_id: Option<InstrumentId>,
2231        start: Option<Timestamp>,
2232        end: Option<Timestamp>,
2233        open_only: bool,
2234    ) -> anyhow::Result<Vec<OrderStatusReport>> {
2235        self.request_order_status_reports_checked(account_id, instrument_id, start, end, open_only)
2236            .await
2237            .map(|(reports, _)| reports)
2238    }
2239
2240    /// Requests order status reports, also reporting what the read covered.
2241    ///
2242    /// An in-scope open order whose pair cannot be resolved, or that cannot be parsed, fails the
2243    /// read, except that a read scoped to one instrument skips an order whose pair it cannot
2244    /// resolve, since that order may be on another instrument. The coverage records each such
2245    /// skip, each closed order that cannot be resolved, each in-scope one that cannot be parsed,
2246    /// and a closed-order read that stops at its page cap, which it cannot place in time. A read
2247    /// scoped to an unheld spot instrument reads nothing and rules out no order.
2248    /// `ExecutionMassStatus::set_report_window` records whether it is complete for bounded history,
2249    /// and the single-order lookup whether it rules out an order it did not return.
2250    pub(crate) async fn request_order_status_reports_checked(
2251        &self,
2252        account_id: AccountId,
2253        instrument_id: Option<InstrumentId>,
2254        start: Option<Timestamp>,
2255        end: Option<Timestamp>,
2256        open_only: bool,
2257    ) -> anyhow::Result<(Vec<OrderStatusReport>, ReportCoverage)> {
2258        const PAGE_SIZE: i32 = 50;
2259
2260        let ts_init = self.generate_ts_init();
2261        let mut all_reports = Vec::new();
2262        let mut coverage = ReportCoverage::default();
2263
2264        // A scoped read for an instrument this client does not hold can match nothing, so return
2265        // before the request rather than falling through and reporting every instrument's rows.
2266        // A futures instrument names none of this client's orders, so that read is complete; a
2267        // spot one the listing lacks read nothing, so it rules out none of its orders.
2268        if let Some(ref target_id) = instrument_id
2269            && self
2270                .get_cached_instrument(&target_id.symbol.inner())
2271                .is_none()
2272        {
2273            if product_type_from_symbol(target_id.symbol.as_str()) == KrakenProductType::Spot {
2274                coverage.truncate(None);
2275            }
2276            return Ok((all_reports, coverage));
2277        }
2278
2279        let open_orders = self.inner.get_open_orders(Some(true), None).await?;
2280
2281        for (order_id, order) in &open_orders {
2282            // Kraken spells a pair two ways, so resolve the row and compare instrument ids rather
2283            // than the row's spelling against the cached `raw_symbol`.
2284            let resolved = self.get_instrument_by_raw_symbol(order.descr.pair.as_str());
2285            if let Some(ref target_id) = instrument_id {
2286                match &resolved {
2287                    Some(inst) if inst.id() != *target_id => continue,
2288                    // Unresolved, the row may be the scoped instrument's own
2289                    None => {
2290                        coverage.skip(order_id);
2291                        continue;
2292                    }
2293                    Some(_) => {}
2294                }
2295            }
2296
2297            let instrument = resolved.ok_or_else(|| {
2298                anyhow::anyhow!(
2299                    "OpenOrders: instrument not in cache for pair {}",
2300                    order.descr.pair
2301                )
2302            })?;
2303
2304            // An in-scope open order the client cannot parse fails the read: dropped, it reads to
2305            // reconciliation as an order the venue does not have.
2306            let report =
2307                parse_order_status_report(order_id, order, &instrument, account_id, ts_init)
2308                    .map_err(|e| {
2309                        anyhow::anyhow!("OpenOrders: failed to parse order {order_id}: {e}")
2310                    })?;
2311            all_reports.push(report);
2312        }
2313
2314        if open_only {
2315            return Ok((all_reports, coverage));
2316        }
2317
2318        // Kraken API expects Unix timestamps in seconds
2319        let start_ts = start.map(|dt| dt.as_second());
2320        let end_ts = end.map(|dt| dt.as_second());
2321
2322        let mut offset = 0;
2323        let mut pages = 0;
2324
2325        loop {
2326            if pages >= MAX_REPORT_PAGES {
2327                log::warn!(
2328                    "ClosedOrders pagination hit the cap of {MAX_REPORT_PAGES} pages; returning a truncated set and marking it incomplete"
2329                );
2330                coverage.truncate(None);
2331                break;
2332            }
2333
2334            let closed_orders = self
2335                .inner
2336                .get_closed_orders(Some(true), None, start_ts, end_ts, Some(offset), None)
2337                .await?;
2338
2339            if closed_orders.is_empty() {
2340                break;
2341            }
2342
2343            pages += 1;
2344
2345            for (order_id, order) in &closed_orders {
2346                let resolved = self.get_instrument_by_raw_symbol(order.descr.pair.as_str());
2347                if let Some(ref target_id) = instrument_id {
2348                    match &resolved {
2349                        Some(inst) if inst.id() != *target_id => continue,
2350                        // Unresolved, the row may be the scoped instrument's own
2351                        None => {
2352                            coverage.skip(order_id);
2353                            continue;
2354                        }
2355                        Some(_) => {}
2356                    }
2357                }
2358
2359                // A historical record can reference an instrument absent from the current
2360                // listing, so warn and keep the rest rather than withholding the whole read.
2361                let Some(instrument) = resolved else {
2362                    log::warn!(
2363                        "ClosedOrders: instrument not in cache for pair {}, skipping order {order_id}",
2364                        order.descr.pair
2365                    );
2366                    coverage.skip(order_id);
2367                    continue;
2368                };
2369
2370                match parse_order_status_report(order_id, order, &instrument, account_id, ts_init) {
2371                    Ok(report) => all_reports.push(report),
2372                    Err(e) => {
2373                        log::warn!("Failed to parse order {order_id}: {e}");
2374                        coverage.skip(order_id);
2375                    }
2376                }
2377            }
2378
2379            offset += PAGE_SIZE;
2380        }
2381
2382        Ok((all_reports, coverage))
2383    }
2384
2385    /// Requests fill/trade reports from Kraken.
2386    pub async fn request_fill_reports(
2387        &self,
2388        account_id: AccountId,
2389        instrument_id: Option<InstrumentId>,
2390        start: Option<Timestamp>,
2391        end: Option<Timestamp>,
2392    ) -> anyhow::Result<Vec<FillReport>> {
2393        self.request_fill_reports_checked(account_id, instrument_id, start, end)
2394            .await
2395            .map(|(reports, _)| reports)
2396    }
2397
2398    /// Requests fill reports, also reporting whether the set is complete.
2399    ///
2400    /// The flag is `false` when an in-scope trade cannot be resolved or parsed, or the read stops
2401    /// at its page cap, which `ExecutionMassStatus::set_report_window` records for bounded history.
2402    pub(crate) async fn request_fill_reports_checked(
2403        &self,
2404        account_id: AccountId,
2405        instrument_id: Option<InstrumentId>,
2406        start: Option<Timestamp>,
2407        end: Option<Timestamp>,
2408    ) -> anyhow::Result<(Vec<FillReport>, bool)> {
2409        const PAGE_SIZE: i32 = 50;
2410
2411        let ts_init = self.generate_ts_init();
2412        let mut all_reports = Vec::new();
2413        let mut complete = true;
2414
2415        // As above: a scoped read for an instrument this client does not hold matches nothing.
2416        if let Some(ref target_id) = instrument_id
2417            && self
2418                .get_cached_instrument(&target_id.symbol.inner())
2419                .is_none()
2420        {
2421            return Ok((all_reports, complete));
2422        }
2423
2424        // Kraken API expects Unix timestamps in seconds
2425        let start_ts = start.map(|dt| dt.as_second());
2426        let end_ts = end.map(|dt| dt.as_second());
2427
2428        let mut offset = 0;
2429        let mut pages = 0;
2430
2431        loop {
2432            if pages >= MAX_REPORT_PAGES {
2433                log::warn!(
2434                    "TradesHistory pagination hit the cap of {MAX_REPORT_PAGES} pages; returning a truncated set and marking it incomplete, which only mass status surfaces"
2435                );
2436                complete = false;
2437                break;
2438            }
2439
2440            let trades = self
2441                .inner
2442                .get_trades_history(None, Some(true), start_ts, end_ts, Some(offset))
2443                .await?;
2444
2445            if trades.is_empty() {
2446                break;
2447            }
2448
2449            pages += 1;
2450
2451            for (trade_id, trade) in &trades {
2452                let resolved = self.get_instrument_by_raw_symbol(trade.pair.as_str());
2453                if let Some(ref target_id) = instrument_id
2454                    && resolved.as_ref().is_none_or(|inst| inst.id() != *target_id)
2455                {
2456                    continue;
2457                }
2458
2459                // As above: historical fills outlive the listing, so preserve the usable rows.
2460                let Some(instrument) = resolved else {
2461                    log::warn!(
2462                        "TradesHistory: instrument not in cache for pair {}, skipping trade {trade_id}",
2463                        trade.pair
2464                    );
2465                    complete = false;
2466                    continue;
2467                };
2468
2469                match parse_fill_report(trade_id, trade, &instrument, account_id, ts_init) {
2470                    Ok(report) => all_reports.push(report),
2471                    Err(e) => {
2472                        log::warn!("Failed to parse trade {trade_id}: {e}");
2473                        complete = false;
2474                    }
2475                }
2476            }
2477
2478            offset += PAGE_SIZE;
2479        }
2480
2481        Ok((all_reports, complete))
2482    }
2483
2484    /// Requests position status reports for SPOT instruments.
2485    ///
2486    /// In margin mode: calls `OpenPositions` and returns reports for each open leveraged position.
2487    /// When `use_spot_position_reports` is enabled (cash mode): derives reports from wallet balances.
2488    /// Otherwise returns an empty vector.
2489    pub async fn request_position_status_reports(
2490        &self,
2491        account_id: AccountId,
2492        instrument_id: Option<InstrumentId>,
2493        account_type: AccountType,
2494        use_spot_position_reports: bool,
2495        quote_currency: Ustr,
2496    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2497        if account_type == AccountType::Margin {
2498            self.generate_margin_position_reports(account_id, instrument_id)
2499                .await
2500        } else if use_spot_position_reports {
2501            self.generate_spot_position_reports_from_wallet(
2502                account_id,
2503                instrument_id,
2504                quote_currency,
2505            )
2506            .await
2507        } else {
2508            Ok(Vec::new())
2509        }
2510    }
2511
2512    /// Returns whether a bulk position read covers `instrument_id`, so that an absent report is
2513    /// evidence the position is flat.
2514    ///
2515    /// This mirrors [`Self::request_position_status_reports`] with `instrument_id` left unset.
2516    /// Margin mode reads `OpenPositions`, which reports leveraged positions only, and cash mode
2517    /// without `use_spot_position_reports` reads nothing, so neither covers a spot holding. The
2518    /// wallet read covers an instrument only when it would enumerate it, which one shared
2519    /// predicate decides for the read and for this answer alike.
2520    pub fn covers_bulk_position_reports(
2521        &self,
2522        instrument_id: InstrumentId,
2523        account_type: AccountType,
2524        use_spot_position_reports: bool,
2525        quote_currency: Ustr,
2526    ) -> bool {
2527        if account_type == AccountType::Margin || !use_spot_position_reports {
2528            return false;
2529        }
2530
2531        self.get_cached_instrument(&instrument_id.symbol.inner())
2532            .is_some_and(|instrument| {
2533                wallet_report_base_currency(&instrument, quote_currency).is_some()
2534            })
2535    }
2536
2537    /// Generates position reports from Kraken `OpenPositions` (margin mode).
2538    async fn generate_margin_position_reports(
2539        &self,
2540        account_id: AccountId,
2541        instrument_id: Option<InstrumentId>,
2542    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2543        let open_positions = self
2544            .inner
2545            .get_open_positions(&SpotOpenPositionsParams::default())
2546            .await?;
2547
2548        let ts_init = self.generate_ts_init();
2549
2550        // Aggregate individual lot entries by pair into a signed net quantity.
2551        // Kraken returns one entry per order lot (keyed by ordertxid); buy lots add to net
2552        // quantity and sell lots subtract. A single signed value per pair is correct for a
2553        // NETTING account and avoids emitting conflicting long+short reports for the same
2554        // instrument when opposing lots exist on the same pair. Aggregation uses `Decimal`
2555        // so opposing lots cancel exactly and partial-close noise does not leave residual
2556        // float dust in the reported quantity.
2557        let mut agg: IndexMap<String, OpenPositionAggregate> = IndexMap::new();
2558
2559        let target_pair: Option<Ustr> = match &instrument_id {
2560            Some(target_id) => match self.get_cached_instrument(&target_id.symbol.inner()) {
2561                Some(inst) => Some(Ustr::from(inst.raw_symbol().as_str())),
2562                None => return Ok(Vec::new()),
2563            },
2564            None => None,
2565        };
2566
2567        for (_pos_id, pos) in open_positions.iter() {
2568            if let Some(pair) = target_pair
2569                && pair.as_str() != pos.pair
2570            {
2571                continue;
2572            }
2573
2574            if let Some(status) = pos.posstatus.as_deref()
2575                && status != "open"
2576            {
2577                log::debug!(
2578                    "Skipping non-open OpenPositions entry for {}: posstatus={status}",
2579                    pos.pair,
2580                );
2581                continue;
2582            }
2583
2584            let instrument = self
2585                .get_instrument_by_raw_symbol(pos.pair.as_str())
2586                .ok_or_else(|| {
2587                    anyhow::anyhow!(
2588                        "OpenPositions: instrument not in cache for pair {}",
2589                        pos.pair
2590                    )
2591                })?;
2592
2593            let vol = Decimal::from_str_exact(&pos.vol)
2594                .with_context(|| format!("OpenPositions: failed to parse vol for {}", pos.pair))?;
2595            let vol_closed = Decimal::from_str_exact(&pos.vol_closed).with_context(|| {
2596                format!("OpenPositions: failed to parse vol_closed for {}", pos.pair)
2597            })?;
2598
2599            let lot_net = (vol - vol_closed).max(Decimal::ZERO);
2600            let signed_lot = match pos.side {
2601                KrakenOrderSide::Buy => lot_net,
2602                KrakenOrderSide::Sell => -lot_net,
2603            };
2604
2605            // `cost` is the quote volume for the whole `vol`, so the lot's entry price is their
2606            // ratio. Accumulate each side separately: a blended average across opposing lots
2607            // would not describe the surviving exposure.
2608            let cost = Decimal::from_str_exact(&pos.cost)
2609                .with_context(|| format!("OpenPositions: failed to parse cost for {}", pos.pair))?;
2610
2611            let entry = agg
2612                .entry(pos.pair.clone())
2613                .or_insert_with(|| OpenPositionAggregate::new(instrument.id()));
2614            entry.signed_qty += signed_lot;
2615
2616            if !vol.is_zero() {
2617                let entry_px = cost / vol;
2618
2619                match pos.side {
2620                    KrakenOrderSide::Buy => {
2621                        entry.long_qty += lot_net;
2622                        entry.long_notional += lot_net * entry_px;
2623                    }
2624                    KrakenOrderSide::Sell => {
2625                        entry.short_qty += lot_net;
2626                        entry.short_notional += lot_net * entry_px;
2627                    }
2628                }
2629            }
2630        }
2631
2632        let mut reports = Vec::new();
2633
2634        for (_, aggregate) in agg {
2635            let signed_qty = aggregate.signed_qty;
2636            let inst_id = aggregate.instrument_id;
2637            let instrument = self
2638                .get_cached_instrument(&inst_id.symbol.inner())
2639                .ok_or_else(|| InstrumentLookupError::not_found(inst_id))?;
2640
2641            let side = if signed_qty.is_sign_positive() && !signed_qty.is_zero() {
2642                PositionSide::Long
2643            } else if signed_qty.is_sign_negative() && !signed_qty.is_zero() {
2644                PositionSide::Short
2645            } else {
2646                PositionSide::Flat
2647            };
2648            let quantity = Quantity::from_decimal_dp(signed_qty.abs(), instrument.size_precision())
2649                .map_err(|e| {
2650                    anyhow::anyhow!("OpenPositions: failed to build Quantity for {inst_id}: {e:?}")
2651                })?;
2652            let avg_px_open = aggregate.avg_px_open(side);
2653            let report = PositionStatusReport::new(
2654                account_id,
2655                inst_id,
2656                side,
2657                quantity,
2658                ts_init,
2659                ts_init,
2660                None,
2661                None,
2662                avg_px_open,
2663            )
2664            // Kraken averages the lots that remain open under FIFO, so after a partial close it
2665            // describes the surviving lots rather than the opening fills. It can open a position
2666            // from flat, but must not be compared with a netting average.
2667            .with_avg_px_open_reconciliation(AvgPxReconciliation::OpeningOnly);
2668            reports.push(report);
2669        }
2670
2671        // If a specific instrument was requested but no open position exists for it, emit
2672        // a FLAT report so the engine can reconcile a previously-open position to closed.
2673        // (Kraken omits fully-closed positions from OpenPositions entirely.)
2674        // Only emit for instruments known to this spot client; a missing cache entry means
2675        // the target belongs to a different product type (e.g. futures) and must not receive
2676        // a spurious FLAT from the spot reconciliation path.
2677        if let Some(target_id) = instrument_id {
2678            let already_reported = reports.iter().any(|r| r.instrument_id == target_id);
2679
2680            if !already_reported
2681                && let Some(instrument) = self.get_cached_instrument(&target_id.symbol.inner())
2682            {
2683                let precision = instrument.size_precision();
2684                reports.push(PositionStatusReport::new(
2685                    account_id,
2686                    target_id,
2687                    PositionSide::Flat,
2688                    Quantity::zero(precision),
2689                    ts_init,
2690                    ts_init,
2691                    None,
2692                    None,
2693                    None,
2694                ));
2695            }
2696        }
2697
2698        Ok(reports)
2699    }
2700
2701    /// Generates SPOT position reports from wallet balances.
2702    ///
2703    /// Kraken spot balances are simple totals (no borrowing concept).
2704    /// Positive balances are reported as LONG positions.
2705    /// Zero balances are reported as FLAT.
2706    async fn generate_spot_position_reports_from_wallet(
2707        &self,
2708        account_id: AccountId,
2709        instrument_id: Option<InstrumentId>,
2710        quote_currency: Ustr,
2711    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2712        let balances_raw = self.inner.get_balance().await?;
2713        let ts_init = self.generate_ts_init();
2714        let mut wallet_by_coin: HashMap<Ustr, Decimal> = HashMap::new();
2715
2716        for (currency_code, amount_str) in &balances_raw {
2717            let balance = match Decimal::from_str_exact(amount_str) {
2718                Ok(b) => b,
2719                Err(_) => continue,
2720            };
2721
2722            if balance.is_zero() {
2723                continue;
2724            }
2725
2726            wallet_by_coin.insert(Ustr::from(normalize_currency_code(currency_code)), balance);
2727        }
2728
2729        let mut reports = Vec::new();
2730
2731        if let Some(instrument_id) = instrument_id {
2732            if let Some(instrument) = self.get_cached_instrument(&instrument_id.symbol.inner()) {
2733                let base_currency = match instrument.base_currency() {
2734                    Some(currency) => currency,
2735                    None => return Ok(reports),
2736                };
2737
2738                let coin = Ustr::from(base_currency.code.as_str());
2739                let wallet_balance = wallet_by_coin.get(&coin).copied().unwrap_or(Decimal::ZERO);
2740
2741                let side = if wallet_balance > Decimal::ZERO {
2742                    PositionSide::Long
2743                } else {
2744                    PositionSide::Flat
2745                };
2746
2747                let abs_balance = wallet_balance.abs();
2748                let quantity = Quantity::from_decimal_dp(abs_balance, instrument.size_precision())?;
2749
2750                let report = PositionStatusReport::new(
2751                    account_id,
2752                    instrument_id,
2753                    side,
2754                    quantity,
2755                    ts_init,
2756                    ts_init,
2757                    None,
2758                    None,
2759                    None,
2760                );
2761
2762                reports.push(report);
2763            }
2764        } else {
2765            let instruments_guard = self.instruments_cache.load();
2766            for instrument in instruments_guard.values() {
2767                let Some(base_currency) = wallet_report_base_currency(instrument, quote_currency)
2768                else {
2769                    continue;
2770                };
2771
2772                let coin = Ustr::from(base_currency.code.as_str());
2773                let wallet_balance = wallet_by_coin.get(&coin).copied().unwrap_or(Decimal::ZERO);
2774
2775                if wallet_balance.is_zero() {
2776                    continue;
2777                }
2778
2779                let side = PositionSide::Long;
2780                let quantity =
2781                    Quantity::from_decimal_dp(wallet_balance, instrument.size_precision())?;
2782
2783                if quantity.is_zero() {
2784                    continue;
2785                }
2786
2787                log::debug!(
2788                    "Spot position: {} {} (quote: {})",
2789                    quantity,
2790                    base_currency.code,
2791                    instrument.quote_currency().code
2792                );
2793
2794                let report = PositionStatusReport::new(
2795                    account_id,
2796                    instrument.id(),
2797                    side,
2798                    quantity,
2799                    ts_init,
2800                    ts_init,
2801                    None,
2802                    None,
2803                    None,
2804                );
2805
2806                reports.push(report);
2807            }
2808        }
2809
2810        Ok(reports)
2811    }
2812
2813    /// Submits a new order to the Kraken Spot exchange.
2814    ///
2815    /// Returns the venue order ID on success. WebSocket handles all execution events.
2816    ///
2817    /// # Errors
2818    ///
2819    /// Returns an error if:
2820    /// - Credentials are missing.
2821    /// - The instrument is not found in cache.
2822    /// - The order type or time in force is not supported.
2823    /// - The request fails.
2824    /// - The order is rejected.
2825    #[expect(clippy::too_many_arguments)]
2826    pub async fn submit_order(
2827        &self,
2828        _account_id: AccountId,
2829        instrument_id: InstrumentId,
2830        client_order_id: ClientOrderId,
2831        order_side: OrderSide,
2832        order_type: OrderType,
2833        quantity: Quantity,
2834        time_in_force: TimeInForce,
2835        expire_time: Option<UnixNanos>,
2836        price: Option<Price>,
2837        trigger_price: Option<Price>,
2838        trigger_type: Option<TriggerType>,
2839        trailing_offset: Option<Decimal>,
2840        limit_offset: Option<Decimal>,
2841        reduce_only: bool,
2842        post_only: bool,
2843        quote_quantity: bool,
2844        display_qty: Option<Quantity>,
2845        leverage: Option<u16>,
2846        account_type: AccountType,
2847    ) -> anyhow::Result<VenueOrderId> {
2848        let params = self.build_add_order_params(
2849            instrument_id,
2850            client_order_id,
2851            order_side,
2852            order_type,
2853            quantity,
2854            time_in_force,
2855            expire_time,
2856            price,
2857            trigger_price,
2858            trigger_type,
2859            trailing_offset,
2860            limit_offset,
2861            reduce_only,
2862            post_only,
2863            quote_quantity,
2864            display_qty,
2865            leverage,
2866            account_type,
2867        )?;
2868        let response = self.inner.add_order(&params).await?;
2869
2870        let venue_order_id =
2871            response
2872                .txid
2873                .first()
2874                .ok_or_else(|| KrakenSubmitOrderError::MissingOrderId {
2875                    detail: "transaction ID was absent".to_string(),
2876                })?;
2877
2878        Ok(VenueOrderId::new(venue_order_id))
2879    }
2880
2881    /// Submits multiple orders to the Kraken Spot exchange.
2882    ///
2883    /// Automatically groups orders by pair and chunks batch requests at the venue
2884    /// limit. Single-order groups fall back to `AddOrder`.
2885    #[expect(clippy::type_complexity)]
2886    pub async fn submit_orders_batch(
2887        &self,
2888        orders: Vec<(
2889            InstrumentId,
2890            ClientOrderId,
2891            OrderSide,
2892            OrderType,
2893            Quantity,
2894            TimeInForce,
2895            Option<UnixNanos>,
2896            Option<Price>,
2897            Option<Price>,
2898            Option<TriggerType>,
2899            Option<Decimal>,
2900            Option<Decimal>,
2901            bool,
2902            bool,
2903            bool,
2904            Option<Quantity>,
2905            Option<u16>,
2906        )>,
2907        account_type: AccountType,
2908    ) -> anyhow::Result<Vec<String>> {
2909        Ok(self
2910            .send_order_batches(orders, account_type)
2911            .await
2912            .into_iter()
2913            .map(|result| match result {
2914                Ok(order) if order.txid.is_some() => "placed".to_string(),
2915                Ok(order) => order.error.unwrap_or_else(|| "Unknown error".to_string()),
2916                Err(e)
2917                    if matches!(
2918                        e.downcast_ref::<KrakenBatchOrderError>(),
2919                        Some(KrakenBatchOrderError::Validation { .. })
2920                    ) =>
2921                {
2922                    format!("validation_error: {e}")
2923                }
2924                Err(e) => format!("batch_error: {e}"),
2925            })
2926            .collect())
2927    }
2928
2929    pub(crate) async fn send_order_batches(
2930        &self,
2931        orders: Vec<SpotBatchOrder>,
2932        account_type: AccountType,
2933    ) -> Vec<anyhow::Result<SpotBatchOrderResponse>> {
2934        let count = orders.len();
2935        if count == 0 {
2936            return Vec::new();
2937        }
2938
2939        let mut results: Vec<Option<anyhow::Result<SpotBatchOrderResponse>>> =
2940            (0..count).map(|_| None).collect();
2941        let mut grouped: AHashMap<Ustr, Vec<(usize, KrakenSpotAddOrderParams)>> = AHashMap::new();
2942
2943        for (
2944            idx,
2945            (
2946                instrument_id,
2947                client_order_id,
2948                order_side,
2949                order_type,
2950                quantity,
2951                time_in_force,
2952                expire_time,
2953                price,
2954                trigger_price,
2955                trigger_type,
2956                trailing_offset,
2957                limit_offset,
2958                reduce_only,
2959                post_only,
2960                quote_quantity,
2961                display_qty,
2962                leverage,
2963            ),
2964        ) in orders.into_iter().enumerate()
2965        {
2966            match self.build_add_order_params(
2967                instrument_id,
2968                client_order_id,
2969                order_side,
2970                order_type,
2971                quantity,
2972                time_in_force,
2973                expire_time,
2974                price,
2975                trigger_price,
2976                trigger_type,
2977                trailing_offset,
2978                limit_offset,
2979                reduce_only,
2980                post_only,
2981                quote_quantity,
2982                display_qty,
2983                leverage,
2984                account_type,
2985            ) {
2986                Ok(params) => {
2987                    grouped.entry(params.pair).or_default().push((idx, params));
2988                }
2989                Err(e) => {
2990                    results[idx] = Some(Err(KrakenBatchOrderError::Validation {
2991                        reason: e.to_string(),
2992                    }
2993                    .into()));
2994                }
2995            }
2996        }
2997
2998        let mut batches = Vec::new();
2999        let mut grouped_orders: Vec<_> = grouped.into_values().collect();
3000        grouped_orders.sort_by_key(|group| group.first().map_or(usize::MAX, |(idx, _)| *idx));
3001        for group in grouped_orders {
3002            batches.extend(group.chunks(BATCH_SUBMIT_LIMIT).map(|chunk| chunk.to_vec()));
3003        }
3004
3005        for (batch_index, batch) in batches.iter().enumerate() {
3006            let response = if batch.len() == 1 {
3007                self.inner
3008                    .add_order(&batch[0].1)
3009                    .await
3010                    .map(|response| SpotAddOrderBatchResponse {
3011                        orders: vec![SpotBatchOrderResponse {
3012                            descr: response.descr,
3013                            error: None,
3014                            txid: response.txid.into_iter().next(),
3015                        }],
3016                    })
3017            } else {
3018                let params = KrakenSpotAddOrderBatchParams {
3019                    pair: batch[0].1.pair,
3020                    orders: batch
3021                        .iter()
3022                        .map(|(_, params)| params.clone().into())
3023                        .collect(),
3024                    asset_class: batch[0].1.asset_class,
3025                };
3026                self.inner.add_order_batch(&params).await
3027            };
3028
3029            match response {
3030                // AddOrderBatch returns result items in request order, so exact cardinality makes
3031                // positional correlation unambiguous.
3032                Ok(response) if response.orders.len() == batch.len() => {
3033                    for ((idx, _), order) in batch.iter().zip(response.orders) {
3034                        results[*idx] = Some(Ok(order));
3035                    }
3036                }
3037                Ok(response) => {
3038                    for (idx, _) in batch {
3039                        results[*idx] = Some(Err(KrakenBatchOrderError::ResponseCount {
3040                            expected: batch.len(),
3041                            actual: response.orders.len(),
3042                        }
3043                        .into()));
3044                    }
3045                }
3046                Err(e) => {
3047                    for (idx, _) in batch {
3048                        results[*idx] = Some(Err(anyhow::Error::new(e.clone())));
3049                    }
3050
3051                    for later_batch in &batches[batch_index + 1..] {
3052                        for (idx, _) in later_batch {
3053                            results[*idx] = Some(Err(KrakenBatchOrderError::NotAttempted.into()));
3054                        }
3055                    }
3056                    break;
3057                }
3058            }
3059        }
3060
3061        results
3062            .into_iter()
3063            .map(|result| result.unwrap_or_else(|| Err(KrakenBatchOrderError::NotAttempted.into())))
3064            .collect()
3065    }
3066
3067    /// Modifies an existing order on the Kraken Spot exchange using atomic amend.
3068    ///
3069    /// Uses the AmendOrder endpoint which modifies the order in-place,
3070    /// keeping the same order ID and queue position.
3071    ///
3072    /// # Errors
3073    ///
3074    /// Returns an error if:
3075    /// - Neither `client_order_id` nor `venue_order_id` is provided.
3076    /// - The instrument is not found in cache.
3077    /// - The request fails.
3078    pub async fn modify_order(
3079        &self,
3080        instrument_id: InstrumentId,
3081        client_order_id: Option<ClientOrderId>,
3082        venue_order_id: Option<VenueOrderId>,
3083        quantity: Option<Quantity>,
3084        price: Option<Price>,
3085        trigger_price: Option<Price>,
3086    ) -> anyhow::Result<VenueOrderId> {
3087        let _ = self
3088            .get_cached_instrument(&instrument_id.symbol.inner())
3089            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
3090
3091        let txid = venue_order_id.as_ref().map(|id| id.to_string());
3092        let cl_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
3093
3094        if txid.is_none() && cl_ord_id.is_none() {
3095            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
3096        }
3097
3098        let mut builder = KrakenSpotAmendOrderParamsBuilder::default();
3099        if let Some(ref id) = txid {
3100            builder.txid(id.clone());
3101        } else if let Some(ref id) = cl_ord_id {
3102            builder.cl_ord_id(id.clone());
3103        }
3104
3105        if let Some(qty) = quantity {
3106            builder.order_qty(qty.to_string());
3107        }
3108
3109        if let Some(p) = price {
3110            builder.limit_price(p.to_string());
3111        }
3112
3113        if let Some(tp) = trigger_price {
3114            builder.trigger_price(tp.to_string());
3115        }
3116
3117        let params = builder
3118            .build()
3119            .map_err(|e| anyhow::anyhow!("Failed to build amend order params: {e}"))?;
3120
3121        let _response = self.inner.amend_order(&params).await?;
3122
3123        // AmendOrder modifies in-place, so the order keeps its original ID
3124        let order_id = venue_order_id.ok_or(KrakenModifyOrderError::MissingOrderId)?;
3125
3126        Ok(order_id)
3127    }
3128
3129    /// Cancels an order on the Kraken Spot exchange.
3130    ///
3131    /// # Errors
3132    ///
3133    /// Returns an error if:
3134    /// - Credentials are missing.
3135    /// - Neither client_order_id nor venue_order_id is provided.
3136    /// - The request fails.
3137    /// - The order cancellation is rejected.
3138    pub async fn cancel_order(
3139        &self,
3140        _account_id: AccountId,
3141        instrument_id: InstrumentId,
3142        client_order_id: Option<ClientOrderId>,
3143        venue_order_id: Option<VenueOrderId>,
3144    ) -> anyhow::Result<()> {
3145        let _ = self
3146            .get_cached_instrument(&instrument_id.symbol.inner())
3147            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
3148
3149        let txid = venue_order_id.as_ref().map(|id| id.to_string());
3150        let cl_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
3151
3152        if txid.is_none() && cl_ord_id.is_none() {
3153            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
3154        }
3155
3156        // Prefer txid (venue identifier) since Kraken always knows it.
3157        // cl_ord_id may not be known to Kraken for reconciled orders.
3158        let mut builder = KrakenSpotCancelOrderParamsBuilder::default();
3159
3160        if let Some(ref id) = txid {
3161            builder.txid(id.clone());
3162        } else if let Some(ref id) = cl_ord_id {
3163            builder.cl_ord_id(id.clone());
3164        }
3165        let params = builder
3166            .build()
3167            .map_err(|e| anyhow::anyhow!("Failed to build cancel params: {e}"))?;
3168
3169        self.inner.cancel_order(&params).await?;
3170
3171        Ok(())
3172    }
3173
3174    /// Cancels multiple orders on the Kraken Spot exchange (batched, max 50 per request).
3175    pub async fn cancel_orders_batch(
3176        &self,
3177        venue_order_ids: Vec<VenueOrderId>,
3178    ) -> anyhow::Result<i32> {
3179        if venue_order_ids.is_empty() {
3180            return Ok(0);
3181        }
3182
3183        let mut total_cancelled = 0;
3184
3185        for chunk in venue_order_ids.chunks(BATCH_CANCEL_LIMIT) {
3186            let orders: Vec<String> = chunk.iter().map(|id| id.to_string()).collect();
3187            let params = KrakenSpotCancelOrderBatchParams {
3188                orders,
3189                cl_ord_ids: Vec::new(),
3190            };
3191
3192            let response = self.inner.cancel_order_batch(&params).await?;
3193            total_cancelled += response.count;
3194        }
3195
3196        Ok(total_cancelled)
3197    }
3198
3199    #[expect(clippy::too_many_arguments)]
3200    fn build_add_order_params(
3201        &self,
3202        instrument_id: InstrumentId,
3203        client_order_id: ClientOrderId,
3204        order_side: OrderSide,
3205        order_type: OrderType,
3206        quantity: Quantity,
3207        time_in_force: TimeInForce,
3208        expire_time: Option<UnixNanos>,
3209        price: Option<Price>,
3210        trigger_price: Option<Price>,
3211        trigger_type: Option<TriggerType>,
3212        trailing_offset: Option<Decimal>,
3213        limit_offset: Option<Decimal>,
3214        reduce_only: bool,
3215        post_only: bool,
3216        quote_quantity: bool,
3217        display_qty: Option<Quantity>,
3218        leverage: Option<u16>,
3219        account_type: AccountType,
3220    ) -> anyhow::Result<KrakenSpotAddOrderParams> {
3221        let instrument = self
3222            .get_cached_instrument(&instrument_id.symbol.inner())
3223            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
3224
3225        let raw_symbol = instrument.raw_symbol().inner();
3226        let asset_class = Self::asset_class_for(&instrument);
3227
3228        let kraken_side = match order_side {
3229            OrderSide::Buy => KrakenOrderSide::Buy,
3230            OrderSide::Sell => KrakenOrderSide::Sell,
3231        };
3232
3233        let kraken_order_type = match order_type {
3234            OrderType::Market => KrakenOrderType::Market,
3235            OrderType::Limit => KrakenOrderType::Limit,
3236            OrderType::StopMarket => KrakenOrderType::StopLoss,
3237            OrderType::StopLimit => KrakenOrderType::StopLossLimit,
3238            OrderType::MarketIfTouched => KrakenOrderType::TakeProfit,
3239            OrderType::LimitIfTouched => KrakenOrderType::TakeProfitLimit,
3240            OrderType::TrailingStopMarket => KrakenOrderType::TrailingStop,
3241            OrderType::TrailingStopLimit => KrakenOrderType::TrailingStopLimit,
3242            _ => anyhow::bail!("Unsupported order type: {order_type:?}"),
3243        };
3244
3245        let mut oflags = Vec::new();
3246        let is_limit_order = matches!(
3247            order_type,
3248            OrderType::Limit
3249                | OrderType::StopLimit
3250                | OrderType::LimitIfTouched
3251                | OrderType::TrailingStopLimit
3252        );
3253
3254        if time_in_force == TimeInForce::Fok && order_type != OrderType::Limit {
3255            anyhow::bail!("FOK time in force only supported for LIMIT orders on Kraken Spot");
3256        }
3257
3258        let (timeinforce, expiretm) =
3259            compute_time_in_force(is_limit_order, time_in_force, expire_time)?;
3260
3261        if post_only {
3262            oflags.push(KRAKEN_OFLAG_POST_ONLY);
3263        }
3264
3265        if quote_quantity {
3266            oflags.push(KRAKEN_OFLAG_QUOTE_QUANTITY);
3267        }
3268
3269        let mut builder = KrakenSpotAddOrderParamsBuilder::default();
3270        builder
3271            .cl_ord_id(truncate_cl_ord_id(&client_order_id))
3272            .broker(NAUTILUS_KRAKEN_BROKER_ID)
3273            .pair(raw_symbol)
3274            .side(kraken_side)
3275            .volume(quantity.to_string())
3276            .order_type(kraken_order_type);
3277
3278        let is_conditional = matches!(
3279            order_type,
3280            OrderType::StopMarket
3281                | OrderType::StopLimit
3282                | OrderType::MarketIfTouched
3283                | OrderType::LimitIfTouched
3284                | OrderType::TrailingStopMarket
3285                | OrderType::TrailingStopLimit
3286        );
3287
3288        let is_trailing = matches!(
3289            order_type,
3290            OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
3291        );
3292
3293        if is_trailing {
3294            if trigger_price.is_some() {
3295                anyhow::bail!(
3296                    "Kraken Spot trailing stops do not support activation trigger prices"
3297                );
3298            }
3299
3300            if let Some(offset) = trailing_offset {
3301                builder.price(offset.to_string());
3302            }
3303
3304            if let Some(offset) = limit_offset {
3305                builder.price2(offset.to_string());
3306            }
3307        } else if is_conditional {
3308            if let Some(trigger) = trigger_price {
3309                builder.price(trigger.to_string());
3310            }
3311
3312            if let Some(limit) = price {
3313                builder.price2(limit.to_string());
3314            }
3315        } else if let Some(limit) = price {
3316            builder.price(limit.to_string());
3317        }
3318
3319        if is_conditional {
3320            match trigger_type {
3321                Some(TriggerType::IndexPrice) => {
3322                    builder.trigger("index".to_string());
3323                }
3324                Some(TriggerType::LastPrice | TriggerType::Default) | None => {}
3325                Some(other) => {
3326                    anyhow::bail!(
3327                        "Unsupported trigger type for Kraken Spot: {other:?} (only LastPrice and IndexPrice supported)"
3328                    );
3329                }
3330            }
3331        }
3332
3333        if !oflags.is_empty() {
3334            builder.oflags(oflags.join(","));
3335        }
3336
3337        if let Some(tif) = timeinforce {
3338            builder.timeinforce(tif);
3339        }
3340
3341        if let Some(expire) = expiretm {
3342            builder.expiretm(expire);
3343        }
3344
3345        if let Some(dq) = display_qty {
3346            builder.displayvol(dq.to_string());
3347        }
3348
3349        if let Some(ac) = asset_class {
3350            builder.asset_class(ac);
3351        }
3352
3353        if leverage.is_some() && account_type != AccountType::Margin {
3354            anyhow::bail!("leverage requires spot_account_type=Margin (current: Cash)");
3355        }
3356
3357        if let Some(n) = leverage {
3358            let tiers = self.leverage_tiers_cache.get_cloned(&raw_symbol);
3359            let (buy_tiers, sell_tiers) = tiers.ok_or_else(|| {
3360                anyhow::anyhow!(
3361                    "Leverage tiers not loaded for {raw_symbol}; cannot validate leverage {n}:1 (instruments must be initialized before submitting margin orders)"
3362                )
3363            })?;
3364            let valid_tiers = match order_side {
3365                OrderSide::Buy => buy_tiers,
3366                _ => sell_tiers,
3367            };
3368            let side_label = match order_side {
3369                OrderSide::Buy => "buy",
3370                _ => "sell",
3371            };
3372
3373            if valid_tiers.is_empty() {
3374                anyhow::bail!("Leverage not supported for {raw_symbol} on {side_label} side");
3375            }
3376
3377            if !valid_tiers.contains(&(n as i32)) {
3378                anyhow::bail!(
3379                    "Leverage {n}:1 not supported for {raw_symbol} on {side_label} side (valid: {valid_tiers:?})"
3380                );
3381            }
3382            builder.leverage(format!("{n}:1"));
3383        }
3384
3385        if reduce_only {
3386            if account_type != AccountType::Margin {
3387                anyhow::bail!("reduce_only requires spot_account_type=Margin (current: Cash)");
3388            }
3389            builder.reduce_only(true);
3390        }
3391
3392        builder
3393            .build()
3394            .map_err(|e| anyhow::anyhow!("Failed to build order params: {e}"))
3395    }
3396}
3397
3398fn collect_spot_statuses(
3399    asset_pairs: &AssetPairsResponse,
3400) -> AHashMap<InstrumentId, MarketStatusAction> {
3401    asset_pairs
3402        .iter()
3403        .map(|(_, definition)| {
3404            let symbol_str = definition.wsname.as_ref().unwrap_or(&definition.altname);
3405            let normalized_symbol = normalize_spot_symbol(symbol_str.as_str());
3406            let instrument_id = InstrumentId::new(Symbol::new(&normalized_symbol), *KRAKEN_VENUE);
3407            let action = definition
3408                .status
3409                .map_or(MarketStatusAction::Trading, MarketStatusAction::from);
3410
3411            (instrument_id, action)
3412        })
3413        .collect()
3414}
3415
3416/// Maps raw symbol (altname, e.g. "XBTUSD") to leverage tiers.
3417type LeverageTiersCache = Arc<AtomicMap<Ustr, (Vec<i32>, Vec<i32>)>>;
3418
3419/// Accumulates `OpenPositions` lots for one pair.
3420///
3421/// Each side is tracked separately, so the entry average describes the side that survives netting
3422/// rather than blending opposing lots.
3423struct OpenPositionAggregate {
3424    instrument_id: InstrumentId,
3425    signed_qty: Decimal,
3426    long_qty: Decimal,
3427    long_notional: Decimal,
3428    short_qty: Decimal,
3429    short_notional: Decimal,
3430}
3431
3432impl OpenPositionAggregate {
3433    fn new(instrument_id: InstrumentId) -> Self {
3434        Self {
3435            instrument_id,
3436            signed_qty: Decimal::ZERO,
3437            long_qty: Decimal::ZERO,
3438            long_notional: Decimal::ZERO,
3439            short_qty: Decimal::ZERO,
3440            short_notional: Decimal::ZERO,
3441        }
3442    }
3443
3444    /// Returns the entry average for the netted side, or `None` when it cannot be derived.
3445    fn avg_px_open(&self, side: PositionSide) -> Option<Decimal> {
3446        match side {
3447            PositionSide::Long if !self.long_qty.is_zero() => {
3448                Some(self.long_notional / self.long_qty)
3449            }
3450            PositionSide::Short if !self.short_qty.is_zero() => {
3451                Some(self.short_notional / self.short_qty)
3452            }
3453            _ => None,
3454        }
3455    }
3456}
3457
3458struct TradeBalanceSnapshot {
3459    margins: Vec<MarginBalance>,
3460    metrics: IndexMap<String, String>,
3461    free_margin: Decimal,
3462    equity: Decimal,
3463}
3464
3465/// Returns the base currency whose wallet balance the bulk position read reports for `instrument`,
3466/// or `None` when the read skips it.
3467///
3468/// The read only covers pairs quoted in the configured `spot_positions_quote_currency`, and it
3469/// needs a base currency to name the holding. A configured code matches in either spelling, so an
3470/// existing `ZEUR` setting still selects euro-quoted instruments now that they carry `EUR`. Both
3471/// decisions live here so that the coverage declared through
3472/// [`KrakenSpotHttpClient::covers_bulk_position_reports`] cannot drift from what the read actually
3473/// enumerates.
3474fn wallet_report_base_currency(
3475    instrument: &InstrumentAny,
3476    quote_currency: Ustr,
3477) -> Option<Currency> {
3478    let quote_filter = Ustr::from(normalize_currency_code(quote_currency.as_str()));
3479    if instrument.quote_currency().code != quote_filter {
3480        return None;
3481    }
3482
3483    instrument.base_currency()
3484}
3485
3486/// Parses an optional `BalanceEx` credit amount, treating an absent field as zero.
3487///
3488/// Kraken only includes `credit` and `credit_used` for accounts holding a credit line, so their
3489/// absence means no credit rather than an unknown amount. Returns `None` when the field is present
3490/// but unparsable, so the caller can skip the balance rather than understate it.
3491fn optional_credit_amount(value: Option<&str>) -> Option<Decimal> {
3492    match value {
3493        Some(amount) => Decimal::from_str_exact(amount).ok(),
3494        None => Some(Decimal::ZERO),
3495    }
3496}
3497
3498/// Resolves the Nautilus [`Currency`] used to denominate `TradeBalance` margin metrics.
3499///
3500/// Kraken's `TradeBalance` defaults to `ZUSD` when no asset is supplied. This maps Kraken's
3501/// legacy asset codes to their standard equivalents and falls back to a 2dp fiat currency for
3502/// unknown codes so unusual collateral assets still produce a tagged `MarginBalance`.
3503fn trade_balance_currency(asset: Option<&str>) -> Currency {
3504    let raw = asset.unwrap_or("ZUSD");
3505    let normalized = normalize_currency_code(raw);
3506    Currency::try_from_str(normalized)
3507        .unwrap_or_else(|| Currency::new(normalized, 2, 0, normalized, CurrencyType::Fiat))
3508}
3509
3510fn compute_time_in_force(
3511    is_limit_order: bool,
3512    time_in_force: TimeInForce,
3513    expire_time: Option<UnixNanos>,
3514) -> anyhow::Result<(Option<String>, Option<String>)> {
3515    if !is_limit_order {
3516        return Ok((None, None));
3517    }
3518
3519    match time_in_force {
3520        TimeInForce::Gtc => Ok((None, None)),
3521        TimeInForce::Ioc => Ok((Some("IOC".to_string()), None)),
3522        TimeInForce::Fok => Ok((Some("FOK".to_string()), None)),
3523        TimeInForce::Gtd => {
3524            let expire = expire_time.ok_or_else(|| {
3525                anyhow::anyhow!("GTD time in force requires expire_time parameter")
3526            })?;
3527            let expire_secs = expire.as_u64() / NANOSECONDS_IN_SECOND;
3528            Ok((Some("GTD".to_string()), Some(expire_secs.to_string())))
3529        }
3530        _ => anyhow::bail!("Unsupported time in force: {time_in_force:?}"),
3531    }
3532}
3533
3534#[cfg(test)]
3535mod tests {
3536    use std::{sync::Arc, time::Duration};
3537
3538    use nautilus_model::instruments::CurrencyPair;
3539    use nautilus_testkit::http::assert_http_redirect_rejected;
3540    use rstest::rstest;
3541
3542    use super::*;
3543
3544    #[tokio::test]
3545    async fn test_authenticated_client_rejects_redirects() {
3546        let client = KrakenSpotRawHttpClient::with_credentials(
3547            "key".into(),
3548            "secret".into(),
3549            KrakenEnvironment::Live,
3550            None,
3551            3,
3552            Some(0),
3553            None,
3554            None,
3555            None,
3556            10,
3557        )
3558        .unwrap()
3559        .client;
3560        assert_http_redirect_rejected(|url| async move {
3561            client
3562                .get(url, None, None, Some(3), None)
3563                .await
3564                .unwrap()
3565                .status
3566                .as_u16()
3567        })
3568        .await;
3569    }
3570
3571    #[rstest]
3572    fn test_raw_client_creation() {
3573        let client = KrakenSpotRawHttpClient::default();
3574        assert!(client.credential.is_none());
3575    }
3576
3577    #[rstest]
3578    fn test_raw_client_with_credentials() {
3579        let client = KrakenSpotRawHttpClient::with_credentials(
3580            "test_key".to_string(),
3581            "test_secret".to_string(),
3582            KrakenEnvironment::Live,
3583            None,
3584            60,
3585            None,
3586            None,
3587            None,
3588            None,
3589            KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3590        )
3591        .unwrap();
3592        assert!(client.credential.is_some());
3593    }
3594
3595    #[rstest]
3596    #[tokio::test]
3597    async fn test_order_request_cancellation_before_transport() {
3598        let client = Arc::new(KrakenSpotRawHttpClient::default());
3599        let guard = client.auth_mutex.lock().await;
3600        let cancellation_token = client.cancellation_token();
3601        let waiting_token = cancellation_token.clone();
3602        let waiting_client = Arc::clone(&client);
3603        let waiting = tokio::spawn(async move {
3604            waiting_client
3605                .lock_order_request(&waiting_token)
3606                .await
3607                .map(drop)
3608        });
3609
3610        tokio::task::yield_now().await;
3611        client.cancel_all_requests();
3612        let waiting_result = tokio::time::timeout(Duration::from_secs(1), waiting)
3613            .await
3614            .expect("auth lock wait should stop on cancellation")
3615            .expect("auth lock task should complete");
3616        let request_result = client
3617            .send_order_request(
3618                Method::POST,
3619                "/0/private/AddOrder",
3620                KrakenSpotRawHttpClient::default_headers(),
3621                None,
3622                &cancellation_token,
3623            )
3624            .await;
3625        drop(guard);
3626        client.reset_cancellation_token();
3627        let reset_token = client.cancellation_token();
3628
3629        assert!(matches!(
3630            waiting_result,
3631            Err(KrakenHttpError::RequestNotStarted(ref message))
3632                if message == "Request canceled before transport invocation"
3633        ));
3634        assert!(matches!(
3635            request_result,
3636            Err(KrakenHttpError::RequestNotStarted(ref message))
3637                if message == "Request canceled before transport invocation"
3638        ));
3639        assert!(!reset_token.is_cancelled());
3640    }
3641
3642    #[rstest]
3643    #[tokio::test]
3644    async fn test_trade_volume_request_cancellation_before_transport() {
3645        let client = KrakenSpotRawHttpClient::with_credentials(
3646            "test_key".to_string(),
3647            "dGVzdF9hcGlfc2VjcmV0X2Jhc2U2NA==".to_string(),
3648            KrakenEnvironment::Live,
3649            Some("http://127.0.0.1:1".to_string()),
3650            1,
3651            None,
3652            None,
3653            None,
3654            None,
3655            KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3656        )
3657        .unwrap();
3658        let params = SpotTradeVolumeParams {
3659            pair: SpotTradeVolumePairs::Names("XBTUSDT".to_string()),
3660        };
3661        client.cancel_all_requests();
3662
3663        let result = client.get_trade_volume(&params).await;
3664
3665        assert!(matches!(
3666            result,
3667            Err(KrakenHttpError::NetworkError(ref message))
3668                if message.contains("canceled") || message.contains("cancelled")
3669        ));
3670    }
3671
3672    #[rstest]
3673    fn test_client_creation() {
3674        let client = KrakenSpotHttpClient::default();
3675        assert!(client.instruments_cache.is_empty());
3676    }
3677
3678    #[rstest]
3679    fn test_client_with_credentials() {
3680        let client = KrakenSpotHttpClient::with_credentials(
3681            "test_key".to_string(),
3682            "test_secret".to_string(),
3683            KrakenEnvironment::Live,
3684            None,
3685            60,
3686            None,
3687            None,
3688            None,
3689            None,
3690            KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3691        )
3692        .unwrap();
3693        assert!(client.instruments_cache.is_empty());
3694    }
3695
3696    #[rstest]
3697    fn test_nonce_generation_strictly_increasing() {
3698        let client = KrakenSpotRawHttpClient::default();
3699
3700        let nonce1 = client.generate_nonce();
3701        let nonce2 = client.generate_nonce();
3702        let nonce3 = client.generate_nonce();
3703
3704        assert!(
3705            nonce2 > nonce1,
3706            "nonce2 ({nonce2}) should be > nonce1 ({nonce1})"
3707        );
3708        assert!(
3709            nonce3 > nonce2,
3710            "nonce3 ({nonce3}) should be > nonce2 ({nonce2})"
3711        );
3712    }
3713
3714    #[rstest]
3715    fn test_nonce_is_nanosecond_timestamp() {
3716        let client = KrakenSpotRawHttpClient::default();
3717
3718        let nonce = client.generate_nonce();
3719
3720        // Nonce should be a nanosecond timestamp (roughly 1.7e18 for Dec 2025)
3721        // Verify it's in a reasonable range (> 1.5e18, which is ~2017)
3722        assert!(
3723            nonce > 1_500_000_000_000_000_000,
3724            "Nonce should be nanosecond timestamp"
3725        );
3726    }
3727
3728    #[rstest]
3729    #[case::gtc_limit(true, TimeInForce::Gtc, None, None, None)]
3730    #[case::ioc_limit(true, TimeInForce::Ioc, None, Some("IOC"), None)]
3731    #[case::fok_limit(true, TimeInForce::Fok, None, Some("FOK"), None)]
3732    #[case::gtd_limit_with_expire(
3733        true,
3734        TimeInForce::Gtd,
3735        Some(1_704_067_200_000_000_000u64),
3736        Some("GTD"),
3737        Some("1704067200")
3738    )]
3739    #[case::gtc_market(false, TimeInForce::Gtc, None, None, None)]
3740    #[case::ioc_market(false, TimeInForce::Ioc, None, None, None)]
3741    fn test_compute_time_in_force_success(
3742        #[case] is_limit: bool,
3743        #[case] tif: TimeInForce,
3744        #[case] expire_nanos: Option<u64>,
3745        #[case] expected_tif: Option<&str>,
3746        #[case] expected_expire: Option<&str>,
3747    ) {
3748        let expire_time = expire_nanos.map(UnixNanos::from);
3749        let result = compute_time_in_force(is_limit, tif, expire_time).unwrap();
3750        assert_eq!(result.0, expected_tif.map(String::from));
3751        assert_eq!(result.1, expected_expire.map(String::from));
3752    }
3753
3754    #[rstest]
3755    #[case::gtd_missing_expire(TimeInForce::Gtd, None, "expire_time")]
3756    fn test_compute_time_in_force_errors(
3757        #[case] tif: TimeInForce,
3758        #[case] expire_nanos: Option<u64>,
3759        #[case] expected_error: &str,
3760    ) {
3761        let expire_time = expire_nanos.map(UnixNanos::from);
3762        let result = compute_time_in_force(true, tif, expire_time);
3763        assert!(result.is_err());
3764        assert!(result.unwrap_err().to_string().contains(expected_error));
3765    }
3766
3767    #[rstest]
3768    fn test_build_add_order_params_sets_index_trigger_for_conditional_orders() {
3769        let client = KrakenSpotHttpClient::default();
3770        let instrument_id = cache_test_spot_instrument(&client);
3771
3772        let params = client
3773            .build_add_order_params(
3774                instrument_id,
3775                ClientOrderId::new("spot-trigger-index"),
3776                OrderSide::Buy,
3777                OrderType::StopMarket,
3778                Quantity::from("0.01"),
3779                TimeInForce::Gtc,
3780                None,
3781                None,
3782                Some(Price::from("50000")),
3783                Some(TriggerType::IndexPrice),
3784                None,
3785                None,
3786                false,
3787                false,
3788                false,
3789                None,
3790                None,
3791                AccountType::Cash,
3792            )
3793            .unwrap();
3794
3795        assert_eq!(params.trigger, Some("index".to_string()));
3796        assert_eq!(params.price, Some("50000".to_string()));
3797    }
3798
3799    #[rstest]
3800    fn test_build_add_order_params_sets_trailing_offsets() {
3801        let client = KrakenSpotHttpClient::default();
3802        let instrument_id = cache_test_spot_instrument(&client);
3803
3804        let params = client
3805            .build_add_order_params(
3806                instrument_id,
3807                ClientOrderId::new("spot-trailing"),
3808                OrderSide::Sell,
3809                OrderType::TrailingStopLimit,
3810                Quantity::from("0.01"),
3811                TimeInForce::Gtc,
3812                None,
3813                Some(Price::from("49900")),
3814                None,
3815                Some(TriggerType::LastPrice),
3816                Some(Decimal::from(50)),
3817                Some(Decimal::from(25)),
3818                false,
3819                false,
3820                false,
3821                Some(Quantity::from("0.005")),
3822                None,
3823                AccountType::Cash,
3824            )
3825            .unwrap();
3826
3827        assert_eq!(params.price, Some("50".to_string()));
3828        assert_eq!(params.price2, Some("25".to_string()));
3829        assert_eq!(params.trigger, None);
3830        assert_eq!(params.displayvol, Some("0.005".to_string()));
3831    }
3832
3833    #[rstest]
3834    fn test_build_add_order_params_rejects_unsupported_trigger_type() {
3835        let client = KrakenSpotHttpClient::default();
3836        let instrument_id = cache_test_spot_instrument(&client);
3837
3838        let error = client
3839            .build_add_order_params(
3840                instrument_id,
3841                ClientOrderId::new("spot-trigger-invalid"),
3842                OrderSide::Buy,
3843                OrderType::StopMarket,
3844                Quantity::from("0.01"),
3845                TimeInForce::Gtc,
3846                None,
3847                None,
3848                Some(Price::from("50000")),
3849                Some(TriggerType::MarkPrice),
3850                None,
3851                None,
3852                false,
3853                false,
3854                false,
3855                None,
3856                None,
3857                AccountType::Cash,
3858            )
3859            .unwrap_err();
3860
3861        assert!(
3862            error
3863                .to_string()
3864                .contains("Unsupported trigger type for Kraken Spot")
3865        );
3866    }
3867
3868    fn cache_spot_pair(
3869        client: &KrakenSpotHttpClient,
3870        symbol: &str,
3871        quote: Currency,
3872    ) -> InstrumentId {
3873        let instrument_id = InstrumentId::from(symbol);
3874
3875        client.cache_instrument(InstrumentAny::CurrencyPair(
3876            CurrencyPair::builder()
3877                .instrument_id(instrument_id)
3878                .raw_symbol(Symbol::new(instrument_id.symbol.as_str()))
3879                .base_currency(Currency::BTC())
3880                .quote_currency(quote)
3881                .price_precision(1)
3882                .size_precision(8)
3883                .price_increment(Price::from("0.1"))
3884                .size_increment(Quantity::from("0.00000001"))
3885                .ts_event(0.into())
3886                .ts_init(0.into())
3887                .build()
3888                .unwrap(),
3889        ));
3890
3891        instrument_id
3892    }
3893
3894    /// Bulk position coverage must answer for exactly the instruments the bulk read enumerates.
3895    ///
3896    /// The wallet read only reports pairs quoted in `spot_positions_quote_currency`, so claiming
3897    /// coverage for every instrument would let an absent report force-close a holding the read
3898    /// could never have reported.
3899    #[rstest]
3900    #[case(AccountType::Margin, true, "XBT/USDT.KRAKEN", false)]
3901    #[case(AccountType::Margin, false, "XBT/USDT.KRAKEN", false)]
3902    #[case(AccountType::Cash, false, "XBT/USDT.KRAKEN", false)]
3903    #[case(AccountType::Cash, true, "XBT/USDT.KRAKEN", true)]
3904    #[case(AccountType::Cash, true, "XBT/USD.KRAKEN", false)]
3905    #[case(AccountType::Cash, true, "XBT/EUR.KRAKEN", false)]
3906    fn test_covers_bulk_position_reports(
3907        #[case] account_type: AccountType,
3908        #[case] use_spot_position_reports: bool,
3909        #[case] instrument: &str,
3910        #[case] expected: bool,
3911    ) {
3912        let client = KrakenSpotHttpClient::default();
3913        cache_spot_pair(&client, "XBT/USDT.KRAKEN", Currency::USDT());
3914        cache_spot_pair(&client, "XBT/USD.KRAKEN", Currency::USD());
3915
3916        // `XBT/EUR` is never cached, standing for an instrument the read cannot enumerate.
3917        let covered = client.covers_bulk_position_reports(
3918            InstrumentId::from(instrument),
3919            account_type,
3920            use_spot_position_reports,
3921            Ustr::from("USDT"),
3922        );
3923
3924        assert_eq!(covered, expected);
3925    }
3926
3927    /// A legacy quote code must select the same instruments as its standard spelling.
3928    ///
3929    /// Instruments now carry standard codes, so `ZEUR` has to normalize to `EUR` or an existing
3930    /// setting would stop matching and an absent report could close a holding the read still sees.
3931    #[rstest]
3932    fn test_covers_bulk_position_reports_accepts_legacy_quote_code() {
3933        let client = KrakenSpotHttpClient::default();
3934        let eur = cache_spot_pair(&client, "XBT/EUR.KRAKEN", Currency::EUR());
3935        cache_spot_pair(&client, "XBT/USDT.KRAKEN", Currency::USDT());
3936
3937        let legacy =
3938            client.covers_bulk_position_reports(eur, AccountType::Cash, true, Ustr::from("ZEUR"));
3939        let standard =
3940            client.covers_bulk_position_reports(eur, AccountType::Cash, true, Ustr::from("EUR"));
3941        let other = client.covers_bulk_position_reports(
3942            InstrumentId::from("XBT/USDT.KRAKEN"),
3943            AccountType::Cash,
3944            true,
3945            Ustr::from("ZEUR"),
3946        );
3947
3948        assert!(legacy);
3949        assert!(standard);
3950        assert!(!other);
3951    }
3952
3953    fn cache_test_spot_instrument(client: &KrakenSpotHttpClient) -> InstrumentId {
3954        let instrument_id = InstrumentId::from("XBT/USD.KRAKEN");
3955
3956        client.cache_instrument(InstrumentAny::CurrencyPair(
3957            CurrencyPair::builder()
3958                .instrument_id(instrument_id)
3959                .raw_symbol(Symbol::new("XBTUSD"))
3960                .base_currency(Currency::BTC())
3961                .quote_currency(Currency::USD())
3962                .price_precision(1)
3963                .size_precision(8)
3964                .price_increment(Price::from("0.1"))
3965                .size_increment(Quantity::from("0.00000001"))
3966                .ts_event(0.into())
3967                .ts_init(0.into())
3968                .build()
3969                .unwrap(),
3970        ));
3971
3972        instrument_id
3973    }
3974
3975    fn cache_test_spot_instrument_with_leverage(
3976        client: &KrakenSpotHttpClient,
3977        leverage_buy: &[i32],
3978        leverage_sell: &[i32],
3979    ) -> InstrumentId {
3980        let instrument_id = cache_test_spot_instrument(client);
3981        let raw_symbol = Ustr::from("XBTUSD");
3982        client.leverage_tiers_cache.rcu(|m| {
3983            m.insert(raw_symbol, (leverage_buy.to_vec(), leverage_sell.to_vec()));
3984        });
3985        instrument_id
3986    }
3987
3988    #[rstest]
3989    fn test_build_add_order_params_leverage_serialized_as_ratio() {
3990        let client = KrakenSpotHttpClient::default();
3991        let instrument_id =
3992            cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
3993
3994        let params = client
3995            .build_add_order_params(
3996                instrument_id,
3997                ClientOrderId::new("spot-margin-buy"),
3998                OrderSide::Buy,
3999                OrderType::Limit,
4000                Quantity::from("0.01"),
4001                TimeInForce::Gtc,
4002                None,
4003                Some(Price::from("50000")),
4004                None,
4005                None,
4006                None,
4007                None,
4008                false,
4009                false,
4010                false,
4011                None,
4012                Some(3),
4013                AccountType::Margin,
4014            )
4015            .unwrap();
4016
4017        assert_eq!(params.leverage, Some("3:1".to_string()));
4018    }
4019
4020    #[rstest]
4021    fn test_build_add_order_params_invalid_leverage_rejected() {
4022        let client = KrakenSpotHttpClient::default();
4023        let instrument_id =
4024            cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
4025
4026        let err = client
4027            .build_add_order_params(
4028                instrument_id,
4029                ClientOrderId::new("spot-margin-bad"),
4030                OrderSide::Buy,
4031                OrderType::Limit,
4032                Quantity::from("0.01"),
4033                TimeInForce::Gtc,
4034                None,
4035                Some(Price::from("50000")),
4036                None,
4037                None,
4038                None,
4039                None,
4040                false,
4041                false,
4042                false,
4043                None,
4044                Some(7),
4045                AccountType::Margin,
4046            )
4047            .unwrap_err();
4048
4049        assert!(
4050            err.to_string().contains("not supported"),
4051            "Expected tier-validation error: {err}"
4052        );
4053    }
4054
4055    #[rstest]
4056    fn test_build_add_order_params_no_leverage_is_cash() {
4057        let client = KrakenSpotHttpClient::default();
4058        let instrument_id =
4059            cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
4060
4061        let params = client
4062            .build_add_order_params(
4063                instrument_id,
4064                ClientOrderId::new("spot-cash"),
4065                OrderSide::Buy,
4066                OrderType::Limit,
4067                Quantity::from("0.01"),
4068                TimeInForce::Gtc,
4069                None,
4070                Some(Price::from("50000")),
4071                None,
4072                None,
4073                None,
4074                None,
4075                false,
4076                false,
4077                false,
4078                None,
4079                None,
4080                AccountType::Cash,
4081            )
4082            .unwrap();
4083
4084        assert_eq!(
4085            params.leverage, None,
4086            "Cash order should not have leverage field"
4087        );
4088    }
4089
4090    #[rstest]
4091    fn test_build_add_order_params_rejects_per_order_leverage_in_cash_mode() {
4092        let client = KrakenSpotHttpClient::default();
4093        let instrument_id = cache_test_spot_instrument(&client);
4094
4095        let err = client
4096            .build_add_order_params(
4097                instrument_id,
4098                ClientOrderId::new("cash-with-leverage"),
4099                OrderSide::Buy,
4100                OrderType::Limit,
4101                Quantity::from("0.01"),
4102                TimeInForce::Gtc,
4103                None,
4104                Some(Price::from("50000")),
4105                None,
4106                None,
4107                None,
4108                None,
4109                false,
4110                false,
4111                false,
4112                None,
4113                Some(3),
4114                AccountType::Cash,
4115            )
4116            .unwrap_err();
4117
4118        assert!(
4119            err.to_string().contains("Margin"),
4120            "Expected Margin mode rejection: {err}"
4121        );
4122    }
4123
4124    #[rstest]
4125    fn test_build_add_order_params_reduce_only_forwarded_in_margin_mode() {
4126        let client = KrakenSpotHttpClient::default();
4127        let instrument_id = cache_test_spot_instrument(&client);
4128
4129        let params = client
4130            .build_add_order_params(
4131                instrument_id,
4132                ClientOrderId::new("margin-reduce-only"),
4133                OrderSide::Sell,
4134                OrderType::Limit,
4135                Quantity::from("0.01"),
4136                TimeInForce::Gtc,
4137                None,
4138                Some(Price::from("50000")),
4139                None,
4140                None,
4141                None,
4142                None,
4143                true,
4144                false,
4145                false,
4146                None,
4147                None,
4148                AccountType::Margin,
4149            )
4150            .unwrap();
4151
4152        assert_eq!(params.reduce_only, Some(true));
4153    }
4154
4155    #[rstest]
4156    fn test_build_add_order_params_rejects_reduce_only_in_cash_mode() {
4157        let client = KrakenSpotHttpClient::default();
4158        let instrument_id = cache_test_spot_instrument(&client);
4159
4160        let err = client
4161            .build_add_order_params(
4162                instrument_id,
4163                ClientOrderId::new("cash-reduce-only"),
4164                OrderSide::Sell,
4165                OrderType::Limit,
4166                Quantity::from("0.01"),
4167                TimeInForce::Gtc,
4168                None,
4169                Some(Price::from("50000")),
4170                None,
4171                None,
4172                None,
4173                None,
4174                true,
4175                false,
4176                false,
4177                None,
4178                None,
4179                AccountType::Cash,
4180            )
4181            .unwrap_err();
4182
4183        assert!(
4184            err.to_string().contains("reduce_only requires"),
4185            "expected reduce_only Margin rejection: {err}"
4186        );
4187    }
4188
4189    #[rstest]
4190    fn test_build_add_order_params_rejects_leverage_when_tiers_not_loaded() {
4191        let client = KrakenSpotHttpClient::default();
4192        let instrument_id = cache_test_spot_instrument(&client);
4193
4194        let err = client
4195            .build_add_order_params(
4196                instrument_id,
4197                ClientOrderId::new("missing-tiers"),
4198                OrderSide::Buy,
4199                OrderType::Limit,
4200                Quantity::from("0.01"),
4201                TimeInForce::Gtc,
4202                None,
4203                Some(Price::from("50000")),
4204                None,
4205                None,
4206                None,
4207                None,
4208                false,
4209                false,
4210                false,
4211                None,
4212                Some(3),
4213                AccountType::Margin,
4214            )
4215            .unwrap_err();
4216
4217        assert!(
4218            err.to_string().contains("Leverage tiers not loaded"),
4219            "expected cache-miss rejection: {err}"
4220        );
4221    }
4222}