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;
29use jiff::Timestamp;
30use nautilus_common::cache::InstrumentLookupError;
31use nautilus_core::{
32    AtomicMap, AtomicTime, UUID4, nanos::UnixNanos, time::get_atomic_clock_realtime,
33};
34use nautilus_model::{
35    data::{Bar, BarType, BookOrder, FundingRateUpdate, TradeTick},
36    enums::{
37        AccountType, BookType, CurrencyType, MarketStatusAction, OrderSide, OrderType, TimeInForce,
38        TriggerType,
39    },
40    events::AccountState,
41    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
42    instruments::{Instrument, InstrumentAny},
43    orderbook::OrderBook,
44    reports::{FillReport, OrderStatusReport, PositionStatusReport},
45    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
46};
47use nautilus_network::{
48    http::{
49        HttpClient, HttpRedirectPolicy, HttpResponse, Method, create_standard_nautilus_headers,
50    },
51    ratelimiter::quota::Quota,
52    retry::{RetryConfig, RetryError, RetryManager},
53};
54use parking_lot::RwLock;
55use rust_decimal::Decimal;
56use serde::de::DeserializeOwned;
57use tokio_util::sync::CancellationToken;
58use ustr::Ustr;
59
60use super::{models::*, query::*};
61use crate::{
62    common::{
63        consts::{KRAKEN_VENUE, NAUTILUS_KRAKEN_BROKER_ID},
64        credential::KrakenCredential,
65        enums::{
66            KrakenApiResult, KrakenEnvironment, KrakenFuturesOrderStatus, KrakenFuturesOrderType,
67            KrakenOrderSide, KrakenProductType, KrakenSendStatus, KrakenTriggerSignal,
68        },
69        parse::{
70            bar_type_to_futures_resolution, parse_bar, parse_futures_fill_report,
71            parse_futures_instrument, parse_futures_order_event_status_report,
72            parse_futures_order_status_details_report, parse_futures_order_status_report,
73            parse_futures_position_status_report, parse_futures_public_execution,
74            truncate_cl_ord_id,
75        },
76        urls::get_kraken_http_base_url,
77    },
78    http::{
79        apply_count_limit,
80        error::{
81            KrakenBatchOrderError, KrakenHttpError, KrakenModifyOrderError, KrakenSubmitOrderError,
82            kraken_http_should_retry,
83        },
84        models::OhlcData,
85    },
86};
87
88/// Default Kraken Futures REST API rate limit (requests per second).
89pub const KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND: u32 = 5;
90
91const KRAKEN_GLOBAL_RATE_KEY: &str = "kraken:futures:global";
92
93/// Maximum orders per batch cancel request for Kraken Futures API.
94const BATCH_CANCEL_LIMIT: usize = 50;
95
96/// Maximum operations per batch order request for Kraken Futures API.
97const BATCH_ORDER_LIMIT: usize = 10;
98
99/// Raw HTTP client for low-level Kraken Futures API operations.
100///
101/// This client handles request/response operations with the Kraken Futures API,
102/// returning venue-specific response types. It does not parse to Nautilus domain types.
103pub struct KrakenFuturesRawHttpClient {
104    base_url: String,
105    client: HttpClient,
106    credential: Option<KrakenCredential>,
107    retry_manager: RetryManager<KrakenHttpError>,
108    cancellation_token: RwLock<CancellationToken>,
109    clock: &'static AtomicTime,
110    /// Mutex to serialize authenticated requests, ensuring nonces arrive at Kraken in order
111    auth_mutex: tokio::sync::Mutex<()>,
112}
113
114impl Default for KrakenFuturesRawHttpClient {
115    fn default() -> Self {
116        Self::new(
117            KrakenEnvironment::Live,
118            None,
119            60,
120            None,
121            None,
122            None,
123            None,
124            KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND,
125        )
126        .expect("Failed to create default KrakenFuturesRawHttpClient")
127    }
128}
129
130impl Debug for KrakenFuturesRawHttpClient {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.debug_struct(stringify!(KrakenFuturesRawHttpClient))
133            .field("base_url", &self.base_url)
134            .field("has_credentials", &self.credential.is_some())
135            .finish()
136    }
137}
138
139impl KrakenFuturesRawHttpClient {
140    /// Creates a new [`KrakenFuturesRawHttpClient`].
141    #[expect(clippy::too_many_arguments)]
142    pub fn new(
143        environment: KrakenEnvironment,
144        base_url_override: Option<String>,
145        timeout_secs: u64,
146        max_retries: Option<u32>,
147        retry_delay_ms: Option<u64>,
148        retry_delay_max_ms: Option<u64>,
149        proxy_url: Option<String>,
150        max_requests_per_second: u32,
151    ) -> anyhow::Result<Self> {
152        let retry_config = RetryConfig {
153            max_retries: max_retries.unwrap_or(3),
154            initial_delay_ms: retry_delay_ms.unwrap_or(1000),
155            max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
156            backoff_factor: 2.0,
157            jitter_ms: 1000,
158            operation_timeout_ms: Some(60_000),
159            immediate_first: false,
160            max_elapsed_ms: Some(180_000),
161        };
162
163        let retry_manager = RetryManager::new(retry_config);
164        let base_url = base_url_override.unwrap_or_else(|| {
165            get_kraken_http_base_url(KrakenProductType::Futures, environment).to_string()
166        });
167
168        Ok(Self {
169            base_url,
170            client: HttpClient::builder()
171                .headers(Self::default_headers())
172                .keyed_quotas(Self::rate_limiter_quotas(max_requests_per_second)?)
173                .default_quota(Self::default_quota(max_requests_per_second)?)
174                .timeout_secs(timeout_secs)
175                .maybe_proxy_url(proxy_url)
176                .build()
177                .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
178            credential: None,
179            retry_manager,
180            cancellation_token: RwLock::new(CancellationToken::new()),
181            clock: get_atomic_clock_realtime(),
182            auth_mutex: tokio::sync::Mutex::new(()),
183        })
184    }
185
186    /// Creates a new [`KrakenFuturesRawHttpClient`] with credentials.
187    #[expect(clippy::too_many_arguments)]
188    pub fn with_credentials(
189        api_key: String,
190        api_secret: String,
191        environment: KrakenEnvironment,
192        base_url_override: Option<String>,
193        timeout_secs: u64,
194        max_retries: Option<u32>,
195        retry_delay_ms: Option<u64>,
196        retry_delay_max_ms: Option<u64>,
197        proxy_url: Option<String>,
198        max_requests_per_second: u32,
199    ) -> anyhow::Result<Self> {
200        let retry_config = RetryConfig {
201            max_retries: max_retries.unwrap_or(3),
202            initial_delay_ms: retry_delay_ms.unwrap_or(1000),
203            max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
204            backoff_factor: 2.0,
205            jitter_ms: 1000,
206            operation_timeout_ms: Some(60_000),
207            immediate_first: false,
208            max_elapsed_ms: Some(180_000),
209        };
210
211        let retry_manager = RetryManager::new(retry_config);
212        let base_url = base_url_override.unwrap_or_else(|| {
213            get_kraken_http_base_url(KrakenProductType::Futures, environment).to_string()
214        });
215
216        Ok(Self {
217            base_url,
218            client: HttpClient::builder()
219                .redirect_policy(HttpRedirectPolicy::Reject)
220                .headers(Self::default_headers())
221                .keyed_quotas(Self::rate_limiter_quotas(max_requests_per_second)?)
222                .default_quota(Self::default_quota(max_requests_per_second)?)
223                .timeout_secs(timeout_secs)
224                .maybe_proxy_url(proxy_url)
225                .build()
226                .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
227            credential: Some(KrakenCredential::new(api_key, api_secret)),
228            retry_manager,
229            cancellation_token: RwLock::new(CancellationToken::new()),
230            clock: get_atomic_clock_realtime(),
231            auth_mutex: tokio::sync::Mutex::new(()),
232        })
233    }
234
235    /// Generates a unique nonce for Kraken Futures API requests.
236    ///
237    /// Uses `AtomicTime` for strict monotonicity. The nanosecond timestamp
238    /// guarantees uniqueness even for rapid consecutive calls.
239    fn generate_nonce(&self) -> u64 {
240        self.clock.get_time_ns().as_u64()
241    }
242
243    /// Returns the base URL for this client.
244    pub fn base_url(&self) -> &str {
245        &self.base_url
246    }
247
248    /// Returns the credential for this client, if set.
249    pub fn credential(&self) -> Option<&KrakenCredential> {
250        self.credential.as_ref()
251    }
252
253    /// Cancels all pending HTTP requests.
254    pub fn cancel_all_requests(&self) {
255        self.cancellation_token.read().cancel();
256    }
257
258    /// Replaces the canceled token so requests can proceed after reconnect.
259    pub fn reset_cancellation_token(&self) {
260        *self.cancellation_token.write() = CancellationToken::new();
261    }
262
263    /// Returns a clone of the current cancellation token.
264    pub fn cancellation_token(&self) -> CancellationToken {
265        self.cancellation_token.read().clone()
266    }
267
268    fn default_headers() -> HashMap<String, String> {
269        create_standard_nautilus_headers().into_iter().collect()
270    }
271
272    fn default_quota(max_requests_per_second: u32) -> anyhow::Result<Quota> {
273        let burst = NonZeroU32::new(max_requests_per_second).unwrap_or(
274            NonZeroU32::new(KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND).expect("non-zero"),
275        );
276        Quota::per_second(burst).ok_or_else(|| {
277            anyhow::anyhow!(
278                "Invalid max_requests_per_second: {max_requests_per_second} exceeds maximum"
279            )
280        })
281    }
282
283    fn rate_limiter_quotas(max_requests_per_second: u32) -> anyhow::Result<Vec<(String, Quota)>> {
284        Ok(vec![(
285            KRAKEN_GLOBAL_RATE_KEY.to_string(),
286            Self::default_quota(max_requests_per_second)?,
287        )])
288    }
289
290    fn rate_limit_keys(endpoint: &str) -> Vec<String> {
291        let normalized = endpoint.split('?').next().unwrap_or(endpoint);
292        let route = format!("kraken:futures:{normalized}");
293        vec![KRAKEN_GLOBAL_RATE_KEY.to_string(), route]
294    }
295
296    async fn send_request<T: DeserializeOwned>(
297        &self,
298        method: Method,
299        endpoint: &str,
300        url: String,
301        authenticate: bool,
302    ) -> anyhow::Result<T, KrakenHttpError> {
303        // Serialize authenticated requests to ensure nonces arrive at Kraken in order.
304        // Without this, concurrent requests can race through the network and arrive
305        // out-of-order, causing "Invalid nonce" errors.
306        let _guard = if authenticate {
307            Some(self.auth_mutex.lock().await)
308        } else {
309            None
310        };
311
312        let endpoint = endpoint.to_string();
313        let method_clone = method.clone();
314        let url_clone = url.clone();
315        let credential = self.credential.clone();
316
317        let operation = || {
318            let url = url_clone.clone();
319            let method = method_clone.clone();
320            let endpoint = endpoint.clone();
321            let credential = credential.clone();
322
323            async move {
324                let mut headers = Self::default_headers();
325
326                if authenticate {
327                    let cred = credential.as_ref().ok_or_else(|| {
328                        KrakenHttpError::AuthenticationError(
329                            "Missing credentials for authenticated request".to_string(),
330                        )
331                    })?;
332
333                    let nonce = self.generate_nonce();
334
335                    let signature = cred.sign_futures(&endpoint, "", nonce).map_err(|e| {
336                        KrakenHttpError::AuthenticationError(format!("Failed to sign request: {e}"))
337                    })?;
338
339                    let base_url = &self.base_url;
340                    log::debug!(
341                        "Kraken Futures auth: endpoint={endpoint}, nonce={nonce}, base_url={base_url}"
342                    );
343
344                    headers.insert("APIKey".to_string(), cred.api_key().to_string());
345                    headers.insert("Authent".to_string(), signature);
346                    headers.insert("Nonce".to_string(), nonce.to_string());
347                }
348
349                let rate_limit_keys = Self::rate_limit_keys(&endpoint);
350
351                let response = self
352                    .client
353                    .request(
354                        method,
355                        url,
356                        None,
357                        Some(headers),
358                        None,
359                        None,
360                        Some(rate_limit_keys),
361                    )
362                    .await
363                    .map_err(|e| KrakenHttpError::NetworkError(e.to_string()))?;
364
365                let status = response.status.as_u16();
366                if status >= 400 {
367                    let body = String::from_utf8_lossy(&response.body).to_string();
368                    // Don't retry authentication errors
369                    if status == 401 || status == 403 {
370                        return Err(KrakenHttpError::AuthenticationError(format!(
371                            "HTTP error {status}: {body}"
372                        )));
373                    }
374                    return Err(KrakenHttpError::NetworkError(format!(
375                        "HTTP error {status}: {body}"
376                    )));
377                }
378
379                let response_text = String::from_utf8(response.body.to_vec()).map_err(|e| {
380                    KrakenHttpError::ParseError(format!("Failed to parse response as UTF-8: {e}"))
381                })?;
382
383                serde_json::from_str(&response_text).map_err(|e| {
384                    KrakenHttpError::ParseError(format!(
385                        "Failed to deserialize futures response: {e}"
386                    ))
387                })
388            }
389        };
390
391        let should_retry = kraken_http_should_retry;
392        let create_error = |error: RetryError| KrakenHttpError::NetworkError(error.to_string());
393
394        let cancellation_token = self.cancellation_token();
395
396        self.retry_manager
397            .invocation(&endpoint, operation, should_retry, create_error)
398            .cancellation_token(&cancellation_token)
399            .execute()
400            .await
401    }
402
403    /// Sends authenticated GET request with query parameters included in signature.
404    ///
405    /// For Kraken Futures, GET requests with query params must include them in postData
406    /// for signing: message = postData + nonce + endpoint
407    async fn send_get_with_query<T: DeserializeOwned>(
408        &self,
409        endpoint: &str,
410        url: String,
411        query_string: &str,
412    ) -> anyhow::Result<T, KrakenHttpError> {
413        let _guard = self.auth_mutex.lock().await;
414        let cancellation_token = self.cancellation_token();
415
416        if cancellation_token.is_cancelled() {
417            return Err(KrakenHttpError::NetworkError(
418                "Request cancelled".to_string(),
419            ));
420        }
421
422        let credential = self.credential.as_ref().ok_or_else(|| {
423            KrakenHttpError::AuthenticationError("Missing credentials".to_string())
424        })?;
425
426        let nonce = self.generate_nonce();
427
428        // Query params go in postData for signing (not in endpoint)
429        let signature = credential
430            .sign_futures(endpoint, query_string, nonce)
431            .map_err(|e| {
432                KrakenHttpError::AuthenticationError(format!("Failed to sign request: {e}"))
433            })?;
434
435        log::debug!(
436            "Kraken Futures GET with query: endpoint={endpoint}, query={query_string}, nonce={nonce}"
437        );
438
439        let mut headers = Self::default_headers();
440        headers.insert("APIKey".to_string(), credential.api_key().to_string());
441        headers.insert("Authent".to_string(), signature);
442        headers.insert("Nonce".to_string(), nonce.to_string());
443
444        let rate_limit_keys = Self::rate_limit_keys(endpoint);
445
446        let response = self
447            .client
448            .request(
449                Method::GET,
450                url,
451                None,
452                Some(headers),
453                None,
454                None,
455                Some(rate_limit_keys),
456            )
457            .await
458            .map_err(|e| KrakenHttpError::NetworkError(e.to_string()))?;
459
460        let status = response.status.as_u16();
461        if status >= 400 {
462            let body = String::from_utf8_lossy(&response.body).to_string();
463
464            if status == 401 || status == 403 {
465                return Err(KrakenHttpError::AuthenticationError(format!(
466                    "HTTP error {status}: {body}"
467                )));
468            }
469            return Err(KrakenHttpError::NetworkError(format!(
470                "HTTP error {status}: {body}"
471            )));
472        }
473
474        let response_text = String::from_utf8(response.body.to_vec()).map_err(|e| {
475            KrakenHttpError::ParseError(format!("Failed to parse response as UTF-8: {e}"))
476        })?;
477
478        serde_json::from_str(&response_text).map_err(|e| {
479            KrakenHttpError::ParseError(format!("Failed to deserialize futures response: {e}"))
480        })
481    }
482
483    async fn send_request_with_body<T: DeserializeOwned>(
484        &self,
485        endpoint: &str,
486        params: HashMap<String, String>,
487    ) -> anyhow::Result<T, KrakenHttpError> {
488        let post_data = serde_urlencoded::to_string(&params).map_err(|e| {
489            KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
490        })?;
491        self.send_authenticated_post(endpoint, post_data).await
492    }
493
494    /// Sends a request with typed parameters (serializable struct).
495    async fn send_request_with_params<P: serde::Serialize, T: DeserializeOwned>(
496        &self,
497        endpoint: &str,
498        params: &P,
499    ) -> anyhow::Result<T, KrakenHttpError> {
500        let post_data = serde_urlencoded::to_string(params).map_err(|e| {
501            KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
502        })?;
503        self.send_authenticated_post(endpoint, post_data).await
504    }
505
506    /// Core authenticated POST request - takes raw post_data string.
507    async fn send_authenticated_post<T: DeserializeOwned>(
508        &self,
509        endpoint: &str,
510        post_data: String,
511    ) -> anyhow::Result<T, KrakenHttpError> {
512        let cancellation_token = self.cancellation_token();
513        if cancellation_token.is_cancelled() {
514            return Err(KrakenHttpError::RequestNotStarted(
515                "Request cancelled".to_string(),
516            ));
517        }
518
519        // Serialize authenticated requests to ensure nonces arrive at Kraken in order
520        let _guard = tokio::select! {
521            biased;
522            () = cancellation_token.cancelled() => {
523                return Err(KrakenHttpError::RequestNotStarted(
524                    "Request cancelled".to_string(),
525                ));
526            }
527            guard = self.auth_mutex.lock() => guard,
528        };
529
530        let credential = self
531            .credential
532            .as_ref()
533            .ok_or(KrakenHttpError::MissingCredentials)?;
534
535        let nonce = self.generate_nonce();
536        log::debug!("Generated nonce {nonce} for {endpoint}");
537
538        let signature = credential
539            .sign_futures(endpoint, &post_data, nonce)
540            .map_err(|e| {
541                KrakenHttpError::RequestNotStarted(format!("Failed to sign request: {e}"))
542            })?;
543
544        let url = format!("{}{endpoint}", self.base_url);
545        let mut headers = Self::default_headers();
546        headers.insert(
547            "Content-Type".to_string(),
548            "application/x-www-form-urlencoded".to_string(),
549        );
550        headers.insert("APIKey".to_string(), credential.api_key().to_string());
551        headers.insert("Authent".to_string(), signature);
552        headers.insert("Nonce".to_string(), nonce.to_string());
553
554        let rate_limit_keys = Self::rate_limit_keys(endpoint);
555
556        let response = self
557            .send_order_request(
558                url,
559                headers,
560                post_data.into_bytes(),
561                rate_limit_keys,
562                &cancellation_token,
563            )
564            .await?;
565
566        if response.status.as_u16() >= 400 {
567            let status = response.status.as_u16();
568            let body = String::from_utf8_lossy(&response.body).to_string();
569            return Err(KrakenHttpError::NetworkError(format!(
570                "HTTP error {status}: {body}"
571            )));
572        }
573
574        let response_text = String::from_utf8(response.body.to_vec()).map_err(|e| {
575            KrakenHttpError::ParseError(format!("Failed to parse response as UTF-8: {e}"))
576        })?;
577
578        serde_json::from_str(&response_text).map_err(|e| {
579            log::error!("Failed to parse response from {endpoint}: {response_text}");
580            KrakenHttpError::ParseError(format!("Failed to deserialize response: {e}"))
581        })
582    }
583
584    async fn send_order_request(
585        &self,
586        url: String,
587        headers: HashMap<String, String>,
588        body: Vec<u8>,
589        rate_limit_keys: Vec<String>,
590        cancellation_token: &CancellationToken,
591    ) -> anyhow::Result<HttpResponse, KrakenHttpError> {
592        if cancellation_token.is_cancelled() {
593            return Err(KrakenHttpError::RequestNotStarted(
594                "Request cancelled".to_string(),
595            ));
596        }
597
598        let request_started = AtomicBool::new(false);
599        let request = async {
600            request_started.store(true, Ordering::Relaxed);
601            self.client
602                .request(
603                    Method::POST,
604                    url,
605                    None,
606                    Some(headers),
607                    Some(body),
608                    None,
609                    Some(rate_limit_keys),
610                )
611                .await
612        };
613        tokio::pin!(request);
614
615        tokio::select! {
616            biased;
617            () = cancellation_token.cancelled() => {
618                if request_started.load(Ordering::Relaxed) {
619                    Err(KrakenHttpError::NetworkError(
620                        "Request cancelled after transport invocation".to_string(),
621                    ))
622                } else {
623                    Err(KrakenHttpError::RequestNotStarted(
624                        "Request cancelled".to_string(),
625                    ))
626                }
627            }
628            response = &mut request => response
629                .map_err(|e| KrakenHttpError::NetworkError(e.to_string())),
630        }
631    }
632
633    /// Requests tradable instruments from Kraken Futures.
634    pub async fn get_instruments(
635        &self,
636    ) -> anyhow::Result<FuturesInstrumentsResponse, KrakenHttpError> {
637        let endpoint = "/derivatives/api/v3/instruments";
638        let url = format!("{}{endpoint}", self.base_url);
639
640        self.send_request(Method::GET, endpoint, url, false).await
641    }
642
643    /// Requests ticker information for all futures instruments.
644    pub async fn get_tickers(&self) -> anyhow::Result<FuturesTickersResponse, KrakenHttpError> {
645        let endpoint = "/derivatives/api/v3/tickers";
646        let url = format!("{}{endpoint}", self.base_url);
647
648        self.send_request(Method::GET, endpoint, url, false).await
649    }
650
651    /// Requests order book depth for a futures symbol.
652    pub async fn get_orderbook(
653        &self,
654        symbol: &str,
655    ) -> anyhow::Result<FuturesOrderBookResponse, KrakenHttpError> {
656        let endpoint = format!("/derivatives/api/v3/orderbook?symbol={symbol}");
657        let url = format!("{}{endpoint}", self.base_url);
658
659        self.send_request(Method::GET, &endpoint, url, false).await
660    }
661
662    /// Requests historical funding rates for a futures symbol.
663    pub async fn get_historical_funding_rates(
664        &self,
665        symbol: &str,
666    ) -> anyhow::Result<FuturesHistoricalFundingRatesResponse, KrakenHttpError> {
667        let endpoint = format!("/derivatives/api/v4/historicalfundingrates?symbol={symbol}");
668        let url = format!("{}{endpoint}", self.base_url);
669
670        self.send_request(Method::GET, &endpoint, url, false).await
671    }
672
673    /// Requests OHLC candlestick data for a futures symbol.
674    pub async fn get_ohlc(
675        &self,
676        tick_type: &str,
677        symbol: &str,
678        resolution: &str,
679        from: Option<i64>,
680        to: Option<i64>,
681    ) -> anyhow::Result<FuturesCandlesResponse, KrakenHttpError> {
682        let endpoint = format!("/api/charts/v1/{tick_type}/{symbol}/{resolution}");
683
684        let mut url = format!("{}{endpoint}", self.base_url);
685
686        let mut query_params = Vec::new();
687
688        if let Some(from_ts) = from {
689            query_params.push(format!("from={from_ts}"));
690        }
691
692        if let Some(to_ts) = to {
693            query_params.push(format!("to={to_ts}"));
694        }
695
696        if !query_params.is_empty() {
697            url.push('?');
698            url.push_str(&query_params.join("&"));
699        }
700
701        self.send_request(Method::GET, &endpoint, url, false).await
702    }
703
704    /// Gets public execution events (trades) for a futures symbol.
705    pub async fn get_public_executions(
706        &self,
707        symbol: &str,
708        since: Option<i64>,
709        before: Option<i64>,
710        sort: Option<&str>,
711        continuation_token: Option<&str>,
712    ) -> anyhow::Result<FuturesPublicExecutionsResponse, KrakenHttpError> {
713        let endpoint = format!("/api/history/v3/market/{symbol}/executions");
714
715        let mut url = format!("{}{endpoint}", self.base_url);
716
717        let mut query_params = Vec::new();
718
719        if let Some(since_ts) = since {
720            query_params.push(format!("since={since_ts}"));
721        }
722
723        if let Some(before_ts) = before {
724            query_params.push(format!("before={before_ts}"));
725        }
726
727        if let Some(sort_order) = sort {
728            query_params.push(format!("sort={sort_order}"));
729        }
730
731        if let Some(token) = continuation_token {
732            query_params.push(format!("continuationToken={token}"));
733        }
734
735        if !query_params.is_empty() {
736            url.push('?');
737            url.push_str(&query_params.join("&"));
738        }
739
740        self.send_request(Method::GET, &endpoint, url, false).await
741    }
742
743    /// Requests all open orders (requires authentication).
744    pub async fn get_open_orders(
745        &self,
746    ) -> anyhow::Result<FuturesOpenOrdersResponse, KrakenHttpError> {
747        if self.credential.is_none() {
748            return Err(KrakenHttpError::AuthenticationError(
749                "API credentials required for futures open orders".to_string(),
750            ));
751        }
752
753        let endpoint = "/derivatives/api/v3/openorders";
754        let url = format!("{}{endpoint}", self.base_url);
755
756        self.send_request(Method::GET, endpoint, url, true).await
757    }
758
759    /// Requests historical order events (requires authentication).
760    pub async fn get_order_events(
761        &self,
762        before: Option<i64>,
763        since: Option<i64>,
764        continuation_token: Option<&str>,
765    ) -> anyhow::Result<FuturesOrderEventsResponse, KrakenHttpError> {
766        if self.credential.is_none() {
767            return Err(KrakenHttpError::AuthenticationError(
768                "API credentials required for futures order events".to_string(),
769            ));
770        }
771
772        let endpoint = "/api/history/v2/orders";
773        let mut query_params = Vec::new();
774
775        if let Some(before_ts) = before {
776            query_params.push(format!("before={before_ts}"));
777        }
778
779        if let Some(since_ts) = since {
780            query_params.push(format!("since={since_ts}"));
781        }
782
783        if let Some(token) = continuation_token {
784            query_params.push(format!("continuation_token={token}"));
785        }
786
787        // Build URL with query params
788        let query_string = query_params.join("&");
789        let url = if query_string.is_empty() {
790            format!("{}{endpoint}", self.base_url)
791        } else {
792            format!("{}{endpoint}?{query_string}", self.base_url)
793        };
794
795        // For signing: query params go in postData, not endpoint
796        // Kraken: message = postData + nonce + endpoint
797        self.send_get_with_query(endpoint, url, &query_string).await
798    }
799
800    /// Requests the status of specific orders (requires authentication).
801    ///
802    /// The venue reports orders which are open or were filled/cancelled in
803    /// the last 5 seconds, which covers the Maker Protection race where an
804    /// order is absent from `/openorders` because its terminal transition is
805    /// younger than the open-orders snapshot.
806    pub async fn get_orders_status(
807        &self,
808        order_ids: &[String],
809        cli_ord_ids: &[String],
810    ) -> anyhow::Result<FuturesOrdersStatusResponse, KrakenHttpError> {
811        if self.credential.is_none() {
812            return Err(KrakenHttpError::AuthenticationError(
813                "API credentials required for futures orders status".to_string(),
814            ));
815        }
816
817        let pairs: Vec<(&str, &str)> = order_ids
818            .iter()
819            .map(|order_id| ("orderIds", order_id.as_str()))
820            .chain(
821                cli_ord_ids
822                    .iter()
823                    .map(|cli_ord_id| ("cliOrdIds", cli_ord_id.as_str())),
824            )
825            .collect();
826
827        let post_data = serde_urlencoded::to_string(&pairs).map_err(|e| {
828            KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
829        })?;
830
831        let endpoint = "/derivatives/api/v3/orders/status";
832        self.send_authenticated_post(endpoint, post_data).await
833    }
834
835    /// Requests fill/trade history (requires authentication).
836    pub async fn get_fills(
837        &self,
838        last_fill_time: Option<&str>,
839    ) -> anyhow::Result<FuturesFillsResponse, KrakenHttpError> {
840        if self.credential.is_none() {
841            return Err(KrakenHttpError::AuthenticationError(
842                "API credentials required for futures fills".to_string(),
843            ));
844        }
845
846        let endpoint = "/derivatives/api/v3/fills";
847        let query_string = last_fill_time
848            .map(|t| format!("lastFillTime={t}"))
849            .unwrap_or_default();
850
851        let url = if query_string.is_empty() {
852            format!("{}{endpoint}", self.base_url)
853        } else {
854            format!("{}{endpoint}?{query_string}", self.base_url)
855        };
856
857        // Query params go in postData for signing
858        self.send_get_with_query(endpoint, url, &query_string).await
859    }
860
861    /// Requests open positions (requires authentication).
862    pub async fn get_open_positions(
863        &self,
864    ) -> anyhow::Result<FuturesOpenPositionsResponse, KrakenHttpError> {
865        if self.credential.is_none() {
866            return Err(KrakenHttpError::AuthenticationError(
867                "API credentials required for futures open positions".to_string(),
868            ));
869        }
870
871        let endpoint = "/derivatives/api/v3/openpositions";
872        let url = format!("{}{endpoint}", self.base_url);
873
874        self.send_request(Method::GET, endpoint, url, true).await
875    }
876
877    /// Requests all accounts (cash and margin) with balances and margin info.
878    pub async fn get_accounts(&self) -> anyhow::Result<FuturesAccountsResponse, KrakenHttpError> {
879        if self.credential.is_none() {
880            return Err(KrakenHttpError::AuthenticationError(
881                "API credentials required for futures accounts".to_string(),
882            ));
883        }
884
885        let endpoint = "/derivatives/api/v3/accounts";
886        let url = format!("{}{endpoint}", self.base_url);
887
888        self.send_request(Method::GET, endpoint, url, true).await
889    }
890
891    /// Submits a new order (requires authentication).
892    pub async fn send_order(
893        &self,
894        params: HashMap<String, String>,
895    ) -> anyhow::Result<FuturesSendOrderResponse, KrakenHttpError> {
896        if self.credential.is_none() {
897            return Err(KrakenHttpError::AuthenticationError(
898                "API credentials required for sending orders".to_string(),
899            ));
900        }
901
902        let endpoint = "/derivatives/api/v3/sendorder";
903        self.send_request_with_body(endpoint, params).await
904    }
905
906    /// Submits a new order using typed parameters (requires authentication).
907    pub async fn send_order_params(
908        &self,
909        params: &KrakenFuturesSendOrderParams,
910    ) -> anyhow::Result<FuturesSendOrderResponse, KrakenHttpError> {
911        if self.credential.is_none() {
912            return Err(KrakenHttpError::MissingCredentials);
913        }
914
915        let endpoint = "/derivatives/api/v3/sendorder";
916        self.send_request_with_params(endpoint, params).await
917    }
918
919    /// Cancels an open order (requires authentication).
920    pub async fn cancel_order(
921        &self,
922        order_id: Option<String>,
923        cli_ord_id: Option<String>,
924    ) -> anyhow::Result<FuturesCancelOrderResponse, KrakenHttpError> {
925        if self.credential.is_none() {
926            return Err(KrakenHttpError::AuthenticationError(
927                "API credentials required for canceling orders".to_string(),
928            ));
929        }
930
931        let mut params = HashMap::new();
932
933        if let Some(id) = order_id {
934            params.insert("order_id".to_string(), id);
935        }
936
937        if let Some(id) = cli_ord_id {
938            params.insert("cliOrdId".to_string(), id);
939        }
940
941        let endpoint = "/derivatives/api/v3/cancelorder";
942        self.send_request_with_body(endpoint, params).await
943    }
944
945    /// Edits an existing order (requires authentication).
946    pub async fn edit_order(
947        &self,
948        params: &KrakenFuturesEditOrderParams,
949    ) -> anyhow::Result<FuturesEditOrderResponse, KrakenHttpError> {
950        if self.credential.is_none() {
951            return Err(KrakenHttpError::MissingCredentials);
952        }
953
954        let endpoint = "/derivatives/api/v3/editorder";
955        self.send_request_with_params(endpoint, params).await
956    }
957
958    /// Submits multiple orders in a single batch request (requires authentication).
959    pub async fn batch_order(
960        &self,
961        params: HashMap<String, String>,
962    ) -> anyhow::Result<FuturesBatchOrderResponse, KrakenHttpError> {
963        if self.credential.is_none() {
964            return Err(KrakenHttpError::AuthenticationError(
965                "API credentials required for batch orders".to_string(),
966            ));
967        }
968
969        let endpoint = "/derivatives/api/v3/batchorder";
970        self.send_request_with_body(endpoint, params).await
971    }
972
973    /// Cancels multiple orders in a single batch request (requires authentication).
974    pub async fn cancel_orders_batch(
975        &self,
976        order_ids: Vec<String>,
977    ) -> anyhow::Result<FuturesBatchCancelResponse, KrakenHttpError> {
978        let batch_items: Vec<KrakenFuturesBatchCancelItem> = order_ids
979            .into_iter()
980            .map(KrakenFuturesBatchCancelItem::from_order_id)
981            .collect();
982
983        self.cancel_order_items_batch(batch_items).await
984    }
985
986    /// Cancels multiple order IDs or client order IDs in a single batch request
987    /// (requires authentication).
988    pub async fn cancel_order_items_batch(
989        &self,
990        batch_items: Vec<KrakenFuturesBatchCancelItem>,
991    ) -> anyhow::Result<FuturesBatchCancelResponse, KrakenHttpError> {
992        if self.credential.is_none() {
993            return Err(KrakenHttpError::AuthenticationError(
994                "API credentials required for batch orders".to_string(),
995            ));
996        }
997
998        let params = KrakenFuturesBatchOrderParams::new(batch_items);
999        let post_data = params
1000            .to_body()
1001            .map_err(|e| KrakenHttpError::ParseError(format!("Failed to serialize batch: {e}")))?;
1002
1003        let endpoint = "/derivatives/api/v3/batchorder";
1004        self.send_authenticated_post(endpoint, post_data).await
1005    }
1006
1007    /// Submits multiple orders in a single batch request (requires authentication).
1008    pub async fn submit_orders_batch(
1009        &self,
1010        items: Vec<KrakenFuturesBatchSendItem>,
1011    ) -> anyhow::Result<FuturesBatchOrderResponse, KrakenHttpError> {
1012        if self.credential.is_none() {
1013            return Err(KrakenHttpError::MissingCredentials);
1014        }
1015
1016        let params = KrakenFuturesBatchOrderParams::new(items);
1017        let post_data = params.to_body().map_err(|e| {
1018            KrakenHttpError::RequestNotStarted(format!("Failed to serialize batch: {e}"))
1019        })?;
1020
1021        let endpoint = "/derivatives/api/v3/batchorder";
1022        self.send_authenticated_post(endpoint, post_data).await
1023    }
1024
1025    /// Edits multiple orders in a single batch request (requires authentication).
1026    pub async fn edit_orders_batch(
1027        &self,
1028        items: Vec<KrakenFuturesBatchEditItem>,
1029    ) -> anyhow::Result<FuturesBatchOrderResponse, KrakenHttpError> {
1030        if self.credential.is_none() {
1031            return Err(KrakenHttpError::AuthenticationError(
1032                "API credentials required for batch orders".to_string(),
1033            ));
1034        }
1035
1036        let params = KrakenFuturesBatchOrderParams::new(items);
1037        let post_data = params
1038            .to_body()
1039            .map_err(|e| KrakenHttpError::ParseError(format!("Failed to serialize batch: {e}")))?;
1040
1041        let endpoint = "/derivatives/api/v3/batchorder";
1042        self.send_authenticated_post(endpoint, post_data).await
1043    }
1044
1045    /// Cancels all open orders, optionally filtered by symbol (requires authentication).
1046    pub async fn cancel_all_orders(
1047        &self,
1048        symbol: Option<String>,
1049    ) -> anyhow::Result<FuturesCancelAllOrdersResponse, KrakenHttpError> {
1050        if self.credential.is_none() {
1051            return Err(KrakenHttpError::AuthenticationError(
1052                "API credentials required for canceling orders".to_string(),
1053            ));
1054        }
1055
1056        let mut params = HashMap::new();
1057
1058        if let Some(sym) = symbol {
1059            params.insert("symbol".to_string(), sym);
1060        }
1061
1062        let endpoint = "/derivatives/api/v3/cancelallorders";
1063        self.send_request_with_body(endpoint, params).await
1064    }
1065}
1066
1067pub(crate) type FuturesBatchOrder = (
1068    InstrumentId,
1069    ClientOrderId,
1070    OrderSide,
1071    OrderType,
1072    Quantity,
1073    TimeInForce,
1074    Option<Price>,
1075    Option<Price>,
1076    Option<TriggerType>,
1077    bool,
1078    bool,
1079);
1080
1081pub(crate) struct FuturesBatchSubmitItem {
1082    pub result: KrakenApiResult,
1083    pub status: FuturesSendStatus,
1084}
1085
1086/// High-level HTTP client for the Kraken Futures REST API.
1087///
1088/// This client wraps the raw client and provides Nautilus domain types.
1089/// It maintains an instrument cache and uses it to parse venue responses
1090/// into Nautilus domain objects.
1091#[cfg_attr(
1092    feature = "python",
1093    pyo3::pyclass(module = "nautilus_trader.adapters.kraken", from_py_object)
1094)]
1095#[cfg_attr(
1096    feature = "python",
1097    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.kraken")
1098)]
1099pub struct KrakenFuturesHttpClient {
1100    pub(crate) inner: Arc<KrakenFuturesRawHttpClient>,
1101    pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
1102    clock: &'static AtomicTime,
1103    cache_initialized: Arc<AtomicBool>,
1104}
1105
1106impl Clone for KrakenFuturesHttpClient {
1107    fn clone(&self) -> Self {
1108        Self {
1109            inner: self.inner.clone(),
1110            instruments_cache: self.instruments_cache.clone(),
1111            cache_initialized: self.cache_initialized.clone(),
1112            clock: self.clock,
1113        }
1114    }
1115}
1116
1117impl Default for KrakenFuturesHttpClient {
1118    fn default() -> Self {
1119        Self::new(
1120            KrakenEnvironment::Live,
1121            None,
1122            60,
1123            None,
1124            None,
1125            None,
1126            None,
1127            KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND,
1128        )
1129        .expect("Failed to create default KrakenFuturesHttpClient")
1130    }
1131}
1132
1133impl Debug for KrakenFuturesHttpClient {
1134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1135        f.debug_struct(stringify!(KrakenFuturesHttpClient))
1136            .field("inner", &self.inner)
1137            .finish()
1138    }
1139}
1140
1141impl KrakenFuturesHttpClient {
1142    /// Creates a new [`KrakenFuturesHttpClient`].
1143    #[expect(clippy::too_many_arguments)]
1144    pub fn new(
1145        environment: KrakenEnvironment,
1146        base_url_override: Option<String>,
1147        timeout_secs: u64,
1148        max_retries: Option<u32>,
1149        retry_delay_ms: Option<u64>,
1150        retry_delay_max_ms: Option<u64>,
1151        proxy_url: Option<String>,
1152        max_requests_per_second: u32,
1153    ) -> anyhow::Result<Self> {
1154        Ok(Self {
1155            inner: Arc::new(KrakenFuturesRawHttpClient::new(
1156                environment,
1157                base_url_override,
1158                timeout_secs,
1159                max_retries,
1160                retry_delay_ms,
1161                retry_delay_max_ms,
1162                proxy_url,
1163                max_requests_per_second,
1164            )?),
1165            instruments_cache: Arc::new(AtomicMap::new()),
1166            cache_initialized: Arc::new(AtomicBool::new(false)),
1167            clock: get_atomic_clock_realtime(),
1168        })
1169    }
1170
1171    /// Creates a new [`KrakenFuturesHttpClient`] with credentials.
1172    #[expect(clippy::too_many_arguments)]
1173    pub fn with_credentials(
1174        api_key: String,
1175        api_secret: String,
1176        environment: KrakenEnvironment,
1177        base_url_override: Option<String>,
1178        timeout_secs: u64,
1179        max_retries: Option<u32>,
1180        retry_delay_ms: Option<u64>,
1181        retry_delay_max_ms: Option<u64>,
1182        proxy_url: Option<String>,
1183        max_requests_per_second: u32,
1184    ) -> anyhow::Result<Self> {
1185        Ok(Self {
1186            inner: Arc::new(KrakenFuturesRawHttpClient::with_credentials(
1187                api_key,
1188                api_secret,
1189                environment,
1190                base_url_override,
1191                timeout_secs,
1192                max_retries,
1193                retry_delay_ms,
1194                retry_delay_max_ms,
1195                proxy_url,
1196                max_requests_per_second,
1197            )?),
1198            instruments_cache: Arc::new(AtomicMap::new()),
1199            cache_initialized: Arc::new(AtomicBool::new(false)),
1200            clock: get_atomic_clock_realtime(),
1201        })
1202    }
1203
1204    /// Creates a new [`KrakenFuturesHttpClient`] loading credentials from environment variables.
1205    ///
1206    /// Looks for `KRAKEN_FUTURES_API_KEY` and `KRAKEN_FUTURES_API_SECRET` (live)
1207    /// or `KRAKEN_FUTURES_DEMO_API_KEY` and `KRAKEN_FUTURES_DEMO_API_SECRET` (demo).
1208    ///
1209    /// Falls back to unauthenticated client if credentials are not set.
1210    #[expect(clippy::too_many_arguments)]
1211    pub fn from_env(
1212        environment: KrakenEnvironment,
1213        base_url_override: Option<String>,
1214        timeout_secs: u64,
1215        max_retries: Option<u32>,
1216        retry_delay_ms: Option<u64>,
1217        retry_delay_max_ms: Option<u64>,
1218        proxy_url: Option<String>,
1219        max_requests_per_second: u32,
1220    ) -> anyhow::Result<Self> {
1221        let demo = environment == KrakenEnvironment::Demo;
1222
1223        if let Some(credential) = KrakenCredential::from_env_futures(demo) {
1224            let (api_key, api_secret) = credential.into_parts();
1225            Self::with_credentials(
1226                api_key,
1227                api_secret,
1228                environment,
1229                base_url_override,
1230                timeout_secs,
1231                max_retries,
1232                retry_delay_ms,
1233                retry_delay_max_ms,
1234                proxy_url,
1235                max_requests_per_second,
1236            )
1237        } else {
1238            Self::new(
1239                environment,
1240                base_url_override,
1241                timeout_secs,
1242                max_retries,
1243                retry_delay_ms,
1244                retry_delay_max_ms,
1245                proxy_url,
1246                max_requests_per_second,
1247            )
1248        }
1249    }
1250
1251    /// Cancels all pending HTTP requests.
1252    pub fn cancel_all_requests(&self) {
1253        self.inner.cancel_all_requests();
1254    }
1255
1256    /// Replaces the canceled token so requests can proceed after reconnect.
1257    pub fn reset_cancellation_token(&self) {
1258        self.inner.reset_cancellation_token();
1259    }
1260
1261    /// Returns a clone of the current cancellation token.
1262    pub fn cancellation_token(&self) -> CancellationToken {
1263        self.inner.cancellation_token()
1264    }
1265
1266    /// Caches an instrument for symbol lookup.
1267    pub fn cache_instrument(&self, instrument: InstrumentAny) {
1268        self.instruments_cache
1269            .insert(instrument.symbol().inner(), instrument);
1270        self.cache_initialized.store(true, Ordering::Release);
1271    }
1272
1273    /// Caches multiple instruments for symbol lookup.
1274    pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1275        self.instruments_cache.rcu(|m| {
1276            for instrument in instruments {
1277                m.insert(instrument.symbol().inner(), instrument.clone());
1278            }
1279        });
1280        self.cache_initialized.store(true, Ordering::Release);
1281    }
1282
1283    /// Gets an instrument from the cache by symbol.
1284    pub fn get_cached_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1285        self.instruments_cache.get_cloned(symbol)
1286    }
1287
1288    fn get_instrument_by_raw_symbol(&self, raw_symbol: &str) -> Option<InstrumentAny> {
1289        self.instruments_cache
1290            .load()
1291            .values()
1292            .find(|inst| inst.raw_symbol().as_str() == raw_symbol)
1293            .cloned()
1294    }
1295
1296    fn generate_ts_init(&self) -> UnixNanos {
1297        self.clock.get_time_ns()
1298    }
1299
1300    /// Requests the complete tradable instrument catalog from Kraken Futures.
1301    ///
1302    /// # Errors
1303    ///
1304    /// Returns an error if the underlying request fails or any instrument definition cannot be
1305    /// parsed. An instrument parse failure returns [`KrakenHttpError::ParseError`] without a
1306    /// partial catalog.
1307    pub async fn request_instruments(&self) -> anyhow::Result<Vec<InstrumentAny>, KrakenHttpError> {
1308        let ts_init = self.generate_ts_init();
1309        let response = self.inner.get_instruments().await?;
1310
1311        response
1312            .instruments
1313            .iter()
1314            .map(|fut_instrument| {
1315                parse_futures_instrument(fut_instrument, ts_init, ts_init)
1316                    .map_err(|e| KrakenHttpError::ParseError(e.to_string()))
1317            })
1318            .collect()
1319    }
1320
1321    /// Requests the current market status for Kraken Futures instruments.
1322    pub async fn request_instrument_statuses(
1323        &self,
1324    ) -> anyhow::Result<AHashMap<InstrumentId, MarketStatusAction>, KrakenHttpError> {
1325        let response = self.inner.get_instruments().await?;
1326
1327        Ok(response
1328            .instruments
1329            .iter()
1330            .map(|instrument| {
1331                let instrument_id =
1332                    InstrumentId::new(Symbol::new(&instrument.symbol), *KRAKEN_VENUE);
1333                let action = if instrument.tradeable {
1334                    MarketStatusAction::Trading
1335                } else {
1336                    MarketStatusAction::NotAvailableForTrading
1337                };
1338
1339                (instrument_id, action)
1340            })
1341            .collect())
1342    }
1343
1344    /// Requests the mark price for an instrument.
1345    pub async fn request_mark_price(
1346        &self,
1347        instrument_id: InstrumentId,
1348    ) -> anyhow::Result<Decimal, KrakenHttpError> {
1349        let instrument = self
1350            .get_cached_instrument(&instrument_id.symbol.inner())
1351            .ok_or_else(|| {
1352                KrakenHttpError::ParseError(
1353                    InstrumentLookupError::not_found(instrument_id).to_string(),
1354                )
1355            })?;
1356
1357        let raw_symbol = instrument.raw_symbol().to_string();
1358        let tickers = self.inner.get_tickers().await?;
1359
1360        tickers
1361            .tickers
1362            .iter()
1363            .find(|t| t.symbol == raw_symbol)
1364            .ok_or_else(|| {
1365                KrakenHttpError::ParseError(format!("Symbol {raw_symbol} not found in tickers"))
1366            })
1367            .and_then(|t| {
1368                t.mark_price.ok_or_else(|| {
1369                    KrakenHttpError::ParseError(format!(
1370                        "Mark price not available for {raw_symbol} (may not be available in testnet)"
1371                    ))
1372                })
1373            })
1374    }
1375
1376    pub async fn request_index_price(
1377        &self,
1378        instrument_id: InstrumentId,
1379    ) -> anyhow::Result<Decimal, KrakenHttpError> {
1380        let instrument = self
1381            .get_cached_instrument(&instrument_id.symbol.inner())
1382            .ok_or_else(|| {
1383                KrakenHttpError::ParseError(
1384                    InstrumentLookupError::not_found(instrument_id).to_string(),
1385                )
1386            })?;
1387
1388        let raw_symbol = instrument.raw_symbol().to_string();
1389        let tickers = self.inner.get_tickers().await?;
1390
1391        tickers
1392            .tickers
1393            .iter()
1394            .find(|t| t.symbol == raw_symbol)
1395            .ok_or_else(|| {
1396                KrakenHttpError::ParseError(format!("Symbol {raw_symbol} not found in tickers"))
1397            })
1398            .and_then(|t| {
1399                t.index_price.ok_or_else(|| {
1400                    KrakenHttpError::ParseError(format!(
1401                        "Index price not available for {raw_symbol} (may not be available in testnet)"
1402                    ))
1403                })
1404            })
1405    }
1406
1407    pub async fn request_trades(
1408        &self,
1409        instrument_id: InstrumentId,
1410        start: Option<Timestamp>,
1411        end: Option<Timestamp>,
1412        limit: Option<u64>,
1413    ) -> anyhow::Result<Vec<TradeTick>, KrakenHttpError> {
1414        let instrument = self
1415            .get_cached_instrument(&instrument_id.symbol.inner())
1416            .ok_or_else(|| {
1417                KrakenHttpError::ParseError(
1418                    InstrumentLookupError::not_found(instrument_id).to_string(),
1419                )
1420            })?;
1421
1422        let raw_symbol = instrument.raw_symbol().to_string();
1423        let ts_init = self.generate_ts_init();
1424
1425        let since = start.map(|dt| dt.as_millisecond());
1426        let before = end.map(|dt| dt.as_millisecond());
1427
1428        // Executions are oldest-anchored for `sort=asc`; count-only fetches the
1429        // newest page with `sort=desc` (reversed to ascending below)
1430        let sort = if start.is_some() { "asc" } else { "desc" };
1431
1432        let response = self
1433            .inner
1434            .get_public_executions(&raw_symbol, since, before, Some(sort), None)
1435            .await?;
1436
1437        let mut trades = Vec::new();
1438
1439        for element in &response.elements {
1440            let execution = &element.event.execution.execution;
1441            match parse_futures_public_execution(execution, &instrument, ts_init) {
1442                Ok(trade_tick) => trades.push(trade_tick),
1443                Err(e) => {
1444                    log::warn!("Failed to parse futures trade tick: {e}");
1445                }
1446            }
1447        }
1448
1449        if start.is_none() {
1450            trades.reverse();
1451        }
1452
1453        apply_count_limit(&mut trades, start, limit);
1454
1455        Ok(trades)
1456    }
1457
1458    pub async fn request_bars(
1459        &self,
1460        bar_type: BarType,
1461        start: Option<Timestamp>,
1462        end: Option<Timestamp>,
1463        limit: Option<u64>,
1464    ) -> anyhow::Result<Vec<Bar>, KrakenHttpError> {
1465        let instrument_id = bar_type.instrument_id();
1466        let instrument = self
1467            .get_cached_instrument(&instrument_id.symbol.inner())
1468            .ok_or_else(|| {
1469                KrakenHttpError::ParseError(
1470                    InstrumentLookupError::not_found(instrument_id).to_string(),
1471                )
1472            })?;
1473
1474        let raw_symbol = instrument.raw_symbol().to_string();
1475        let ts_init = self.generate_ts_init();
1476        let tick_type = "trade";
1477        let resolution = bar_type_to_futures_resolution(bar_type)
1478            .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1479
1480        // Kraken Futures OHLC API expects Unix timestamp in seconds
1481        let from = start.map(|dt| dt.as_second());
1482        let to = end.map(|dt| dt.as_second());
1483        let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1484
1485        let response = self
1486            .inner
1487            .get_ohlc(tick_type, &raw_symbol, resolution, from, to)
1488            .await?;
1489
1490        let mut bars = Vec::new();
1491
1492        for candle in response.candles {
1493            let ohlc = OhlcData {
1494                time: candle.time / 1000,
1495                open: candle.open,
1496                high: candle.high,
1497                low: candle.low,
1498                close: candle.close,
1499                vwap: "0".to_string(),
1500                volume: candle.volume,
1501                count: 0,
1502            };
1503
1504            match parse_bar(&ohlc, &instrument, bar_type, ts_init) {
1505                Ok(bar) => {
1506                    if let Some(end_nanos) = end_ns
1507                        && bar.ts_event.as_u64() > end_nanos
1508                    {
1509                        continue;
1510                    }
1511                    bars.push(bar);
1512                }
1513                Err(e) => {
1514                    log::warn!("Failed to parse futures bar: {e}");
1515                }
1516            }
1517        }
1518
1519        // Kraken returns the page oldest-first; keep the most recent `limit`
1520        // bars for count-only requests rather than the oldest (issue #4254).
1521        apply_count_limit(&mut bars, start, limit);
1522
1523        Ok(bars)
1524    }
1525
1526    /// Requests an order book snapshot for a futures instrument.
1527    pub async fn request_book_snapshot(
1528        &self,
1529        instrument_id: InstrumentId,
1530        depth: Option<u32>,
1531    ) -> anyhow::Result<OrderBook, KrakenHttpError> {
1532        let instrument = self
1533            .get_cached_instrument(&instrument_id.symbol.inner())
1534            .ok_or_else(|| {
1535                KrakenHttpError::ParseError(
1536                    InstrumentLookupError::not_found(instrument_id).to_string(),
1537                )
1538            })?;
1539
1540        let raw_symbol = instrument.raw_symbol().to_string();
1541        let price_precision = instrument.price_precision();
1542        let size_precision = instrument.size_precision();
1543        let ts_event = self.generate_ts_init();
1544
1545        let response = self.inner.get_orderbook(&raw_symbol).await?;
1546        let book_data = &response.order_book;
1547
1548        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
1549
1550        let bid_limit = depth.map_or(book_data.bids.len(), |d| {
1551            (d as usize).min(book_data.bids.len())
1552        });
1553        let ask_limit = depth.map_or(book_data.asks.len(), |d| {
1554            (d as usize).min(book_data.asks.len())
1555        });
1556
1557        // Pass sequence=0 so the snapshot does not advance the book's high-water sequence,
1558        // the WS subscription owns sequencing once it starts streaming deltas.
1559        for (i, level) in book_data.bids.iter().take(bid_limit).enumerate() {
1560            let price = Price::from_decimal_dp(level.price, price_precision)
1561                .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1562            let size = Quantity::from_decimal_dp(level.qty, size_precision)
1563                .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1564            let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
1565            book.add(order, 0, 0, ts_event);
1566        }
1567
1568        for (i, level) in book_data.asks.iter().take(ask_limit).enumerate() {
1569            let price = Price::from_decimal_dp(level.price, price_precision)
1570                .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1571            let size = Quantity::from_decimal_dp(level.qty, size_precision)
1572                .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1573            let order = BookOrder::new(OrderSide::Sell, price, size, (bid_limit + i) as u64);
1574            book.add(order, 0, 0, ts_event);
1575        }
1576
1577        Ok(book)
1578    }
1579
1580    /// Requests historical funding rates for a futures instrument.
1581    ///
1582    /// Kraken returns all available rates; client-side filtering applies
1583    /// the `start`, `end`, and `limit` constraints from the caller.
1584    pub async fn request_funding_rates(
1585        &self,
1586        instrument_id: InstrumentId,
1587        start: Option<Timestamp>,
1588        end: Option<Timestamp>,
1589        limit: Option<usize>,
1590    ) -> anyhow::Result<Vec<FundingRateUpdate>, KrakenHttpError> {
1591        let instrument = self
1592            .get_cached_instrument(&instrument_id.symbol.inner())
1593            .ok_or_else(|| {
1594                KrakenHttpError::ParseError(
1595                    InstrumentLookupError::not_found(instrument_id).to_string(),
1596                )
1597            })?;
1598
1599        let raw_symbol = instrument.raw_symbol().to_string();
1600        let ts_init = self.generate_ts_init();
1601        let start_ns = start.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1602        let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1603
1604        let response = self.inner.get_historical_funding_rates(&raw_symbol).await?;
1605
1606        let mut rates = Vec::new();
1607
1608        for entry in &response.rates {
1609            let ts_event = entry.timestamp.parse::<Timestamp>().map_or(ts_init, |dt| {
1610                UnixNanos::from(u64::try_from(dt.as_nanosecond()).unwrap_or(0))
1611            });
1612
1613            if let Some(s) = start_ns
1614                && ts_event.as_u64() < s
1615            {
1616                continue;
1617            }
1618
1619            if let Some(e) = end_ns
1620                && ts_event.as_u64() > e
1621            {
1622                continue;
1623            }
1624
1625            rates.push(FundingRateUpdate::new(
1626                instrument_id,
1627                entry.relative_funding_rate,
1628                None,
1629                None,
1630                ts_event,
1631                ts_init,
1632            ));
1633
1634            if let Some(lim) = limit
1635                && rates.len() >= lim
1636            {
1637                break;
1638            }
1639        }
1640
1641        // Kraken returns newest-first; reverse to ascending chronological order
1642        rates.reverse();
1643
1644        Ok(rates)
1645    }
1646
1647    /// Requests account state from the Kraken Futures exchange.
1648    ///
1649    /// This queries the accounts endpoint and converts the response into a
1650    /// Nautilus `AccountState` event containing balances and margin info.
1651    ///
1652    /// # Errors
1653    ///
1654    /// Returns an error if:
1655    /// - Credentials are missing.
1656    /// - The request fails.
1657    /// - Response parsing fails.
1658    pub async fn request_account_state(
1659        &self,
1660        account_id: AccountId,
1661    ) -> anyhow::Result<AccountState> {
1662        let accounts_response = self.inner.get_accounts().await?;
1663
1664        if accounts_response.result != KrakenApiResult::Success {
1665            let error_msg = accounts_response
1666                .error
1667                .unwrap_or_else(|| "Unknown error".to_string());
1668            anyhow::bail!("Failed to get futures accounts: {error_msg}");
1669        }
1670
1671        let ts_init = self.generate_ts_init();
1672
1673        let mut balances: Vec<AccountBalance> = Vec::new();
1674        let mut margins: Vec<MarginBalance> = Vec::new();
1675
1676        for account in accounts_response.accounts.values() {
1677            match account.account_type {
1678                KrakenFuturesAccountType::MultiCollateralMarginAccount => {
1679                    parse_multi_collateral_balances(account, &mut balances);
1680                    parse_multi_collateral_margins(account, &mut margins);
1681                }
1682                KrakenFuturesAccountType::MarginAccount => {
1683                    parse_margin_account_balances(account, &mut balances);
1684                    parse_margin_account_margins(account, &mut margins);
1685                }
1686                KrakenFuturesAccountType::CashAccount => {
1687                    parse_cash_account_balances(account, &mut balances);
1688                }
1689                KrakenFuturesAccountType::Unknown => {
1690                    log::debug!("Unknown account type: {:?}", account.account_type);
1691                }
1692            }
1693        }
1694
1695        Ok(AccountState::new(
1696            account_id,
1697            AccountType::Margin,
1698            balances,
1699            margins,
1700            true,
1701            UUID4::new(),
1702            ts_init,
1703            ts_init,
1704            None,
1705        ))
1706    }
1707
1708    pub async fn request_order_status_reports(
1709        &self,
1710        account_id: AccountId,
1711        instrument_id: Option<InstrumentId>,
1712        start: Option<Timestamp>,
1713        end: Option<Timestamp>,
1714        open_only: bool,
1715    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1716        let ts_init = self.generate_ts_init();
1717        let mut all_reports = Vec::new();
1718
1719        let response = self
1720            .inner
1721            .get_open_orders()
1722            .await
1723            .map_err(|e| anyhow::anyhow!("get_open_orders failed: {e}"))?;
1724
1725        if response.result != KrakenApiResult::Success {
1726            let error_msg = response
1727                .error
1728                .unwrap_or_else(|| "Unknown error".to_string());
1729            anyhow::bail!("Failed to get open orders: {error_msg}");
1730        }
1731
1732        let position_sizes = if response
1733            .open_orders
1734            .iter()
1735            .any(|order| order.unfilled_size.is_none())
1736        {
1737            match self.inner.get_open_positions().await {
1738                Ok(response) if response.result == KrakenApiResult::Success => response
1739                    .open_positions
1740                    .into_iter()
1741                    .map(|position| (position.symbol, position.size))
1742                    .collect::<AHashMap<_, _>>(),
1743                Ok(response) => {
1744                    let error = response
1745                        .error
1746                        .unwrap_or_else(|| "Unknown error".to_string());
1747                    log::warn!("Failed to get open positions for order quantities: {error}");
1748                    AHashMap::new()
1749                }
1750                Err(e) => {
1751                    log::warn!("Failed to get open positions for order quantities: {e}");
1752                    AHashMap::new()
1753                }
1754            }
1755        } else {
1756            AHashMap::new()
1757        };
1758
1759        for order in &response.open_orders {
1760            if let Some(ref target_id) = instrument_id {
1761                let instrument = self.get_cached_instrument(&target_id.symbol.inner());
1762                if let Some(inst) = instrument
1763                    && inst.raw_symbol().as_str() != order.symbol
1764                {
1765                    continue;
1766                }
1767            }
1768
1769            if let Some(instrument) = self.get_instrument_by_raw_symbol(&order.symbol) {
1770                let position_size = if order.unfilled_size.is_none()
1771                    && matches!(
1772                        order.order_type,
1773                        KrakenFuturesOrderType::Stop
1774                            | KrakenFuturesOrderType::StopLower
1775                            | KrakenFuturesOrderType::StopLoss
1776                            | KrakenFuturesOrderType::TakeProfit
1777                    )
1778                    && order.status == KrakenFuturesOrderStatus::Untouched
1779                    && order.reduce_only == Some(true)
1780                {
1781                    position_sizes.get(&order.symbol).copied()
1782                } else {
1783                    None
1784                };
1785
1786                match parse_futures_order_status_report(
1787                    order,
1788                    &instrument,
1789                    account_id,
1790                    position_size,
1791                    ts_init,
1792                ) {
1793                    Ok(report) => all_reports.push(report),
1794                    Err(e) => {
1795                        let order_id = &order.order_id;
1796                        log::warn!("Failed to parse futures order {order_id}: {e}");
1797                    }
1798                }
1799            }
1800        }
1801
1802        if !open_only {
1803            // Kraken Futures order events API expects Unix timestamp in milliseconds
1804            let start_ms = start.map(|dt| dt.as_millisecond());
1805            let end_ms = end.map(|dt| dt.as_millisecond());
1806            let response = self
1807                .inner
1808                .get_order_events(end_ms, start_ms, None)
1809                .await
1810                .map_err(|e| anyhow::anyhow!("get_order_events failed: {e}"))?;
1811
1812            for event_wrapper in response.order_events {
1813                let event = &event_wrapper.order;
1814
1815                if let Some(ref target_id) = instrument_id {
1816                    let instrument = self.get_cached_instrument(&target_id.symbol.inner());
1817                    if let Some(inst) = instrument
1818                        && inst.raw_symbol().as_str() != event.symbol
1819                    {
1820                        continue;
1821                    }
1822                }
1823
1824                if let Some(instrument) = self.get_instrument_by_raw_symbol(&event.symbol) {
1825                    match parse_futures_order_event_status_report(
1826                        event,
1827                        Some(event_wrapper.event_type),
1828                        &instrument,
1829                        account_id,
1830                        ts_init,
1831                    ) {
1832                        Ok(report) => all_reports.push(report),
1833                        Err(e) => {
1834                            let order_id = &event.order_id;
1835                            log::warn!("Failed to parse futures order event {order_id}: {e}");
1836                        }
1837                    }
1838                }
1839            }
1840        }
1841
1842        Ok(all_reports)
1843    }
1844
1845    /// Requests order status reports from the venue's `/orders/status`
1846    /// 5-second window for the given venue order IDs and client order IDs.
1847    ///
1848    /// Orders the venue no longer reports within that window are simply absent
1849    /// from the result, which callers treat as absence of recent evidence.
1850    ///
1851    /// # Errors
1852    ///
1853    /// Returns an error if the underlying request fails or the venue rejects
1854    /// it.
1855    pub async fn request_orders_status_reports(
1856        &self,
1857        account_id: AccountId,
1858        order_ids: &[String],
1859        cli_ord_ids: &[String],
1860    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1861        let ts_init = self.generate_ts_init();
1862
1863        let response = self
1864            .inner
1865            .get_orders_status(order_ids, cli_ord_ids)
1866            .await
1867            .map_err(|e| anyhow::anyhow!("get_orders_status failed: {e}"))?;
1868
1869        if response.result != KrakenApiResult::Success {
1870            let error_msg = response
1871                .error
1872                .unwrap_or_else(|| "Unknown error".to_string());
1873            anyhow::bail!("Failed to get orders status: {error_msg}");
1874        }
1875
1876        let mut reports = Vec::with_capacity(response.orders.len());
1877        for details in &response.orders {
1878            let Some(instrument) = self.get_instrument_by_raw_symbol(&details.order.symbol) else {
1879                anyhow::bail!(
1880                    "No cached instrument for symbol: {}, order {} cannot be reported; \
1881                     treating the lookup as failed rather than the order as absent",
1882                    details.order.symbol,
1883                    details.order.order_id,
1884                );
1885            };
1886
1887            match parse_futures_order_status_details_report(
1888                details,
1889                &instrument,
1890                account_id,
1891                ts_init,
1892            ) {
1893                Ok(report) => reports.push(report),
1894                Err(e) => anyhow::bail!(
1895                    "Failed to parse futures order status {}: {e}; treating the lookup as \
1896                     failed rather than the order as absent",
1897                    details.order.order_id,
1898                ),
1899            }
1900        }
1901
1902        Ok(reports)
1903    }
1904
1905    pub async fn request_fill_reports(
1906        &self,
1907        account_id: AccountId,
1908        instrument_id: Option<InstrumentId>,
1909        start: Option<Timestamp>,
1910        end: Option<Timestamp>,
1911    ) -> anyhow::Result<Vec<FillReport>> {
1912        let ts_init = self.generate_ts_init();
1913        let mut all_reports = Vec::new();
1914
1915        let response = self.inner.get_fills(None).await?;
1916        if response.result != KrakenApiResult::Success {
1917            let error_msg = response
1918                .error
1919                .unwrap_or_else(|| "Unknown error".to_string());
1920            anyhow::bail!("Failed to get fills: {error_msg}");
1921        }
1922
1923        let start_ms = start.map(|dt| dt.as_millisecond());
1924        let end_ms = end.map(|dt| dt.as_millisecond());
1925
1926        for fill in response.fills {
1927            if let Some(start_threshold) = start_ms
1928                && let Ok(fill_ts) = fill.fill_time.parse::<Timestamp>()
1929            {
1930                let fill_ms = fill_ts.as_millisecond();
1931                if fill_ms < start_threshold {
1932                    continue;
1933                }
1934            }
1935
1936            if let Some(end_threshold) = end_ms
1937                && let Ok(fill_ts) = fill.fill_time.parse::<Timestamp>()
1938            {
1939                let fill_ms = fill_ts.as_millisecond();
1940                if fill_ms > end_threshold {
1941                    continue;
1942                }
1943            }
1944
1945            if let Some(ref target_id) = instrument_id {
1946                let instrument = self.get_cached_instrument(&target_id.symbol.inner());
1947                if let Some(inst) = instrument
1948                    && inst.raw_symbol().as_str() != fill.symbol
1949                {
1950                    continue;
1951                }
1952            }
1953
1954            if let Some(instrument) = self.get_instrument_by_raw_symbol(&fill.symbol) {
1955                match parse_futures_fill_report(&fill, &instrument, account_id, ts_init) {
1956                    Ok(report) => all_reports.push(report),
1957                    Err(e) => {
1958                        let fill_id = &fill.fill_id;
1959                        log::warn!("Failed to parse futures fill {fill_id}: {e}");
1960                    }
1961                }
1962            }
1963        }
1964
1965        Ok(all_reports)
1966    }
1967
1968    pub async fn request_position_status_reports(
1969        &self,
1970        account_id: AccountId,
1971        instrument_id: Option<InstrumentId>,
1972    ) -> anyhow::Result<Vec<PositionStatusReport>> {
1973        let ts_init = self.generate_ts_init();
1974        let mut all_reports = Vec::new();
1975
1976        let response = self.inner.get_open_positions().await?;
1977        if response.result != KrakenApiResult::Success {
1978            let error_msg = response
1979                .error
1980                .unwrap_or_else(|| "Unknown error".to_string());
1981            anyhow::bail!("Failed to get open positions: {error_msg}");
1982        }
1983
1984        for position in response.open_positions {
1985            if let Some(ref target_id) = instrument_id {
1986                let instrument = self.get_cached_instrument(&target_id.symbol.inner());
1987                if let Some(inst) = instrument
1988                    && inst.raw_symbol().as_str() != position.symbol
1989                {
1990                    continue;
1991                }
1992            }
1993
1994            if let Some(instrument) = self.get_instrument_by_raw_symbol(&position.symbol) {
1995                match parse_futures_position_status_report(
1996                    &position,
1997                    &instrument,
1998                    account_id,
1999                    ts_init,
2000                ) {
2001                    Ok(report) => all_reports.push(report),
2002                    Err(e) => {
2003                        let symbol = &position.symbol;
2004                        log::warn!("Failed to parse futures position {symbol}: {e}");
2005                    }
2006                }
2007            }
2008        }
2009
2010        Ok(all_reports)
2011    }
2012
2013    #[expect(clippy::too_many_arguments)]
2014    fn build_send_order_params(
2015        &self,
2016        instrument_id: InstrumentId,
2017        client_order_id: ClientOrderId,
2018        order_side: OrderSide,
2019        order_type: OrderType,
2020        quantity: Quantity,
2021        time_in_force: TimeInForce,
2022        price: Option<Price>,
2023        trigger_price: Option<Price>,
2024        trigger_type: Option<TriggerType>,
2025        reduce_only: bool,
2026        post_only: bool,
2027    ) -> anyhow::Result<KrakenFuturesSendOrderParams> {
2028        let instrument = self
2029            .get_cached_instrument(&instrument_id.symbol.inner())
2030            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2031
2032        let raw_symbol = instrument.raw_symbol().inner();
2033
2034        // Map order type and time-in-force to Kraken order type
2035        // Kraken Futures encodes TIF in the orderType field:
2036        // - lmt = limit (GTC)
2037        // - ioc = immediate-or-cancel
2038        // - post = post-only (maker only)
2039        // - mkt = market
2040        let kraken_order_type = match order_type {
2041            OrderType::Market => KrakenFuturesOrderType::Market,
2042            OrderType::Limit => {
2043                if post_only {
2044                    KrakenFuturesOrderType::Post
2045                } else {
2046                    match time_in_force {
2047                        TimeInForce::Ioc => KrakenFuturesOrderType::Ioc,
2048                        TimeInForce::Fok => {
2049                            anyhow::bail!("FOK not supported by Kraken Futures, use IOC instead")
2050                        }
2051                        TimeInForce::Gtd => {
2052                            anyhow::bail!("GTD not supported by Kraken Futures, use GTC instead")
2053                        }
2054                        _ => KrakenFuturesOrderType::Limit, // GTC is default
2055                    }
2056                }
2057            }
2058            OrderType::StopMarket | OrderType::StopLimit => KrakenFuturesOrderType::Stop,
2059            OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2060                KrakenFuturesOrderType::TakeProfit
2061            }
2062            _ => anyhow::bail!("Unsupported order type: {order_type:?}"),
2063        };
2064
2065        let kraken_side = KrakenOrderSide::from(order_side);
2066
2067        let mut builder = KrakenFuturesSendOrderParamsBuilder::default();
2068        builder
2069            .cli_ord_id(truncate_cl_ord_id(&client_order_id))
2070            .broker(NAUTILUS_KRAKEN_BROKER_ID)
2071            .symbol(raw_symbol)
2072            .side(kraken_side)
2073            .size(quantity.to_string())
2074            .order_type(kraken_order_type);
2075
2076        if matches!(
2077            order_type,
2078            OrderType::StopMarket
2079                | OrderType::StopLimit
2080                | OrderType::MarketIfTouched
2081                | OrderType::LimitIfTouched
2082        ) && let Some(signal) = map_futures_trigger_signal(trigger_type)?
2083        {
2084            builder.trigger_signal(signal);
2085        }
2086
2087        match order_type {
2088            OrderType::StopMarket => {
2089                if let Some(trigger) = trigger_price {
2090                    builder.stop_price(trigger.to_string());
2091                }
2092            }
2093            OrderType::StopLimit => {
2094                if let Some(trigger) = trigger_price {
2095                    builder.stop_price(trigger.to_string());
2096                }
2097
2098                if let Some(limit) = price {
2099                    builder.limit_price(limit.to_string());
2100                }
2101            }
2102            OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2103                if let Some(trigger) = trigger_price {
2104                    builder.stop_price(trigger.to_string());
2105                }
2106
2107                if let Some(limit) = price {
2108                    builder.limit_price(limit.to_string());
2109                }
2110            }
2111            _ => {
2112                if let Some(limit) = price {
2113                    builder.limit_price(limit.to_string());
2114                }
2115            }
2116        }
2117
2118        if reduce_only {
2119            builder.reduce_only(true);
2120        }
2121
2122        builder
2123            .build()
2124            .map_err(|e| anyhow::anyhow!("Failed to build order params: {e}"))
2125    }
2126
2127    /// Submits a new order to the Kraken Futures exchange.
2128    ///
2129    /// # Errors
2130    ///
2131    /// Returns an error if:
2132    /// - Credentials are missing.
2133    /// - The instrument is not found in cache.
2134    /// - The order type or time in force is not supported.
2135    /// - The request fails.
2136    /// - The order is rejected.
2137    #[expect(clippy::too_many_arguments)]
2138    pub async fn submit_order(
2139        &self,
2140        account_id: AccountId,
2141        instrument_id: InstrumentId,
2142        client_order_id: ClientOrderId,
2143        order_side: OrderSide,
2144        order_type: OrderType,
2145        quantity: Quantity,
2146        time_in_force: TimeInForce,
2147        price: Option<Price>,
2148        trigger_price: Option<Price>,
2149        trigger_type: Option<TriggerType>,
2150        reduce_only: bool,
2151        post_only: bool,
2152    ) -> anyhow::Result<OrderStatusReport> {
2153        let instrument = self
2154            .get_cached_instrument(&instrument_id.symbol.inner())
2155            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2156
2157        let params = self.build_send_order_params(
2158            instrument_id,
2159            client_order_id,
2160            order_side,
2161            order_type,
2162            quantity,
2163            time_in_force,
2164            price,
2165            trigger_price,
2166            trigger_type,
2167            reduce_only,
2168            post_only,
2169        )?;
2170
2171        let response = self.inner.send_order_params(&params).await?;
2172
2173        if response.result != KrakenApiResult::Success {
2174            return Err(KrakenSubmitOrderError::Rejected {
2175                reason: response
2176                    .error
2177                    .unwrap_or_else(|| "Unknown error".to_string()),
2178            }
2179            .into());
2180        }
2181
2182        let send_status = response
2183            .send_status
2184            .ok_or(KrakenSubmitOrderError::MissingStatus)?;
2185
2186        match send_status.status.as_str() {
2187            "placed" | "filled" => {}
2188            "postWouldExecute" => {
2189                let reason = send_status
2190                    .order_events
2191                    .as_ref()
2192                    .and_then(|events| events.first())
2193                    .and_then(|event| event.reason.clone())
2194                    .unwrap_or_else(|| "Post-only order would have crossed".to_string());
2195                return Err(KrakenSubmitOrderError::Rejected {
2196                    reason: format!("POST_ONLY_REJECTED: {reason}"),
2197                }
2198                .into());
2199            }
2200            status if is_futures_submit_rejection(status) => {
2201                return Err(KrakenSubmitOrderError::Rejected {
2202                    reason: status.to_string(),
2203                }
2204                .into());
2205            }
2206            status => {
2207                return Err(KrakenSubmitOrderError::UnknownStatus {
2208                    status: status.to_string(),
2209                }
2210                .into());
2211            }
2212        }
2213
2214        let venue_order_id =
2215            send_status
2216                .order_id
2217                .clone()
2218                .ok_or_else(|| KrakenSubmitOrderError::MissingOrderId {
2219                    detail: format!("send status was {}", send_status.status),
2220                })?;
2221
2222        let report: anyhow::Result<OrderStatusReport> = async {
2223            let ts_init = self.generate_ts_init();
2224
2225            let open_orders_response = self.inner.get_open_orders().await?;
2226            if let Some(order) = open_orders_response
2227                .open_orders
2228                .iter()
2229                .find(|o| o.order_id == venue_order_id)
2230            {
2231                return parse_futures_order_status_report(
2232                    order,
2233                    &instrument,
2234                    account_id,
2235                    Some(quantity.as_decimal()),
2236                    ts_init,
2237                );
2238            }
2239
2240            // Order not in open orders - may have filled immediately (market order or aggressive limit)
2241            // Try to use order_events from send_status first
2242            if let Some(order_events) = &send_status.order_events
2243                && let Some(send_event) = order_events.first()
2244            {
2245                // Handle regular orders, trigger orders, and execution events
2246                let event = if let Some(order_data) = &send_event.order {
2247                    FuturesOrderEvent {
2248                        order_id: order_data.order_id.clone(),
2249                        cli_ord_id: order_data.cli_ord_id.clone(),
2250                        order_type: order_data.order_type,
2251                        symbol: order_data.symbol.clone(),
2252                        side: order_data.side,
2253                        quantity: order_data.quantity,
2254                        filled: order_data.filled,
2255                        limit_price: order_data.limit_price,
2256                        stop_price: order_data.stop_price,
2257                        timestamp: order_data.timestamp.clone(),
2258                        last_update_timestamp: order_data.last_update_timestamp.clone(),
2259                        reduce_only: order_data.reduce_only,
2260                    }
2261                } else if let Some(trigger_data) = &send_event.order_trigger {
2262                    FuturesOrderEvent {
2263                        order_id: trigger_data.uid.clone(),
2264                        cli_ord_id: trigger_data.client_id.clone(),
2265                        order_type: trigger_data.order_type,
2266                        symbol: trigger_data.symbol.clone(),
2267                        side: trigger_data.side,
2268                        quantity: trigger_data.quantity,
2269                        filled: Decimal::ZERO,
2270                        limit_price: trigger_data.limit_price,
2271                        stop_price: Some(trigger_data.trigger_price),
2272                        timestamp: trigger_data.timestamp.clone(),
2273                        last_update_timestamp: trigger_data.last_update_timestamp.clone(),
2274                        reduce_only: trigger_data.reduce_only,
2275                    }
2276                } else if let Some(prior_exec) = &send_event.order_prior_execution {
2277                    // EXECUTION event - use orderPriorExecution data
2278                    FuturesOrderEvent {
2279                        order_id: prior_exec.order_id.clone(),
2280                        cli_ord_id: prior_exec.cli_ord_id.clone(),
2281                        order_type: prior_exec.order_type,
2282                        symbol: prior_exec.symbol.clone(),
2283                        side: prior_exec.side,
2284                        quantity: prior_exec.quantity,
2285                        filled: send_event.amount.unwrap_or(prior_exec.quantity), // Use execution amount
2286                        limit_price: prior_exec.limit_price,
2287                        stop_price: prior_exec.stop_price,
2288                        timestamp: prior_exec.timestamp.clone(),
2289                        last_update_timestamp: prior_exec.last_update_timestamp.clone(),
2290                        reduce_only: prior_exec.reduce_only,
2291                    }
2292                } else {
2293                    anyhow::bail!("No order, orderTrigger, or orderPriorExecution data in event");
2294                };
2295                return parse_futures_order_event_status_report(
2296                    &event,
2297                    Some(send_event.event_type),
2298                    &instrument,
2299                    account_id,
2300                    ts_init,
2301                );
2302            }
2303
2304            // Fall back to querying order events
2305            let events_response = self.inner.get_order_events(None, None, None).await?;
2306            let event_wrapper = events_response
2307                .order_events
2308                .iter()
2309                .find(|e| e.order.order_id == venue_order_id)
2310                .ok_or_else(|| {
2311                    anyhow::anyhow!("Order not found in open orders or events: {venue_order_id}")
2312                })?;
2313
2314            parse_futures_order_event_status_report(
2315                &event_wrapper.order,
2316                Some(event_wrapper.event_type),
2317                &instrument,
2318                account_id,
2319                ts_init,
2320            )
2321        }
2322        .await;
2323
2324        report.map_err(|source| KrakenSubmitOrderError::PostSubmitLookup { source }.into())
2325    }
2326
2327    /// Modifies an existing order on the Kraken Futures exchange.
2328    ///
2329    /// Returns the new venue order ID assigned to the modified order.
2330    ///
2331    /// # Errors
2332    ///
2333    /// Returns an error if:
2334    /// - Neither `client_order_id` nor `venue_order_id` is provided.
2335    /// - The instrument is not found in cache.
2336    /// - The request fails.
2337    /// - The edit fails on the exchange.
2338    pub async fn modify_order(
2339        &self,
2340        instrument_id: InstrumentId,
2341        client_order_id: Option<ClientOrderId>,
2342        venue_order_id: Option<VenueOrderId>,
2343        quantity: Option<Quantity>,
2344        price: Option<Price>,
2345        trigger_price: Option<Price>,
2346    ) -> anyhow::Result<VenueOrderId> {
2347        let params = self.build_edit_order_params(
2348            instrument_id,
2349            client_order_id,
2350            venue_order_id,
2351            quantity,
2352            price,
2353            trigger_price,
2354        )?;
2355        let original_order_id = params.order_id.clone();
2356
2357        let response = self.inner.edit_order(&params).await?;
2358        let status = response.edit_status.status.as_str();
2359
2360        if response.result != KrakenApiResult::Success {
2361            return Err(KrakenModifyOrderError::Rejected {
2362                reason: status.to_string(),
2363            }
2364            .into());
2365        }
2366
2367        match status {
2368            "edited" => {}
2369            status if is_futures_modify_rejection(status) => {
2370                return Err(KrakenModifyOrderError::Rejected {
2371                    reason: status.to_string(),
2372                }
2373                .into());
2374            }
2375            status => {
2376                return Err(KrakenModifyOrderError::UnknownStatus {
2377                    status: status.to_string(),
2378                }
2379                .into());
2380            }
2381        }
2382
2383        // Return the new order_id from the response, or fall back to the original
2384        let new_venue_order_id = response
2385            .edit_status
2386            .order_id
2387            .or(original_order_id)
2388            .ok_or(KrakenModifyOrderError::MissingOrderId)?;
2389
2390        Ok(VenueOrderId::new(&new_venue_order_id))
2391    }
2392
2393    /// Cancels an order on the Kraken Futures exchange.
2394    ///
2395    /// # Errors
2396    ///
2397    /// Returns an error if:
2398    /// - Credentials are missing.
2399    /// - Neither client_order_id nor venue_order_id is provided.
2400    /// - The request fails.
2401    /// - The order cancellation is rejected.
2402    pub async fn cancel_order(
2403        &self,
2404        _account_id: AccountId,
2405        instrument_id: InstrumentId,
2406        client_order_id: Option<ClientOrderId>,
2407        venue_order_id: Option<VenueOrderId>,
2408    ) -> anyhow::Result<()> {
2409        let _ = self
2410            .get_cached_instrument(&instrument_id.symbol.inner())
2411            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2412
2413        let order_id = venue_order_id.as_ref().map(|id| id.to_string());
2414        let cli_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
2415
2416        if order_id.is_none() && cli_ord_id.is_none() {
2417            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
2418        }
2419
2420        let response = self.inner.cancel_order(order_id, cli_ord_id).await?;
2421
2422        if response.result != KrakenApiResult::Success {
2423            let status = &response.cancel_status.status;
2424            anyhow::bail!("Order cancellation failed: {status}");
2425        }
2426
2427        Ok(())
2428    }
2429
2430    /// Cancels multiple orders on the Kraken Futures exchange.
2431    ///
2432    /// Automatically chunks requests into batches of 50 orders.
2433    ///
2434    /// # Parameters
2435    /// - `venue_order_ids` - List of venue order IDs to cancel.
2436    ///
2437    /// # Returns
2438    /// The total number of successfully cancelled orders.
2439    pub async fn cancel_orders_batch(
2440        &self,
2441        venue_order_ids: Vec<VenueOrderId>,
2442    ) -> anyhow::Result<usize> {
2443        if venue_order_ids.is_empty() {
2444            return Ok(0);
2445        }
2446
2447        let mut total_cancelled = 0;
2448
2449        for chunk in venue_order_ids.chunks(BATCH_CANCEL_LIMIT) {
2450            let order_ids: Vec<String> = chunk.iter().map(|id| id.to_string()).collect();
2451            let response = self.inner.cancel_orders_batch(order_ids).await?;
2452
2453            if response.result != KrakenApiResult::Success {
2454                let error_msg = response.error.as_deref().unwrap_or("Unknown error");
2455                anyhow::bail!("Batch cancel failed: {error_msg}");
2456            }
2457
2458            let success_count = response
2459                .batch_status
2460                .iter()
2461                .filter(|s| {
2462                    s.status == Some(KrakenSendStatus::Cancelled)
2463                        || s.cancel_status
2464                            .as_ref()
2465                            .is_some_and(|cs| cs.status == KrakenSendStatus::Cancelled)
2466                })
2467                .count();
2468
2469            total_cancelled += success_count;
2470        }
2471
2472        Ok(total_cancelled)
2473    }
2474
2475    /// Submits multiple orders in a single batch request.
2476    ///
2477    /// Builds batch send items from order parameters, chunks at the batch limit,
2478    /// and returns per-item send statuses.
2479    ///
2480    /// # Errors
2481    ///
2482    /// Returns an error if the batch request fails at the API level.
2483    #[expect(clippy::type_complexity)]
2484    pub async fn submit_orders_batch(
2485        &self,
2486        orders: Vec<(
2487            InstrumentId,
2488            ClientOrderId,
2489            OrderSide,
2490            OrderType,
2491            Quantity,
2492            TimeInForce,
2493            Option<Price>,
2494            Option<Price>,
2495            Option<TriggerType>,
2496            bool,
2497            bool,
2498        )>,
2499    ) -> anyhow::Result<Vec<FuturesSendStatus>> {
2500        Ok(self
2501            .send_order_batches(orders)
2502            .await
2503            .into_iter()
2504            .map(|result| match result {
2505                Ok(item) if item.result == KrakenApiResult::Success => item.status,
2506                Ok(mut item) => {
2507                    item.status.status = format!("api_error: {}", item.status.status);
2508                    item.status
2509                }
2510                Err(e) => FuturesSendStatus {
2511                    order_id: None,
2512                    order_tag: None,
2513                    status: if matches!(
2514                        e.downcast_ref::<KrakenBatchOrderError>(),
2515                        Some(KrakenBatchOrderError::Validation { .. })
2516                    ) {
2517                        format!("validation_error: {e}")
2518                    } else {
2519                        format!("batch_error: {e}")
2520                    },
2521                    order_events: None,
2522                    cli_ord_id: None,
2523                    received_time: None,
2524                },
2525            })
2526            .collect())
2527    }
2528
2529    pub(crate) async fn send_order_batches(
2530        &self,
2531        orders: Vec<FuturesBatchOrder>,
2532    ) -> Vec<anyhow::Result<FuturesBatchSubmitItem>> {
2533        let count = orders.len();
2534        if count == 0 {
2535            return Vec::new();
2536        }
2537
2538        let mut results: Vec<Option<anyhow::Result<FuturesBatchSubmitItem>>> =
2539            (0..count).map(|_| None).collect();
2540        let mut valid_items = Vec::with_capacity(count);
2541
2542        for (
2543            idx,
2544            (
2545                instrument_id,
2546                client_order_id,
2547                order_side,
2548                order_type,
2549                quantity,
2550                time_in_force,
2551                price,
2552                trigger_price,
2553                trigger_type,
2554                reduce_only,
2555                post_only,
2556            ),
2557        ) in orders.into_iter().enumerate()
2558        {
2559            match self.build_send_order_params(
2560                instrument_id,
2561                client_order_id,
2562                order_side,
2563                order_type,
2564                quantity,
2565                time_in_force,
2566                price,
2567                trigger_price,
2568                trigger_type,
2569                reduce_only,
2570                post_only,
2571            ) {
2572                Ok(params) => {
2573                    valid_items.push((
2574                        idx,
2575                        KrakenFuturesBatchSendItem::from_params(params, idx.to_string()),
2576                    ));
2577                }
2578                Err(e) => {
2579                    results[idx] = Some(Err(KrakenBatchOrderError::Validation {
2580                        reason: e.to_string(),
2581                    }
2582                    .into()));
2583                }
2584            }
2585        }
2586
2587        if valid_items.is_empty() {
2588            return results.into_iter().flatten().collect();
2589        }
2590
2591        let chunks: Vec<_> = valid_items.chunks(BATCH_ORDER_LIMIT).collect();
2592        for (chunk_index, chunk) in chunks.iter().enumerate() {
2593            let items = chunk.iter().map(|(_, item)| item.clone()).collect();
2594            match self.inner.submit_orders_batch(items).await {
2595                Ok(response) => {
2596                    let mut by_tag: HashMap<String, Option<FuturesSendStatus>> = HashMap::new();
2597                    let response_result = response.result;
2598
2599                    for status in response.batch_status {
2600                        if let Some(tag) = status.order_tag.clone() {
2601                            by_tag
2602                                .entry(tag)
2603                                .and_modify(|entry| *entry = None)
2604                                .or_insert(Some(status));
2605                        }
2606                    }
2607
2608                    for (idx, item) in *chunk {
2609                        let result = match by_tag.remove(&item.order_tag) {
2610                            Some(Some(status)) => Ok(FuturesBatchSubmitItem {
2611                                result: response_result,
2612                                status,
2613                            }),
2614                            Some(None) => Err(KrakenBatchOrderError::DuplicateResponse {
2615                                key: format!("order_tag {}", item.order_tag),
2616                            }
2617                            .into()),
2618                            None => Err(KrakenBatchOrderError::MissingResponse {
2619                                key: format!("order_tag {}", item.order_tag),
2620                            }
2621                            .into()),
2622                        };
2623                        results[*idx] = Some(result);
2624                    }
2625                }
2626                Err(e) => {
2627                    for (idx, _) in *chunk {
2628                        results[*idx] = Some(Err(anyhow::Error::new(e.clone())));
2629                    }
2630
2631                    for later_chunk in &chunks[chunk_index + 1..] {
2632                        for (idx, _) in *later_chunk {
2633                            results[*idx] = Some(Err(KrakenBatchOrderError::NotAttempted.into()));
2634                        }
2635                    }
2636                    break;
2637                }
2638            }
2639        }
2640
2641        results
2642            .into_iter()
2643            .map(|result| result.unwrap_or_else(|| Err(KrakenBatchOrderError::NotAttempted.into())))
2644            .collect()
2645    }
2646
2647    /// Modifies multiple orders in a single batch request.
2648    #[expect(clippy::type_complexity)]
2649    pub async fn edit_orders_batch(
2650        &self,
2651        orders: Vec<(
2652            InstrumentId,
2653            Option<ClientOrderId>,
2654            Option<VenueOrderId>,
2655            Option<Quantity>,
2656            Option<Price>,
2657            Option<Price>,
2658        )>,
2659    ) -> anyhow::Result<Vec<String>> {
2660        let count = orders.len();
2661        if count == 0 {
2662            return Ok(Vec::new());
2663        }
2664
2665        let mut all_statuses: Vec<Option<String>> = vec![None; count];
2666        let mut valid_items = Vec::with_capacity(count);
2667        let mut valid_indices = Vec::with_capacity(count);
2668
2669        for (
2670            idx,
2671            (instrument_id, client_order_id, venue_order_id, quantity, price, trigger_price),
2672        ) in orders.into_iter().enumerate()
2673        {
2674            match self.build_edit_order_params(
2675                instrument_id,
2676                client_order_id,
2677                venue_order_id,
2678                quantity,
2679                price,
2680                trigger_price,
2681            ) {
2682                Ok(params) => {
2683                    valid_items.push(KrakenFuturesBatchEditItem::from_params(
2684                        params,
2685                        idx.to_string(),
2686                    ));
2687                    valid_indices.push(idx);
2688                }
2689                Err(e) => {
2690                    all_statuses[idx] = Some(format!("validation_error: {e}"));
2691                }
2692            }
2693        }
2694
2695        if valid_items.is_empty() {
2696            return Ok(all_statuses.into_iter().flatten().collect());
2697        }
2698
2699        let mut batch_statuses: Vec<String> = Vec::with_capacity(valid_items.len());
2700
2701        for chunk in valid_items.chunks(BATCH_ORDER_LIMIT) {
2702            match self.inner.edit_orders_batch(chunk.to_vec()).await {
2703                Ok(response) => {
2704                    if response.result == KrakenApiResult::Success {
2705                        batch_statuses.extend(response.batch_status.into_iter().map(|s| s.status));
2706                    } else {
2707                        let error_msg = response
2708                            .batch_status
2709                            .first()
2710                            .map_or("Unknown error", |s| s.status.as_str());
2711
2712                        for _ in 0..chunk.len() {
2713                            batch_statuses.push(format!("api_error: {error_msg}"));
2714                        }
2715                    }
2716                }
2717                Err(e) => {
2718                    let remaining = valid_items.len() - batch_statuses.len();
2719                    for _ in 0..remaining {
2720                        batch_statuses.push(format!("batch_error: {e}"));
2721                    }
2722                    break;
2723                }
2724            }
2725        }
2726
2727        for (batch_idx, &original_idx) in valid_indices.iter().enumerate() {
2728            if let Some(status) = batch_statuses.get(batch_idx) {
2729                all_statuses[original_idx] = Some(status.clone());
2730            }
2731        }
2732
2733        Ok(all_statuses.into_iter().flatten().collect())
2734    }
2735
2736    fn build_edit_order_params(
2737        &self,
2738        instrument_id: InstrumentId,
2739        client_order_id: Option<ClientOrderId>,
2740        venue_order_id: Option<VenueOrderId>,
2741        quantity: Option<Quantity>,
2742        price: Option<Price>,
2743        trigger_price: Option<Price>,
2744    ) -> anyhow::Result<KrakenFuturesEditOrderParams> {
2745        let _ = self
2746            .get_cached_instrument(&instrument_id.symbol.inner())
2747            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2748
2749        let order_id = venue_order_id.as_ref().map(|id| id.to_string());
2750        let cli_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
2751
2752        if order_id.is_none() && cli_ord_id.is_none() {
2753            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
2754        }
2755
2756        let mut builder = KrakenFuturesEditOrderParamsBuilder::default();
2757
2758        if let Some(ref id) = order_id {
2759            builder.order_id(id.clone());
2760        }
2761
2762        if let Some(ref id) = cli_ord_id {
2763            builder.cli_ord_id(id.clone());
2764        }
2765
2766        if let Some(qty) = quantity {
2767            builder.size(qty.to_string());
2768        }
2769
2770        if let Some(p) = price {
2771            builder.limit_price(p.to_string());
2772        }
2773
2774        if let Some(tp) = trigger_price {
2775            builder.stop_price(tp.to_string());
2776        }
2777
2778        builder
2779            .build()
2780            .map_err(|e| anyhow::anyhow!("Failed to build edit order params: {e}"))
2781    }
2782}
2783
2784pub(crate) fn is_futures_submit_rejection(status: &str) -> bool {
2785    matches!(
2786        status.parse::<KrakenSendStatus>(),
2787        Ok(KrakenSendStatus::InsufficientAvailableFunds
2788            | KrakenSendStatus::InvalidOrderType
2789            | KrakenSendStatus::InvalidSize
2790            | KrakenSendStatus::WouldCauseLiquidation
2791            | KrakenSendStatus::PostWouldExecute
2792            | KrakenSendStatus::IocWouldNotExecute
2793            | KrakenSendStatus::ReduceOnlyWouldIncreasePosition)
2794    )
2795}
2796
2797fn is_futures_modify_rejection(status: &str) -> bool {
2798    status.parse::<KrakenSendStatus>().is_ok_and(|status| {
2799        status == KrakenSendStatus::NotFound || is_futures_submit_rejection(status.as_ref())
2800    })
2801}
2802
2803fn map_futures_trigger_signal(
2804    trigger_type: Option<TriggerType>,
2805) -> anyhow::Result<Option<KrakenTriggerSignal>> {
2806    match trigger_type {
2807        None => Ok(None),
2808        Some(TriggerType::Default | TriggerType::LastPrice) => Ok(Some(KrakenTriggerSignal::Last)),
2809        Some(TriggerType::MarkPrice) => Ok(Some(KrakenTriggerSignal::Mark)),
2810        Some(TriggerType::IndexPrice) => Ok(Some(KrakenTriggerSignal::Index)),
2811        Some(other) => anyhow::bail!(
2812            "Unsupported trigger type for Kraken Futures: {other:?} (only LastPrice, MarkPrice, and IndexPrice supported)"
2813        ),
2814    }
2815}
2816
2817fn parse_multi_collateral_balances(account: &FuturesAccount, balances: &mut Vec<AccountBalance>) {
2818    for (currency_code, currency_info) in &account.currencies {
2819        if currency_info.quantity.is_zero() {
2820            continue;
2821        }
2822
2823        let currency = Currency::new(
2824            currency_code.as_str(),
2825            8,
2826            0,
2827            currency_code.as_str(),
2828            CurrencyType::Crypto,
2829        );
2830
2831        let total_amount = currency_info.quantity;
2832        let available_amount = currency_info.available.unwrap_or(total_amount);
2833        let locked_amount = total_amount - available_amount;
2834
2835        push_balance(
2836            balances,
2837            total_amount,
2838            locked_amount,
2839            currency,
2840            currency_code,
2841        );
2842    }
2843
2844    // Multi-collateral accounts track margin in USD even though the
2845    // actual collateral is held in various crypto currencies.
2846    if let Some(portfolio_value) = account.portfolio_value
2847        && portfolio_value > Decimal::ZERO
2848    {
2849        let usd_currency = Currency::USD();
2850        let available_usd = account.available_margin.unwrap_or(portfolio_value);
2851        let locked_usd = portfolio_value - available_usd;
2852
2853        push_balance(balances, portfolio_value, locked_usd, usd_currency, "USD");
2854    }
2855}
2856
2857fn push_balance(
2858    balances: &mut Vec<AccountBalance>,
2859    total: Decimal,
2860    locked: Decimal,
2861    currency: Currency,
2862    ccy_label: &str,
2863) {
2864    match AccountBalance::from_total_and_locked(total, locked, currency) {
2865        Ok(balance) => balances.push(balance),
2866        Err(e) => log::warn!("Skipping {ccy_label} balance: {e}"),
2867    }
2868}
2869
2870fn parse_multi_collateral_margins(account: &FuturesAccount, margins: &mut Vec<MarginBalance>) {
2871    if let Some(initial_margin) = account.initial_margin
2872        && initial_margin > Decimal::ZERO
2873    {
2874        let usd_currency = Currency::USD();
2875        let maintenance = account
2876            .margin_requirements
2877            .as_ref()
2878            .and_then(|mr| mr.mm)
2879            .unwrap_or(Decimal::ZERO);
2880        // Kraken Futures reports cross-margin aggregates in USD; emit as an
2881        // account-wide entry keyed by USD.
2882        push_margin(margins, initial_margin, maintenance, usd_currency);
2883    }
2884}
2885
2886fn parse_margin_account_balances(account: &FuturesAccount, balances: &mut Vec<AccountBalance>) {
2887    for (currency_code, &amount) in &account.balances {
2888        if amount.is_zero() {
2889            continue;
2890        }
2891
2892        let currency = Currency::new(
2893            currency_code.as_str(),
2894            8,
2895            0,
2896            currency_code.as_str(),
2897            CurrencyType::Crypto,
2898        );
2899
2900        let available = account
2901            .auxiliary
2902            .as_ref()
2903            .and_then(|aux| aux.af)
2904            .unwrap_or(amount);
2905        let locked = amount - available;
2906
2907        push_balance(balances, amount, locked, currency, currency_code);
2908    }
2909}
2910
2911fn parse_margin_account_margins(account: &FuturesAccount, margins: &mut Vec<MarginBalance>) {
2912    if let Some(ref mr) = account.margin_requirements {
2913        let im = mr.im.unwrap_or(Decimal::ZERO);
2914        let mm = mr.mm.unwrap_or(Decimal::ZERO);
2915        if im > Decimal::ZERO || mm > Decimal::ZERO {
2916            let usd_currency = Currency::USD();
2917            push_margin(margins, im, mm, usd_currency);
2918        }
2919    }
2920}
2921
2922fn push_margin(
2923    margins: &mut Vec<MarginBalance>,
2924    initial: Decimal,
2925    maintenance: Decimal,
2926    currency: Currency,
2927) {
2928    let initial = Money::from_decimal(initial, currency);
2929    let maintenance = Money::from_decimal(maintenance, currency);
2930
2931    match (initial, maintenance) {
2932        (Ok(initial), Ok(maintenance)) => {
2933            margins.push(MarginBalance::new(initial, maintenance, None));
2934        }
2935        (Err(e), _) => log::warn!("Skipping margin balance with invalid initial margin: {e}"),
2936        (_, Err(e)) => log::warn!("Skipping margin balance with invalid maintenance margin: {e}"),
2937    }
2938}
2939
2940fn parse_cash_account_balances(account: &FuturesAccount, balances: &mut Vec<AccountBalance>) {
2941    for (currency_code, &amount) in &account.balances {
2942        if amount.is_zero() {
2943            continue;
2944        }
2945
2946        let currency = Currency::new(
2947            currency_code.as_str(),
2948            8,
2949            0,
2950            currency_code.as_str(),
2951            CurrencyType::Crypto,
2952        );
2953
2954        push_balance(balances, amount, Decimal::ZERO, currency, currency_code);
2955    }
2956}
2957
2958#[cfg(test)]
2959mod tests {
2960    use std::{sync::Arc, time::Duration};
2961
2962    use ahash::AHashMap;
2963    use nautilus_model::instruments::CryptoPerpetual;
2964    use nautilus_testkit::http::assert_http_redirect_rejected;
2965    use rstest::rstest;
2966    use rust_decimal_macros::dec;
2967
2968    use super::*;
2969
2970    #[tokio::test]
2971    async fn test_authenticated_client_rejects_redirects() {
2972        let client = KrakenFuturesRawHttpClient::with_credentials(
2973            "key".into(),
2974            "secret".into(),
2975            KrakenEnvironment::Live,
2976            None,
2977            3,
2978            Some(0),
2979            None,
2980            None,
2981            None,
2982            10,
2983        )
2984        .unwrap()
2985        .client;
2986        assert_http_redirect_rejected(|url| async move {
2987            client
2988                .get(url, None, None, Some(3), None)
2989                .await
2990                .unwrap()
2991                .status
2992                .as_u16()
2993        })
2994        .await;
2995    }
2996
2997    #[rstest]
2998    fn test_raw_client_creation() {
2999        let client = KrakenFuturesRawHttpClient::default();
3000        assert!(client.credential.is_none());
3001        assert!(client.base_url().contains("futures"));
3002    }
3003
3004    #[rstest]
3005    fn test_raw_client_with_credentials() {
3006        let client = KrakenFuturesRawHttpClient::with_credentials(
3007            "test_key".to_string(),
3008            "test_secret".to_string(),
3009            KrakenEnvironment::Live,
3010            None,
3011            60,
3012            None,
3013            None,
3014            None,
3015            None,
3016            KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND,
3017        )
3018        .unwrap();
3019        assert!(client.credential.is_some());
3020    }
3021
3022    #[rstest]
3023    #[tokio::test]
3024    async fn test_order_request_cancellation_before_transport() {
3025        let client = Arc::new(KrakenFuturesRawHttpClient::default());
3026        let guard = client.auth_mutex.lock().await;
3027        let waiting_client = Arc::clone(&client);
3028        let waiting = tokio::spawn(async move {
3029            waiting_client
3030                .send_authenticated_post::<serde_json::Value>(
3031                    "/derivatives/api/v3/sendorder",
3032                    "orderType=lmt".to_string(),
3033                )
3034                .await
3035        });
3036
3037        tokio::task::yield_now().await;
3038        client.cancel_all_requests();
3039        let waiting_result = tokio::time::timeout(Duration::from_secs(1), waiting)
3040            .await
3041            .expect("auth lock wait should stop on cancellation")
3042            .expect("auth request task should complete");
3043        drop(guard);
3044        client.reset_cancellation_token();
3045        let reset_token = client.cancellation_token();
3046
3047        assert!(matches!(
3048            waiting_result,
3049            Err(KrakenHttpError::RequestNotStarted(ref message))
3050                if message == "Request cancelled"
3051        ));
3052        assert!(!reset_token.is_cancelled());
3053    }
3054
3055    #[rstest]
3056    fn test_client_creation() {
3057        let client = KrakenFuturesHttpClient::default();
3058        assert!(client.instruments_cache.is_empty());
3059    }
3060
3061    #[rstest]
3062    fn test_client_with_credentials() {
3063        let client = KrakenFuturesHttpClient::with_credentials(
3064            "test_key".to_string(),
3065            "test_secret".to_string(),
3066            KrakenEnvironment::Live,
3067            None,
3068            60,
3069            None,
3070            None,
3071            None,
3072            None,
3073            KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND,
3074        )
3075        .unwrap();
3076        assert!(client.instruments_cache.is_empty());
3077    }
3078
3079    #[rstest]
3080    fn test_parse_multi_collateral_margins() {
3081        let account = FuturesAccount {
3082            account_type: KrakenFuturesAccountType::MultiCollateralMarginAccount,
3083            balances: AHashMap::new(),
3084            currencies: AHashMap::new(),
3085            auxiliary: None,
3086            margin_requirements: Some(FuturesMarginRequirements {
3087                im: Some(dec!(500)),
3088                mm: Some(dec!(250)),
3089                lt: None,
3090                tt: None,
3091            }),
3092            portfolio_value: Some(dec!(10000)),
3093            available_margin: Some(dec!(9500)),
3094            initial_margin: Some(dec!(500)),
3095            pnl: None,
3096        };
3097
3098        let mut margins = Vec::new();
3099        parse_multi_collateral_margins(&account, &mut margins);
3100
3101        assert_eq!(margins.len(), 1);
3102        let margin = &margins[0];
3103        assert!(margin.instrument_id.is_none());
3104        assert_eq!(margin.currency.code, "USD");
3105        assert_eq!(margin.initial.as_decimal(), dec!(500));
3106        assert_eq!(margin.maintenance.as_decimal(), dec!(250));
3107    }
3108
3109    #[rstest]
3110    fn test_parse_multi_collateral_margins_zero_skipped() {
3111        let account = FuturesAccount {
3112            account_type: KrakenFuturesAccountType::MultiCollateralMarginAccount,
3113            balances: AHashMap::new(),
3114            currencies: AHashMap::new(),
3115            auxiliary: None,
3116            margin_requirements: None,
3117            portfolio_value: None,
3118            available_margin: None,
3119            initial_margin: Some(Decimal::ZERO),
3120            pnl: None,
3121        };
3122
3123        let mut margins = Vec::new();
3124        parse_multi_collateral_margins(&account, &mut margins);
3125
3126        assert_eq!(margins.len(), 0);
3127    }
3128
3129    #[rstest]
3130    fn test_parse_margin_account_margins() {
3131        let account = FuturesAccount {
3132            account_type: KrakenFuturesAccountType::MarginAccount,
3133            balances: AHashMap::new(),
3134            currencies: AHashMap::new(),
3135            auxiliary: None,
3136            margin_requirements: Some(FuturesMarginRequirements {
3137                im: Some(dec!(100)),
3138                mm: Some(dec!(50)),
3139                lt: None,
3140                tt: None,
3141            }),
3142            portfolio_value: None,
3143            available_margin: None,
3144            initial_margin: None,
3145            pnl: None,
3146        };
3147
3148        let mut margins = Vec::new();
3149        parse_margin_account_margins(&account, &mut margins);
3150
3151        assert_eq!(margins.len(), 1);
3152        let margin = &margins[0];
3153        assert_eq!(margin.initial.as_decimal(), dec!(100));
3154        assert_eq!(margin.maintenance.as_decimal(), dec!(50));
3155    }
3156
3157    #[rstest]
3158    fn test_parse_margin_account_margins_no_requirements() {
3159        let account = FuturesAccount {
3160            account_type: KrakenFuturesAccountType::MarginAccount,
3161            balances: AHashMap::new(),
3162            currencies: AHashMap::new(),
3163            auxiliary: None,
3164            margin_requirements: None,
3165            portfolio_value: None,
3166            available_margin: None,
3167            initial_margin: None,
3168            pnl: None,
3169        };
3170
3171        let mut margins = Vec::new();
3172        parse_margin_account_margins(&account, &mut margins);
3173
3174        assert_eq!(margins.len(), 0);
3175    }
3176
3177    #[rstest]
3178    fn test_parse_multi_collateral_balances() {
3179        let mut currencies = AHashMap::new();
3180        currencies.insert(
3181            "BTC".to_string(),
3182            FuturesFlexCurrency {
3183                quantity: dec!(1.5),
3184                value: None,
3185                collateral: None,
3186                available: Some(dec!(1.2)),
3187            },
3188        );
3189
3190        let account = FuturesAccount {
3191            account_type: KrakenFuturesAccountType::MultiCollateralMarginAccount,
3192            balances: AHashMap::new(),
3193            currencies,
3194            auxiliary: None,
3195            margin_requirements: None,
3196            portfolio_value: Some(dec!(50000)),
3197            available_margin: Some(dec!(45000)),
3198            initial_margin: None,
3199            pnl: None,
3200        };
3201
3202        let mut balances = Vec::new();
3203        parse_multi_collateral_balances(&account, &mut balances);
3204
3205        // BTC balance + USD portfolio balance
3206        assert_eq!(balances.len(), 2);
3207    }
3208
3209    #[rstest]
3210    fn test_parse_margin_account_balances_preserves_exact_values() {
3211        let mut bals = AHashMap::new();
3212        bals.insert("XBT".to_string(), dec!(10.00000003));
3213
3214        let account = FuturesAccount {
3215            account_type: KrakenFuturesAccountType::MarginAccount,
3216            balances: bals,
3217            currencies: AHashMap::new(),
3218            auxiliary: Some(FuturesAuxiliary {
3219                usd: None,
3220                pv: None,
3221                pnl: None,
3222                af: Some(dec!(0.00000004)),
3223                funding: None,
3224            }),
3225            margin_requirements: None,
3226            portfolio_value: None,
3227            available_margin: None,
3228            initial_margin: None,
3229            pnl: None,
3230        };
3231
3232        let mut balances = Vec::new();
3233        parse_margin_account_balances(&account, &mut balances);
3234
3235        assert_eq!(balances.len(), 1);
3236        let balance = &balances[0];
3237        assert_eq!(balance.total.as_decimal(), dec!(10.00000003));
3238        assert_eq!(balance.locked.as_decimal(), dec!(9.99999999));
3239        assert_eq!(balance.free.as_decimal(), dec!(0.00000004));
3240        assert_eq!(balance.total, balance.locked + balance.free);
3241    }
3242
3243    #[rstest]
3244    fn test_parse_cash_account_balances() {
3245        let mut bals = AHashMap::new();
3246        bals.insert("ETH".to_string(), dec!(10));
3247        bals.insert("BTC".to_string(), Decimal::ZERO); // zero, should be skipped
3248
3249        let account = FuturesAccount {
3250            account_type: KrakenFuturesAccountType::CashAccount,
3251            balances: bals,
3252            currencies: AHashMap::new(),
3253            auxiliary: None,
3254            margin_requirements: None,
3255            portfolio_value: None,
3256            available_margin: None,
3257            initial_margin: None,
3258            pnl: None,
3259        };
3260
3261        let mut balances = Vec::new();
3262        parse_cash_account_balances(&account, &mut balances);
3263
3264        assert_eq!(balances.len(), 1);
3265        let balance = &balances[0];
3266        assert_eq!(balance.total.as_decimal(), dec!(10));
3267        assert_eq!(balance.locked.as_decimal(), Decimal::ZERO);
3268    }
3269
3270    #[rstest]
3271    #[case(None, None)]
3272    #[case(Some(TriggerType::Default), Some(KrakenTriggerSignal::Last))]
3273    #[case(Some(TriggerType::LastPrice), Some(KrakenTriggerSignal::Last))]
3274    #[case(Some(TriggerType::MarkPrice), Some(KrakenTriggerSignal::Mark))]
3275    #[case(Some(TriggerType::IndexPrice), Some(KrakenTriggerSignal::Index))]
3276    fn test_build_send_order_params_maps_supported_trigger_signals(
3277        #[case] trigger_type: Option<TriggerType>,
3278        #[case] expected_signal: Option<KrakenTriggerSignal>,
3279    ) {
3280        let client = KrakenFuturesHttpClient::default();
3281        let instrument_id = cache_test_futures_instrument(&client);
3282
3283        let params = client
3284            .build_send_order_params(
3285                instrument_id,
3286                ClientOrderId::new("futures-trigger"),
3287                OrderSide::Buy,
3288                OrderType::StopMarket,
3289                Quantity::from("1"),
3290                TimeInForce::Gtc,
3291                None,
3292                Some(Price::from("45000")),
3293                trigger_type,
3294                false,
3295                false,
3296            )
3297            .unwrap();
3298
3299        assert_eq!(params.trigger_signal, expected_signal);
3300    }
3301
3302    #[rstest]
3303    fn test_build_send_order_params_rejects_unsupported_trigger_signal() {
3304        let client = KrakenFuturesHttpClient::default();
3305        let instrument_id = cache_test_futures_instrument(&client);
3306
3307        let error = client
3308            .build_send_order_params(
3309                instrument_id,
3310                ClientOrderId::new("futures-trigger-invalid"),
3311                OrderSide::Buy,
3312                OrderType::StopMarket,
3313                Quantity::from("1"),
3314                TimeInForce::Gtc,
3315                None,
3316                Some(Price::from("45000")),
3317                Some(TriggerType::BidAsk),
3318                false,
3319                false,
3320            )
3321            .unwrap_err();
3322
3323        assert!(
3324            error
3325                .to_string()
3326                .contains("Unsupported trigger type for Kraken Futures")
3327        );
3328    }
3329
3330    fn cache_test_futures_instrument(client: &KrakenFuturesHttpClient) -> InstrumentId {
3331        let instrument_id = InstrumentId::from("PF_XBTUSD.KRAKEN");
3332
3333        client.cache_instrument(InstrumentAny::CryptoPerpetual(
3334            CryptoPerpetual::builder()
3335                .instrument_id(instrument_id)
3336                .raw_symbol(Symbol::new("PF_XBTUSD"))
3337                .base_currency(Currency::BTC())
3338                .quote_currency(Currency::USD())
3339                .settlement_currency(Currency::USD())
3340                .is_inverse(false)
3341                .price_precision(0)
3342                .size_precision(4)
3343                .price_increment(Price::from("1"))
3344                .size_increment(Quantity::from("0.0001"))
3345                .ts_event(0.into())
3346                .ts_init(0.into())
3347                .build()
3348                .unwrap(),
3349        ));
3350
3351        instrument_id
3352    }
3353}