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