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, 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
86pub const KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND: u32 = 5;
88
89const KRAKEN_GLOBAL_RATE_KEY: &str = "kraken:futures:global";
90
91const BATCH_CANCEL_LIMIT: usize = 50;
93
94const BATCH_ORDER_LIMIT: usize = 10;
96
97pub 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 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 #[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 #[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 fn generate_nonce(&self) -> u64 {
237 self.clock.get_time_ns().as_u64()
238 }
239
240 pub fn base_url(&self) -> &str {
242 &self.base_url
243 }
244
245 pub fn credential(&self) -> Option<&KrakenCredential> {
247 self.credential.as_ref()
248 }
249
250 pub fn cancel_all_requests(&self) {
252 self.cancellation_token.read().cancel();
253 }
254
255 pub fn reset_cancellation_token(&self) {
257 *self.cancellation_token.write() = CancellationToken::new();
258 }
259
260 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 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 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 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 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(¶ms).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 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 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 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 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 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 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 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 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 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 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 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 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 self.send_get_with_query(endpoint, url, &query_string).await
799 }
800
801 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 self.send_get_with_query(endpoint, url, &query_string).await
825 }
826
827 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 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 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 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 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 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 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 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 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 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 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 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#[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 #[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 #[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 #[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 pub fn cancel_all_requests(&self) {
1219 self.inner.cancel_all_requests();
1220 }
1221
1222 pub fn reset_cancellation_token(&self) {
1224 self.inner.reset_cancellation_token();
1225 }
1226
1227 pub fn cancellation_token(&self) -> CancellationToken {
1229 self.inner.cancellation_token()
1230 }
1231
1232 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 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 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 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 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 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 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 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 apply_count_limit(&mut bars, start, limit);
1490
1491 Ok(bars)
1492 }
1493
1494 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 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 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 rates.reverse();
1611
1612 Ok(rates)
1613 }
1614
1615 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 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 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, }
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 #[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(¶ms).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 if let Some(order_events) = &send_status.order_events
2151 && let Some(send_event) = order_events.first()
2152 {
2153 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 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), 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 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 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(¶ms).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 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 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 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 #[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 #[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 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 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 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); 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}