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