Skip to main content

nautilus_kraken/http/futures/
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 Futures 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, AHashSet};
29use indexmap::IndexMap;
30use jiff::Timestamp;
31use nautilus_common::cache::InstrumentLookupError;
32use nautilus_core::{
33    AtomicMap, AtomicTime, UUID4, nanos::UnixNanos, time::get_atomic_clock_realtime,
34};
35use nautilus_model::{
36    data::{Bar, BarType, BookOrder, FundingRateUpdate, TradeTick},
37    enums::{
38        AccountType, BookType, CurrencyType, MarketStatusAction, OrderSide, OrderType, TimeInForce,
39        TriggerType,
40    },
41    events::AccountState,
42    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
43    instruments::{Instrument, InstrumentAny},
44    orderbook::OrderBook,
45    reports::{FillReport, OrderStatusReport, PositionStatusReport},
46    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
47};
48use nautilus_network::{
49    ConnectionUrl,
50    http::{
51        HttpClient, HttpRedirectPolicy, HttpResponse, Method, create_standard_nautilus_headers,
52    },
53    ratelimiter::quota::Quota,
54    retry::{RetryConfig, RetryError, RetryManager},
55};
56use parking_lot::RwLock;
57use rust_decimal::Decimal;
58use serde::de::DeserializeOwned;
59use tokio_util::sync::CancellationToken;
60use ustr::Ustr;
61
62use super::{models::*, query::*};
63use crate::{
64    common::{
65        consts::{KRAKEN_VENUE, NAUTILUS_KRAKEN_BROKER_ID},
66        credential::KrakenCredential,
67        enums::{
68            KrakenApiResult, KrakenEnvironment, KrakenFuturesOrderStatus, KrakenFuturesOrderType,
69            KrakenFuturesQtyMode, KrakenOrderSide, KrakenProductType, KrakenSendStatus,
70            KrakenTriggerSignal, product_type_from_symbol,
71        },
72        parse::{
73            bar_type_to_futures_resolution, normalize_asset_key, parse_bar,
74            parse_futures_fill_report, parse_futures_instrument,
75            parse_futures_order_event_status_report, parse_futures_order_status_details_report,
76            parse_futures_order_status_report, parse_futures_position_status_report,
77            parse_futures_public_execution, truncate_cl_ord_id,
78        },
79        urls::get_kraken_http_base_url,
80    },
81    http::{
82        apply_count_limit,
83        coverage::ReportCoverage,
84        error::{
85            KrakenBatchOrderError, KrakenHttpError, KrakenModifyOrderError, KrakenSubmitOrderError,
86            kraken_http_should_retry,
87        },
88        models::OhlcData,
89    },
90};
91
92/// Default Kraken Futures REST API rate limit (requests per second).
93pub const KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND: u32 = 5;
94
95const KRAKEN_GLOBAL_RATE_KEY: &str = "kraken:futures:global";
96
97/// Maximum orders per batch cancel request for Kraken Futures API.
98const BATCH_CANCEL_LIMIT: usize = 50;
99
100/// Maximum operations per batch order request for Kraken Futures API.
101const BATCH_ORDER_LIMIT: usize = 10;
102
103/// The response header Kraken documents for the order history continuation token; the body's
104/// `continuationToken` is read first and this header stands in when the body carries none.
105const NEXT_CONTINUATION_TOKEN_HEADER: &str = "Next-Continuation-Token";
106
107/// Raw HTTP client for low-level Kraken Futures API operations.
108///
109/// This client handles request/response operations with the Kraken Futures API,
110/// returning venue-specific response types. It does not parse to Nautilus domain types.
111pub struct KrakenFuturesRawHttpClient {
112    base_url: ConnectionUrl,
113    client: HttpClient,
114    credential: Option<KrakenCredential>,
115    retry_manager: RetryManager<KrakenHttpError>,
116    cancellation_token: RwLock<CancellationToken>,
117    clock: &'static AtomicTime,
118    /// Mutex to serialize authenticated requests, ensuring nonces arrive at Kraken in order
119    auth_mutex: tokio::sync::Mutex<()>,
120}
121
122impl Default for KrakenFuturesRawHttpClient {
123    fn default() -> Self {
124        Self::new(
125            KrakenEnvironment::Live,
126            None,
127            60,
128            None,
129            None,
130            None,
131            None,
132            KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND,
133        )
134        .expect("Failed to create default KrakenFuturesRawHttpClient")
135    }
136}
137
138impl Debug for KrakenFuturesRawHttpClient {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_struct(stringify!(KrakenFuturesRawHttpClient))
141            .field("base_url", &self.base_url)
142            .field("has_credentials", &self.credential.is_some())
143            .finish()
144    }
145}
146
147impl KrakenFuturesRawHttpClient {
148    /// Creates a new [`KrakenFuturesRawHttpClient`].
149    #[expect(clippy::too_many_arguments)]
150    pub fn new(
151        environment: KrakenEnvironment,
152        base_url_override: Option<String>,
153        timeout_secs: u64,
154        max_retries: Option<u32>,
155        retry_delay_ms: Option<u64>,
156        retry_delay_max_ms: Option<u64>,
157        proxy_url: Option<String>,
158        max_requests_per_second: u32,
159    ) -> anyhow::Result<Self> {
160        let retry_config = RetryConfig {
161            max_retries: max_retries.unwrap_or(3),
162            initial_delay_ms: retry_delay_ms.unwrap_or(1000),
163            max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
164            backoff_factor: 2.0,
165            jitter_ms: 1000,
166            operation_timeout_ms: Some(60_000),
167            immediate_first: false,
168            max_elapsed_ms: Some(180_000),
169        };
170
171        let retry_manager = RetryManager::new(retry_config);
172        let base_url = base_url_override.unwrap_or_else(|| {
173            get_kraken_http_base_url(KrakenProductType::Futures, environment).to_string()
174        });
175
176        Ok(Self {
177            base_url: base_url.into(),
178            client: HttpClient::builder()
179                .headers(Self::default_headers())
180                .header_keys(vec![NEXT_CONTINUATION_TOKEN_HEADER.to_string()])
181                .keyed_quotas(Self::rate_limiter_quotas(max_requests_per_second)?)
182                .default_quota(Self::default_quota(max_requests_per_second)?)
183                .timeout_secs(timeout_secs)
184                .maybe_proxy_url(proxy_url)
185                .build()
186                .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
187            credential: None,
188            retry_manager,
189            cancellation_token: RwLock::new(CancellationToken::new()),
190            clock: get_atomic_clock_realtime(),
191            auth_mutex: tokio::sync::Mutex::new(()),
192        })
193    }
194
195    /// Creates a new [`KrakenFuturesRawHttpClient`] with credentials.
196    #[expect(clippy::too_many_arguments)]
197    pub fn with_credentials(
198        api_key: String,
199        api_secret: String,
200        environment: KrakenEnvironment,
201        base_url_override: Option<String>,
202        timeout_secs: u64,
203        max_retries: Option<u32>,
204        retry_delay_ms: Option<u64>,
205        retry_delay_max_ms: Option<u64>,
206        proxy_url: Option<String>,
207        max_requests_per_second: u32,
208    ) -> anyhow::Result<Self> {
209        let retry_config = RetryConfig {
210            max_retries: max_retries.unwrap_or(3),
211            initial_delay_ms: retry_delay_ms.unwrap_or(1000),
212            max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
213            backoff_factor: 2.0,
214            jitter_ms: 1000,
215            operation_timeout_ms: Some(60_000),
216            immediate_first: false,
217            max_elapsed_ms: Some(180_000),
218        };
219
220        let retry_manager = RetryManager::new(retry_config);
221        let base_url = base_url_override.unwrap_or_else(|| {
222            get_kraken_http_base_url(KrakenProductType::Futures, environment).to_string()
223        });
224
225        Ok(Self {
226            base_url: base_url.into(),
227            client: HttpClient::builder()
228                .redirect_policy(HttpRedirectPolicy::Reject)
229                .headers(Self::default_headers())
230                .header_keys(vec![NEXT_CONTINUATION_TOKEN_HEADER.to_string()])
231                .keyed_quotas(Self::rate_limiter_quotas(max_requests_per_second)?)
232                .default_quota(Self::default_quota(max_requests_per_second)?)
233                .timeout_secs(timeout_secs)
234                .maybe_proxy_url(proxy_url)
235                .build()
236                .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
237            credential: Some(KrakenCredential::new(api_key, api_secret)),
238            retry_manager,
239            cancellation_token: RwLock::new(CancellationToken::new()),
240            clock: get_atomic_clock_realtime(),
241            auth_mutex: tokio::sync::Mutex::new(()),
242        })
243    }
244
245    /// Generates a unique nonce for Kraken Futures API requests.
246    ///
247    /// Uses `AtomicTime` for strict monotonicity. The nanosecond timestamp
248    /// guarantees uniqueness even for rapid consecutive calls.
249    fn generate_nonce(&self) -> u64 {
250        self.clock.get_time_ns().as_u64()
251    }
252
253    /// Returns the base URL for this client.
254    pub fn base_url(&self) -> &str {
255        self.base_url.expose()
256    }
257
258    /// Returns the credential for this client, if set.
259    pub fn credential(&self) -> Option<&KrakenCredential> {
260        self.credential.as_ref()
261    }
262
263    /// Cancels all pending HTTP requests.
264    pub fn cancel_all_requests(&self) {
265        self.cancellation_token.read().cancel();
266    }
267
268    /// Replaces the canceled token so requests can proceed after reconnect.
269    pub fn reset_cancellation_token(&self) {
270        *self.cancellation_token.write() = CancellationToken::new();
271    }
272
273    /// Returns a clone of the current cancellation token.
274    pub fn cancellation_token(&self) -> CancellationToken {
275        self.cancellation_token.read().clone()
276    }
277
278    fn default_headers() -> HashMap<String, String> {
279        create_standard_nautilus_headers().into_iter().collect()
280    }
281
282    fn default_quota(max_requests_per_second: u32) -> anyhow::Result<Quota> {
283        let burst = NonZeroU32::new(max_requests_per_second).unwrap_or(
284            NonZeroU32::new(KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND).expect("non-zero"),
285        );
286        Quota::per_second(burst).ok_or_else(|| {
287            anyhow::anyhow!(
288                "Invalid max_requests_per_second: {max_requests_per_second} exceeds maximum"
289            )
290        })
291    }
292
293    fn rate_limiter_quotas(max_requests_per_second: u32) -> anyhow::Result<Vec<(String, Quota)>> {
294        Ok(vec![(
295            KRAKEN_GLOBAL_RATE_KEY.to_string(),
296            Self::default_quota(max_requests_per_second)?,
297        )])
298    }
299
300    fn rate_limit_keys(endpoint: &str) -> Vec<String> {
301        let normalized = endpoint.split('?').next().unwrap_or(endpoint);
302        let route = format!("kraken:futures:{normalized}");
303        vec![KRAKEN_GLOBAL_RATE_KEY.to_string(), route]
304    }
305
306    async fn send_request<T: DeserializeOwned>(
307        &self,
308        method: Method,
309        endpoint: &str,
310        url: String,
311        authenticate: bool,
312    ) -> anyhow::Result<T, KrakenHttpError> {
313        // Serialize authenticated requests to ensure nonces arrive at Kraken in order.
314        // Without this, concurrent requests can race through the network and arrive
315        // out-of-order, causing "Invalid nonce" errors.
316        let _guard = if authenticate {
317            Some(self.auth_mutex.lock().await)
318        } else {
319            None
320        };
321
322        let endpoint = endpoint.to_string();
323        let method_clone = method.clone();
324        let url_clone = url.clone();
325        let credential = self.credential.clone();
326
327        let operation = || {
328            let url = url_clone.clone();
329            let method = method_clone.clone();
330            let endpoint = endpoint.clone();
331            let credential = credential.clone();
332
333            async move {
334                let mut headers = Self::default_headers();
335
336                if authenticate {
337                    let cred = credential.as_ref().ok_or_else(|| {
338                        KrakenHttpError::AuthenticationError(
339                            "Missing credentials for authenticated request".to_string(),
340                        )
341                    })?;
342
343                    let nonce = self.generate_nonce();
344
345                    let signature = cred.sign_futures(&endpoint, "", nonce).map_err(|e| {
346                        KrakenHttpError::AuthenticationError(format!("Failed to sign request: {e}"))
347                    })?;
348
349                    log::debug!(
350                        "Kraken Futures auth: endpoint={endpoint}, nonce={nonce}, base_url={}",
351                        self.base_url.origin()
352                    );
353
354                    headers.insert("APIKey".to_string(), cred.api_key().to_string());
355                    headers.insert("Authent".to_string(), signature);
356                    headers.insert("Nonce".to_string(), nonce.to_string());
357                }
358
359                let rate_limit_keys = Self::rate_limit_keys(&endpoint);
360
361                let response = self
362                    .client
363                    .request(
364                        method,
365                        url,
366                        None,
367                        Some(headers),
368                        None,
369                        None,
370                        Some(rate_limit_keys),
371                    )
372                    .await
373                    .map_err(|e| {
374                        KrakenHttpError::NetworkError(format!(
375                            "Request to {} failed: {e}",
376                            self.base_url.origin()
377                        ))
378                    })?;
379
380                let status = response.status.as_u16();
381                if status >= 400 {
382                    let body = String::from_utf8_lossy(&response.body).to_string();
383                    // Don't retry authentication errors
384                    if status == 401 || status == 403 {
385                        return Err(KrakenHttpError::AuthenticationError(format!(
386                            "HTTP error {status}: {body}"
387                        )));
388                    }
389                    return Err(KrakenHttpError::NetworkError(format!(
390                        "HTTP error {status}: {body}"
391                    )));
392                }
393
394                let response_text = String::from_utf8(response.body.to_vec()).map_err(|e| {
395                    KrakenHttpError::ParseError(format!("Failed to parse response as UTF-8: {e}"))
396                })?;
397
398                serde_json::from_str(&response_text).map_err(|e| {
399                    KrakenHttpError::ParseError(format!(
400                        "Failed to deserialize futures response: {e}"
401                    ))
402                })
403            }
404        };
405
406        let should_retry = kraken_http_should_retry;
407        let create_error = |error: RetryError| KrakenHttpError::NetworkError(error.to_string());
408
409        let cancellation_token = self.cancellation_token();
410
411        self.retry_manager
412            .invocation(&endpoint, operation, should_retry, create_error)
413            .cancellation_token(&cancellation_token)
414            .execute()
415            .await
416    }
417
418    /// Sends authenticated GET request with query parameters included in signature.
419    ///
420    /// For Kraken Futures, GET requests with query params must include them in postData
421    /// for signing: message = postData + nonce + endpoint
422    async fn send_get_with_query<T: DeserializeOwned>(
423        &self,
424        endpoint: &str,
425        url: String,
426        query_string: &str,
427    ) -> anyhow::Result<T, KrakenHttpError> {
428        let response = self
429            .send_get_with_query_raw(endpoint, url, query_string)
430            .await?;
431
432        Self::deserialize_body(&response)
433    }
434
435    /// Sends the authenticated GET of [`Self::send_get_with_query`] and hands back the whole
436    /// response, for an endpoint that answers in its headers as well as its body.
437    async fn send_get_with_query_raw(
438        &self,
439        endpoint: &str,
440        url: String,
441        query_string: &str,
442    ) -> anyhow::Result<HttpResponse, KrakenHttpError> {
443        let _guard = self.auth_mutex.lock().await;
444        let cancellation_token = self.cancellation_token();
445
446        if cancellation_token.is_cancelled() {
447            return Err(KrakenHttpError::NetworkError(
448                "Request cancelled".to_string(),
449            ));
450        }
451
452        let credential = self.credential.as_ref().ok_or_else(|| {
453            KrakenHttpError::AuthenticationError("Missing credentials".to_string())
454        })?;
455
456        let nonce = self.generate_nonce();
457
458        // Query params go in postData for signing (not in endpoint)
459        let signature = credential
460            .sign_futures(endpoint, query_string, nonce)
461            .map_err(|e| {
462                KrakenHttpError::AuthenticationError(format!("Failed to sign request: {e}"))
463            })?;
464
465        log::debug!(
466            "Kraken Futures GET with query: endpoint={endpoint}, query={query_string}, nonce={nonce}"
467        );
468
469        let mut headers = Self::default_headers();
470        headers.insert("APIKey".to_string(), credential.api_key().to_string());
471        headers.insert("Authent".to_string(), signature);
472        headers.insert("Nonce".to_string(), nonce.to_string());
473
474        let rate_limit_keys = Self::rate_limit_keys(endpoint);
475
476        let response = self
477            .client
478            .request(
479                Method::GET,
480                url,
481                None,
482                Some(headers),
483                None,
484                None,
485                Some(rate_limit_keys),
486            )
487            .await
488            .map_err(|e| {
489                KrakenHttpError::NetworkError(format!(
490                    "Request to {} failed: {e}",
491                    self.base_url.origin()
492                ))
493            })?;
494
495        let status = response.status.as_u16();
496        if status >= 400 {
497            let body = String::from_utf8_lossy(&response.body).to_string();
498
499            if status == 401 || status == 403 {
500                return Err(KrakenHttpError::AuthenticationError(format!(
501                    "HTTP error {status}: {body}"
502                )));
503            }
504            return Err(KrakenHttpError::NetworkError(format!(
505                "HTTP error {status}: {body}"
506            )));
507        }
508
509        Ok(response)
510    }
511
512    fn deserialize_body<T: DeserializeOwned>(
513        response: &HttpResponse,
514    ) -> anyhow::Result<T, KrakenHttpError> {
515        let response_text = std::str::from_utf8(&response.body).map_err(|e| {
516            KrakenHttpError::ParseError(format!("Failed to parse response as UTF-8: {e}"))
517        })?;
518
519        serde_json::from_str(response_text).map_err(|e| {
520            KrakenHttpError::ParseError(format!("Failed to deserialize futures response: {e}"))
521        })
522    }
523
524    async fn send_request_with_body<T: DeserializeOwned>(
525        &self,
526        endpoint: &str,
527        params: HashMap<String, String>,
528    ) -> anyhow::Result<T, KrakenHttpError> {
529        let post_data = serde_urlencoded::to_string(&params).map_err(|e| {
530            KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
531        })?;
532        self.send_authenticated_post(endpoint, post_data).await
533    }
534
535    /// Sends a request with typed parameters (serializable struct).
536    async fn send_request_with_params<P: serde::Serialize, T: DeserializeOwned>(
537        &self,
538        endpoint: &str,
539        params: &P,
540    ) -> anyhow::Result<T, KrakenHttpError> {
541        let post_data = serde_urlencoded::to_string(params).map_err(|e| {
542            KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
543        })?;
544        self.send_authenticated_post(endpoint, post_data).await
545    }
546
547    /// Core authenticated POST request - takes raw post_data string.
548    async fn send_authenticated_post<T: DeserializeOwned>(
549        &self,
550        endpoint: &str,
551        post_data: String,
552    ) -> anyhow::Result<T, KrakenHttpError> {
553        let cancellation_token = self.cancellation_token();
554        if cancellation_token.is_cancelled() {
555            return Err(KrakenHttpError::RequestNotStarted(
556                "Request cancelled".to_string(),
557            ));
558        }
559
560        // Serialize authenticated requests to ensure nonces arrive at Kraken in order
561        let _guard = tokio::select! {
562            biased;
563            () = cancellation_token.cancelled() => {
564                return Err(KrakenHttpError::RequestNotStarted(
565                    "Request cancelled".to_string(),
566                ));
567            }
568            guard = self.auth_mutex.lock() => guard,
569        };
570
571        let credential = self
572            .credential
573            .as_ref()
574            .ok_or(KrakenHttpError::MissingCredentials)?;
575
576        let nonce = self.generate_nonce();
577        log::debug!("Generated nonce {nonce} for {endpoint}");
578
579        let signature = credential
580            .sign_futures(endpoint, &post_data, nonce)
581            .map_err(|e| {
582                KrakenHttpError::RequestNotStarted(format!("Failed to sign request: {e}"))
583            })?;
584
585        let url = format!("{}{endpoint}", self.base_url.expose());
586        let mut headers = Self::default_headers();
587        headers.insert(
588            "Content-Type".to_string(),
589            "application/x-www-form-urlencoded".to_string(),
590        );
591        headers.insert("APIKey".to_string(), credential.api_key().to_string());
592        headers.insert("Authent".to_string(), signature);
593        headers.insert("Nonce".to_string(), nonce.to_string());
594
595        let rate_limit_keys = Self::rate_limit_keys(endpoint);
596
597        let response = self
598            .send_order_request(
599                url,
600                headers,
601                post_data.into_bytes(),
602                rate_limit_keys,
603                &cancellation_token,
604            )
605            .await?;
606
607        if response.status.as_u16() >= 400 {
608            let status = response.status.as_u16();
609            let body = String::from_utf8_lossy(&response.body).to_string();
610            return Err(KrakenHttpError::NetworkError(format!(
611                "HTTP error {status}: {body}"
612            )));
613        }
614
615        let response_text = String::from_utf8(response.body.to_vec()).map_err(|e| {
616            KrakenHttpError::ParseError(format!("Failed to parse response as UTF-8: {e}"))
617        })?;
618
619        serde_json::from_str(&response_text).map_err(|e| {
620            log::warn!("Failed to parse response from {endpoint}: {response_text}");
621            KrakenHttpError::ParseError(format!("Failed to deserialize response: {e}"))
622        })
623    }
624
625    async fn send_order_request(
626        &self,
627        url: String,
628        headers: HashMap<String, String>,
629        body: Vec<u8>,
630        rate_limit_keys: Vec<String>,
631        cancellation_token: &CancellationToken,
632    ) -> anyhow::Result<HttpResponse, KrakenHttpError> {
633        if cancellation_token.is_cancelled() {
634            return Err(KrakenHttpError::RequestNotStarted(
635                "Request cancelled".to_string(),
636            ));
637        }
638
639        let request_started = AtomicBool::new(false);
640        let request = async {
641            request_started.store(true, Ordering::Relaxed);
642            self.client
643                .request(
644                    Method::POST,
645                    url,
646                    None,
647                    Some(headers),
648                    Some(body),
649                    None,
650                    Some(rate_limit_keys),
651                )
652                .await
653        };
654        tokio::pin!(request);
655
656        tokio::select! {
657            biased;
658            () = cancellation_token.cancelled() => {
659                if request_started.load(Ordering::Relaxed) {
660                    Err(KrakenHttpError::NetworkError(
661                        "Request cancelled after transport invocation".to_string(),
662                    ))
663                } else {
664                    Err(KrakenHttpError::RequestNotStarted(
665                        "Request cancelled".to_string(),
666                    ))
667                }
668            }
669            response = &mut request => response
670                .map_err(|e| KrakenHttpError::NetworkError(
671                    format!("Request to {} failed: {e}", self.base_url.origin())
672                )),
673        }
674    }
675
676    /// Requests tradable instruments from Kraken Futures.
677    pub async fn get_instruments(
678        &self,
679    ) -> anyhow::Result<FuturesInstrumentsResponse, KrakenHttpError> {
680        let endpoint = "/derivatives/api/v3/instruments";
681        let url = format!("{}{endpoint}", self.base_url.expose());
682
683        self.send_request(Method::GET, endpoint, url, false).await
684    }
685
686    /// Requests ticker information for all futures instruments.
687    pub async fn get_tickers(&self) -> anyhow::Result<FuturesTickersResponse, KrakenHttpError> {
688        let endpoint = "/derivatives/api/v3/tickers";
689        let url = format!("{}{endpoint}", self.base_url.expose());
690
691        self.send_request(Method::GET, endpoint, url, false).await
692    }
693
694    /// Requests order book depth for a futures symbol.
695    pub async fn get_orderbook(
696        &self,
697        symbol: &str,
698    ) -> anyhow::Result<FuturesOrderBookResponse, KrakenHttpError> {
699        let endpoint = format!("/derivatives/api/v3/orderbook?symbol={symbol}");
700        let url = format!("{}{endpoint}", self.base_url.expose());
701
702        self.send_request(Method::GET, &endpoint, url, false).await
703    }
704
705    /// Requests historical funding rates for a futures symbol.
706    pub async fn get_historical_funding_rates(
707        &self,
708        symbol: &str,
709    ) -> anyhow::Result<FuturesHistoricalFundingRatesResponse, KrakenHttpError> {
710        let endpoint = format!("/derivatives/api/v4/historicalfundingrates?symbol={symbol}");
711        let url = format!("{}{endpoint}", self.base_url.expose());
712
713        self.send_request(Method::GET, &endpoint, url, false).await
714    }
715
716    /// Requests OHLC candlestick data for a futures symbol.
717    pub async fn get_ohlc(
718        &self,
719        tick_type: &str,
720        symbol: &str,
721        resolution: &str,
722        from: Option<i64>,
723        to: Option<i64>,
724    ) -> anyhow::Result<FuturesCandlesResponse, KrakenHttpError> {
725        let endpoint = format!("/api/charts/v1/{tick_type}/{symbol}/{resolution}");
726
727        let mut url = format!("{}{endpoint}", self.base_url.expose());
728
729        let mut query_params = Vec::new();
730
731        if let Some(from_ts) = from {
732            query_params.push(format!("from={from_ts}"));
733        }
734
735        if let Some(to_ts) = to {
736            query_params.push(format!("to={to_ts}"));
737        }
738
739        if !query_params.is_empty() {
740            url.push('?');
741            url.push_str(&query_params.join("&"));
742        }
743
744        self.send_request(Method::GET, &endpoint, url, false).await
745    }
746
747    /// Gets public execution events (trades) for a futures symbol.
748    pub async fn get_public_executions(
749        &self,
750        symbol: &str,
751        since: Option<i64>,
752        before: Option<i64>,
753        sort: Option<&str>,
754        continuation_token: Option<&str>,
755    ) -> anyhow::Result<FuturesPublicExecutionsResponse, KrakenHttpError> {
756        let endpoint = format!("/api/history/v3/market/{symbol}/executions");
757
758        let mut url = format!("{}{endpoint}", self.base_url.expose());
759
760        let mut query_params = Vec::new();
761
762        if let Some(since_ts) = since {
763            query_params.push(format!("since={since_ts}"));
764        }
765
766        if let Some(before_ts) = before {
767            query_params.push(format!("before={before_ts}"));
768        }
769
770        if let Some(sort_order) = sort {
771            query_params.push(format!("sort={sort_order}"));
772        }
773
774        if let Some(token) = continuation_token {
775            query_params.push(format!("continuationToken={token}"));
776        }
777
778        if !query_params.is_empty() {
779            url.push('?');
780            url.push_str(&query_params.join("&"));
781        }
782
783        self.send_request(Method::GET, &endpoint, url, false).await
784    }
785
786    /// Requests all open orders (requires authentication).
787    pub async fn get_open_orders(
788        &self,
789    ) -> anyhow::Result<FuturesOpenOrdersResponse, KrakenHttpError> {
790        if self.credential.is_none() {
791            return Err(KrakenHttpError::AuthenticationError(
792                "API credentials required for futures open orders".to_string(),
793            ));
794        }
795
796        let endpoint = "/derivatives/api/v3/openorders";
797        let url = format!("{}{endpoint}", self.base_url.expose());
798
799        self.send_request(Method::GET, endpoint, url, true).await
800    }
801
802    /// Requests historical order events (requires authentication).
803    pub async fn get_order_events(
804        &self,
805        before: Option<i64>,
806        since: Option<i64>,
807        continuation_token: Option<&str>,
808    ) -> anyhow::Result<FuturesOrderEventsResponse, KrakenHttpError> {
809        if self.credential.is_none() {
810            return Err(KrakenHttpError::AuthenticationError(
811                "API credentials required for futures order events".to_string(),
812            ));
813        }
814
815        let endpoint = "/api/history/v3/orders";
816        let mut query_params = Vec::new();
817
818        if let Some(before_ts) = before {
819            query_params.push(format!("before={before_ts}"));
820        }
821
822        if let Some(since_ts) = since {
823            query_params.push(format!("since={since_ts}"));
824        }
825
826        if let Some(token) = continuation_token {
827            query_params.push(format!("continuation_token={token}"));
828        }
829
830        // A page that hands back a continuation token must hold the newest events, which the
831        // single-order lookup relies on to tell how far back the page reaches, so the order is
832        // pinned rather than left to the venue default
833        query_params.push("sort=desc".to_string());
834
835        // Build URL with query params
836        let query_string = query_params.join("&");
837
838        let url = if query_string.is_empty() {
839            format!("{}{endpoint}", self.base_url.expose())
840        } else {
841            format!("{}{endpoint}?{query_string}", self.base_url.expose())
842        };
843
844        // For signing: query params go in postData, not endpoint
845        // Kraken: message = postData + nonce + endpoint
846        let response = self
847            .send_get_with_query_raw(endpoint, url, &query_string)
848            .await?;
849        let page: FuturesOrderHistoryResponse = Self::deserialize_body(&response)?;
850        let mut events = page.into_order_events()?;
851
852        // Kraken documents the token in the response header and shows it in the body, so a page
853        // that carries it in the header alone must not read as the last one.
854        if events
855            .continuation_token
856            .as_deref()
857            .is_none_or(str::is_empty)
858        {
859            events.continuation_token = response
860                .headers
861                .get(NEXT_CONTINUATION_TOKEN_HEADER)
862                .filter(|token| !token.is_empty())
863                .cloned();
864        }
865
866        Ok(events)
867    }
868
869    /// Requests the status of specific orders (requires authentication).
870    ///
871    /// The venue reports orders which are open or were filled/cancelled in
872    /// the last 5 seconds, which covers the Maker Protection race where an
873    /// order is absent from `/openorders` because its terminal transition is
874    /// younger than the open-orders snapshot.
875    pub async fn get_orders_status(
876        &self,
877        order_ids: &[String],
878        cli_ord_ids: &[String],
879    ) -> anyhow::Result<FuturesOrdersStatusResponse, KrakenHttpError> {
880        if self.credential.is_none() {
881            return Err(KrakenHttpError::AuthenticationError(
882                "API credentials required for futures orders status".to_string(),
883            ));
884        }
885
886        let pairs: Vec<(&str, &str)> = order_ids
887            .iter()
888            .map(|order_id| ("orderIds", order_id.as_str()))
889            .chain(
890                cli_ord_ids
891                    .iter()
892                    .map(|cli_ord_id| ("cliOrdIds", cli_ord_id.as_str())),
893            )
894            .collect();
895
896        let post_data = serde_urlencoded::to_string(&pairs).map_err(|e| {
897            KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
898        })?;
899
900        let endpoint = "/derivatives/api/v3/orders/status";
901        self.send_authenticated_post(endpoint, post_data).await
902    }
903
904    /// Requests fill/trade history (requires authentication).
905    pub async fn get_fills(
906        &self,
907        last_fill_time: Option<&str>,
908    ) -> anyhow::Result<FuturesFillsResponse, KrakenHttpError> {
909        if self.credential.is_none() {
910            return Err(KrakenHttpError::AuthenticationError(
911                "API credentials required for futures fills".to_string(),
912            ));
913        }
914
915        let endpoint = "/derivatives/api/v3/fills";
916        let query_string = last_fill_time
917            .map(|t| format!("lastFillTime={t}"))
918            .unwrap_or_default();
919
920        let url = if query_string.is_empty() {
921            format!("{}{endpoint}", self.base_url.expose())
922        } else {
923            format!("{}{endpoint}?{query_string}", self.base_url.expose())
924        };
925
926        // Query params go in postData for signing
927        self.send_get_with_query(endpoint, url, &query_string).await
928    }
929
930    /// Requests open positions (requires authentication).
931    pub async fn get_open_positions(
932        &self,
933    ) -> anyhow::Result<FuturesOpenPositionsResponse, KrakenHttpError> {
934        if self.credential.is_none() {
935            return Err(KrakenHttpError::AuthenticationError(
936                "API credentials required for futures open positions".to_string(),
937            ));
938        }
939
940        let endpoint = "/derivatives/api/v3/openpositions";
941        let url = format!("{}{endpoint}", self.base_url.expose());
942
943        self.send_request(Method::GET, endpoint, url, true).await
944    }
945
946    /// Requests all accounts (cash and margin) with balances and margin info.
947    pub async fn get_accounts(&self) -> anyhow::Result<FuturesAccountsResponse, KrakenHttpError> {
948        if self.credential.is_none() {
949            return Err(KrakenHttpError::AuthenticationError(
950                "API credentials required for futures accounts".to_string(),
951            ));
952        }
953
954        let endpoint = "/derivatives/api/v3/accounts";
955        let url = format!("{}{endpoint}", self.base_url.expose());
956
957        self.send_request(Method::GET, endpoint, url, true).await
958    }
959
960    /// Submits a new order (requires authentication).
961    pub async fn send_order(
962        &self,
963        params: HashMap<String, String>,
964    ) -> anyhow::Result<FuturesSendOrderResponse, KrakenHttpError> {
965        if self.credential.is_none() {
966            return Err(KrakenHttpError::AuthenticationError(
967                "API credentials required for sending orders".to_string(),
968            ));
969        }
970
971        let endpoint = "/derivatives/api/v3/sendorder";
972        self.send_request_with_body(endpoint, params).await
973    }
974
975    /// Submits a new order using typed parameters (requires authentication).
976    pub async fn send_order_params(
977        &self,
978        params: &KrakenFuturesSendOrderParams,
979    ) -> anyhow::Result<FuturesSendOrderResponse, KrakenHttpError> {
980        if self.credential.is_none() {
981            return Err(KrakenHttpError::MissingCredentials);
982        }
983
984        let endpoint = "/derivatives/api/v3/sendorder";
985        self.send_request_with_params(endpoint, params).await
986    }
987
988    /// Cancels an open order (requires authentication).
989    pub async fn cancel_order(
990        &self,
991        order_id: Option<String>,
992        cli_ord_id: Option<String>,
993    ) -> anyhow::Result<FuturesCancelOrderResponse, KrakenHttpError> {
994        if self.credential.is_none() {
995            return Err(KrakenHttpError::AuthenticationError(
996                "API credentials required for canceling orders".to_string(),
997            ));
998        }
999
1000        let mut params = HashMap::new();
1001
1002        if let Some(id) = order_id {
1003            params.insert("order_id".to_string(), id);
1004        }
1005
1006        if let Some(id) = cli_ord_id {
1007            params.insert("cliOrdId".to_string(), id);
1008        }
1009
1010        let endpoint = "/derivatives/api/v3/cancelorder";
1011        self.send_request_with_body(endpoint, params).await
1012    }
1013
1014    /// Edits an existing order (requires authentication).
1015    pub async fn edit_order(
1016        &self,
1017        params: &KrakenFuturesEditOrderParams,
1018    ) -> anyhow::Result<FuturesEditOrderResponse, KrakenHttpError> {
1019        if self.credential.is_none() {
1020            return Err(KrakenHttpError::MissingCredentials);
1021        }
1022
1023        let endpoint = "/derivatives/api/v3/editorder";
1024        self.send_request_with_params(endpoint, params).await
1025    }
1026
1027    /// Submits multiple orders in a single batch request (requires authentication).
1028    pub async fn batch_order(
1029        &self,
1030        params: HashMap<String, String>,
1031    ) -> anyhow::Result<FuturesBatchOrderResponse, KrakenHttpError> {
1032        if self.credential.is_none() {
1033            return Err(KrakenHttpError::AuthenticationError(
1034                "API credentials required for batch orders".to_string(),
1035            ));
1036        }
1037
1038        let endpoint = "/derivatives/api/v3/batchorder";
1039        self.send_request_with_body(endpoint, params).await
1040    }
1041
1042    /// Cancels multiple orders in a single batch request (requires authentication).
1043    pub async fn cancel_orders_batch(
1044        &self,
1045        order_ids: Vec<String>,
1046    ) -> anyhow::Result<FuturesBatchCancelResponse, KrakenHttpError> {
1047        let batch_items: Vec<KrakenFuturesBatchCancelItem> = order_ids
1048            .into_iter()
1049            .map(KrakenFuturesBatchCancelItem::from_order_id)
1050            .collect();
1051
1052        self.cancel_order_items_batch(batch_items).await
1053    }
1054
1055    /// Cancels multiple order IDs or client order IDs in a single batch request
1056    /// (requires authentication).
1057    pub async fn cancel_order_items_batch(
1058        &self,
1059        batch_items: Vec<KrakenFuturesBatchCancelItem>,
1060    ) -> anyhow::Result<FuturesBatchCancelResponse, KrakenHttpError> {
1061        if self.credential.is_none() {
1062            return Err(KrakenHttpError::AuthenticationError(
1063                "API credentials required for batch orders".to_string(),
1064            ));
1065        }
1066
1067        let params = KrakenFuturesBatchOrderParams::new(batch_items);
1068        let post_data = params
1069            .to_body()
1070            .map_err(|e| KrakenHttpError::ParseError(format!("Failed to serialize batch: {e}")))?;
1071
1072        let endpoint = "/derivatives/api/v3/batchorder";
1073        self.send_authenticated_post(endpoint, post_data).await
1074    }
1075
1076    /// Submits multiple orders in a single batch request (requires authentication).
1077    pub async fn submit_orders_batch(
1078        &self,
1079        items: Vec<KrakenFuturesBatchSendItem>,
1080    ) -> anyhow::Result<FuturesBatchOrderResponse, KrakenHttpError> {
1081        if self.credential.is_none() {
1082            return Err(KrakenHttpError::MissingCredentials);
1083        }
1084
1085        let params = KrakenFuturesBatchOrderParams::new(items);
1086        let post_data = params.to_body().map_err(|e| {
1087            KrakenHttpError::RequestNotStarted(format!("Failed to serialize batch: {e}"))
1088        })?;
1089
1090        let endpoint = "/derivatives/api/v3/batchorder";
1091        self.send_authenticated_post(endpoint, post_data).await
1092    }
1093
1094    /// Edits multiple orders in a single batch request (requires authentication).
1095    pub async fn edit_orders_batch(
1096        &self,
1097        items: Vec<KrakenFuturesBatchEditItem>,
1098    ) -> anyhow::Result<FuturesBatchOrderResponse, KrakenHttpError> {
1099        if self.credential.is_none() {
1100            return Err(KrakenHttpError::AuthenticationError(
1101                "API credentials required for batch orders".to_string(),
1102            ));
1103        }
1104
1105        let params = KrakenFuturesBatchOrderParams::new(items);
1106        let post_data = params
1107            .to_body()
1108            .map_err(|e| KrakenHttpError::ParseError(format!("Failed to serialize batch: {e}")))?;
1109
1110        let endpoint = "/derivatives/api/v3/batchorder";
1111        self.send_authenticated_post(endpoint, post_data).await
1112    }
1113
1114    /// Cancels all open orders, optionally filtered by symbol (requires authentication).
1115    pub async fn cancel_all_orders(
1116        &self,
1117        symbol: Option<String>,
1118    ) -> anyhow::Result<FuturesCancelAllOrdersResponse, KrakenHttpError> {
1119        if self.credential.is_none() {
1120            return Err(KrakenHttpError::AuthenticationError(
1121                "API credentials required for canceling orders".to_string(),
1122            ));
1123        }
1124
1125        let mut params = HashMap::new();
1126
1127        if let Some(sym) = symbol {
1128            params.insert("symbol".to_string(), sym);
1129        }
1130
1131        let endpoint = "/derivatives/api/v3/cancelallorders";
1132        self.send_request_with_body(endpoint, params).await
1133    }
1134}
1135
1136pub(crate) type FuturesBatchOrder = (
1137    InstrumentId,
1138    ClientOrderId,
1139    OrderSide,
1140    OrderType,
1141    Quantity,
1142    TimeInForce,
1143    Option<Price>,
1144    Option<Price>,
1145    Option<TriggerType>,
1146    bool,
1147    bool,
1148);
1149
1150pub(crate) struct FuturesBatchSubmitItem {
1151    pub result: KrakenApiResult,
1152    pub status: FuturesSendStatus,
1153}
1154
1155/// High-level HTTP client for the Kraken Futures REST API.
1156///
1157/// This client wraps the raw client and provides Nautilus domain types.
1158/// It maintains an instrument cache and uses it to parse venue responses
1159/// into Nautilus domain objects.
1160#[cfg_attr(
1161    feature = "python",
1162    pyo3::pyclass(module = "nautilus_trader.adapters.kraken", from_py_object)
1163)]
1164#[cfg_attr(
1165    feature = "python",
1166    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.kraken")
1167)]
1168pub struct KrakenFuturesHttpClient {
1169    pub(crate) inner: Arc<KrakenFuturesRawHttpClient>,
1170    pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
1171    clock: &'static AtomicTime,
1172    cache_initialized: Arc<AtomicBool>,
1173}
1174
1175impl Clone for KrakenFuturesHttpClient {
1176    fn clone(&self) -> Self {
1177        Self {
1178            inner: self.inner.clone(),
1179            instruments_cache: self.instruments_cache.clone(),
1180            cache_initialized: self.cache_initialized.clone(),
1181            clock: self.clock,
1182        }
1183    }
1184}
1185
1186impl Default for KrakenFuturesHttpClient {
1187    fn default() -> Self {
1188        Self::new(
1189            KrakenEnvironment::Live,
1190            None,
1191            60,
1192            None,
1193            None,
1194            None,
1195            None,
1196            KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND,
1197        )
1198        .expect("Failed to create default KrakenFuturesHttpClient")
1199    }
1200}
1201
1202impl Debug for KrakenFuturesHttpClient {
1203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1204        f.debug_struct(stringify!(KrakenFuturesHttpClient))
1205            .field("inner", &self.inner)
1206            .finish()
1207    }
1208}
1209
1210impl KrakenFuturesHttpClient {
1211    /// Creates a new [`KrakenFuturesHttpClient`].
1212    #[expect(clippy::too_many_arguments)]
1213    pub fn new(
1214        environment: KrakenEnvironment,
1215        base_url_override: Option<String>,
1216        timeout_secs: u64,
1217        max_retries: Option<u32>,
1218        retry_delay_ms: Option<u64>,
1219        retry_delay_max_ms: Option<u64>,
1220        proxy_url: Option<String>,
1221        max_requests_per_second: u32,
1222    ) -> anyhow::Result<Self> {
1223        Ok(Self {
1224            inner: Arc::new(KrakenFuturesRawHttpClient::new(
1225                environment,
1226                base_url_override,
1227                timeout_secs,
1228                max_retries,
1229                retry_delay_ms,
1230                retry_delay_max_ms,
1231                proxy_url,
1232                max_requests_per_second,
1233            )?),
1234            instruments_cache: Arc::new(AtomicMap::new()),
1235            cache_initialized: Arc::new(AtomicBool::new(false)),
1236            clock: get_atomic_clock_realtime(),
1237        })
1238    }
1239
1240    /// Creates a new [`KrakenFuturesHttpClient`] with credentials.
1241    #[expect(clippy::too_many_arguments)]
1242    pub fn with_credentials(
1243        api_key: String,
1244        api_secret: String,
1245        environment: KrakenEnvironment,
1246        base_url_override: Option<String>,
1247        timeout_secs: u64,
1248        max_retries: Option<u32>,
1249        retry_delay_ms: Option<u64>,
1250        retry_delay_max_ms: Option<u64>,
1251        proxy_url: Option<String>,
1252        max_requests_per_second: u32,
1253    ) -> anyhow::Result<Self> {
1254        Ok(Self {
1255            inner: Arc::new(KrakenFuturesRawHttpClient::with_credentials(
1256                api_key,
1257                api_secret,
1258                environment,
1259                base_url_override,
1260                timeout_secs,
1261                max_retries,
1262                retry_delay_ms,
1263                retry_delay_max_ms,
1264                proxy_url,
1265                max_requests_per_second,
1266            )?),
1267            instruments_cache: Arc::new(AtomicMap::new()),
1268            cache_initialized: Arc::new(AtomicBool::new(false)),
1269            clock: get_atomic_clock_realtime(),
1270        })
1271    }
1272
1273    /// Creates a new [`KrakenFuturesHttpClient`] loading credentials from environment variables.
1274    ///
1275    /// Looks for `KRAKEN_FUTURES_API_KEY` and `KRAKEN_FUTURES_API_SECRET` (live)
1276    /// or `KRAKEN_FUTURES_DEMO_API_KEY` and `KRAKEN_FUTURES_DEMO_API_SECRET` (demo).
1277    ///
1278    /// Falls back to unauthenticated client if credentials are not set.
1279    #[expect(clippy::too_many_arguments)]
1280    pub fn from_env(
1281        environment: KrakenEnvironment,
1282        base_url_override: Option<String>,
1283        timeout_secs: u64,
1284        max_retries: Option<u32>,
1285        retry_delay_ms: Option<u64>,
1286        retry_delay_max_ms: Option<u64>,
1287        proxy_url: Option<String>,
1288        max_requests_per_second: u32,
1289    ) -> anyhow::Result<Self> {
1290        let demo = environment == KrakenEnvironment::Demo;
1291
1292        if let Some(credential) = KrakenCredential::from_env_futures(demo) {
1293            let (api_key, api_secret) = credential.into_parts();
1294            Self::with_credentials(
1295                api_key,
1296                api_secret,
1297                environment,
1298                base_url_override,
1299                timeout_secs,
1300                max_retries,
1301                retry_delay_ms,
1302                retry_delay_max_ms,
1303                proxy_url,
1304                max_requests_per_second,
1305            )
1306        } else {
1307            Self::new(
1308                environment,
1309                base_url_override,
1310                timeout_secs,
1311                max_retries,
1312                retry_delay_ms,
1313                retry_delay_max_ms,
1314                proxy_url,
1315                max_requests_per_second,
1316            )
1317        }
1318    }
1319
1320    /// Cancels all pending HTTP requests.
1321    pub fn cancel_all_requests(&self) {
1322        self.inner.cancel_all_requests();
1323    }
1324
1325    /// Replaces the canceled token so requests can proceed after reconnect.
1326    pub fn reset_cancellation_token(&self) {
1327        self.inner.reset_cancellation_token();
1328    }
1329
1330    /// Returns a clone of the current cancellation token.
1331    pub fn cancellation_token(&self) -> CancellationToken {
1332        self.inner.cancellation_token()
1333    }
1334
1335    /// Caches an instrument for symbol lookup.
1336    pub fn cache_instrument(&self, instrument: InstrumentAny) {
1337        self.instruments_cache
1338            .insert(instrument.symbol().inner(), instrument);
1339        self.cache_initialized.store(true, Ordering::Release);
1340    }
1341
1342    /// Caches multiple instruments for symbol lookup.
1343    pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1344        self.instruments_cache.rcu(|m| {
1345            for instrument in instruments {
1346                m.insert(instrument.symbol().inner(), instrument.clone());
1347            }
1348        });
1349        self.cache_initialized.store(true, Ordering::Release);
1350    }
1351
1352    /// Gets an instrument from the cache by symbol.
1353    pub fn get_cached_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1354        self.instruments_cache.get_cloned(symbol)
1355    }
1356
1357    fn get_instrument_by_raw_symbol(&self, raw_symbol: &str) -> Option<InstrumentAny> {
1358        self.instruments_cache
1359            .load()
1360            .values()
1361            .find(|inst| inst.raw_symbol().as_str() == raw_symbol)
1362            .cloned()
1363    }
1364
1365    /// Resolves the contract a history row names, which the venue may spell in another case than
1366    /// the listing (the documented example is `pi_xbtusd`), so the exact spelling is tried first
1367    /// and a case-insensitive match second.
1368    fn get_instrument_by_history_tradeable(&self, tradeable: &str) -> Option<InstrumentAny> {
1369        self.get_instrument_by_raw_symbol(tradeable).or_else(|| {
1370            self.instruments_cache
1371                .load()
1372                .values()
1373                .find(|inst| inst.raw_symbol().as_str().eq_ignore_ascii_case(tradeable))
1374                .cloned()
1375        })
1376    }
1377
1378    fn generate_ts_init(&self) -> UnixNanos {
1379        self.clock.get_time_ns()
1380    }
1381
1382    /// Requests the complete tradable instrument catalog from Kraken Futures.
1383    ///
1384    /// # Errors
1385    ///
1386    /// Returns an error if the underlying request fails or any instrument definition cannot be
1387    /// parsed. An instrument parse failure returns [`KrakenHttpError::ParseError`] without a
1388    /// partial catalog.
1389    pub async fn request_instruments(&self) -> anyhow::Result<Vec<InstrumentAny>, KrakenHttpError> {
1390        let ts_init = self.generate_ts_init();
1391        let response = self.inner.get_instruments().await?;
1392
1393        response
1394            .instruments
1395            .iter()
1396            .map(|fut_instrument| {
1397                parse_futures_instrument(fut_instrument, ts_init, ts_init)
1398                    .map_err(|e| KrakenHttpError::ParseError(e.to_string()))
1399            })
1400            .collect()
1401    }
1402
1403    /// Requests the current market status for Kraken Futures instruments.
1404    pub async fn request_instrument_statuses(
1405        &self,
1406    ) -> anyhow::Result<AHashMap<InstrumentId, MarketStatusAction>, KrakenHttpError> {
1407        let response = self.inner.get_instruments().await?;
1408
1409        Ok(response
1410            .instruments
1411            .iter()
1412            .map(|instrument| {
1413                let instrument_id =
1414                    InstrumentId::new(Symbol::new(&instrument.symbol), *KRAKEN_VENUE);
1415                let action = if instrument.tradeable {
1416                    MarketStatusAction::Trading
1417                } else {
1418                    MarketStatusAction::NotAvailableForTrading
1419                };
1420
1421                (instrument_id, action)
1422            })
1423            .collect())
1424    }
1425
1426    /// Requests the mark price for an instrument.
1427    pub async fn request_mark_price(
1428        &self,
1429        instrument_id: InstrumentId,
1430    ) -> anyhow::Result<Decimal, KrakenHttpError> {
1431        let instrument = self
1432            .get_cached_instrument(&instrument_id.symbol.inner())
1433            .ok_or_else(|| {
1434                KrakenHttpError::ParseError(
1435                    InstrumentLookupError::not_found(instrument_id).to_string(),
1436                )
1437            })?;
1438
1439        let raw_symbol = instrument.raw_symbol().to_string();
1440        let tickers = self.inner.get_tickers().await?;
1441
1442        tickers
1443            .tickers
1444            .iter()
1445            .find(|t| t.symbol == raw_symbol)
1446            .ok_or_else(|| {
1447                KrakenHttpError::ParseError(format!("Symbol {raw_symbol} not found in tickers"))
1448            })
1449            .and_then(|t| {
1450                t.mark_price.ok_or_else(|| {
1451                    KrakenHttpError::ParseError(format!(
1452                        "Mark price not available for {raw_symbol} (may not be available in testnet)"
1453                    ))
1454                })
1455            })
1456    }
1457
1458    pub async fn request_index_price(
1459        &self,
1460        instrument_id: InstrumentId,
1461    ) -> anyhow::Result<Decimal, KrakenHttpError> {
1462        let instrument = self
1463            .get_cached_instrument(&instrument_id.symbol.inner())
1464            .ok_or_else(|| {
1465                KrakenHttpError::ParseError(
1466                    InstrumentLookupError::not_found(instrument_id).to_string(),
1467                )
1468            })?;
1469
1470        let raw_symbol = instrument.raw_symbol().to_string();
1471        let tickers = self.inner.get_tickers().await?;
1472
1473        tickers
1474            .tickers
1475            .iter()
1476            .find(|t| t.symbol == raw_symbol)
1477            .ok_or_else(|| {
1478                KrakenHttpError::ParseError(format!("Symbol {raw_symbol} not found in tickers"))
1479            })
1480            .and_then(|t| {
1481                t.index_price.ok_or_else(|| {
1482                    KrakenHttpError::ParseError(format!(
1483                        "Index price not available for {raw_symbol} (may not be available in testnet)"
1484                    ))
1485                })
1486            })
1487    }
1488
1489    pub async fn request_trades(
1490        &self,
1491        instrument_id: InstrumentId,
1492        start: Option<Timestamp>,
1493        end: Option<Timestamp>,
1494        limit: Option<u64>,
1495    ) -> anyhow::Result<Vec<TradeTick>, KrakenHttpError> {
1496        let instrument = self
1497            .get_cached_instrument(&instrument_id.symbol.inner())
1498            .ok_or_else(|| {
1499                KrakenHttpError::ParseError(
1500                    InstrumentLookupError::not_found(instrument_id).to_string(),
1501                )
1502            })?;
1503
1504        let raw_symbol = instrument.raw_symbol().to_string();
1505        let ts_init = self.generate_ts_init();
1506
1507        let since = start.map(|dt| dt.as_millisecond());
1508        let before = end.map(|dt| dt.as_millisecond());
1509
1510        // Executions are oldest-anchored for `sort=asc`; count-only fetches the
1511        // newest page with `sort=desc` (reversed to ascending below)
1512        let sort = if start.is_some() { "asc" } else { "desc" };
1513
1514        let response = self
1515            .inner
1516            .get_public_executions(&raw_symbol, since, before, Some(sort), None)
1517            .await?;
1518
1519        let mut trades = Vec::new();
1520
1521        for element in &response.elements {
1522            let execution = &element.event.execution.execution;
1523            match parse_futures_public_execution(execution, &instrument, ts_init) {
1524                Ok(trade_tick) => trades.push(trade_tick),
1525                Err(e) => {
1526                    log::warn!("Failed to parse futures trade tick: {e}");
1527                }
1528            }
1529        }
1530
1531        if start.is_none() {
1532            trades.reverse();
1533        }
1534
1535        apply_count_limit(&mut trades, start, limit);
1536
1537        Ok(trades)
1538    }
1539
1540    pub async fn request_bars(
1541        &self,
1542        bar_type: BarType,
1543        start: Option<Timestamp>,
1544        end: Option<Timestamp>,
1545        limit: Option<u64>,
1546    ) -> anyhow::Result<Vec<Bar>, KrakenHttpError> {
1547        let instrument_id = bar_type.instrument_id();
1548        let instrument = self
1549            .get_cached_instrument(&instrument_id.symbol.inner())
1550            .ok_or_else(|| {
1551                KrakenHttpError::ParseError(
1552                    InstrumentLookupError::not_found(instrument_id).to_string(),
1553                )
1554            })?;
1555
1556        let raw_symbol = instrument.raw_symbol().to_string();
1557        let ts_init = self.generate_ts_init();
1558        let tick_type = "trade";
1559        let resolution = bar_type_to_futures_resolution(bar_type)
1560            .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1561
1562        // Kraken Futures OHLC API expects Unix timestamp in seconds
1563        let from = start.map(|dt| dt.as_second());
1564        let to = end.map(|dt| dt.as_second());
1565        let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1566
1567        let response = self
1568            .inner
1569            .get_ohlc(tick_type, &raw_symbol, resolution, from, to)
1570            .await?;
1571
1572        let mut bars = Vec::new();
1573
1574        for candle in response.candles {
1575            let ohlc = OhlcData {
1576                time: candle.time / 1000,
1577                open: candle.open,
1578                high: candle.high,
1579                low: candle.low,
1580                close: candle.close,
1581                vwap: "0".to_string(),
1582                volume: candle.volume,
1583                count: 0,
1584            };
1585
1586            match parse_bar(&ohlc, &instrument, bar_type, ts_init) {
1587                Ok(bar) => {
1588                    if let Some(end_nanos) = end_ns
1589                        && bar.ts_event.as_u64() > end_nanos
1590                    {
1591                        continue;
1592                    }
1593                    bars.push(bar);
1594                }
1595                Err(e) => {
1596                    log::warn!("Failed to parse futures bar: {e}");
1597                }
1598            }
1599        }
1600
1601        // Kraken returns the page oldest-first; keep the most recent `limit`
1602        // bars for count-only requests rather than the oldest (issue #4254).
1603        apply_count_limit(&mut bars, start, limit);
1604
1605        Ok(bars)
1606    }
1607
1608    /// Requests an order book snapshot for a futures instrument.
1609    pub async fn request_book_snapshot(
1610        &self,
1611        instrument_id: InstrumentId,
1612        depth: Option<u32>,
1613    ) -> anyhow::Result<OrderBook, KrakenHttpError> {
1614        let instrument = self
1615            .get_cached_instrument(&instrument_id.symbol.inner())
1616            .ok_or_else(|| {
1617                KrakenHttpError::ParseError(
1618                    InstrumentLookupError::not_found(instrument_id).to_string(),
1619                )
1620            })?;
1621
1622        let raw_symbol = instrument.raw_symbol().to_string();
1623        let price_precision = instrument.price_precision();
1624        let size_precision = instrument.size_precision();
1625        let ts_event = self.generate_ts_init();
1626
1627        let response = self.inner.get_orderbook(&raw_symbol).await?;
1628        let book_data = &response.order_book;
1629
1630        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
1631
1632        let bid_limit = depth.map_or(book_data.bids.len(), |d| {
1633            (d as usize).min(book_data.bids.len())
1634        });
1635        let ask_limit = depth.map_or(book_data.asks.len(), |d| {
1636            (d as usize).min(book_data.asks.len())
1637        });
1638
1639        // Pass sequence=0 so the snapshot does not advance the book's high-water sequence,
1640        // the WS subscription owns sequencing once it starts streaming deltas.
1641        for (i, level) in book_data.bids.iter().take(bid_limit).enumerate() {
1642            let price = Price::from_decimal_dp(level.price, price_precision)
1643                .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1644            let size = Quantity::from_decimal_dp(level.qty, size_precision)
1645                .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1646            let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
1647            book.add(order, 0, 0, ts_event);
1648        }
1649
1650        for (i, level) in book_data.asks.iter().take(ask_limit).enumerate() {
1651            let price = Price::from_decimal_dp(level.price, price_precision)
1652                .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1653            let size = Quantity::from_decimal_dp(level.qty, size_precision)
1654                .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1655            let order = BookOrder::new(OrderSide::Sell, price, size, (bid_limit + i) as u64);
1656            book.add(order, 0, 0, ts_event);
1657        }
1658
1659        Ok(book)
1660    }
1661
1662    /// Requests historical funding rates for a futures instrument.
1663    ///
1664    /// Kraken returns all available rates; client-side filtering applies
1665    /// the `start`, `end`, and `limit` constraints from the caller.
1666    pub async fn request_funding_rates(
1667        &self,
1668        instrument_id: InstrumentId,
1669        start: Option<Timestamp>,
1670        end: Option<Timestamp>,
1671        limit: Option<usize>,
1672    ) -> anyhow::Result<Vec<FundingRateUpdate>, KrakenHttpError> {
1673        let instrument = self
1674            .get_cached_instrument(&instrument_id.symbol.inner())
1675            .ok_or_else(|| {
1676                KrakenHttpError::ParseError(
1677                    InstrumentLookupError::not_found(instrument_id).to_string(),
1678                )
1679            })?;
1680
1681        let raw_symbol = instrument.raw_symbol().to_string();
1682        let ts_init = self.generate_ts_init();
1683        let start_ns = start.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1684        let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1685
1686        let response = self.inner.get_historical_funding_rates(&raw_symbol).await?;
1687
1688        let mut rates = Vec::new();
1689
1690        for entry in &response.rates {
1691            let ts_event = entry.timestamp.parse::<Timestamp>().map_or(ts_init, |dt| {
1692                UnixNanos::from(u64::try_from(dt.as_nanosecond()).unwrap_or(0))
1693            });
1694
1695            if let Some(s) = start_ns
1696                && ts_event.as_u64() < s
1697            {
1698                continue;
1699            }
1700
1701            if let Some(e) = end_ns
1702                && ts_event.as_u64() > e
1703            {
1704                continue;
1705            }
1706
1707            rates.push(FundingRateUpdate::new(
1708                instrument_id,
1709                entry.relative_funding_rate,
1710                None,
1711                None,
1712                ts_event,
1713                ts_init,
1714            ));
1715
1716            if let Some(lim) = limit
1717                && rates.len() >= lim
1718            {
1719                break;
1720            }
1721        }
1722
1723        // Kraken returns newest-first; reverse to ascending chronological order
1724        rates.reverse();
1725
1726        Ok(rates)
1727    }
1728
1729    /// Requests account state from the Kraken Futures exchange.
1730    ///
1731    /// This queries the accounts endpoint and converts the response into a
1732    /// Nautilus `AccountState` event containing balances and margin info.
1733    ///
1734    /// # Errors
1735    ///
1736    /// Returns an error if:
1737    /// - Credentials are missing.
1738    /// - The request fails.
1739    /// - Response parsing fails.
1740    pub async fn request_account_state(
1741        &self,
1742        account_id: AccountId,
1743    ) -> anyhow::Result<AccountState> {
1744        let accounts_response = self.inner.get_accounts().await?;
1745
1746        if accounts_response.result != KrakenApiResult::Success {
1747            let error_msg = accounts_response
1748                .error
1749                .unwrap_or_else(|| "Unknown error".to_string());
1750            anyhow::bail!("Failed to get futures accounts: {error_msg}");
1751        }
1752
1753        let ts_init = self.generate_ts_init();
1754        let (balances, margins) = parse_account_entries(&accounts_response.accounts);
1755
1756        Ok(AccountState::new(
1757            account_id,
1758            AccountType::Margin,
1759            balances,
1760            margins,
1761            true,
1762            UUID4::new(),
1763            ts_init,
1764            ts_init,
1765            None,
1766        ))
1767    }
1768
1769    pub async fn request_order_status_reports(
1770        &self,
1771        account_id: AccountId,
1772        instrument_id: Option<InstrumentId>,
1773        start: Option<Timestamp>,
1774        end: Option<Timestamp>,
1775        open_only: bool,
1776    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1777        self.request_order_status_reports_checked(account_id, instrument_id, start, end, open_only)
1778            .await
1779            .map(|(reports, _)| reports)
1780    }
1781
1782    /// Requests order status reports, also reporting what the read covered.
1783    ///
1784    /// An in-scope open order whose instrument cannot be resolved, or that cannot be parsed, fails
1785    /// the read, except an untouched reduce-only trigger listed without an unfilled size that no
1786    /// open position sizes, which is skipped, and an order a read scoped to one instrument cannot
1787    /// resolve, which may be on another instrument and is skipped. The coverage records each such
1788    /// skip, each history row that cannot be parsed, resolved or represented, and a continuation
1789    /// token, which cuts the read short at the page's oldest event: the read takes one page, since
1790    /// every Kraken `/history` endpoint draws on one pool of 100 tokens, replenished at 100 every
1791    /// 10 minutes, at a token per page. A read scoped to an unheld futures instrument reads nothing
1792    /// and rules out no order. `ExecutionMassStatus::set_report_window` records whether it is
1793    /// complete for bounded history, and the single-order lookup whether it rules out an order it
1794    /// did not return.
1795    pub(crate) async fn request_order_status_reports_checked(
1796        &self,
1797        account_id: AccountId,
1798        instrument_id: Option<InstrumentId>,
1799        start: Option<Timestamp>,
1800        end: Option<Timestamp>,
1801        open_only: bool,
1802    ) -> anyhow::Result<(Vec<OrderStatusReport>, ReportCoverage)> {
1803        let mut coverage = ReportCoverage::default();
1804
1805        // A scoped read for an instrument this client does not hold can match nothing, so
1806        // return before the request rather than reporting every instrument's rows. A spot
1807        // instrument names none of this client's orders, so that read is complete; a futures one
1808        // the listing lacks read nothing, so it rules out none of its orders.
1809        if let Some(ref target_id) = instrument_id
1810            && self
1811                .get_cached_instrument(&target_id.symbol.inner())
1812                .is_none()
1813        {
1814            if product_type_from_symbol(target_id.symbol.as_str()) == KrakenProductType::Futures {
1815                coverage.truncate(None);
1816            }
1817            return Ok((Vec::new(), coverage));
1818        }
1819        let ts_init = self.generate_ts_init();
1820        let mut all_reports = Vec::new();
1821
1822        let response = self
1823            .inner
1824            .get_open_orders()
1825            .await
1826            .map_err(|e| anyhow::anyhow!("get_open_orders failed: {e}"))?;
1827
1828        if response.result != KrakenApiResult::Success {
1829            let error_msg = response
1830                .error
1831                .unwrap_or_else(|| "Unknown error".to_string());
1832            anyhow::bail!("Failed to get open orders: {error_msg}");
1833        }
1834
1835        let position_sizes = if response
1836            .open_orders
1837            .iter()
1838            .any(|order| order.unfilled_size.is_none())
1839        {
1840            match self.inner.get_open_positions().await {
1841                Ok(response) if response.result == KrakenApiResult::Success => Ok(response
1842                    .open_positions
1843                    .into_iter()
1844                    .map(|position| (position.symbol, position.size))
1845                    .collect::<AHashMap<_, _>>()),
1846                Ok(response) => {
1847                    let reason = response
1848                        .error
1849                        .unwrap_or_else(|| "Unknown error".to_string());
1850                    log::warn!("Failed to get open positions for order quantities: {reason}");
1851                    Err(reason)
1852                }
1853                Err(e) => {
1854                    log::warn!("Failed to get open positions for order quantities: {e}");
1855                    Err(e.to_string())
1856                }
1857            }
1858        } else {
1859            Ok(AHashMap::new())
1860        };
1861        let mut skipped_open_order_ids = AHashSet::new();
1862
1863        for order in &response.open_orders {
1864            // Resolve the row and compare instrument ids, so a scoped read cannot match on a
1865            // spelling and cannot fall through to every instrument when the id is not held.
1866            let resolved = self.get_instrument_by_raw_symbol(&order.symbol);
1867            if let Some(ref target_id) = instrument_id {
1868                match &resolved {
1869                    Some(inst) if inst.id() != *target_id => continue,
1870                    // Unresolved, the row may be the scoped instrument's own
1871                    None => {
1872                        coverage.skip(&order.order_id);
1873                        continue;
1874                    }
1875                    Some(_) => {}
1876                }
1877            }
1878
1879            if let Some(instrument) = resolved {
1880                let sized_by_position = order.unfilled_size.is_none()
1881                    && matches!(
1882                        order.order_type,
1883                        KrakenFuturesOrderType::Stop
1884                            | KrakenFuturesOrderType::StopLower
1885                            | KrakenFuturesOrderType::StopLoss
1886                            | KrakenFuturesOrderType::TakeProfit
1887                    )
1888                    && order.status == KrakenFuturesOrderStatus::Untouched
1889                    && order.reduce_only == Some(true);
1890                let position_size = match &position_sizes {
1891                    Ok(sizes) if sized_by_position => sizes.get(&order.symbol).copied(),
1892                    _ => None,
1893                };
1894
1895                // Such a trigger has no size of its own until it fires. With no open position on
1896                // its contract it has none to report, and failing would fail every unscoped
1897                // futures order read while it rests, so it is skipped, its history rows with it;
1898                // a failed positions read leaves its size unknown instead, which fails the read.
1899                if sized_by_position && position_size.is_none() {
1900                    if let Err(reason) = &position_sizes {
1901                        anyhow::bail!(
1902                            "OpenOrders: futures order {} lists no unfilled size and the open \
1903                             positions read failed: {reason}",
1904                            order.order_id
1905                        );
1906                    }
1907                    log::warn!(
1908                        "Skipping futures order {} on {}: the venue lists no unfilled size and no \
1909                         open position sizes it; the set is incomplete",
1910                        order.order_id,
1911                        order.symbol
1912                    );
1913                    coverage.skip(&order.order_id);
1914                    skipped_open_order_ids.insert(VenueOrderId::new(order.order_id.as_str()));
1915                    continue;
1916                }
1917
1918                // An in-scope open order the client cannot parse fails the read: dropped, it reads
1919                // to reconciliation as an order the venue does not have.
1920                let report = parse_futures_order_status_report(
1921                    order,
1922                    &instrument,
1923                    account_id,
1924                    position_size,
1925                    ts_init,
1926                )
1927                .map_err(|e| {
1928                    anyhow::anyhow!(
1929                        "OpenOrders: failed to parse futures order {}: {e}",
1930                        order.order_id
1931                    )
1932                })?;
1933                all_reports.push(report);
1934            } else {
1935                // An in-scope open order the client cannot resolve fails the read, as the spot
1936                // client's does: dropped, it reads to reconciliation as an order the venue never had.
1937                anyhow::bail!(
1938                    "OpenOrders: instrument not in cache for futures symbol {}",
1939                    order.symbol
1940                );
1941            }
1942        }
1943
1944        if !open_only {
1945            // Kraken Futures order events API expects Unix timestamp in milliseconds
1946            let start_ms = start.map(|dt| dt.as_millisecond());
1947            let end_ms = end.map(|dt| dt.as_millisecond());
1948            // Typed, so the mass status can tell a refusal from a fault; the message repeats the
1949            // cause for callers that print only the outermost context.
1950            let response = self
1951                .inner
1952                .get_order_events(end_ms, start_ms, None)
1953                .await
1954                .map_err(|e| {
1955                    let message = format!("get_order_events failed: {e}");
1956                    anyhow::Error::new(e).context(message)
1957                })?;
1958
1959            // A page that hands back a continuation token leaves events of the window unread.
1960            if response
1961                .continuation_token
1962                .as_deref()
1963                .is_some_and(|token| !token.is_empty())
1964            {
1965                log::warn!(
1966                    "Order history window since={start_ms:?} before={end_ms:?} holds more events than one page; the events beyond it are not read, marking the set incomplete"
1967                );
1968                // The page holds the newest events, so it covers every event from its oldest one
1969                // on; a page with no usable event time covers nothing it can place.
1970                coverage.truncate(
1971                    response
1972                        .oldest_event_ms
1973                        .and_then(UnixNanos::from_millis_checked),
1974                );
1975            }
1976
1977            // The history lists every lifecycle event, so an order appears once per event. Each
1978            // report reconciles against the same cached state, so the read hands back one report
1979            // per order: the open-order snapshot when the venue still lists it, else the latest
1980            // history state. An open order the snapshot skips is not reported from its history.
1981            let open_order_ids: AHashSet<VenueOrderId> = all_reports
1982                .iter()
1983                .map(|r| r.venue_order_id)
1984                .chain(skipped_open_order_ids.iter().copied())
1985                .collect();
1986            let mut latest: IndexMap<VenueOrderId, OrderStatusReport> = IndexMap::new();
1987
1988            if response.skipped_rows > 0 {
1989                log::warn!(
1990                    "Order history page had {} row(s) the adapter could not represent; marking the set incomplete",
1991                    response.skipped_rows
1992                );
1993
1994                for order_id in &response.skipped_order_ids {
1995                    coverage.skip(order_id);
1996                }
1997
1998                if response.skipped_rows > response.skipped_order_ids.len() {
1999                    coverage.skip_unidentified();
2000                }
2001            }
2002
2003            for event_wrapper in response.order_events {
2004                let event = &event_wrapper.order;
2005
2006                // Resolve the row and compare instrument ids, so a scoped read cannot match on a
2007                // spelling and cannot fall through to every instrument when the id is not held.
2008                let resolved = self.get_instrument_by_history_tradeable(&event.symbol);
2009                if let Some(ref target_id) = instrument_id {
2010                    match &resolved {
2011                        Some(inst) if inst.id() != *target_id => continue,
2012                        // Unresolved, the row may be the scoped instrument's own
2013                        None => {
2014                            coverage.skip(&event.order_id);
2015                            continue;
2016                        }
2017                        Some(_) => {}
2018                    }
2019                }
2020
2021                if let Some(instrument) = resolved {
2022                    match parse_futures_order_event_status_report(
2023                        event,
2024                        Some(event_wrapper.event_type),
2025                        &instrument,
2026                        account_id,
2027                        ts_init,
2028                    ) {
2029                        Ok(report) => {
2030                            if open_order_ids.contains(&report.venue_order_id) {
2031                                continue;
2032                            }
2033
2034                            match latest.get(&report.venue_order_id) {
2035                                Some(existing) if !supersedes(&report, existing) => {}
2036                                _ => {
2037                                    latest.insert(report.venue_order_id, report);
2038                                }
2039                            }
2040                        }
2041                        Err(e) => {
2042                            let order_id = &event.order_id;
2043                            log::warn!("Failed to parse futures order event {order_id}: {e}");
2044                            coverage.skip(order_id);
2045                        }
2046                    }
2047                } else {
2048                    log::warn!(
2049                        "Instrument not in cache for futures symbol {}, skipping order event",
2050                        event.symbol
2051                    );
2052                    coverage.skip(&event.order_id);
2053                }
2054            }
2055
2056            all_reports.extend(latest.into_values());
2057        }
2058
2059        Ok((all_reports, coverage))
2060    }
2061
2062    /// Requests order status reports from the venue's `/orders/status`
2063    /// 5-second window for the given venue order IDs and client order IDs.
2064    ///
2065    /// Orders the venue no longer reports within that window are simply absent
2066    /// from the result, which callers treat as absence of recent evidence.
2067    ///
2068    /// # Errors
2069    ///
2070    /// Returns an error if the underlying request fails or the venue rejects
2071    /// it.
2072    pub async fn request_orders_status_reports(
2073        &self,
2074        account_id: AccountId,
2075        order_ids: &[String],
2076        cli_ord_ids: &[String],
2077    ) -> anyhow::Result<Vec<OrderStatusReport>> {
2078        let ts_init = self.generate_ts_init();
2079
2080        let response = self
2081            .inner
2082            .get_orders_status(order_ids, cli_ord_ids)
2083            .await
2084            .map_err(|e| anyhow::anyhow!("get_orders_status failed: {e}"))?;
2085
2086        if response.result != KrakenApiResult::Success {
2087            let error_msg = response
2088                .error
2089                .unwrap_or_else(|| "Unknown error".to_string());
2090            anyhow::bail!("Failed to get orders status: {error_msg}");
2091        }
2092
2093        let mut reports = Vec::with_capacity(response.orders.len());
2094        for details in &response.orders {
2095            let Some(instrument) = self.get_instrument_by_raw_symbol(&details.order.symbol) else {
2096                anyhow::bail!(
2097                    "No cached instrument for symbol: {}, order {} cannot be reported; \
2098                     treating the lookup as failed rather than the order as absent",
2099                    details.order.symbol,
2100                    details.order.order_id,
2101                );
2102            };
2103
2104            match parse_futures_order_status_details_report(
2105                details,
2106                &instrument,
2107                account_id,
2108                ts_init,
2109            ) {
2110                Ok(report) => reports.push(report),
2111                Err(e) => anyhow::bail!(
2112                    "Failed to parse futures order status {}: {e}; treating the lookup as \
2113                     failed rather than the order as absent",
2114                    details.order.order_id,
2115                ),
2116            }
2117        }
2118
2119        Ok(reports)
2120    }
2121
2122    pub async fn request_fill_reports(
2123        &self,
2124        account_id: AccountId,
2125        instrument_id: Option<InstrumentId>,
2126        start: Option<Timestamp>,
2127        end: Option<Timestamp>,
2128    ) -> anyhow::Result<Vec<FillReport>> {
2129        self.request_fill_reports_checked(account_id, instrument_id, start, end)
2130            .await
2131            .map(|(reports, _)| reports)
2132    }
2133
2134    /// Requests fill reports, also reporting whether the set is complete.
2135    ///
2136    /// The flag is `false` when an in-scope fill cannot be resolved or parsed, which
2137    /// `ExecutionMassStatus::set_report_window` records for bounded history.
2138    pub(crate) async fn request_fill_reports_checked(
2139        &self,
2140        account_id: AccountId,
2141        instrument_id: Option<InstrumentId>,
2142        start: Option<Timestamp>,
2143        end: Option<Timestamp>,
2144    ) -> anyhow::Result<(Vec<FillReport>, bool)> {
2145        let mut complete = true;
2146        let ts_init = self.generate_ts_init();
2147        let mut all_reports = Vec::new();
2148
2149        // As above: a scoped read for an instrument this client does not hold matches nothing.
2150        if let Some(ref target_id) = instrument_id
2151            && self
2152                .get_cached_instrument(&target_id.symbol.inner())
2153                .is_none()
2154        {
2155            return Ok((all_reports, complete));
2156        }
2157
2158        let response = self.inner.get_fills(None).await?;
2159        if response.result != KrakenApiResult::Success {
2160            let error_msg = response
2161                .error
2162                .unwrap_or_else(|| "Unknown error".to_string());
2163            anyhow::bail!("Failed to get fills: {error_msg}");
2164        }
2165
2166        let start_ms = start.map(|dt| dt.as_millisecond());
2167        let end_ms = end.map(|dt| dt.as_millisecond());
2168
2169        for fill in response.fills {
2170            if let Some(start_threshold) = start_ms
2171                && let Ok(fill_ts) = fill.fill_time.parse::<Timestamp>()
2172            {
2173                let fill_ms = fill_ts.as_millisecond();
2174                if fill_ms < start_threshold {
2175                    continue;
2176                }
2177            }
2178
2179            if let Some(end_threshold) = end_ms
2180                && let Ok(fill_ts) = fill.fill_time.parse::<Timestamp>()
2181            {
2182                let fill_ms = fill_ts.as_millisecond();
2183                if fill_ms > end_threshold {
2184                    continue;
2185                }
2186            }
2187
2188            // Resolve the row and compare instrument ids, so a scoped read cannot match on a
2189            // spelling and cannot fall through to every instrument when the id is not held.
2190            let resolved = self.get_instrument_by_raw_symbol(&fill.symbol);
2191            if let Some(ref target_id) = instrument_id
2192                && resolved.as_ref().is_none_or(|inst| inst.id() != *target_id)
2193            {
2194                continue;
2195            }
2196
2197            if let Some(instrument) = resolved {
2198                match parse_futures_fill_report(&fill, &instrument, account_id, ts_init) {
2199                    Ok(report) => all_reports.push(report),
2200                    Err(e) => {
2201                        let fill_id = &fill.fill_id;
2202                        log::warn!("Failed to parse futures fill {fill_id}: {e}");
2203                        complete = false;
2204                    }
2205                }
2206            } else {
2207                log::warn!(
2208                    "Instrument not in cache for futures symbol {}, skipping fill",
2209                    fill.symbol
2210                );
2211                complete = false;
2212            }
2213        }
2214
2215        Ok((all_reports, complete))
2216    }
2217
2218    pub async fn request_position_status_reports(
2219        &self,
2220        account_id: AccountId,
2221        instrument_id: Option<InstrumentId>,
2222    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2223        let ts_init = self.generate_ts_init();
2224        let mut all_reports = Vec::new();
2225
2226        // As above: a scoped read for an instrument this client does not hold matches nothing.
2227        if let Some(ref target_id) = instrument_id
2228            && self
2229                .get_cached_instrument(&target_id.symbol.inner())
2230                .is_none()
2231        {
2232            return Ok(all_reports);
2233        }
2234
2235        let response = self.inner.get_open_positions().await?;
2236        if response.result != KrakenApiResult::Success {
2237            let error_msg = response
2238                .error
2239                .unwrap_or_else(|| "Unknown error".to_string());
2240            anyhow::bail!("Failed to get open positions: {error_msg}");
2241        }
2242
2243        for position in response.open_positions {
2244            // Resolve the row and compare instrument ids, so a scoped read cannot match on a
2245            // spelling and cannot fall through to every instrument when the id is not held.
2246            let resolved = self.get_instrument_by_raw_symbol(&position.symbol);
2247            if let Some(ref target_id) = instrument_id
2248                && resolved.as_ref().is_none_or(|inst| inst.id() != *target_id)
2249            {
2250                continue;
2251            }
2252
2253            // An in-scope position the client cannot resolve or parse fails the read: dropped,
2254            // it reads to reconciliation as flat.
2255            let Some(instrument) = resolved else {
2256                anyhow::bail!(
2257                    "OpenPositions: instrument not in cache for futures symbol {}",
2258                    position.symbol
2259                );
2260            };
2261
2262            let report =
2263                parse_futures_position_status_report(&position, &instrument, account_id, ts_init)
2264                    .map_err(|e| {
2265                    anyhow::anyhow!(
2266                        "OpenPositions: failed to parse futures position {}: {e}",
2267                        position.symbol
2268                    )
2269                })?;
2270            all_reports.push(report);
2271        }
2272
2273        Ok(all_reports)
2274    }
2275
2276    #[expect(clippy::too_many_arguments)]
2277    fn build_send_order_params(
2278        &self,
2279        instrument_id: InstrumentId,
2280        client_order_id: ClientOrderId,
2281        order_side: OrderSide,
2282        order_type: OrderType,
2283        quantity: Quantity,
2284        time_in_force: TimeInForce,
2285        price: Option<Price>,
2286        trigger_price: Option<Price>,
2287        trigger_type: Option<TriggerType>,
2288        reduce_only: bool,
2289        post_only: bool,
2290    ) -> anyhow::Result<KrakenFuturesSendOrderParams> {
2291        let instrument = self
2292            .get_cached_instrument(&instrument_id.symbol.inner())
2293            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2294
2295        let raw_symbol = instrument.raw_symbol().inner();
2296
2297        // Map order type and time-in-force to Kraken order type
2298        // Kraken Futures encodes TIF in the orderType field:
2299        // - lmt = limit (GTC)
2300        // - ioc = immediate-or-cancel
2301        // - post = post-only (maker only)
2302        // - mkt = market
2303        let kraken_order_type = match order_type {
2304            OrderType::Market => KrakenFuturesOrderType::Market,
2305            OrderType::Limit => {
2306                if post_only {
2307                    KrakenFuturesOrderType::Post
2308                } else {
2309                    match time_in_force {
2310                        TimeInForce::Ioc => KrakenFuturesOrderType::Ioc,
2311                        TimeInForce::Fok => {
2312                            anyhow::bail!("FOK not supported by Kraken Futures, use IOC instead")
2313                        }
2314                        TimeInForce::Gtd => {
2315                            anyhow::bail!("GTD not supported by Kraken Futures, use GTC instead")
2316                        }
2317                        _ => KrakenFuturesOrderType::Limit, // GTC is default
2318                    }
2319                }
2320            }
2321            OrderType::StopMarket | OrderType::StopLimit => KrakenFuturesOrderType::Stop,
2322            OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2323                KrakenFuturesOrderType::TakeProfit
2324            }
2325            _ => anyhow::bail!("Unsupported order type: {order_type:?}"),
2326        };
2327
2328        let kraken_side = KrakenOrderSide::from(order_side);
2329
2330        let mut builder = KrakenFuturesSendOrderParamsBuilder::default();
2331        builder
2332            .cli_ord_id(truncate_cl_ord_id(&client_order_id))
2333            .broker(NAUTILUS_KRAKEN_BROKER_ID)
2334            .symbol(raw_symbol)
2335            .side(kraken_side)
2336            .size(quantity.to_string())
2337            .order_type(kraken_order_type);
2338
2339        if matches!(
2340            order_type,
2341            OrderType::StopMarket
2342                | OrderType::StopLimit
2343                | OrderType::MarketIfTouched
2344                | OrderType::LimitIfTouched
2345        ) && let Some(signal) = map_futures_trigger_signal(trigger_type)?
2346        {
2347            builder.trigger_signal(signal);
2348        }
2349
2350        match order_type {
2351            OrderType::StopMarket => {
2352                if let Some(trigger) = trigger_price {
2353                    builder.stop_price(trigger.to_string());
2354                }
2355            }
2356            OrderType::StopLimit => {
2357                if let Some(trigger) = trigger_price {
2358                    builder.stop_price(trigger.to_string());
2359                }
2360
2361                if let Some(limit) = price {
2362                    builder.limit_price(limit.to_string());
2363                }
2364            }
2365            OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2366                if let Some(trigger) = trigger_price {
2367                    builder.stop_price(trigger.to_string());
2368                }
2369
2370                if let Some(limit) = price {
2371                    builder.limit_price(limit.to_string());
2372                }
2373            }
2374            _ => {
2375                if let Some(limit) = price {
2376                    builder.limit_price(limit.to_string());
2377                }
2378            }
2379        }
2380
2381        if reduce_only {
2382            builder.reduce_only(true);
2383        }
2384
2385        builder
2386            .build()
2387            .map_err(|e| anyhow::anyhow!("Failed to build order params: {e}"))
2388    }
2389
2390    /// Submits a new order to the Kraken Futures exchange.
2391    ///
2392    /// # Errors
2393    ///
2394    /// Returns an error if:
2395    /// - Credentials are missing.
2396    /// - The instrument is not found in cache.
2397    /// - The order type or time in force is not supported.
2398    /// - The request fails.
2399    /// - The order is rejected.
2400    #[expect(clippy::too_many_arguments)]
2401    pub async fn submit_order(
2402        &self,
2403        account_id: AccountId,
2404        instrument_id: InstrumentId,
2405        client_order_id: ClientOrderId,
2406        order_side: OrderSide,
2407        order_type: OrderType,
2408        quantity: Quantity,
2409        time_in_force: TimeInForce,
2410        price: Option<Price>,
2411        trigger_price: Option<Price>,
2412        trigger_type: Option<TriggerType>,
2413        reduce_only: bool,
2414        post_only: bool,
2415    ) -> anyhow::Result<OrderStatusReport> {
2416        let instrument = self
2417            .get_cached_instrument(&instrument_id.symbol.inner())
2418            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2419
2420        let params = self.build_send_order_params(
2421            instrument_id,
2422            client_order_id,
2423            order_side,
2424            order_type,
2425            quantity,
2426            time_in_force,
2427            price,
2428            trigger_price,
2429            trigger_type,
2430            reduce_only,
2431            post_only,
2432        )?;
2433
2434        let response = self.inner.send_order_params(&params).await?;
2435
2436        if response.result != KrakenApiResult::Success {
2437            return Err(KrakenSubmitOrderError::Rejected {
2438                reason: response
2439                    .error
2440                    .unwrap_or_else(|| "Unknown error".to_string()),
2441            }
2442            .into());
2443        }
2444
2445        let send_status = response
2446            .send_status
2447            .ok_or(KrakenSubmitOrderError::MissingStatus)?;
2448
2449        match send_status.status.as_str() {
2450            "placed" | "filled" => {}
2451            "postWouldExecute" => {
2452                let reason = send_status
2453                    .order_events
2454                    .as_ref()
2455                    .and_then(|events| events.first())
2456                    .and_then(|event| event.reason.clone())
2457                    .unwrap_or_else(|| "Post-only order would have crossed".to_string());
2458                return Err(KrakenSubmitOrderError::Rejected {
2459                    reason: format!("POST_ONLY_REJECTED: {reason}"),
2460                }
2461                .into());
2462            }
2463            status if is_futures_submit_rejection(status) => {
2464                return Err(KrakenSubmitOrderError::Rejected {
2465                    reason: status.to_string(),
2466                }
2467                .into());
2468            }
2469            status => {
2470                return Err(KrakenSubmitOrderError::UnknownStatus {
2471                    status: status.to_string(),
2472                }
2473                .into());
2474            }
2475        }
2476
2477        let venue_order_id =
2478            send_status
2479                .order_id
2480                .clone()
2481                .ok_or_else(|| KrakenSubmitOrderError::MissingOrderId {
2482                    detail: format!("send status was {}", send_status.status),
2483                })?;
2484
2485        let report: anyhow::Result<OrderStatusReport> = async {
2486            let ts_init = self.generate_ts_init();
2487
2488            let open_orders_response = self.inner.get_open_orders().await?;
2489            if let Some(order) = open_orders_response
2490                .open_orders
2491                .iter()
2492                .find(|o| o.order_id == venue_order_id)
2493            {
2494                return parse_futures_order_status_report(
2495                    order,
2496                    &instrument,
2497                    account_id,
2498                    Some(quantity.as_decimal()),
2499                    ts_init,
2500                );
2501            }
2502
2503            // Order not in open orders - may have filled immediately (market order or aggressive limit)
2504            // Try to use order_events from send_status first
2505            if let Some(order_events) = &send_status.order_events
2506                && let Some(send_event) = order_events.first()
2507            {
2508                // Handle regular orders, trigger orders, and execution events
2509                let event = if let Some(order_data) = &send_event.order {
2510                    FuturesOrderEvent {
2511                        order_id: order_data.order_id.clone(),
2512                        cli_ord_id: order_data.cli_ord_id.clone(),
2513                        order_type: order_data.order_type,
2514                        symbol: order_data.symbol.clone(),
2515                        side: order_data.side,
2516                        quantity: order_data.quantity,
2517                        filled: order_data.filled,
2518                        limit_price: order_data.limit_price,
2519                        stop_price: order_data.stop_price,
2520                        timestamp: order_data.timestamp.clone(),
2521                        last_update_timestamp: order_data.last_update_timestamp.clone(),
2522                        reduce_only: order_data.reduce_only,
2523                    }
2524                } else if let Some(trigger_data) = &send_event.order_trigger {
2525                    FuturesOrderEvent {
2526                        order_id: trigger_data.uid.clone(),
2527                        cli_ord_id: trigger_data.client_id.clone(),
2528                        order_type: trigger_data.order_type,
2529                        symbol: trigger_data.symbol.clone(),
2530                        side: trigger_data.side,
2531                        quantity: trigger_data.quantity,
2532                        filled: Decimal::ZERO,
2533                        limit_price: trigger_data.limit_price,
2534                        stop_price: Some(trigger_data.trigger_price),
2535                        timestamp: trigger_data.timestamp.clone(),
2536                        last_update_timestamp: trigger_data.last_update_timestamp.clone(),
2537                        reduce_only: trigger_data.reduce_only,
2538                    }
2539                } else if let Some(prior_exec) = &send_event.order_prior_execution {
2540                    // EXECUTION event - use orderPriorExecution data
2541                    FuturesOrderEvent {
2542                        order_id: prior_exec.order_id.clone(),
2543                        cli_ord_id: prior_exec.cli_ord_id.clone(),
2544                        order_type: prior_exec.order_type,
2545                        symbol: prior_exec.symbol.clone(),
2546                        side: prior_exec.side,
2547                        quantity: prior_exec.quantity,
2548                        filled: send_event.amount.unwrap_or(prior_exec.quantity), // Use execution amount
2549                        limit_price: prior_exec.limit_price,
2550                        stop_price: prior_exec.stop_price,
2551                        timestamp: prior_exec.timestamp.clone(),
2552                        last_update_timestamp: prior_exec.last_update_timestamp.clone(),
2553                        reduce_only: prior_exec.reduce_only,
2554                    }
2555                } else {
2556                    anyhow::bail!("No order, orderTrigger, or orderPriorExecution data in event");
2557                };
2558                return parse_futures_order_event_status_report(
2559                    &event,
2560                    Some(send_event.event_type),
2561                    &instrument,
2562                    account_id,
2563                    ts_init,
2564                );
2565            }
2566
2567            // Fall back to querying order events
2568            let events_response = self.inner.get_order_events(None, None, None).await?;
2569            let event_wrapper = events_response
2570                .order_events
2571                .iter()
2572                .find(|e| e.order.order_id == venue_order_id)
2573                .ok_or_else(|| {
2574                    anyhow::anyhow!("Order not found in open orders or events: {venue_order_id}")
2575                })?;
2576
2577            parse_futures_order_event_status_report(
2578                &event_wrapper.order,
2579                Some(event_wrapper.event_type),
2580                &instrument,
2581                account_id,
2582                ts_init,
2583            )
2584        }
2585        .await;
2586
2587        report.map_err(|source| KrakenSubmitOrderError::PostSubmitLookup { source }.into())
2588    }
2589
2590    /// Modifies an existing order on the Kraken Futures exchange.
2591    ///
2592    /// Returns the new venue order ID assigned to the modified order.
2593    ///
2594    /// # Errors
2595    ///
2596    /// Returns an error if:
2597    /// - Neither `client_order_id` nor `venue_order_id` is provided.
2598    /// - The instrument is not found in cache.
2599    /// - The request fails.
2600    /// - The edit fails on the exchange.
2601    pub async fn modify_order(
2602        &self,
2603        instrument_id: InstrumentId,
2604        client_order_id: Option<ClientOrderId>,
2605        venue_order_id: Option<VenueOrderId>,
2606        quantity: Option<Quantity>,
2607        price: Option<Price>,
2608        trigger_price: Option<Price>,
2609    ) -> anyhow::Result<VenueOrderId> {
2610        let params = self.build_edit_order_params(
2611            instrument_id,
2612            client_order_id,
2613            venue_order_id,
2614            quantity,
2615            price,
2616            trigger_price,
2617        )?;
2618        let original_order_id = params.order_id.clone();
2619
2620        let response = self.inner.edit_order(&params).await?;
2621        let status = response.edit_status.status.as_str();
2622
2623        if response.result != KrakenApiResult::Success {
2624            return Err(KrakenModifyOrderError::Rejected {
2625                reason: status.to_string(),
2626            }
2627            .into());
2628        }
2629
2630        match status {
2631            "edited" => {}
2632            status if is_futures_modify_rejection(status) => {
2633                return Err(KrakenModifyOrderError::Rejected {
2634                    reason: status.to_string(),
2635                }
2636                .into());
2637            }
2638            status => {
2639                return Err(KrakenModifyOrderError::UnknownStatus {
2640                    status: status.to_string(),
2641                }
2642                .into());
2643            }
2644        }
2645
2646        // Return the new order_id from the response, or fall back to the original
2647        let new_venue_order_id = response
2648            .edit_status
2649            .order_id
2650            .or(original_order_id)
2651            .ok_or(KrakenModifyOrderError::MissingOrderId)?;
2652
2653        Ok(VenueOrderId::new(&new_venue_order_id))
2654    }
2655
2656    /// Cancels an order on the Kraken Futures exchange.
2657    ///
2658    /// # Errors
2659    ///
2660    /// Returns an error if:
2661    /// - Credentials are missing.
2662    /// - Neither client_order_id nor venue_order_id is provided.
2663    /// - The request fails.
2664    /// - The order cancellation is rejected.
2665    pub async fn cancel_order(
2666        &self,
2667        _account_id: AccountId,
2668        instrument_id: InstrumentId,
2669        client_order_id: Option<ClientOrderId>,
2670        venue_order_id: Option<VenueOrderId>,
2671    ) -> anyhow::Result<()> {
2672        let _ = self
2673            .get_cached_instrument(&instrument_id.symbol.inner())
2674            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2675
2676        let order_id = venue_order_id.as_ref().map(|id| id.to_string());
2677        let cli_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
2678
2679        if order_id.is_none() && cli_ord_id.is_none() {
2680            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
2681        }
2682
2683        let response = self.inner.cancel_order(order_id, cli_ord_id).await?;
2684
2685        if response.result != KrakenApiResult::Success {
2686            let status = &response.cancel_status.status;
2687            anyhow::bail!("Order cancellation failed: {status}");
2688        }
2689
2690        Ok(())
2691    }
2692
2693    /// Cancels multiple orders on the Kraken Futures exchange.
2694    ///
2695    /// Automatically chunks requests into batches of 50 orders.
2696    ///
2697    /// # Parameters
2698    /// - `venue_order_ids` - List of venue order IDs to cancel.
2699    ///
2700    /// # Returns
2701    /// The total number of successfully cancelled orders.
2702    pub async fn cancel_orders_batch(
2703        &self,
2704        venue_order_ids: Vec<VenueOrderId>,
2705    ) -> anyhow::Result<usize> {
2706        if venue_order_ids.is_empty() {
2707            return Ok(0);
2708        }
2709
2710        let mut total_cancelled = 0;
2711
2712        for chunk in venue_order_ids.chunks(BATCH_CANCEL_LIMIT) {
2713            let order_ids: Vec<String> = chunk.iter().map(|id| id.to_string()).collect();
2714            let response = self.inner.cancel_orders_batch(order_ids).await?;
2715
2716            if response.result != KrakenApiResult::Success {
2717                let error_msg = response.error.as_deref().unwrap_or("Unknown error");
2718                anyhow::bail!("Batch cancel failed: {error_msg}");
2719            }
2720
2721            let success_count = response
2722                .batch_status
2723                .iter()
2724                .filter(|s| {
2725                    s.status == Some(KrakenSendStatus::Cancelled)
2726                        || s.cancel_status
2727                            .as_ref()
2728                            .is_some_and(|cs| cs.status == KrakenSendStatus::Cancelled)
2729                })
2730                .count();
2731
2732            total_cancelled += success_count;
2733        }
2734
2735        Ok(total_cancelled)
2736    }
2737
2738    /// Submits multiple orders in a single batch request.
2739    ///
2740    /// Builds batch send items from order parameters, chunks at the batch limit,
2741    /// and returns per-item send statuses.
2742    ///
2743    /// # Errors
2744    ///
2745    /// Returns an error if the batch request fails at the API level.
2746    #[expect(clippy::type_complexity)]
2747    pub async fn submit_orders_batch(
2748        &self,
2749        orders: Vec<(
2750            InstrumentId,
2751            ClientOrderId,
2752            OrderSide,
2753            OrderType,
2754            Quantity,
2755            TimeInForce,
2756            Option<Price>,
2757            Option<Price>,
2758            Option<TriggerType>,
2759            bool,
2760            bool,
2761        )>,
2762    ) -> anyhow::Result<Vec<FuturesSendStatus>> {
2763        Ok(self
2764            .send_order_batches(orders)
2765            .await
2766            .into_iter()
2767            .map(|result| match result {
2768                Ok(item) if item.result == KrakenApiResult::Success => item.status,
2769                Ok(mut item) => {
2770                    item.status.status = format!("api_error: {}", item.status.status);
2771                    item.status
2772                }
2773                Err(e) => FuturesSendStatus {
2774                    order_id: None,
2775                    order_tag: None,
2776                    status: if matches!(
2777                        e.downcast_ref::<KrakenBatchOrderError>(),
2778                        Some(KrakenBatchOrderError::Validation { .. })
2779                    ) {
2780                        format!("validation_error: {e}")
2781                    } else {
2782                        format!("batch_error: {e}")
2783                    },
2784                    order_events: None,
2785                    cli_ord_id: None,
2786                    received_time: None,
2787                },
2788            })
2789            .collect())
2790    }
2791
2792    pub(crate) async fn send_order_batches(
2793        &self,
2794        orders: Vec<FuturesBatchOrder>,
2795    ) -> Vec<anyhow::Result<FuturesBatchSubmitItem>> {
2796        let count = orders.len();
2797        if count == 0 {
2798            return Vec::new();
2799        }
2800
2801        let mut results: Vec<Option<anyhow::Result<FuturesBatchSubmitItem>>> =
2802            (0..count).map(|_| None).collect();
2803        let mut valid_items = Vec::with_capacity(count);
2804
2805        for (
2806            idx,
2807            (
2808                instrument_id,
2809                client_order_id,
2810                order_side,
2811                order_type,
2812                quantity,
2813                time_in_force,
2814                price,
2815                trigger_price,
2816                trigger_type,
2817                reduce_only,
2818                post_only,
2819            ),
2820        ) in orders.into_iter().enumerate()
2821        {
2822            match self.build_send_order_params(
2823                instrument_id,
2824                client_order_id,
2825                order_side,
2826                order_type,
2827                quantity,
2828                time_in_force,
2829                price,
2830                trigger_price,
2831                trigger_type,
2832                reduce_only,
2833                post_only,
2834            ) {
2835                Ok(params) => {
2836                    valid_items.push((
2837                        idx,
2838                        KrakenFuturesBatchSendItem::from_params(params, idx.to_string()),
2839                    ));
2840                }
2841                Err(e) => {
2842                    results[idx] = Some(Err(KrakenBatchOrderError::Validation {
2843                        reason: e.to_string(),
2844                    }
2845                    .into()));
2846                }
2847            }
2848        }
2849
2850        if valid_items.is_empty() {
2851            return results.into_iter().flatten().collect();
2852        }
2853
2854        let chunks: Vec<_> = valid_items.chunks(BATCH_ORDER_LIMIT).collect();
2855        for (chunk_index, chunk) in chunks.iter().enumerate() {
2856            let items = chunk.iter().map(|(_, item)| item.clone()).collect();
2857            match self.inner.submit_orders_batch(items).await {
2858                Ok(response) => {
2859                    let mut by_tag: HashMap<String, Option<FuturesSendStatus>> = HashMap::new();
2860                    let response_result = response.result;
2861
2862                    for status in response.batch_status {
2863                        if let Some(tag) = status.order_tag.clone() {
2864                            by_tag
2865                                .entry(tag)
2866                                .and_modify(|entry| *entry = None)
2867                                .or_insert(Some(status));
2868                        }
2869                    }
2870
2871                    for (idx, item) in *chunk {
2872                        let result = match by_tag.remove(&item.order_tag) {
2873                            Some(Some(status)) => Ok(FuturesBatchSubmitItem {
2874                                result: response_result,
2875                                status,
2876                            }),
2877                            Some(None) => Err(KrakenBatchOrderError::DuplicateResponse {
2878                                key: format!("order_tag {}", item.order_tag),
2879                            }
2880                            .into()),
2881                            None => Err(KrakenBatchOrderError::MissingResponse {
2882                                key: format!("order_tag {}", item.order_tag),
2883                            }
2884                            .into()),
2885                        };
2886                        results[*idx] = Some(result);
2887                    }
2888                }
2889                Err(e) => {
2890                    for (idx, _) in *chunk {
2891                        results[*idx] = Some(Err(anyhow::Error::new(e.clone())));
2892                    }
2893
2894                    for later_chunk in &chunks[chunk_index + 1..] {
2895                        for (idx, _) in *later_chunk {
2896                            results[*idx] = Some(Err(KrakenBatchOrderError::NotAttempted.into()));
2897                        }
2898                    }
2899                    break;
2900                }
2901            }
2902        }
2903
2904        results
2905            .into_iter()
2906            .map(|result| result.unwrap_or_else(|| Err(KrakenBatchOrderError::NotAttempted.into())))
2907            .collect()
2908    }
2909
2910    /// Modifies multiple orders in a single batch request.
2911    #[expect(clippy::type_complexity)]
2912    pub async fn edit_orders_batch(
2913        &self,
2914        orders: Vec<(
2915            InstrumentId,
2916            Option<ClientOrderId>,
2917            Option<VenueOrderId>,
2918            Option<Quantity>,
2919            Option<Price>,
2920            Option<Price>,
2921        )>,
2922    ) -> anyhow::Result<Vec<String>> {
2923        let count = orders.len();
2924        if count == 0 {
2925            return Ok(Vec::new());
2926        }
2927
2928        let mut all_statuses: Vec<Option<String>> = vec![None; count];
2929        let mut valid_items = Vec::with_capacity(count);
2930        let mut valid_indices = Vec::with_capacity(count);
2931
2932        for (
2933            idx,
2934            (instrument_id, client_order_id, venue_order_id, quantity, price, trigger_price),
2935        ) in orders.into_iter().enumerate()
2936        {
2937            match self.build_edit_order_params(
2938                instrument_id,
2939                client_order_id,
2940                venue_order_id,
2941                quantity,
2942                price,
2943                trigger_price,
2944            ) {
2945                Ok(params) => {
2946                    valid_items.push(KrakenFuturesBatchEditItem::from_params(
2947                        params,
2948                        idx.to_string(),
2949                    ));
2950                    valid_indices.push(idx);
2951                }
2952                Err(e) => {
2953                    all_statuses[idx] = Some(format!("validation_error: {e}"));
2954                }
2955            }
2956        }
2957
2958        if valid_items.is_empty() {
2959            return Ok(all_statuses.into_iter().flatten().collect());
2960        }
2961
2962        let mut batch_statuses: Vec<String> = Vec::with_capacity(valid_items.len());
2963
2964        for chunk in valid_items.chunks(BATCH_ORDER_LIMIT) {
2965            match self.inner.edit_orders_batch(chunk.to_vec()).await {
2966                Ok(response) => {
2967                    if response.result == KrakenApiResult::Success {
2968                        batch_statuses.extend(response.batch_status.into_iter().map(|s| s.status));
2969                    } else {
2970                        let error_msg = response
2971                            .batch_status
2972                            .first()
2973                            .map_or("Unknown error", |s| s.status.as_str());
2974
2975                        for _ in 0..chunk.len() {
2976                            batch_statuses.push(format!("api_error: {error_msg}"));
2977                        }
2978                    }
2979                }
2980                Err(e) => {
2981                    let remaining = valid_items.len() - batch_statuses.len();
2982                    for _ in 0..remaining {
2983                        batch_statuses.push(format!("batch_error: {e}"));
2984                    }
2985                    break;
2986                }
2987            }
2988        }
2989
2990        for (batch_idx, &original_idx) in valid_indices.iter().enumerate() {
2991            if let Some(status) = batch_statuses.get(batch_idx) {
2992                all_statuses[original_idx] = Some(status.clone());
2993            }
2994        }
2995
2996        Ok(all_statuses.into_iter().flatten().collect())
2997    }
2998
2999    fn build_edit_order_params(
3000        &self,
3001        instrument_id: InstrumentId,
3002        client_order_id: Option<ClientOrderId>,
3003        venue_order_id: Option<VenueOrderId>,
3004        quantity: Option<Quantity>,
3005        price: Option<Price>,
3006        trigger_price: Option<Price>,
3007    ) -> anyhow::Result<KrakenFuturesEditOrderParams> {
3008        let _ = self
3009            .get_cached_instrument(&instrument_id.symbol.inner())
3010            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
3011
3012        let order_id = venue_order_id.as_ref().map(|id| id.to_string());
3013        let cli_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
3014
3015        if order_id.is_none() && cli_ord_id.is_none() {
3016            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
3017        }
3018
3019        let mut builder = KrakenFuturesEditOrderParamsBuilder::default();
3020
3021        if let Some(ref id) = order_id {
3022            builder.order_id(id.clone());
3023        }
3024
3025        if let Some(ref id) = cli_ord_id {
3026            builder.cli_ord_id(id.clone());
3027        }
3028
3029        // A Nautilus quantity is the order's total, fills included, while the venue reads a bare
3030        // size as the open size
3031        if let Some(qty) = quantity {
3032            builder.size(qty.to_string());
3033            builder.qty_mode(KrakenFuturesQtyMode::Absolute);
3034        }
3035
3036        if let Some(p) = price {
3037            builder.limit_price(p.to_string());
3038        }
3039
3040        if let Some(tp) = trigger_price {
3041            builder.stop_price(tp.to_string());
3042        }
3043
3044        builder
3045            .build()
3046            .map_err(|e| anyhow::anyhow!("Failed to build edit order params: {e}"))
3047    }
3048}
3049
3050pub(crate) fn is_futures_submit_rejection(status: &str) -> bool {
3051    matches!(
3052        status.parse::<KrakenSendStatus>(),
3053        Ok(KrakenSendStatus::InsufficientAvailableFunds
3054            | KrakenSendStatus::InvalidOrderType
3055            | KrakenSendStatus::InvalidSize
3056            | KrakenSendStatus::WouldCauseLiquidation
3057            | KrakenSendStatus::PostWouldExecute
3058            | KrakenSendStatus::IocWouldNotExecute
3059            | KrakenSendStatus::ReduceOnlyWouldIncreasePosition)
3060    )
3061}
3062
3063fn is_futures_modify_rejection(status: &str) -> bool {
3064    status.parse::<KrakenSendStatus>().is_ok_and(|status| {
3065        status == KrakenSendStatus::NotFound || is_futures_submit_rejection(status.as_ref())
3066    })
3067}
3068
3069fn map_futures_trigger_signal(
3070    trigger_type: Option<TriggerType>,
3071) -> anyhow::Result<Option<KrakenTriggerSignal>> {
3072    match trigger_type {
3073        None => Ok(None),
3074        Some(TriggerType::Default | TriggerType::LastPrice) => Ok(Some(KrakenTriggerSignal::Last)),
3075        Some(TriggerType::MarkPrice) => Ok(Some(KrakenTriggerSignal::Mark)),
3076        Some(TriggerType::IndexPrice) => Ok(Some(KrakenTriggerSignal::Index)),
3077        Some(other) => anyhow::bail!(
3078            "Unsupported trigger type for Kraken Futures: {other:?} (only LastPrice, MarkPrice, and IndexPrice supported)"
3079        ),
3080    }
3081}
3082
3083/// Accumulated `(total, locked)` balance amounts per standard code.
3084type AmountsByCode = AHashMap<Ustr, (Decimal, Decimal)>;
3085
3086/// Combines the per-wallet balances and margins of a Kraken Futures accounts response.
3087///
3088/// The venue keys the same asset differently per wallet, `xbt` in the cash and single-collateral
3089/// wallets against `XBT` in the flex wallet, and every spelling maps to one standard code. The
3090/// accounts map is unordered, so balances accumulate per code and are emitted once in code order.
3091/// Pushing them into a flat vector instead would leave the surviving entry to iteration order,
3092/// since the account keys its balances by currency.
3093///
3094/// A wallet the venue lists at zero contributes a zero, so an asset drawn down to nothing is
3095/// reported at zero rather than left at its previous value: the engine only ever inserts
3096/// balances, and nothing downstream can clear one.
3097///
3098/// Margins are summed per currency the same way. A single-collateral wallet's requirement is in
3099/// its `currency` and a flex wallet's in USD, and `MarginAccount` keys account-wide margins by
3100/// currency, so two entries under one code would collapse by iteration order.
3101fn parse_account_entries(
3102    accounts: &AHashMap<String, FuturesAccount>,
3103) -> (Vec<AccountBalance>, Vec<MarginBalance>) {
3104    let mut balances: AmountsByCode = AHashMap::new();
3105    let mut margins: AmountsByCode = AHashMap::new();
3106
3107    for account in accounts.values() {
3108        match account.account_type {
3109            KrakenFuturesAccountType::MultiCollateralMarginAccount => {
3110                parse_multi_collateral_balances(account, &mut balances);
3111                parse_multi_collateral_margins(account, &mut margins);
3112            }
3113            KrakenFuturesAccountType::MarginAccount => {
3114                let currency = margin_account_currency(account);
3115
3116                if currency.is_none() {
3117                    // Labeling the requirement, or attributing the available funds, with a
3118                    // guessed currency would misstate them.
3119                    log::warn!(
3120                        "Single-collateral wallet currency unresolved (currency {:?}): its margin requirement is skipped and none of its assets is reported as locked; balances {:?}",
3121                        account.currency,
3122                        account.balances.keys().collect::<Vec<_>>()
3123                    );
3124                }
3125
3126                parse_margin_account_balances(account, currency.as_deref(), &mut balances);
3127                parse_margin_account_margins(account, currency.as_deref(), &mut margins);
3128            }
3129            KrakenFuturesAccountType::CashAccount => {
3130                parse_cash_account_balances(account, &mut balances);
3131            }
3132            KrakenFuturesAccountType::Unknown => {
3133                log::debug!("Unknown account type: {:?}", account.account_type);
3134            }
3135        }
3136    }
3137
3138    (emit_balances(&balances), emit_margins(&margins))
3139}
3140
3141/// Resolves the currency a futures balance or margin requirement is emitted in.
3142///
3143/// Every balance and margin keeps an eight-decimal currency, so wallet amounts are never rounded;
3144/// resolving USD to the registered two-decimal currency would round cash and single-collateral
3145/// USD balances, and the flex USD requirement, to cents.
3146fn futures_balance_currency(code: &str) -> anyhow::Result<Currency> {
3147    Currency::new_checked(code, 8, 0, code, CurrencyType::Crypto)
3148        .map_err(|e| anyhow::anyhow!("Invalid currency code {code:?}: {e}"))
3149}
3150
3151/// Returns the codes of `amounts` in a stable order.
3152fn sorted_codes(amounts: &AmountsByCode) -> Vec<Ustr> {
3153    let mut codes: Vec<Ustr> = amounts.keys().copied().collect();
3154    codes.sort_unstable();
3155    codes
3156}
3157
3158fn emit_balances(amounts: &AmountsByCode) -> Vec<AccountBalance> {
3159    let mut balances = Vec::with_capacity(amounts.len());
3160
3161    for code in sorted_codes(amounts) {
3162        let (total, locked) = amounts[&code];
3163        match futures_balance_currency(code.as_str())
3164            .and_then(|currency| aggregate_balance(total, locked, currency))
3165        {
3166            Ok(balance) => balances.push(balance),
3167            Err(e) => log::warn!("Skipping {code} balance: {e}"),
3168        }
3169    }
3170
3171    balances
3172}
3173
3174/// Builds the balance for amounts already bounded per wallet and summed.
3175///
3176/// `AccountBalance::from_total_and_locked` would clamp the aggregate's locked amount into
3177/// `[0, total]` a second time, which hides a shortfall: a margin wallet at -1 with -2 available
3178/// reserves 1, and combined with 1.5 in cash the sums are 0.5 total and 1 locked, so free is -0.5.
3179/// Clamping would report 0.5 locked and 0 free. The sums are therefore emitted as they are, with
3180/// free derived in fixed point so `total == locked + free` holds exactly.
3181fn aggregate_balance(
3182    total: Decimal,
3183    locked: Decimal,
3184    currency: Currency,
3185) -> anyhow::Result<AccountBalance> {
3186    let total = Money::from_decimal(total, currency)?;
3187    let locked = Money::from_decimal(locked, currency)?;
3188    let free = total
3189        .checked_sub(locked)
3190        .ok_or_else(|| anyhow::anyhow!("Derived `free` for {currency} exceeds Money bounds"))?;
3191
3192    AccountBalance::new_checked(total, locked, free).map_err(Into::into)
3193}
3194
3195/// Adds one wallet's `total` and `locked` to the entry for `code`.
3196///
3197/// The wallet's `locked` is bounded first, exactly as `AccountBalance::from_total_and_locked`
3198/// bounds a single balance: clamped into `[0, total]` for a non-negative total, and passed
3199/// through unchanged for a negative one. Summing the raw figure instead would let one wallet
3200/// cancel another's reservation. A flex wallet holding 1 BTC with 1.6 BTC-equivalent available
3201/// margin reports a raw locked of -0.6, which would erase the 0.6 a single-collateral wallet
3202/// holding 1 BTC with 0.4 available has genuinely reserved.
3203fn accumulate_balance(amounts: &mut AmountsByCode, code: &str, total: Decimal, locked: Decimal) {
3204    let locked = if total.is_sign_negative() {
3205        locked
3206    } else {
3207        locked.clamp(Decimal::ZERO, total)
3208    };
3209
3210    let entry = amounts
3211        .entry(Ustr::from(code))
3212        .or_insert((Decimal::ZERO, Decimal::ZERO));
3213    entry.0 += total;
3214    entry.1 += locked;
3215}
3216
3217fn parse_multi_collateral_balances(account: &FuturesAccount, balances: &mut AmountsByCode) {
3218    // `portfolioValue` is the USD valuation of the whole flex collateral set, so adding the USD
3219    // collateral entry on top of it would count the same funds twice.
3220    let emits_portfolio_value = account
3221        .portfolio_value
3222        .is_some_and(|value| value > Decimal::ZERO);
3223
3224    for (currency_code, currency_info) in &account.currencies {
3225        // The venue keys these by its own spelling and casing, so map to the standard code.
3226        let code = normalize_asset_key(currency_code.as_str());
3227
3228        if emits_portfolio_value && code == "USD" {
3229            continue;
3230        }
3231
3232        let total_amount = currency_info.quantity;
3233        let available_amount = currency_info.available.unwrap_or(total_amount);
3234        let locked_amount = total_amount - available_amount;
3235
3236        accumulate_balance(balances, &code, total_amount, locked_amount);
3237    }
3238
3239    // Multi-collateral accounts track margin in USD even though the
3240    // actual collateral is held in various crypto currencies.
3241    if let Some(portfolio_value) = account.portfolio_value
3242        && portfolio_value > Decimal::ZERO
3243    {
3244        let available_usd = account.available_margin.unwrap_or(portfolio_value);
3245        let locked_usd = portfolio_value - available_usd;
3246
3247        accumulate_balance(balances, "USD", portfolio_value, locked_usd);
3248    }
3249}
3250
3251fn parse_multi_collateral_margins(account: &FuturesAccount, margins: &mut AmountsByCode) {
3252    // The flex wallet reports its requirement in USD in `initialMargin` and `maintenanceMargin`;
3253    // its schema carries no `marginRequirements`.
3254    let initial_margin = account.initial_margin.unwrap_or(Decimal::ZERO);
3255    let maintenance = account.maintenance_margin.unwrap_or(Decimal::ZERO);
3256
3257    // The same gate as the single-collateral wallet: a requirement counts when either figure is
3258    // positive.
3259    if initial_margin > Decimal::ZERO || maintenance > Decimal::ZERO {
3260        accumulate_margin(margins, "USD", initial_margin, maintenance);
3261    }
3262}
3263
3264/// `wallet_currency` is the wallet's resolved `currency`, or `None` when it cannot be resolved.
3265fn parse_margin_account_balances(
3266    account: &FuturesAccount,
3267    wallet_currency: Option<&str>,
3268    balances: &mut AmountsByCode,
3269) {
3270    // `auxiliary.af` is the wallet's available funds in its `currency`, so it bounds that asset
3271    // alone; another asset the wallet holds has nothing reserved against it. Locked is derived so
3272    // that free equals the venue's available funds, which already net unrealized PnL. Without a
3273    // resolved currency the figure cannot be attributed, and no asset is reported as locked.
3274    let available = account.auxiliary.as_ref().and_then(|aux| aux.af);
3275
3276    for (currency_code, &amount) in &account.balances {
3277        // The venue keys this map by contract symbol as well as by currency, for example
3278        // `FI_XBTUSD_171215`, and those entries are positions rather than balances. No Kraken
3279        // asset code contains an underscore.
3280        if currency_code.contains('_') {
3281            continue;
3282        }
3283
3284        // The venue keys these by its own spelling and casing, so map to the standard code.
3285        let code = normalize_asset_key(currency_code.as_str());
3286
3287        let locked = match available {
3288            Some(af) if wallet_currency == Some(code.as_str()) => amount - af,
3289            _ => Decimal::ZERO,
3290        };
3291
3292        accumulate_balance(balances, &code, amount, locked);
3293    }
3294}
3295
3296/// Returns the currency a single-collateral wallet's figures are denominated in.
3297///
3298/// The wallet schema requires `currency` and states that `auxiliary` and `marginRequirements` are
3299/// in it. Guessing it from the asset keys in `balances` could label a requirement with dust held in
3300/// another asset, so a wallet without a usable field resolves to `None`.
3301fn margin_account_currency(account: &FuturesAccount) -> Option<String> {
3302    account
3303        .currency
3304        .as_deref()
3305        .map(str::trim)
3306        .filter(|code| !code.is_empty())
3307        .map(normalize_asset_key)
3308}
3309
3310/// `wallet_currency` is the wallet's resolved `currency`; a wallet without one contributes no
3311/// margin entry, since labeling the requirement with a guess would misstate it.
3312fn parse_margin_account_margins(
3313    account: &FuturesAccount,
3314    wallet_currency: Option<&str>,
3315    margins: &mut AmountsByCode,
3316) {
3317    if let (Some(mr), Some(code)) = (account.margin_requirements.as_ref(), wallet_currency) {
3318        let im = mr.im.unwrap_or(Decimal::ZERO);
3319        let mm = mr.mm.unwrap_or(Decimal::ZERO);
3320
3321        if im > Decimal::ZERO || mm > Decimal::ZERO {
3322            accumulate_margin(margins, code, im, mm);
3323        }
3324    }
3325}
3326
3327/// Adds one wallet's requirement to the entry for `code`.
3328fn accumulate_margin(
3329    margins: &mut AmountsByCode,
3330    code: &str,
3331    initial: Decimal,
3332    maintenance: Decimal,
3333) {
3334    let entry = margins
3335        .entry(Ustr::from(code))
3336        .or_insert((Decimal::ZERO, Decimal::ZERO));
3337    entry.0 += initial;
3338    entry.1 += maintenance;
3339}
3340
3341fn emit_margins(amounts: &AmountsByCode) -> Vec<MarginBalance> {
3342    let mut margins = Vec::with_capacity(amounts.len());
3343
3344    for code in sorted_codes(amounts) {
3345        let (initial, maintenance) = amounts[&code];
3346        let margin = futures_balance_currency(code.as_str()).and_then(|currency| {
3347            let initial = Money::from_decimal(initial, currency)?;
3348            let maintenance = Money::from_decimal(maintenance, currency)?;
3349            Ok(MarginBalance::new(initial, maintenance, None))
3350        });
3351
3352        match margin {
3353            Ok(margin) => margins.push(margin),
3354            Err(e) => log::warn!("Skipping {code} margin: {e}"),
3355        }
3356    }
3357
3358    margins
3359}
3360
3361fn parse_cash_account_balances(account: &FuturesAccount, balances: &mut AmountsByCode) {
3362    for (currency_code, &amount) in &account.balances {
3363        // The venue keys these by its own spelling and casing, so map to the standard code.
3364        let code = normalize_asset_key(currency_code.as_str());
3365
3366        accumulate_balance(balances, &code, amount, Decimal::ZERO);
3367    }
3368}
3369
3370/// Whether `candidate` describes a later state of the same order than `existing`.
3371///
3372/// A closed order does not reopen, so a closed state beats an open one whatever their stamps: an
3373/// open state stamped later, such as a refused edit logged after a cancel, describes the order as
3374/// it stood before it closed. Between two states of the same kind the later `ts_last` wins, and on
3375/// a tie, which the venue's millisecond stamps allow, the larger filled quantity.
3376pub(crate) fn supersedes(candidate: &OrderStatusReport, existing: &OrderStatusReport) -> bool {
3377    match (
3378        candidate.order_status.is_closed(),
3379        existing.order_status.is_closed(),
3380    ) {
3381        (true, false) => true,
3382        (false, true) => false,
3383        _ if candidate.ts_last != existing.ts_last => candidate.ts_last > existing.ts_last,
3384        _ => candidate.filled_qty > existing.filled_qty,
3385    }
3386}
3387
3388#[cfg(test)]
3389mod tests {
3390    use std::{sync::Arc, time::Duration};
3391
3392    use ahash::AHashMap;
3393    use nautilus_model::{enums::OrderStatus, instruments::CryptoPerpetual};
3394    use nautilus_testkit::http::assert_http_redirect_rejected;
3395    use rstest::rstest;
3396    use rust_decimal_macros::dec;
3397
3398    use super::*;
3399
3400    #[tokio::test]
3401    async fn test_authenticated_client_rejects_redirects() {
3402        let client = KrakenFuturesRawHttpClient::with_credentials(
3403            "key".into(),
3404            "secret".into(),
3405            KrakenEnvironment::Live,
3406            None,
3407            3,
3408            Some(0),
3409            None,
3410            None,
3411            None,
3412            10,
3413        )
3414        .unwrap()
3415        .client;
3416        assert_http_redirect_rejected(|url| async move {
3417            client
3418                .get(url, None, None, Some(3), None)
3419                .await
3420                .unwrap()
3421                .status
3422                .as_u16()
3423        })
3424        .await;
3425    }
3426
3427    #[rstest]
3428    fn test_raw_client_creation() {
3429        let client = KrakenFuturesRawHttpClient::default();
3430        assert!(client.credential.is_none());
3431        assert!(client.base_url().contains("futures"));
3432    }
3433
3434    #[rstest]
3435    fn test_raw_client_with_credentials() {
3436        let client = KrakenFuturesRawHttpClient::with_credentials(
3437            "test_key".to_string(),
3438            "test_secret".to_string(),
3439            KrakenEnvironment::Live,
3440            None,
3441            60,
3442            None,
3443            None,
3444            None,
3445            None,
3446            KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND,
3447        )
3448        .unwrap();
3449        assert!(client.credential.is_some());
3450    }
3451
3452    #[rstest]
3453    #[tokio::test]
3454    async fn test_order_request_cancellation_before_transport() {
3455        let client = Arc::new(KrakenFuturesRawHttpClient::default());
3456        let guard = client.auth_mutex.lock().await;
3457        let waiting_client = Arc::clone(&client);
3458        let waiting = tokio::spawn(async move {
3459            waiting_client
3460                .send_authenticated_post::<serde_json::Value>(
3461                    "/derivatives/api/v3/sendorder",
3462                    "orderType=lmt".to_string(),
3463                )
3464                .await
3465        });
3466
3467        tokio::task::yield_now().await;
3468        client.cancel_all_requests();
3469        let waiting_result = tokio::time::timeout(Duration::from_secs(1), waiting)
3470            .await
3471            .expect("auth lock wait should stop on cancellation")
3472            .expect("auth request task should complete");
3473        drop(guard);
3474        client.reset_cancellation_token();
3475        let reset_token = client.cancellation_token();
3476
3477        assert!(matches!(
3478            waiting_result,
3479            Err(KrakenHttpError::RequestNotStarted(ref message))
3480                if message == "Request cancelled"
3481        ));
3482        assert!(!reset_token.is_cancelled());
3483    }
3484
3485    #[rstest]
3486    fn test_client_creation() {
3487        let client = KrakenFuturesHttpClient::default();
3488        assert!(client.instruments_cache.is_empty());
3489    }
3490
3491    #[rstest]
3492    fn test_client_with_credentials() {
3493        let client = KrakenFuturesHttpClient::with_credentials(
3494            "test_key".to_string(),
3495            "test_secret".to_string(),
3496            KrakenEnvironment::Live,
3497            None,
3498            60,
3499            None,
3500            None,
3501            None,
3502            None,
3503            KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND,
3504        )
3505        .unwrap();
3506        assert!(client.instruments_cache.is_empty());
3507    }
3508
3509    fn entries_for(
3510        accounts: &[(&str, FuturesAccount)],
3511    ) -> (Vec<AccountBalance>, Vec<MarginBalance>) {
3512        let map: AHashMap<String, FuturesAccount> = accounts
3513            .iter()
3514            .map(|(name, account)| ((*name).to_string(), account.clone()))
3515            .collect();
3516
3517        parse_account_entries(&map)
3518    }
3519
3520    fn flex_wallet(
3521        currencies: &[(&str, Decimal)],
3522        portfolio_value: Option<Decimal>,
3523    ) -> FuturesAccount {
3524        FuturesAccount {
3525            account_type: KrakenFuturesAccountType::MultiCollateralMarginAccount,
3526            currency: None,
3527            balances: AHashMap::new(),
3528            currencies: currencies
3529                .iter()
3530                .map(|(code, quantity)| {
3531                    (
3532                        (*code).to_string(),
3533                        FuturesFlexCurrency {
3534                            quantity: *quantity,
3535                            value: None,
3536                            collateral: None,
3537                            available: Some(*quantity),
3538                        },
3539                    )
3540                })
3541                .collect(),
3542            auxiliary: None,
3543            margin_requirements: None,
3544            portfolio_value,
3545            available_margin: portfolio_value,
3546            initial_margin: None,
3547            maintenance_margin: None,
3548            pnl: None,
3549        }
3550    }
3551
3552    fn cash_wallet(balances: &[(&str, Decimal)]) -> FuturesAccount {
3553        FuturesAccount {
3554            account_type: KrakenFuturesAccountType::CashAccount,
3555            currency: None,
3556            balances: balances
3557                .iter()
3558                .map(|(code, amount)| ((*code).to_string(), *amount))
3559                .collect(),
3560            currencies: AHashMap::new(),
3561            auxiliary: None,
3562            margin_requirements: None,
3563            portfolio_value: None,
3564            available_margin: None,
3565            initial_margin: None,
3566            maintenance_margin: None,
3567            pnl: None,
3568        }
3569    }
3570
3571    /// One asset held in two wallets must produce exactly one balance, holding both amounts.
3572    ///
3573    /// The cash wallet keys it `xbt` and the flex wallet `XBT`; both map to `BTC`. Emitting two
3574    /// entries would leave the survivor to hash-map order, since the account keys balances by
3575    /// currency.
3576    #[rstest]
3577    fn test_parse_account_entries_combines_one_asset_across_wallets() {
3578        let (balances, _) = entries_for(&[
3579            ("cash", cash_wallet(&[("xbt", dec!(1.5))])),
3580            ("flex", flex_wallet(&[("XBT", dec!(0.25))], None)),
3581        ]);
3582
3583        let btc: Vec<_> = balances
3584            .iter()
3585            .filter(|b| b.currency.code.as_str() == "BTC")
3586            .collect();
3587        assert_eq!(btc.len(), 1, "expected one BTC balance: {balances:?}");
3588        assert_eq!(btc[0].total.as_decimal(), dec!(1.75));
3589    }
3590
3591    /// Entry order must not depend on hash-map iteration order.
3592    #[rstest]
3593    fn test_parse_account_entries_emits_balances_in_code_order() {
3594        let (balances, _) = entries_for(&[(
3595            "cash",
3596            cash_wallet(&[("xrp", dec!(10)), ("xbt", dec!(1)), ("eth", dec!(2))]),
3597        )]);
3598
3599        let codes: Vec<&str> = balances.iter().map(|b| b.currency.code.as_str()).collect();
3600        assert_eq!(codes, vec!["BTC", "ETH", "XRP"]);
3601    }
3602
3603    /// `portfolioValue` already values the flex collateral set, so its USD entry wins alone.
3604    #[rstest]
3605    fn test_parse_account_entries_does_not_double_count_flex_usd() {
3606        let (balances, _) = entries_for(&[(
3607            "flex",
3608            flex_wallet(&[("USD", dec!(5000))], Some(dec!(34995.52))),
3609        )]);
3610
3611        let usd: Vec<_> = balances
3612            .iter()
3613            .filter(|b| b.currency.code.as_str() == "USD")
3614            .collect();
3615        assert_eq!(usd.len(), 1, "expected one USD balance: {balances:?}");
3616        assert_eq!(usd[0].total.as_decimal(), dec!(34995.52));
3617    }
3618
3619    /// Each wallet's `locked` is bounded before the sum, so one wallet cannot cancel another's.
3620    ///
3621    /// A single-collateral wallet with 1 BTC and 0.4 available has reserved 0.6. A flex wallet
3622    /// with 1 BTC and 1.6 BTC-equivalent available margin reports a raw locked of -0.6, which
3623    /// summed unbounded would erase that reservation.
3624    #[rstest]
3625    fn test_parse_account_entries_bounds_locked_per_wallet() {
3626        let single = FuturesAccount {
3627            account_type: KrakenFuturesAccountType::MarginAccount,
3628            currency: Some("xbt".to_string()),
3629            balances: [("xbt".to_string(), dec!(1))].into_iter().collect(),
3630            currencies: AHashMap::new(),
3631            auxiliary: Some(FuturesAuxiliary {
3632                usd: None,
3633                pv: None,
3634                pnl: None,
3635                af: Some(dec!(0.4)),
3636                funding: None,
3637            }),
3638            margin_requirements: None,
3639            portfolio_value: None,
3640            available_margin: None,
3641            initial_margin: None,
3642            maintenance_margin: None,
3643            pnl: None,
3644        };
3645
3646        let mut flex = flex_wallet(&[], None);
3647        flex.currencies.insert(
3648            "XBT".to_string(),
3649            FuturesFlexCurrency {
3650                quantity: dec!(1),
3651                value: None,
3652                collateral: None,
3653                available: Some(dec!(1.6)),
3654            },
3655        );
3656
3657        let (balances, _) = entries_for(&[("fi_xbtusd", single), ("flex", flex)]);
3658
3659        let btc = balances
3660            .iter()
3661            .find(|b| b.currency.code.as_str() == "BTC")
3662            .expect("one BTC balance");
3663        assert_eq!(btc.total.as_decimal(), dec!(2));
3664        assert_eq!(
3665            btc.locked.as_decimal(),
3666            dec!(0.6),
3667            "the flex wallet's negative raw locked must not cancel the reservation"
3668        );
3669        assert_eq!(btc.free.as_decimal(), dec!(1.4));
3670    }
3671
3672    /// Summed wallet-local amounts are emitted as summed, so a shortfall stays visible.
3673    ///
3674    /// A margin wallet at -1 BTC with -2 BTC available reserves 1 BTC. Combined with 1.5 BTC in
3675    /// cash the sums are 0.5 total and 1 locked, leaving free at -0.5. Clamping the aggregate
3676    /// would report 0.5 locked and 0 free, dropping half the reservation.
3677    #[rstest]
3678    fn test_parse_account_entries_preserves_summed_amounts_across_mixed_sign_wallets() {
3679        let margin = FuturesAccount {
3680            account_type: KrakenFuturesAccountType::MarginAccount,
3681            currency: Some("xbt".to_string()),
3682            balances: [("xbt".to_string(), dec!(-1))].into_iter().collect(),
3683            currencies: AHashMap::new(),
3684            auxiliary: Some(FuturesAuxiliary {
3685                usd: None,
3686                pv: None,
3687                pnl: None,
3688                af: Some(dec!(-2)),
3689                funding: None,
3690            }),
3691            margin_requirements: None,
3692            portfolio_value: None,
3693            available_margin: None,
3694            initial_margin: None,
3695            maintenance_margin: None,
3696            pnl: None,
3697        };
3698
3699        let (balances, _) = entries_for(&[
3700            ("fi_xbtusd", margin),
3701            ("cash", cash_wallet(&[("xbt", dec!(1.5))])),
3702        ]);
3703
3704        let btc = balances
3705            .iter()
3706            .find(|b| b.currency.code.as_str() == "BTC")
3707            .expect("one BTC balance");
3708        assert_eq!(btc.total.as_decimal(), dec!(0.5));
3709        assert_eq!(
3710            btc.locked.as_decimal(),
3711            dec!(1),
3712            "the margin wallet's reservation must survive the aggregate"
3713        );
3714        assert_eq!(
3715            btc.free.as_decimal(),
3716            dec!(-0.5),
3717            "the shortfall must stay visible rather than clamp to zero"
3718        );
3719    }
3720
3721    /// A negative total keeps its reported `locked`, as a single balance always has.
3722    #[rstest]
3723    fn test_parse_account_entries_passes_a_negative_total_through() {
3724        let account = FuturesAccount {
3725            account_type: KrakenFuturesAccountType::MarginAccount,
3726            currency: Some("xbt".to_string()),
3727            balances: [("xbt".to_string(), dec!(-1))].into_iter().collect(),
3728            currencies: AHashMap::new(),
3729            auxiliary: Some(FuturesAuxiliary {
3730                usd: None,
3731                pv: None,
3732                pnl: None,
3733                af: Some(dec!(-0.4)),
3734                funding: None,
3735            }),
3736            margin_requirements: None,
3737            portfolio_value: None,
3738            available_margin: None,
3739            initial_margin: None,
3740            maintenance_margin: None,
3741            pnl: None,
3742        };
3743
3744        let (balances, _) = entries_for(&[("fi_xbtusd", account)]);
3745
3746        assert_eq!(balances.len(), 1);
3747        assert_eq!(balances[0].total.as_decimal(), dec!(-1));
3748        assert_eq!(balances[0].locked.as_decimal(), dec!(-0.6));
3749        assert_eq!(balances[0].free.as_decimal(), dec!(-0.4));
3750    }
3751
3752    /// A USD wallet balance keeps eight decimals rather than rounding to cents.
3753    #[rstest]
3754    fn test_parse_account_entries_keeps_usd_balance_at_eight_decimals() {
3755        let (balances, _) = entries_for(&[("cash", cash_wallet(&[("usd", dec!(1234.56789012))]))]);
3756
3757        let usd = balances
3758            .iter()
3759            .find(|b| b.currency.code.as_str() == "USD")
3760            .expect("one USD balance");
3761        assert_eq!(usd.currency.precision, 8);
3762        assert_eq!(usd.total.as_decimal(), dec!(1234.56789012));
3763    }
3764
3765    /// Margins are keyed by currency: a single-collateral wallet's in its `currency`, the flex
3766    /// wallet's in USD, so requirements in different currencies stay apart.
3767    #[rstest]
3768    fn test_parse_account_entries_keeps_margins_per_currency() {
3769        let mut flex = flex_wallet(&[], Some(dec!(10000)));
3770        flex.initial_margin = Some(dec!(500));
3771        flex.maintenance_margin = Some(dec!(250));
3772
3773        let single = FuturesAccount {
3774            account_type: KrakenFuturesAccountType::MarginAccount,
3775            currency: Some("xbt".to_string()),
3776            balances: [("xbt".to_string(), dec!(2))].into_iter().collect(),
3777            currencies: AHashMap::new(),
3778            auxiliary: None,
3779            margin_requirements: Some(FuturesMarginRequirements {
3780                im: Some(dec!(0.1)),
3781                mm: Some(dec!(0.05)),
3782                lt: None,
3783                tt: None,
3784            }),
3785            portfolio_value: None,
3786            available_margin: None,
3787            initial_margin: None,
3788            maintenance_margin: None,
3789            pnl: None,
3790        };
3791
3792        let (_, margins) = entries_for(&[("flex", flex), ("fi_xbtusd", single)]);
3793
3794        assert_eq!(
3795            margins.len(),
3796            2,
3797            "one margin entry per currency: {margins:?}"
3798        );
3799        let mut entries: Vec<(String, Decimal)> = margins
3800            .iter()
3801            .map(|m| (m.currency.code.to_string(), m.initial.as_decimal()))
3802            .collect();
3803        entries.sort();
3804        assert_eq!(
3805            entries,
3806            vec![
3807                ("BTC".to_string(), dec!(0.1)),
3808                ("USD".to_string(), dec!(500))
3809            ],
3810            "each currency keeps its own entry"
3811        );
3812    }
3813
3814    /// The wallet's available funds bound its own currency alone; another asset it holds has
3815    /// nothing reserved against it.
3816    #[rstest]
3817    fn test_parse_margin_account_balances_bound_locked_to_the_wallet_currency() {
3818        let wallet = FuturesAccount {
3819            account_type: KrakenFuturesAccountType::MarginAccount,
3820            currency: Some("xbt".to_string()),
3821            balances: [("xbt".to_string(), dec!(0.3)), ("xrp".to_string(), dec!(5))]
3822                .into_iter()
3823                .collect(),
3824            currencies: AHashMap::new(),
3825            auxiliary: Some(FuturesAuxiliary {
3826                usd: None,
3827                pv: None,
3828                pnl: None,
3829                af: Some(dec!(0.1)),
3830                funding: None,
3831            }),
3832            margin_requirements: None,
3833            portfolio_value: None,
3834            available_margin: None,
3835            initial_margin: None,
3836            maintenance_margin: None,
3837            pnl: None,
3838        };
3839
3840        let (balances, _) = entries_for(&[("fi_xbtusd", wallet)]);
3841
3842        let by_code = |code: &str| {
3843            balances
3844                .iter()
3845                .find(|b| b.currency.code.as_str() == code)
3846                .unwrap_or_else(|| panic!("{code} balance"))
3847        };
3848        assert_eq!(by_code("BTC").locked.as_decimal(), dec!(0.2));
3849        assert_eq!(by_code("XRP").locked.as_decimal(), dec!(0));
3850        assert_eq!(by_code("XRP").free.as_decimal(), dec!(5));
3851    }
3852
3853    /// Contract-symbol keys in `balances` are positions, not balances, and are skipped.
3854    #[rstest]
3855    fn test_parse_margin_account_balances_skip_contract_symbol_keys() {
3856        let wallet = FuturesAccount {
3857            account_type: KrakenFuturesAccountType::MarginAccount,
3858            currency: Some("xbt".to_string()),
3859            balances: [
3860                ("xbt".to_string(), dec!(0.5)),
3861                ("FI_XBTUSD_171215".to_string(), dec!(50000)),
3862                ("FI_XBTUSD_180615".to_string(), dec!(-15000)),
3863            ]
3864            .into_iter()
3865            .collect(),
3866            currencies: AHashMap::new(),
3867            auxiliary: None,
3868            margin_requirements: None,
3869            portfolio_value: None,
3870            available_margin: None,
3871            initial_margin: None,
3872            maintenance_margin: None,
3873            pnl: None,
3874        };
3875
3876        let (balances, _) = entries_for(&[("fi_xbtusd", wallet)]);
3877
3878        assert_eq!(balances.len(), 1, "{balances:?}");
3879        assert_eq!(balances[0].currency.code.as_str(), "BTC");
3880    }
3881
3882    /// A flex wallet reports its requirement in USD in the top-level `initialMargin` and
3883    /// `maintenanceMargin` fields, and both figures reach the account-wide entry.
3884    ///
3885    /// Kraken's documented `flex` wallet shape, with the two requirement figures set.
3886    #[rstest]
3887    fn test_parse_multi_collateral_margins_read_the_documented_fields() {
3888        let json = r#"{
3889            "type": "multiCollateralMarginAccount",
3890            "currencies": {
3891                "XBT": {"quantity": 0.1185308247, "value": 4998.721054420551, "collateral": 4886.49976674881, "available": 0.1185308247},
3892                "USD": {"quantity": 5000, "value": 5000, "collateral": 5000, "available": 5000}
3893            },
3894            "balanceValue": 9998.72,
3895            "portfolioValue": 9998.72,
3896            "collateralValue": 9886.5,
3897            "initialMargin": 500,
3898            "initialMarginWithOrders": 500,
3899            "maintenanceMargin": 250,
3900            "pnl": 0,
3901            "unrealizedFunding": 0,
3902            "totalUnrealized": 0,
3903            "totalUnrealizedAsMargin": 0,
3904            "marginEquity": 9886.5,
3905            "availableMargin": 9386.5
3906        }"#;
3907        let account: FuturesAccount = serde_json::from_str(json).unwrap();
3908
3909        let (_, margins) = entries_for(&[("flex", account)]);
3910
3911        assert_eq!(margins.len(), 1, "{margins:?}");
3912        assert_eq!(margins[0].currency.code.as_str(), "USD");
3913        assert_eq!(margins[0].initial.as_decimal(), dec!(500));
3914        assert_eq!(margins[0].maintenance.as_decimal(), dec!(250));
3915        assert_eq!(margins[0].instrument_id, None);
3916    }
3917
3918    /// A flex wallet with no requirement contributes no entry; one with a requirement contributes
3919    /// an account-wide USD entry, so the maintenance figure alone is enough as on the
3920    /// single-collateral wallet.
3921    #[rstest]
3922    #[case::none(None, None, 0)]
3923    #[case::initial_only(Some(dec!(100)), None, 1)]
3924    #[case::maintenance_only(None, Some(dec!(40)), 1)]
3925    fn test_parse_multi_collateral_margins_follow_the_requirement_gate(
3926        #[case] initial_margin: Option<Decimal>,
3927        #[case] maintenance: Option<Decimal>,
3928        #[case] expected: usize,
3929    ) {
3930        let mut flex = flex_wallet(&[("USD", dec!(1000))], Some(dec!(1000)));
3931        flex.initial_margin = initial_margin;
3932        flex.maintenance_margin = maintenance;
3933
3934        let (_, margins) = entries_for(&[("flex", flex)]);
3935
3936        assert_eq!(margins.len(), expected, "{margins:?}");
3937        for margin in &margins {
3938            assert_eq!(margin.currency.code.as_str(), "USD");
3939            assert_eq!(
3940                margin.instrument_id, None,
3941                "the requirement is account-wide"
3942            );
3943        }
3944    }
3945
3946    /// A single-collateral wallet's requirement is denominated in the wallet's `currency`.
3947    ///
3948    /// Kraken's documented `fi_xbtusd` wallet: `currency: xbt`, a funded `xbt` key, an `xrp: 0` key
3949    /// and two contract-symbol keys. The requirement is reported in BTC at eight decimals.
3950    #[rstest]
3951    fn test_parse_margin_account_margins_use_the_wallet_currency() {
3952        let json = r#"{
3953            "auxiliary": {"af": 100.73891563, "funding": 100.73891563, "pnl": 12.42134766, "pv": 153.73891563, "usd": 0},
3954            "balances": {"FI_XBTUSD_171215": "50000", "FI_XBTUSD_180615": "-15000", "xbt": "141.31756797", "xrp": "0"},
3955            "currency": "xbt",
3956            "marginRequirements": {"im": 52.8, "lt": 39.6, "mm": 23.76, "tt": 15.84},
3957            "triggerEstimates": {"im": 3110, "lt": 2890, "mm": 3000, "tt": 2830},
3958            "type": "marginAccount"
3959        }"#;
3960        let account: FuturesAccount = serde_json::from_str(json).unwrap();
3961
3962        let (balances, margins) = entries_for(&[("fi_xbtusd", account)]);
3963
3964        assert_eq!(margins.len(), 1, "{margins:?}");
3965        assert_eq!(margins[0].currency.code.as_str(), "BTC");
3966        assert_eq!(margins[0].currency.precision, 8);
3967        assert_eq!(margins[0].initial.as_decimal(), dec!(52.8));
3968        assert_eq!(margins[0].maintenance.as_decimal(), dec!(23.76));
3969        let btc = balances
3970            .iter()
3971            .find(|b| b.currency.code.as_str() == "BTC")
3972            .expect("BTC balance");
3973        assert_eq!(btc.total.as_decimal(), dec!(141.31756797));
3974    }
3975
3976    /// A requirement whose currency cannot be resolved is skipped rather than labeled by a guess.
3977    ///
3978    /// Without the `currency` field, or with an empty one, the funded `xbt` key beside dust in
3979    /// `xrp` is not evidence of the denomination, and an empty code must not reach the currency
3980    /// constructor.
3981    #[rstest]
3982    #[case::absent(None)]
3983    #[case::empty(Some(""))]
3984    #[case::blank(Some("  "))]
3985    fn test_parse_margin_account_margins_skip_a_wallet_whose_currency_is_unresolved(
3986        #[case] currency: Option<&str>,
3987    ) {
3988        let mut wallet = cash_wallet(&[("xbt", dec!(1.5)), ("xrp", Decimal::ZERO)]);
3989        wallet.account_type = KrakenFuturesAccountType::MarginAccount;
3990        wallet.currency = currency.map(str::to_string);
3991        wallet.margin_requirements = Some(FuturesMarginRequirements {
3992            im: Some(dec!(0.05)),
3993            mm: Some(dec!(0.025)),
3994            lt: None,
3995            tt: None,
3996        });
3997
3998        let (_, margins) = entries_for(&[("fi_xbtusd", wallet)]);
3999
4000        assert!(margins.is_empty(), "no guessed denomination: {margins:?}");
4001    }
4002
4003    /// Wallets sharing a currency sum into one margin entry.
4004    ///
4005    /// `MarginAccount` keys account-wide margins by currency, so two entries under one code would
4006    /// survive by iteration order instead.
4007    #[rstest]
4008    fn test_parse_account_entries_sums_margins_of_wallets_sharing_a_currency() {
4009        let requirement = |im: Decimal, mm: Decimal| {
4010            Some(FuturesMarginRequirements {
4011                im: Some(im),
4012                mm: Some(mm),
4013                lt: None,
4014                tt: None,
4015            })
4016        };
4017        let mut first = cash_wallet(&[("xbt", dec!(2))]);
4018        first.account_type = KrakenFuturesAccountType::MarginAccount;
4019        first.currency = Some("xbt".to_string());
4020        first.margin_requirements = requirement(dec!(0.1), dec!(0.05));
4021        let mut second = cash_wallet(&[("xbt", dec!(1))]);
4022        second.account_type = KrakenFuturesAccountType::MarginAccount;
4023        second.currency = Some("XBT".to_string());
4024        second.margin_requirements = requirement(dec!(0.02), dec!(0.01));
4025
4026        let (_, margins) = entries_for(&[("fi_a", first), ("fi_b", second)]);
4027
4028        assert_eq!(margins.len(), 1, "one entry per currency: {margins:?}");
4029        assert_eq!(margins[0].currency.code.as_str(), "BTC");
4030        assert_eq!(margins[0].initial.as_decimal(), dec!(0.12));
4031        assert_eq!(margins[0].maintenance.as_decimal(), dec!(0.06));
4032    }
4033
4034    /// An asset key the currency constructor rejects is skipped, not a panic.
4035    #[rstest]
4036    fn test_parse_account_entries_skips_an_empty_asset_key() {
4037        let (balances, _) =
4038            entries_for(&[("cash", cash_wallet(&[("", dec!(1)), ("xbt", dec!(2))]))]);
4039
4040        assert_eq!(balances.len(), 1, "{balances:?}");
4041        assert_eq!(balances[0].currency.code.as_str(), "BTC");
4042    }
4043
4044    #[rstest]
4045    fn test_parse_margin_account_margins() {
4046        let account = FuturesAccount {
4047            account_type: KrakenFuturesAccountType::MarginAccount,
4048            currency: Some("xbt".to_string()),
4049            balances: [("xbt".to_string(), dec!(2))].into_iter().collect(),
4050            currencies: AHashMap::new(),
4051            auxiliary: None,
4052            margin_requirements: Some(FuturesMarginRequirements {
4053                im: Some(dec!(100)),
4054                mm: Some(dec!(50)),
4055                lt: None,
4056                tt: None,
4057            }),
4058            portfolio_value: None,
4059            available_margin: None,
4060            initial_margin: None,
4061            maintenance_margin: None,
4062            pnl: None,
4063        };
4064
4065        let (_, margins) = entries_for(&[("wallet", account)]);
4066
4067        assert_eq!(margins.len(), 1);
4068        let margin = &margins[0];
4069        assert_eq!(margin.currency.code.as_str(), "BTC");
4070        assert_eq!(margin.initial.as_decimal(), dec!(100));
4071        assert_eq!(margin.maintenance.as_decimal(), dec!(50));
4072    }
4073
4074    #[rstest]
4075    fn test_parse_margin_account_margins_no_requirements() {
4076        let account = FuturesAccount {
4077            account_type: KrakenFuturesAccountType::MarginAccount,
4078            currency: None,
4079            balances: AHashMap::new(),
4080            currencies: AHashMap::new(),
4081            auxiliary: None,
4082            margin_requirements: None,
4083            portfolio_value: None,
4084            available_margin: None,
4085            initial_margin: None,
4086            maintenance_margin: None,
4087            pnl: None,
4088        };
4089
4090        let (_, margins) = entries_for(&[("wallet", account)]);
4091
4092        assert_eq!(margins.len(), 0);
4093    }
4094
4095    #[rstest]
4096    fn test_parse_multi_collateral_balances() {
4097        let mut currencies = AHashMap::new();
4098        currencies.insert(
4099            "BTC".to_string(),
4100            FuturesFlexCurrency {
4101                quantity: dec!(1.5),
4102                value: None,
4103                collateral: None,
4104                available: Some(dec!(1.2)),
4105            },
4106        );
4107
4108        let account = FuturesAccount {
4109            account_type: KrakenFuturesAccountType::MultiCollateralMarginAccount,
4110            currency: None,
4111            balances: AHashMap::new(),
4112            currencies,
4113            auxiliary: None,
4114            margin_requirements: None,
4115            portfolio_value: Some(dec!(50000)),
4116            available_margin: Some(dec!(45000)),
4117            initial_margin: None,
4118            maintenance_margin: None,
4119            pnl: None,
4120        };
4121
4122        let (balances, _) = entries_for(&[("wallet", account)]);
4123
4124        // BTC balance + USD portfolio balance
4125        assert_eq!(balances.len(), 2);
4126    }
4127
4128    #[rstest]
4129    fn test_parse_margin_account_balances_preserves_exact_values() {
4130        let mut bals = AHashMap::new();
4131        bals.insert("XBT".to_string(), dec!(10.00000003));
4132
4133        let account = FuturesAccount {
4134            account_type: KrakenFuturesAccountType::MarginAccount,
4135            currency: Some("xbt".to_string()),
4136            balances: bals,
4137            currencies: AHashMap::new(),
4138            auxiliary: Some(FuturesAuxiliary {
4139                usd: None,
4140                pv: None,
4141                pnl: None,
4142                af: Some(dec!(0.00000004)),
4143                funding: None,
4144            }),
4145            margin_requirements: None,
4146            portfolio_value: None,
4147            available_margin: None,
4148            initial_margin: None,
4149            maintenance_margin: None,
4150            pnl: None,
4151        };
4152
4153        let (balances, _) = entries_for(&[("wallet", account)]);
4154
4155        assert_eq!(balances.len(), 1);
4156        let balance = &balances[0];
4157        assert_eq!(balance.total.as_decimal(), dec!(10.00000003));
4158        assert_eq!(balance.locked.as_decimal(), dec!(9.99999999));
4159        assert_eq!(balance.free.as_decimal(), dec!(0.00000004));
4160        assert_eq!(balance.total, balance.locked + balance.free);
4161    }
4162
4163    #[rstest]
4164    fn test_parse_cash_account_balances() {
4165        let mut bals = AHashMap::new();
4166        bals.insert("ETH".to_string(), dec!(10));
4167        bals.insert("BTC".to_string(), Decimal::ZERO);
4168
4169        let account = FuturesAccount {
4170            account_type: KrakenFuturesAccountType::CashAccount,
4171            currency: None,
4172            balances: bals,
4173            currencies: AHashMap::new(),
4174            auxiliary: None,
4175            margin_requirements: None,
4176            portfolio_value: None,
4177            available_margin: None,
4178            initial_margin: None,
4179            maintenance_margin: None,
4180            pnl: None,
4181        };
4182
4183        let (balances, _) = entries_for(&[("wallet", account)]);
4184
4185        assert_eq!(
4186            balances.len(),
4187            2,
4188            "a wallet listed at zero is reported: {balances:?}"
4189        );
4190        let eth = balances
4191            .iter()
4192            .find(|b| b.currency.code.as_str() == "ETH")
4193            .expect("ETH balance");
4194        assert_eq!(eth.total.as_decimal(), dec!(10));
4195        assert_eq!(eth.locked.as_decimal(), Decimal::ZERO);
4196        let btc = balances
4197            .iter()
4198            .find(|b| b.currency.code.as_str() == "BTC")
4199            .expect("BTC balance");
4200        assert_eq!(btc.total.as_decimal(), Decimal::ZERO);
4201        assert_eq!(btc.free.as_decimal(), Decimal::ZERO);
4202    }
4203
4204    /// A flex collateral currency at zero quantity is reported at zero.
4205    #[rstest]
4206    fn test_parse_multi_collateral_balances_reports_zero_quantity() {
4207        let (balances, _) = entries_for(&[("flex", flex_wallet(&[("XBT", Decimal::ZERO)], None))]);
4208
4209        assert_eq!(balances.len(), 1, "{balances:?}");
4210        assert_eq!(balances[0].currency.code.as_str(), "BTC");
4211        assert_eq!(balances[0].total.as_decimal(), Decimal::ZERO);
4212        assert_eq!(balances[0].free.as_decimal(), Decimal::ZERO);
4213    }
4214
4215    /// A single-collateral wallet drawn down to zero is reported at zero.
4216    #[rstest]
4217    fn test_parse_margin_account_balances_reports_zero_wallet() {
4218        let mut wallet = cash_wallet(&[("xbt", Decimal::ZERO)]);
4219        wallet.account_type = KrakenFuturesAccountType::MarginAccount;
4220
4221        let (balances, _) = entries_for(&[("fi_xbtusd", wallet)]);
4222
4223        assert_eq!(balances.len(), 1, "{balances:?}");
4224        assert_eq!(balances[0].currency.code.as_str(), "BTC");
4225        assert_eq!(balances[0].total.as_decimal(), Decimal::ZERO);
4226        assert_eq!(balances[0].free.as_decimal(), Decimal::ZERO);
4227    }
4228
4229    /// An empty wallet joins the per-currency sum, so it cannot displace a funded wallet.
4230    #[rstest]
4231    fn test_parse_account_entries_zero_wallet_does_not_displace_a_funded_one() {
4232        let mut margin = cash_wallet(&[("xbt", dec!(1.5))]);
4233        margin.account_type = KrakenFuturesAccountType::MarginAccount;
4234
4235        let (balances, _) = entries_for(&[
4236            ("cash", cash_wallet(&[("xbt", Decimal::ZERO)])),
4237            ("fi_xbtusd", margin),
4238            ("flex", flex_wallet(&[("XBT", Decimal::ZERO)], None)),
4239        ]);
4240
4241        assert_eq!(balances.len(), 1, "one BTC balance: {balances:?}");
4242        assert_eq!(balances[0].currency.code.as_str(), "BTC");
4243        assert_eq!(balances[0].total.as_decimal(), dec!(1.5));
4244        assert_eq!(balances[0].free.as_decimal(), dec!(1.5));
4245    }
4246
4247    /// An asset every wallet lists at zero is reported once, at zero.
4248    #[rstest]
4249    fn test_parse_account_entries_reports_an_asset_all_wallets_hold_at_zero() {
4250        let mut margin = cash_wallet(&[("xbt", Decimal::ZERO)]);
4251        margin.account_type = KrakenFuturesAccountType::MarginAccount;
4252
4253        let (balances, _) = entries_for(&[
4254            ("cash", cash_wallet(&[("xbt", Decimal::ZERO)])),
4255            ("fi_xbtusd", margin),
4256            ("flex", flex_wallet(&[("XBT", Decimal::ZERO)], None)),
4257        ]);
4258
4259        assert_eq!(balances.len(), 1, "one BTC balance: {balances:?}");
4260        assert_eq!(balances[0].currency.code.as_str(), "BTC");
4261        assert_eq!(balances[0].total.as_decimal(), Decimal::ZERO);
4262        assert_eq!(balances[0].locked.as_decimal(), Decimal::ZERO);
4263        assert_eq!(balances[0].free.as_decimal(), Decimal::ZERO);
4264    }
4265
4266    #[rstest]
4267    #[case(None, None)]
4268    #[case(Some(TriggerType::Default), Some(KrakenTriggerSignal::Last))]
4269    #[case(Some(TriggerType::LastPrice), Some(KrakenTriggerSignal::Last))]
4270    #[case(Some(TriggerType::MarkPrice), Some(KrakenTriggerSignal::Mark))]
4271    #[case(Some(TriggerType::IndexPrice), Some(KrakenTriggerSignal::Index))]
4272    fn test_build_send_order_params_maps_supported_trigger_signals(
4273        #[case] trigger_type: Option<TriggerType>,
4274        #[case] expected_signal: Option<KrakenTriggerSignal>,
4275    ) {
4276        let client = KrakenFuturesHttpClient::default();
4277        let instrument_id = cache_test_futures_instrument(&client);
4278
4279        let params = client
4280            .build_send_order_params(
4281                instrument_id,
4282                ClientOrderId::new("futures-trigger"),
4283                OrderSide::Buy,
4284                OrderType::StopMarket,
4285                Quantity::from("1"),
4286                TimeInForce::Gtc,
4287                None,
4288                Some(Price::from("45000")),
4289                trigger_type,
4290                false,
4291                false,
4292            )
4293            .unwrap();
4294
4295        assert_eq!(params.trigger_signal, expected_signal);
4296    }
4297
4298    #[rstest]
4299    fn test_build_send_order_params_rejects_unsupported_trigger_signal() {
4300        let client = KrakenFuturesHttpClient::default();
4301        let instrument_id = cache_test_futures_instrument(&client);
4302
4303        let error = client
4304            .build_send_order_params(
4305                instrument_id,
4306                ClientOrderId::new("futures-trigger-invalid"),
4307                OrderSide::Buy,
4308                OrderType::StopMarket,
4309                Quantity::from("1"),
4310                TimeInForce::Gtc,
4311                None,
4312                Some(Price::from("45000")),
4313                Some(TriggerType::BidAsk),
4314                false,
4315                false,
4316            )
4317            .unwrap_err();
4318
4319        assert!(
4320            error
4321                .to_string()
4322                .contains("Unsupported trigger type for Kraken Futures")
4323        );
4324    }
4325
4326    /// A history page of two rows on `PF_XBTUSD`; the first row's order type is `first_order_type`
4327    /// and the page hands back `continuation_token` when one is given.
4328    fn history_page(first_order_type: &str, continuation_token: Option<&str>) -> String {
4329        let token = continuation_token
4330            .map(|token| format!(r#","continuationToken":"{token}""#))
4331            .unwrap_or_default();
4332        format!(
4333            r#"{{"elements":[{{"uid":"e1","timestamp":1680876930250,"event":{{"OrderPlaced":{{"order":{{"uid":"H-SKIP-1","tradeable":"PF_XBTUSD","direction":"Sell","quantity":"3","filled":"1","limitPrice":"70000","orderType":"{first_order_type}","clientId":"cl-1","reduceOnly":false,"timestamp":1680876930250,"lastUpdateTimestamp":1680876930250}}}}}}}},{{"uid":"e2","timestamp":1680877245500,"event":{{"OrderPlaced":{{"order":{{"uid":"H-KEEP-2","tradeable":"PF_XBTUSD","direction":"Buy","quantity":"2","filled":"0.5","limitPrice":"69500","orderType":"Limit","clientId":"cl-2","reduceOnly":false,"timestamp":1680877245500,"lastUpdateTimestamp":1680877245500}}}}}}}}]{token}}}"#
4334        )
4335    }
4336
4337    /// A client against a mock venue with no open orders whose order history serves `history`,
4338    /// with `header_token` in the `Next-Continuation-Token` header when one is given.
4339    async fn history_test_client(
4340        history: Arc<RwLock<String>>,
4341        header_token: Option<&'static str>,
4342    ) -> KrakenFuturesHttpClient {
4343        use axum::{
4344            Router, body::Body, extract::State, http::header, response::Response, routing::any,
4345        };
4346
4347        let app = Router::new()
4348            .route(
4349                "/derivatives/api/v3/openorders",
4350                any(|| async {
4351                    (
4352                        [(header::CONTENT_TYPE, "application/json")],
4353                        r#"{"result":"success","openOrders":[]}"#,
4354                    )
4355                }),
4356            )
4357            .route(
4358                "/api/history/v3/orders",
4359                any(
4360                    move |State(history): State<Arc<RwLock<String>>>| async move {
4361                        let mut response =
4362                            Response::builder().header(header::CONTENT_TYPE, "application/json");
4363
4364                        if let Some(token) = header_token {
4365                            response = response.header(NEXT_CONTINUATION_TOKEN_HEADER, token);
4366                        }
4367                        response.body(Body::from(history.read().clone())).unwrap()
4368                    },
4369                ),
4370            )
4371            .with_state(history);
4372        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4373        let addr = listener.local_addr().unwrap();
4374        tokio::spawn(async move {
4375            axum::serve(listener, app).await.unwrap();
4376        });
4377
4378        KrakenFuturesHttpClient::with_credentials(
4379            "test".to_string(),
4380            "test".to_string(),
4381            KrakenEnvironment::Live,
4382            Some(format!("http://{addr}")),
4383            10,
4384            None,
4385            None,
4386            None,
4387            None,
4388            10,
4389        )
4390        .unwrap()
4391    }
4392
4393    /// A page that hands back a continuation token, in the body or only in the
4394    /// `Next-Continuation-Token` header, leaves events of the window unread, so the read reports
4395    /// the page's rows and marks the set incomplete; without a token the same page reads complete.
4396    #[rstest]
4397    #[case::body_token(Some("c2ltYjE3OA=="), None, false)]
4398    #[case::header_token(None, Some("c2ltYjE3OA=="), false)]
4399    #[case::no_token(None, None, true)]
4400    #[tokio::test]
4401    async fn test_request_order_status_reports_marks_a_page_with_a_continuation_token_incomplete(
4402        #[case] body_token: Option<&str>,
4403        #[case] header_token: Option<&'static str>,
4404        #[case] expected_complete: bool,
4405    ) {
4406        let history = Arc::new(RwLock::new(history_page("Limit", body_token)));
4407        let client = history_test_client(history, header_token).await;
4408        cache_test_futures_instrument(&client);
4409
4410        let (reports, coverage) = client
4411            .request_order_status_reports_checked(
4412                AccountId::from("KRAKEN-001"),
4413                None,
4414                None,
4415                None,
4416                false,
4417            )
4418            .await
4419            .unwrap();
4420
4421        assert_eq!(coverage.is_complete(), expected_complete);
4422        assert_eq!(
4423            reports
4424                .iter()
4425                .map(|report| report.venue_order_id.as_str())
4426                .collect::<Vec<_>>(),
4427            vec!["H-SKIP-1", "H-KEEP-2"],
4428            "the page's rows are reported either way"
4429        );
4430    }
4431
4432    /// An edit that resizes the order sends its size as the order's total, fills included, since
4433    /// the venue reads a bare size as the open size, on the single and the batch edit alike; a
4434    /// price-only edit sends no size and no mode.
4435    #[rstest]
4436    #[case::resize(Some("5"), Some("ABSOLUTE"))]
4437    #[case::price_only(None, None)]
4438    #[tokio::test]
4439    async fn test_build_edit_order_params_sends_a_size_as_the_total(
4440        #[case] size: Option<&str>,
4441        #[case] expected_qty_mode: Option<&str>,
4442    ) {
4443        let client = history_test_client(Arc::new(RwLock::new(String::new())), None).await;
4444        let instrument_id = cache_test_futures_instrument(&client);
4445
4446        let params = client
4447            .build_edit_order_params(
4448                instrument_id,
4449                None,
4450                Some(VenueOrderId::from("V-EDIT-1")),
4451                size.map(Quantity::from),
4452                Some(Price::from("70000")),
4453                None,
4454            )
4455            .unwrap();
4456
4457        let form: Vec<(String, String)> =
4458            serde_urlencoded::from_str(&serde_urlencoded::to_string(&params).unwrap()).unwrap();
4459        let form_field = |name: &str| {
4460            form.iter()
4461                .find(|(key, _)| key == name)
4462                .map(|(_, value)| value.as_str())
4463        };
4464        assert_eq!(form_field("size"), size, "{form:?}");
4465        assert_eq!(form_field("qtyMode"), expected_qty_mode, "{form:?}");
4466
4467        let item =
4468            serde_json::to_value(KrakenFuturesBatchEditItem::from_params(params, "tag-1")).unwrap();
4469        let item_field = |name: &str| item.get(name).and_then(|value| value.as_str());
4470        assert_eq!(item_field("size"), size, "{item}");
4471        assert_eq!(item_field("qtyMode"), expected_qty_mode, "{item}");
4472    }
4473
4474    /// A closed order does not reopen: a refused edit logged after the cancel carries the order as
4475    /// it stood before the cancel, so the order is reported canceled although the refusal is
4476    /// stamped later.
4477    #[rstest]
4478    #[tokio::test]
4479    async fn test_request_order_status_reports_keep_a_closed_order_closed_after_a_refused_edit() {
4480        let order = |last_update_ms: u64| {
4481            format!(
4482                r#"{{"uid":"H-CLOSED","tradeable":"PF_XBTUSD","direction":"Buy","quantity":"1","filled":"0","limitPrice":"70000","orderType":"Limit","clientId":"","reduceOnly":false,"timestamp":1680876930250,"lastUpdateTimestamp":{last_update_ms}}}"#
4483            )
4484        };
4485        // Newest first, as the venue sorts: the refused edit at T2, the cancel at T1, the
4486        // placement at T0.
4487        let page = format!(
4488            r#"{{"elements":[{{"uid":"e3","timestamp":1680877400000,"event":{{"OrderEditRejected":{{"oldOrder":{},"reason":"order_for_edit_not_found"}}}}}},{{"uid":"e2","timestamp":1680877300000,"event":{{"OrderCancelled":{{"order":{},"reason":"cancelled_by_user"}}}}}},{{"uid":"e1","timestamp":1680876930250,"event":{{"OrderPlaced":{{"order":{},"reason":"new_user_order"}}}}}}]}}"#,
4489            order(1680876930250),
4490            order(1680877300000),
4491            order(1680876930250),
4492        );
4493        let client = history_test_client(Arc::new(RwLock::new(page)), None).await;
4494        cache_test_futures_instrument(&client);
4495
4496        let (reports, coverage) = client
4497            .request_order_status_reports_checked(
4498                AccountId::from("KRAKEN-001"),
4499                None,
4500                None,
4501                None,
4502                false,
4503            )
4504            .await
4505            .unwrap();
4506
4507        assert!(coverage.is_complete());
4508        assert_eq!(reports.len(), 1, "{reports:?}");
4509        assert_eq!(reports[0].venue_order_id, VenueOrderId::from("H-CLOSED"));
4510        assert_eq!(reports[0].order_status, OrderStatus::Canceled);
4511    }
4512
4513    /// A history row the projection cannot represent marks the checked read incomplete while
4514    /// the rows it can represent are still reported.
4515    #[rstest]
4516    #[tokio::test]
4517    async fn test_request_order_status_reports_marks_the_set_incomplete_for_a_skipped_history_row()
4518    {
4519        let history = Arc::new(RwLock::new(history_page("Limit", None)));
4520        let client = history_test_client(history.clone(), None).await;
4521        let instrument_id = cache_test_futures_instrument(&client);
4522        let account_id = AccountId::from("KRAKEN-001");
4523
4524        // Control: the same page with a decodable type reads complete, so the flag below is
4525        // driven by the undecodable type rather than by the page itself.
4526        let (control, coverage) = client
4527            .request_order_status_reports_checked(account_id, None, None, None, false)
4528            .await
4529            .unwrap();
4530        assert!(
4531            coverage.is_complete(),
4532            "the control must be complete, or the skip proves nothing"
4533        );
4534        assert_eq!(control.len(), 2);
4535
4536        *history.write() = history_page("Unknown", None);
4537        let (reports, coverage) = client
4538            .request_order_status_reports_checked(account_id, None, None, None, false)
4539            .await
4540            .unwrap();
4541
4542        assert!(
4543            !coverage.is_complete(),
4544            "a skipped history row must leave the set incomplete"
4545        );
4546        assert_eq!(reports.len(), 1);
4547        let report = &reports[0];
4548        assert_eq!(report.venue_order_id, VenueOrderId::from("H-KEEP-2"));
4549        assert_eq!(report.instrument_id, instrument_id);
4550        assert_eq!(report.account_id, account_id);
4551        assert_eq!(report.client_order_id, Some(ClientOrderId::from("cl-2")));
4552        assert_eq!(report.order_side, Some(OrderSide::Buy));
4553        assert_eq!(report.order_type, OrderType::Limit);
4554        assert_eq!(report.quantity, Quantity::from("2"));
4555        assert_eq!(report.filled_qty, Quantity::from("0.5"));
4556        assert_eq!(report.price, Some(Price::from("69500")));
4557    }
4558
4559    /// The order history request asks for the newest events first rather than relying on the
4560    /// venue default, since a page with a continuation token is read as reaching back to its
4561    /// oldest event.
4562    #[rstest]
4563    #[tokio::test]
4564    async fn test_get_order_events_asks_for_the_newest_events_first() {
4565        use axum::{Router, extract::RawQuery, routing::any};
4566
4567        let query: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
4568        let recorded = query.clone();
4569        let app = Router::new().route(
4570            "/api/history/v3/orders",
4571            any(move |RawQuery(raw): RawQuery| {
4572                let recorded = recorded.clone();
4573                async move {
4574                    *recorded.write() = raw;
4575                    r#"{"elements":[]}"#
4576                }
4577            }),
4578        );
4579        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4580        let addr = listener.local_addr().unwrap();
4581        tokio::spawn(async move {
4582            axum::serve(listener, app).await.unwrap();
4583        });
4584        let client = KrakenFuturesHttpClient::with_credentials(
4585            "test".to_string(),
4586            "test".to_string(),
4587            KrakenEnvironment::Live,
4588            Some(format!("http://{addr}")),
4589            10,
4590            None,
4591            None,
4592            None,
4593            None,
4594            10,
4595        )
4596        .unwrap();
4597
4598        client
4599            .inner
4600            .get_order_events(None, None, None)
4601            .await
4602            .unwrap();
4603
4604        let query = query.read().clone().unwrap_or_default();
4605        assert!(
4606            query.split('&').any(|param| param == "sort=desc"),
4607            "{query}"
4608        );
4609    }
4610
4611    fn cache_test_futures_instrument(client: &KrakenFuturesHttpClient) -> InstrumentId {
4612        let instrument_id = InstrumentId::from("PF_XBTUSD.KRAKEN");
4613
4614        client.cache_instrument(InstrumentAny::CryptoPerpetual(
4615            CryptoPerpetual::builder()
4616                .instrument_id(instrument_id)
4617                .raw_symbol(Symbol::new("PF_XBTUSD"))
4618                .base_currency(Currency::BTC())
4619                .quote_currency(Currency::USD())
4620                .settlement_currency(Currency::USD())
4621                .is_inverse(false)
4622                .price_precision(0)
4623                .size_precision(4)
4624                .price_increment(Price::from("1"))
4625                .size_increment(Quantity::from("0.0001"))
4626                .ts_event(0.into())
4627                .ts_init(0.into())
4628                .build()
4629                .unwrap(),
4630        ));
4631
4632        instrument_id
4633    }
4634}