Skip to main content

nautilus_kraken/http/futures/
client.rs

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