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, AHashSet};
29use indexmap::IndexMap;
30use jiff::Timestamp;
31use nautilus_common::cache::InstrumentLookupError;
32use nautilus_core::{
33 AtomicMap, AtomicTime, UUID4, nanos::UnixNanos, 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 ConnectionUrl,
50 http::{
51 HttpClient, HttpRedirectPolicy, HttpResponse, Method, create_standard_nautilus_headers,
52 },
53 ratelimiter::quota::Quota,
54 retry::{RetryConfig, RetryError, RetryManager},
55};
56use parking_lot::RwLock;
57use rust_decimal::Decimal;
58use serde::de::DeserializeOwned;
59use tokio_util::sync::CancellationToken;
60use ustr::Ustr;
61
62use super::{models::*, query::*};
63use crate::{
64 common::{
65 consts::{KRAKEN_VENUE, NAUTILUS_KRAKEN_BROKER_ID},
66 credential::KrakenCredential,
67 enums::{
68 KrakenApiResult, KrakenEnvironment, KrakenFuturesOrderStatus, KrakenFuturesOrderType,
69 KrakenFuturesQtyMode, KrakenOrderSide, KrakenProductType, KrakenSendStatus,
70 KrakenTriggerSignal, product_type_from_symbol,
71 },
72 parse::{
73 bar_type_to_futures_resolution, normalize_asset_key, parse_bar,
74 parse_futures_fill_report, parse_futures_instrument,
75 parse_futures_order_event_status_report, parse_futures_order_status_details_report,
76 parse_futures_order_status_report, parse_futures_position_status_report,
77 parse_futures_public_execution, truncate_cl_ord_id,
78 },
79 urls::get_kraken_http_base_url,
80 },
81 http::{
82 apply_count_limit,
83 coverage::ReportCoverage,
84 error::{
85 KrakenBatchOrderError, KrakenHttpError, KrakenModifyOrderError, KrakenSubmitOrderError,
86 kraken_http_should_retry,
87 },
88 models::OhlcData,
89 },
90};
91
92pub const KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND: u32 = 5;
94
95const KRAKEN_GLOBAL_RATE_KEY: &str = "kraken:futures:global";
96
97const BATCH_CANCEL_LIMIT: usize = 50;
99
100const BATCH_ORDER_LIMIT: usize = 10;
102
103const NEXT_CONTINUATION_TOKEN_HEADER: &str = "Next-Continuation-Token";
106
107pub struct KrakenFuturesRawHttpClient {
112 base_url: ConnectionUrl,
113 client: HttpClient,
114 credential: Option<KrakenCredential>,
115 retry_manager: RetryManager<KrakenHttpError>,
116 cancellation_token: RwLock<CancellationToken>,
117 clock: &'static AtomicTime,
118 auth_mutex: tokio::sync::Mutex<()>,
120}
121
122impl Default for KrakenFuturesRawHttpClient {
123 fn default() -> Self {
124 Self::new(
125 KrakenEnvironment::Live,
126 None,
127 60,
128 None,
129 None,
130 None,
131 None,
132 KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND,
133 )
134 .expect("Failed to create default KrakenFuturesRawHttpClient")
135 }
136}
137
138impl Debug for KrakenFuturesRawHttpClient {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.debug_struct(stringify!(KrakenFuturesRawHttpClient))
141 .field("base_url", &self.base_url)
142 .field("has_credentials", &self.credential.is_some())
143 .finish()
144 }
145}
146
147impl KrakenFuturesRawHttpClient {
148 #[expect(clippy::too_many_arguments)]
150 pub fn new(
151 environment: KrakenEnvironment,
152 base_url_override: Option<String>,
153 timeout_secs: u64,
154 max_retries: Option<u32>,
155 retry_delay_ms: Option<u64>,
156 retry_delay_max_ms: Option<u64>,
157 proxy_url: Option<String>,
158 max_requests_per_second: u32,
159 ) -> anyhow::Result<Self> {
160 let retry_config = RetryConfig {
161 max_retries: max_retries.unwrap_or(3),
162 initial_delay_ms: retry_delay_ms.unwrap_or(1000),
163 max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
164 backoff_factor: 2.0,
165 jitter_ms: 1000,
166 operation_timeout_ms: Some(60_000),
167 immediate_first: false,
168 max_elapsed_ms: Some(180_000),
169 };
170
171 let retry_manager = RetryManager::new(retry_config);
172 let base_url = base_url_override.unwrap_or_else(|| {
173 get_kraken_http_base_url(KrakenProductType::Futures, environment).to_string()
174 });
175
176 Ok(Self {
177 base_url: base_url.into(),
178 client: HttpClient::builder()
179 .headers(Self::default_headers())
180 .header_keys(vec![NEXT_CONTINUATION_TOKEN_HEADER.to_string()])
181 .keyed_quotas(Self::rate_limiter_quotas(max_requests_per_second)?)
182 .default_quota(Self::default_quota(max_requests_per_second)?)
183 .timeout_secs(timeout_secs)
184 .maybe_proxy_url(proxy_url)
185 .build()
186 .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
187 credential: None,
188 retry_manager,
189 cancellation_token: RwLock::new(CancellationToken::new()),
190 clock: get_atomic_clock_realtime(),
191 auth_mutex: tokio::sync::Mutex::new(()),
192 })
193 }
194
195 #[expect(clippy::too_many_arguments)]
197 pub fn with_credentials(
198 api_key: String,
199 api_secret: String,
200 environment: KrakenEnvironment,
201 base_url_override: Option<String>,
202 timeout_secs: u64,
203 max_retries: Option<u32>,
204 retry_delay_ms: Option<u64>,
205 retry_delay_max_ms: Option<u64>,
206 proxy_url: Option<String>,
207 max_requests_per_second: u32,
208 ) -> anyhow::Result<Self> {
209 let retry_config = RetryConfig {
210 max_retries: max_retries.unwrap_or(3),
211 initial_delay_ms: retry_delay_ms.unwrap_or(1000),
212 max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
213 backoff_factor: 2.0,
214 jitter_ms: 1000,
215 operation_timeout_ms: Some(60_000),
216 immediate_first: false,
217 max_elapsed_ms: Some(180_000),
218 };
219
220 let retry_manager = RetryManager::new(retry_config);
221 let base_url = base_url_override.unwrap_or_else(|| {
222 get_kraken_http_base_url(KrakenProductType::Futures, environment).to_string()
223 });
224
225 Ok(Self {
226 base_url: base_url.into(),
227 client: HttpClient::builder()
228 .redirect_policy(HttpRedirectPolicy::Reject)
229 .headers(Self::default_headers())
230 .header_keys(vec![NEXT_CONTINUATION_TOKEN_HEADER.to_string()])
231 .keyed_quotas(Self::rate_limiter_quotas(max_requests_per_second)?)
232 .default_quota(Self::default_quota(max_requests_per_second)?)
233 .timeout_secs(timeout_secs)
234 .maybe_proxy_url(proxy_url)
235 .build()
236 .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
237 credential: Some(KrakenCredential::new(api_key, api_secret)),
238 retry_manager,
239 cancellation_token: RwLock::new(CancellationToken::new()),
240 clock: get_atomic_clock_realtime(),
241 auth_mutex: tokio::sync::Mutex::new(()),
242 })
243 }
244
245 fn generate_nonce(&self) -> u64 {
250 self.clock.get_time_ns().as_u64()
251 }
252
253 pub fn base_url(&self) -> &str {
255 self.base_url.expose()
256 }
257
258 pub fn credential(&self) -> Option<&KrakenCredential> {
260 self.credential.as_ref()
261 }
262
263 pub fn cancel_all_requests(&self) {
265 self.cancellation_token.read().cancel();
266 }
267
268 pub fn reset_cancellation_token(&self) {
270 *self.cancellation_token.write() = CancellationToken::new();
271 }
272
273 pub fn cancellation_token(&self) -> CancellationToken {
275 self.cancellation_token.read().clone()
276 }
277
278 fn default_headers() -> HashMap<String, String> {
279 create_standard_nautilus_headers().into_iter().collect()
280 }
281
282 fn default_quota(max_requests_per_second: u32) -> anyhow::Result<Quota> {
283 let burst = NonZeroU32::new(max_requests_per_second).unwrap_or(
284 NonZeroU32::new(KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND).expect("non-zero"),
285 );
286 Quota::per_second(burst).ok_or_else(|| {
287 anyhow::anyhow!(
288 "Invalid max_requests_per_second: {max_requests_per_second} exceeds maximum"
289 )
290 })
291 }
292
293 fn rate_limiter_quotas(max_requests_per_second: u32) -> anyhow::Result<Vec<(String, Quota)>> {
294 Ok(vec![(
295 KRAKEN_GLOBAL_RATE_KEY.to_string(),
296 Self::default_quota(max_requests_per_second)?,
297 )])
298 }
299
300 fn rate_limit_keys(endpoint: &str) -> Vec<String> {
301 let normalized = endpoint.split('?').next().unwrap_or(endpoint);
302 let route = format!("kraken:futures:{normalized}");
303 vec![KRAKEN_GLOBAL_RATE_KEY.to_string(), route]
304 }
305
306 async fn send_request<T: DeserializeOwned>(
307 &self,
308 method: Method,
309 endpoint: &str,
310 url: String,
311 authenticate: bool,
312 ) -> anyhow::Result<T, KrakenHttpError> {
313 let _guard = if authenticate {
317 Some(self.auth_mutex.lock().await)
318 } else {
319 None
320 };
321
322 let endpoint = endpoint.to_string();
323 let method_clone = method.clone();
324 let url_clone = url.clone();
325 let credential = self.credential.clone();
326
327 let operation = || {
328 let url = url_clone.clone();
329 let method = method_clone.clone();
330 let endpoint = endpoint.clone();
331 let credential = credential.clone();
332
333 async move {
334 let mut headers = Self::default_headers();
335
336 if authenticate {
337 let cred = credential.as_ref().ok_or_else(|| {
338 KrakenHttpError::AuthenticationError(
339 "Missing credentials for authenticated request".to_string(),
340 )
341 })?;
342
343 let nonce = self.generate_nonce();
344
345 let signature = cred.sign_futures(&endpoint, "", nonce).map_err(|e| {
346 KrakenHttpError::AuthenticationError(format!("Failed to sign request: {e}"))
347 })?;
348
349 log::debug!(
350 "Kraken Futures auth: endpoint={endpoint}, nonce={nonce}, base_url={}",
351 self.base_url.origin()
352 );
353
354 headers.insert("APIKey".to_string(), cred.api_key().to_string());
355 headers.insert("Authent".to_string(), signature);
356 headers.insert("Nonce".to_string(), nonce.to_string());
357 }
358
359 let rate_limit_keys = Self::rate_limit_keys(&endpoint);
360
361 let response = self
362 .client
363 .request(
364 method,
365 url,
366 None,
367 Some(headers),
368 None,
369 None,
370 Some(rate_limit_keys),
371 )
372 .await
373 .map_err(|e| {
374 KrakenHttpError::NetworkError(format!(
375 "Request to {} failed: {e}",
376 self.base_url.origin()
377 ))
378 })?;
379
380 let status = response.status.as_u16();
381 if status >= 400 {
382 let body = String::from_utf8_lossy(&response.body).to_string();
383 if status == 401 || status == 403 {
385 return Err(KrakenHttpError::AuthenticationError(format!(
386 "HTTP error {status}: {body}"
387 )));
388 }
389 return Err(KrakenHttpError::NetworkError(format!(
390 "HTTP error {status}: {body}"
391 )));
392 }
393
394 let response_text = String::from_utf8(response.body.to_vec()).map_err(|e| {
395 KrakenHttpError::ParseError(format!("Failed to parse response as UTF-8: {e}"))
396 })?;
397
398 serde_json::from_str(&response_text).map_err(|e| {
399 KrakenHttpError::ParseError(format!(
400 "Failed to deserialize futures response: {e}"
401 ))
402 })
403 }
404 };
405
406 let should_retry = kraken_http_should_retry;
407 let create_error = |error: RetryError| KrakenHttpError::NetworkError(error.to_string());
408
409 let cancellation_token = self.cancellation_token();
410
411 self.retry_manager
412 .invocation(&endpoint, operation, should_retry, create_error)
413 .cancellation_token(&cancellation_token)
414 .execute()
415 .await
416 }
417
418 async fn send_get_with_query<T: DeserializeOwned>(
423 &self,
424 endpoint: &str,
425 url: String,
426 query_string: &str,
427 ) -> anyhow::Result<T, KrakenHttpError> {
428 let response = self
429 .send_get_with_query_raw(endpoint, url, query_string)
430 .await?;
431
432 Self::deserialize_body(&response)
433 }
434
435 async fn send_get_with_query_raw(
438 &self,
439 endpoint: &str,
440 url: String,
441 query_string: &str,
442 ) -> anyhow::Result<HttpResponse, KrakenHttpError> {
443 let _guard = self.auth_mutex.lock().await;
444 let cancellation_token = self.cancellation_token();
445
446 if cancellation_token.is_cancelled() {
447 return Err(KrakenHttpError::NetworkError(
448 "Request cancelled".to_string(),
449 ));
450 }
451
452 let credential = self.credential.as_ref().ok_or_else(|| {
453 KrakenHttpError::AuthenticationError("Missing credentials".to_string())
454 })?;
455
456 let nonce = self.generate_nonce();
457
458 let signature = credential
460 .sign_futures(endpoint, query_string, nonce)
461 .map_err(|e| {
462 KrakenHttpError::AuthenticationError(format!("Failed to sign request: {e}"))
463 })?;
464
465 log::debug!(
466 "Kraken Futures GET with query: endpoint={endpoint}, query={query_string}, nonce={nonce}"
467 );
468
469 let mut headers = Self::default_headers();
470 headers.insert("APIKey".to_string(), credential.api_key().to_string());
471 headers.insert("Authent".to_string(), signature);
472 headers.insert("Nonce".to_string(), nonce.to_string());
473
474 let rate_limit_keys = Self::rate_limit_keys(endpoint);
475
476 let response = self
477 .client
478 .request(
479 Method::GET,
480 url,
481 None,
482 Some(headers),
483 None,
484 None,
485 Some(rate_limit_keys),
486 )
487 .await
488 .map_err(|e| {
489 KrakenHttpError::NetworkError(format!(
490 "Request to {} failed: {e}",
491 self.base_url.origin()
492 ))
493 })?;
494
495 let status = response.status.as_u16();
496 if status >= 400 {
497 let body = String::from_utf8_lossy(&response.body).to_string();
498
499 if status == 401 || status == 403 {
500 return Err(KrakenHttpError::AuthenticationError(format!(
501 "HTTP error {status}: {body}"
502 )));
503 }
504 return Err(KrakenHttpError::NetworkError(format!(
505 "HTTP error {status}: {body}"
506 )));
507 }
508
509 Ok(response)
510 }
511
512 fn deserialize_body<T: DeserializeOwned>(
513 response: &HttpResponse,
514 ) -> anyhow::Result<T, KrakenHttpError> {
515 let response_text = std::str::from_utf8(&response.body).map_err(|e| {
516 KrakenHttpError::ParseError(format!("Failed to parse response as UTF-8: {e}"))
517 })?;
518
519 serde_json::from_str(response_text).map_err(|e| {
520 KrakenHttpError::ParseError(format!("Failed to deserialize futures response: {e}"))
521 })
522 }
523
524 async fn send_request_with_body<T: DeserializeOwned>(
525 &self,
526 endpoint: &str,
527 params: HashMap<String, String>,
528 ) -> anyhow::Result<T, KrakenHttpError> {
529 let post_data = serde_urlencoded::to_string(¶ms).map_err(|e| {
530 KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
531 })?;
532 self.send_authenticated_post(endpoint, post_data).await
533 }
534
535 async fn send_request_with_params<P: serde::Serialize, T: DeserializeOwned>(
537 &self,
538 endpoint: &str,
539 params: &P,
540 ) -> anyhow::Result<T, KrakenHttpError> {
541 let post_data = serde_urlencoded::to_string(params).map_err(|e| {
542 KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
543 })?;
544 self.send_authenticated_post(endpoint, post_data).await
545 }
546
547 async fn send_authenticated_post<T: DeserializeOwned>(
549 &self,
550 endpoint: &str,
551 post_data: String,
552 ) -> anyhow::Result<T, KrakenHttpError> {
553 let cancellation_token = self.cancellation_token();
554 if cancellation_token.is_cancelled() {
555 return Err(KrakenHttpError::RequestNotStarted(
556 "Request cancelled".to_string(),
557 ));
558 }
559
560 let _guard = tokio::select! {
562 biased;
563 () = cancellation_token.cancelled() => {
564 return Err(KrakenHttpError::RequestNotStarted(
565 "Request cancelled".to_string(),
566 ));
567 }
568 guard = self.auth_mutex.lock() => guard,
569 };
570
571 let credential = self
572 .credential
573 .as_ref()
574 .ok_or(KrakenHttpError::MissingCredentials)?;
575
576 let nonce = self.generate_nonce();
577 log::debug!("Generated nonce {nonce} for {endpoint}");
578
579 let signature = credential
580 .sign_futures(endpoint, &post_data, nonce)
581 .map_err(|e| {
582 KrakenHttpError::RequestNotStarted(format!("Failed to sign request: {e}"))
583 })?;
584
585 let url = format!("{}{endpoint}", self.base_url.expose());
586 let mut headers = Self::default_headers();
587 headers.insert(
588 "Content-Type".to_string(),
589 "application/x-www-form-urlencoded".to_string(),
590 );
591 headers.insert("APIKey".to_string(), credential.api_key().to_string());
592 headers.insert("Authent".to_string(), signature);
593 headers.insert("Nonce".to_string(), nonce.to_string());
594
595 let rate_limit_keys = Self::rate_limit_keys(endpoint);
596
597 let response = self
598 .send_order_request(
599 url,
600 headers,
601 post_data.into_bytes(),
602 rate_limit_keys,
603 &cancellation_token,
604 )
605 .await?;
606
607 if response.status.as_u16() >= 400 {
608 let status = response.status.as_u16();
609 let body = String::from_utf8_lossy(&response.body).to_string();
610 return Err(KrakenHttpError::NetworkError(format!(
611 "HTTP error {status}: {body}"
612 )));
613 }
614
615 let response_text = String::from_utf8(response.body.to_vec()).map_err(|e| {
616 KrakenHttpError::ParseError(format!("Failed to parse response as UTF-8: {e}"))
617 })?;
618
619 serde_json::from_str(&response_text).map_err(|e| {
620 log::warn!("Failed to parse response from {endpoint}: {response_text}");
621 KrakenHttpError::ParseError(format!("Failed to deserialize response: {e}"))
622 })
623 }
624
625 async fn send_order_request(
626 &self,
627 url: String,
628 headers: HashMap<String, String>,
629 body: Vec<u8>,
630 rate_limit_keys: Vec<String>,
631 cancellation_token: &CancellationToken,
632 ) -> anyhow::Result<HttpResponse, KrakenHttpError> {
633 if cancellation_token.is_cancelled() {
634 return Err(KrakenHttpError::RequestNotStarted(
635 "Request cancelled".to_string(),
636 ));
637 }
638
639 let request_started = AtomicBool::new(false);
640 let request = async {
641 request_started.store(true, Ordering::Relaxed);
642 self.client
643 .request(
644 Method::POST,
645 url,
646 None,
647 Some(headers),
648 Some(body),
649 None,
650 Some(rate_limit_keys),
651 )
652 .await
653 };
654 tokio::pin!(request);
655
656 tokio::select! {
657 biased;
658 () = cancellation_token.cancelled() => {
659 if request_started.load(Ordering::Relaxed) {
660 Err(KrakenHttpError::NetworkError(
661 "Request cancelled after transport invocation".to_string(),
662 ))
663 } else {
664 Err(KrakenHttpError::RequestNotStarted(
665 "Request cancelled".to_string(),
666 ))
667 }
668 }
669 response = &mut request => response
670 .map_err(|e| KrakenHttpError::NetworkError(
671 format!("Request to {} failed: {e}", self.base_url.origin())
672 )),
673 }
674 }
675
676 pub async fn get_instruments(
678 &self,
679 ) -> anyhow::Result<FuturesInstrumentsResponse, KrakenHttpError> {
680 let endpoint = "/derivatives/api/v3/instruments";
681 let url = format!("{}{endpoint}", self.base_url.expose());
682
683 self.send_request(Method::GET, endpoint, url, false).await
684 }
685
686 pub async fn get_tickers(&self) -> anyhow::Result<FuturesTickersResponse, KrakenHttpError> {
688 let endpoint = "/derivatives/api/v3/tickers";
689 let url = format!("{}{endpoint}", self.base_url.expose());
690
691 self.send_request(Method::GET, endpoint, url, false).await
692 }
693
694 pub async fn get_orderbook(
696 &self,
697 symbol: &str,
698 ) -> anyhow::Result<FuturesOrderBookResponse, KrakenHttpError> {
699 let endpoint = format!("/derivatives/api/v3/orderbook?symbol={symbol}");
700 let url = format!("{}{endpoint}", self.base_url.expose());
701
702 self.send_request(Method::GET, &endpoint, url, false).await
703 }
704
705 pub async fn get_historical_funding_rates(
707 &self,
708 symbol: &str,
709 ) -> anyhow::Result<FuturesHistoricalFundingRatesResponse, KrakenHttpError> {
710 let endpoint = format!("/derivatives/api/v4/historicalfundingrates?symbol={symbol}");
711 let url = format!("{}{endpoint}", self.base_url.expose());
712
713 self.send_request(Method::GET, &endpoint, url, false).await
714 }
715
716 pub async fn get_ohlc(
718 &self,
719 tick_type: &str,
720 symbol: &str,
721 resolution: &str,
722 from: Option<i64>,
723 to: Option<i64>,
724 ) -> anyhow::Result<FuturesCandlesResponse, KrakenHttpError> {
725 let endpoint = format!("/api/charts/v1/{tick_type}/{symbol}/{resolution}");
726
727 let mut url = format!("{}{endpoint}", self.base_url.expose());
728
729 let mut query_params = Vec::new();
730
731 if let Some(from_ts) = from {
732 query_params.push(format!("from={from_ts}"));
733 }
734
735 if let Some(to_ts) = to {
736 query_params.push(format!("to={to_ts}"));
737 }
738
739 if !query_params.is_empty() {
740 url.push('?');
741 url.push_str(&query_params.join("&"));
742 }
743
744 self.send_request(Method::GET, &endpoint, url, false).await
745 }
746
747 pub async fn get_public_executions(
749 &self,
750 symbol: &str,
751 since: Option<i64>,
752 before: Option<i64>,
753 sort: Option<&str>,
754 continuation_token: Option<&str>,
755 ) -> anyhow::Result<FuturesPublicExecutionsResponse, KrakenHttpError> {
756 let endpoint = format!("/api/history/v3/market/{symbol}/executions");
757
758 let mut url = format!("{}{endpoint}", self.base_url.expose());
759
760 let mut query_params = Vec::new();
761
762 if let Some(since_ts) = since {
763 query_params.push(format!("since={since_ts}"));
764 }
765
766 if let Some(before_ts) = before {
767 query_params.push(format!("before={before_ts}"));
768 }
769
770 if let Some(sort_order) = sort {
771 query_params.push(format!("sort={sort_order}"));
772 }
773
774 if let Some(token) = continuation_token {
775 query_params.push(format!("continuationToken={token}"));
776 }
777
778 if !query_params.is_empty() {
779 url.push('?');
780 url.push_str(&query_params.join("&"));
781 }
782
783 self.send_request(Method::GET, &endpoint, url, false).await
784 }
785
786 pub async fn get_open_orders(
788 &self,
789 ) -> anyhow::Result<FuturesOpenOrdersResponse, KrakenHttpError> {
790 if self.credential.is_none() {
791 return Err(KrakenHttpError::AuthenticationError(
792 "API credentials required for futures open orders".to_string(),
793 ));
794 }
795
796 let endpoint = "/derivatives/api/v3/openorders";
797 let url = format!("{}{endpoint}", self.base_url.expose());
798
799 self.send_request(Method::GET, endpoint, url, true).await
800 }
801
802 pub async fn get_order_events(
804 &self,
805 before: Option<i64>,
806 since: Option<i64>,
807 continuation_token: Option<&str>,
808 ) -> anyhow::Result<FuturesOrderEventsResponse, KrakenHttpError> {
809 if self.credential.is_none() {
810 return Err(KrakenHttpError::AuthenticationError(
811 "API credentials required for futures order events".to_string(),
812 ));
813 }
814
815 let endpoint = "/api/history/v3/orders";
816 let mut query_params = Vec::new();
817
818 if let Some(before_ts) = before {
819 query_params.push(format!("before={before_ts}"));
820 }
821
822 if let Some(since_ts) = since {
823 query_params.push(format!("since={since_ts}"));
824 }
825
826 if let Some(token) = continuation_token {
827 query_params.push(format!("continuation_token={token}"));
828 }
829
830 query_params.push("sort=desc".to_string());
834
835 let query_string = query_params.join("&");
837
838 let url = if query_string.is_empty() {
839 format!("{}{endpoint}", self.base_url.expose())
840 } else {
841 format!("{}{endpoint}?{query_string}", self.base_url.expose())
842 };
843
844 let response = self
847 .send_get_with_query_raw(endpoint, url, &query_string)
848 .await?;
849 let page: FuturesOrderHistoryResponse = Self::deserialize_body(&response)?;
850 let mut events = page.into_order_events()?;
851
852 if events
855 .continuation_token
856 .as_deref()
857 .is_none_or(str::is_empty)
858 {
859 events.continuation_token = response
860 .headers
861 .get(NEXT_CONTINUATION_TOKEN_HEADER)
862 .filter(|token| !token.is_empty())
863 .cloned();
864 }
865
866 Ok(events)
867 }
868
869 pub async fn get_orders_status(
876 &self,
877 order_ids: &[String],
878 cli_ord_ids: &[String],
879 ) -> anyhow::Result<FuturesOrdersStatusResponse, KrakenHttpError> {
880 if self.credential.is_none() {
881 return Err(KrakenHttpError::AuthenticationError(
882 "API credentials required for futures orders status".to_string(),
883 ));
884 }
885
886 let pairs: Vec<(&str, &str)> = order_ids
887 .iter()
888 .map(|order_id| ("orderIds", order_id.as_str()))
889 .chain(
890 cli_ord_ids
891 .iter()
892 .map(|cli_ord_id| ("cliOrdIds", cli_ord_id.as_str())),
893 )
894 .collect();
895
896 let post_data = serde_urlencoded::to_string(&pairs).map_err(|e| {
897 KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
898 })?;
899
900 let endpoint = "/derivatives/api/v3/orders/status";
901 self.send_authenticated_post(endpoint, post_data).await
902 }
903
904 pub async fn get_fills(
906 &self,
907 last_fill_time: Option<&str>,
908 ) -> anyhow::Result<FuturesFillsResponse, KrakenHttpError> {
909 if self.credential.is_none() {
910 return Err(KrakenHttpError::AuthenticationError(
911 "API credentials required for futures fills".to_string(),
912 ));
913 }
914
915 let endpoint = "/derivatives/api/v3/fills";
916 let query_string = last_fill_time
917 .map(|t| format!("lastFillTime={t}"))
918 .unwrap_or_default();
919
920 let url = if query_string.is_empty() {
921 format!("{}{endpoint}", self.base_url.expose())
922 } else {
923 format!("{}{endpoint}?{query_string}", self.base_url.expose())
924 };
925
926 self.send_get_with_query(endpoint, url, &query_string).await
928 }
929
930 pub async fn get_open_positions(
932 &self,
933 ) -> anyhow::Result<FuturesOpenPositionsResponse, KrakenHttpError> {
934 if self.credential.is_none() {
935 return Err(KrakenHttpError::AuthenticationError(
936 "API credentials required for futures open positions".to_string(),
937 ));
938 }
939
940 let endpoint = "/derivatives/api/v3/openpositions";
941 let url = format!("{}{endpoint}", self.base_url.expose());
942
943 self.send_request(Method::GET, endpoint, url, true).await
944 }
945
946 pub async fn get_accounts(&self) -> anyhow::Result<FuturesAccountsResponse, KrakenHttpError> {
948 if self.credential.is_none() {
949 return Err(KrakenHttpError::AuthenticationError(
950 "API credentials required for futures accounts".to_string(),
951 ));
952 }
953
954 let endpoint = "/derivatives/api/v3/accounts";
955 let url = format!("{}{endpoint}", self.base_url.expose());
956
957 self.send_request(Method::GET, endpoint, url, true).await
958 }
959
960 pub async fn send_order(
962 &self,
963 params: HashMap<String, String>,
964 ) -> anyhow::Result<FuturesSendOrderResponse, KrakenHttpError> {
965 if self.credential.is_none() {
966 return Err(KrakenHttpError::AuthenticationError(
967 "API credentials required for sending orders".to_string(),
968 ));
969 }
970
971 let endpoint = "/derivatives/api/v3/sendorder";
972 self.send_request_with_body(endpoint, params).await
973 }
974
975 pub async fn send_order_params(
977 &self,
978 params: &KrakenFuturesSendOrderParams,
979 ) -> anyhow::Result<FuturesSendOrderResponse, KrakenHttpError> {
980 if self.credential.is_none() {
981 return Err(KrakenHttpError::MissingCredentials);
982 }
983
984 let endpoint = "/derivatives/api/v3/sendorder";
985 self.send_request_with_params(endpoint, params).await
986 }
987
988 pub async fn cancel_order(
990 &self,
991 order_id: Option<String>,
992 cli_ord_id: Option<String>,
993 ) -> anyhow::Result<FuturesCancelOrderResponse, KrakenHttpError> {
994 if self.credential.is_none() {
995 return Err(KrakenHttpError::AuthenticationError(
996 "API credentials required for canceling orders".to_string(),
997 ));
998 }
999
1000 let mut params = HashMap::new();
1001
1002 if let Some(id) = order_id {
1003 params.insert("order_id".to_string(), id);
1004 }
1005
1006 if let Some(id) = cli_ord_id {
1007 params.insert("cliOrdId".to_string(), id);
1008 }
1009
1010 let endpoint = "/derivatives/api/v3/cancelorder";
1011 self.send_request_with_body(endpoint, params).await
1012 }
1013
1014 pub async fn edit_order(
1016 &self,
1017 params: &KrakenFuturesEditOrderParams,
1018 ) -> anyhow::Result<FuturesEditOrderResponse, KrakenHttpError> {
1019 if self.credential.is_none() {
1020 return Err(KrakenHttpError::MissingCredentials);
1021 }
1022
1023 let endpoint = "/derivatives/api/v3/editorder";
1024 self.send_request_with_params(endpoint, params).await
1025 }
1026
1027 pub async fn batch_order(
1029 &self,
1030 params: HashMap<String, String>,
1031 ) -> anyhow::Result<FuturesBatchOrderResponse, KrakenHttpError> {
1032 if self.credential.is_none() {
1033 return Err(KrakenHttpError::AuthenticationError(
1034 "API credentials required for batch orders".to_string(),
1035 ));
1036 }
1037
1038 let endpoint = "/derivatives/api/v3/batchorder";
1039 self.send_request_with_body(endpoint, params).await
1040 }
1041
1042 pub async fn cancel_orders_batch(
1044 &self,
1045 order_ids: Vec<String>,
1046 ) -> anyhow::Result<FuturesBatchCancelResponse, KrakenHttpError> {
1047 let batch_items: Vec<KrakenFuturesBatchCancelItem> = order_ids
1048 .into_iter()
1049 .map(KrakenFuturesBatchCancelItem::from_order_id)
1050 .collect();
1051
1052 self.cancel_order_items_batch(batch_items).await
1053 }
1054
1055 pub async fn cancel_order_items_batch(
1058 &self,
1059 batch_items: Vec<KrakenFuturesBatchCancelItem>,
1060 ) -> anyhow::Result<FuturesBatchCancelResponse, KrakenHttpError> {
1061 if self.credential.is_none() {
1062 return Err(KrakenHttpError::AuthenticationError(
1063 "API credentials required for batch orders".to_string(),
1064 ));
1065 }
1066
1067 let params = KrakenFuturesBatchOrderParams::new(batch_items);
1068 let post_data = params
1069 .to_body()
1070 .map_err(|e| KrakenHttpError::ParseError(format!("Failed to serialize batch: {e}")))?;
1071
1072 let endpoint = "/derivatives/api/v3/batchorder";
1073 self.send_authenticated_post(endpoint, post_data).await
1074 }
1075
1076 pub async fn submit_orders_batch(
1078 &self,
1079 items: Vec<KrakenFuturesBatchSendItem>,
1080 ) -> anyhow::Result<FuturesBatchOrderResponse, KrakenHttpError> {
1081 if self.credential.is_none() {
1082 return Err(KrakenHttpError::MissingCredentials);
1083 }
1084
1085 let params = KrakenFuturesBatchOrderParams::new(items);
1086 let post_data = params.to_body().map_err(|e| {
1087 KrakenHttpError::RequestNotStarted(format!("Failed to serialize batch: {e}"))
1088 })?;
1089
1090 let endpoint = "/derivatives/api/v3/batchorder";
1091 self.send_authenticated_post(endpoint, post_data).await
1092 }
1093
1094 pub async fn edit_orders_batch(
1096 &self,
1097 items: Vec<KrakenFuturesBatchEditItem>,
1098 ) -> anyhow::Result<FuturesBatchOrderResponse, KrakenHttpError> {
1099 if self.credential.is_none() {
1100 return Err(KrakenHttpError::AuthenticationError(
1101 "API credentials required for batch orders".to_string(),
1102 ));
1103 }
1104
1105 let params = KrakenFuturesBatchOrderParams::new(items);
1106 let post_data = params
1107 .to_body()
1108 .map_err(|e| KrakenHttpError::ParseError(format!("Failed to serialize batch: {e}")))?;
1109
1110 let endpoint = "/derivatives/api/v3/batchorder";
1111 self.send_authenticated_post(endpoint, post_data).await
1112 }
1113
1114 pub async fn cancel_all_orders(
1116 &self,
1117 symbol: Option<String>,
1118 ) -> anyhow::Result<FuturesCancelAllOrdersResponse, KrakenHttpError> {
1119 if self.credential.is_none() {
1120 return Err(KrakenHttpError::AuthenticationError(
1121 "API credentials required for canceling orders".to_string(),
1122 ));
1123 }
1124
1125 let mut params = HashMap::new();
1126
1127 if let Some(sym) = symbol {
1128 params.insert("symbol".to_string(), sym);
1129 }
1130
1131 let endpoint = "/derivatives/api/v3/cancelallorders";
1132 self.send_request_with_body(endpoint, params).await
1133 }
1134}
1135
1136pub(crate) type FuturesBatchOrder = (
1137 InstrumentId,
1138 ClientOrderId,
1139 OrderSide,
1140 OrderType,
1141 Quantity,
1142 TimeInForce,
1143 Option<Price>,
1144 Option<Price>,
1145 Option<TriggerType>,
1146 bool,
1147 bool,
1148);
1149
1150pub(crate) struct FuturesBatchSubmitItem {
1151 pub result: KrakenApiResult,
1152 pub status: FuturesSendStatus,
1153}
1154
1155#[cfg_attr(
1161 feature = "python",
1162 pyo3::pyclass(module = "nautilus_trader.adapters.kraken", from_py_object)
1163)]
1164#[cfg_attr(
1165 feature = "python",
1166 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.kraken")
1167)]
1168pub struct KrakenFuturesHttpClient {
1169 pub(crate) inner: Arc<KrakenFuturesRawHttpClient>,
1170 pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
1171 clock: &'static AtomicTime,
1172 cache_initialized: Arc<AtomicBool>,
1173}
1174
1175impl Clone for KrakenFuturesHttpClient {
1176 fn clone(&self) -> Self {
1177 Self {
1178 inner: self.inner.clone(),
1179 instruments_cache: self.instruments_cache.clone(),
1180 cache_initialized: self.cache_initialized.clone(),
1181 clock: self.clock,
1182 }
1183 }
1184}
1185
1186impl Default for KrakenFuturesHttpClient {
1187 fn default() -> Self {
1188 Self::new(
1189 KrakenEnvironment::Live,
1190 None,
1191 60,
1192 None,
1193 None,
1194 None,
1195 None,
1196 KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND,
1197 )
1198 .expect("Failed to create default KrakenFuturesHttpClient")
1199 }
1200}
1201
1202impl Debug for KrakenFuturesHttpClient {
1203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1204 f.debug_struct(stringify!(KrakenFuturesHttpClient))
1205 .field("inner", &self.inner)
1206 .finish()
1207 }
1208}
1209
1210impl KrakenFuturesHttpClient {
1211 #[expect(clippy::too_many_arguments)]
1213 pub fn new(
1214 environment: KrakenEnvironment,
1215 base_url_override: Option<String>,
1216 timeout_secs: u64,
1217 max_retries: Option<u32>,
1218 retry_delay_ms: Option<u64>,
1219 retry_delay_max_ms: Option<u64>,
1220 proxy_url: Option<String>,
1221 max_requests_per_second: u32,
1222 ) -> anyhow::Result<Self> {
1223 Ok(Self {
1224 inner: Arc::new(KrakenFuturesRawHttpClient::new(
1225 environment,
1226 base_url_override,
1227 timeout_secs,
1228 max_retries,
1229 retry_delay_ms,
1230 retry_delay_max_ms,
1231 proxy_url,
1232 max_requests_per_second,
1233 )?),
1234 instruments_cache: Arc::new(AtomicMap::new()),
1235 cache_initialized: Arc::new(AtomicBool::new(false)),
1236 clock: get_atomic_clock_realtime(),
1237 })
1238 }
1239
1240 #[expect(clippy::too_many_arguments)]
1242 pub fn with_credentials(
1243 api_key: String,
1244 api_secret: String,
1245 environment: KrakenEnvironment,
1246 base_url_override: Option<String>,
1247 timeout_secs: u64,
1248 max_retries: Option<u32>,
1249 retry_delay_ms: Option<u64>,
1250 retry_delay_max_ms: Option<u64>,
1251 proxy_url: Option<String>,
1252 max_requests_per_second: u32,
1253 ) -> anyhow::Result<Self> {
1254 Ok(Self {
1255 inner: Arc::new(KrakenFuturesRawHttpClient::with_credentials(
1256 api_key,
1257 api_secret,
1258 environment,
1259 base_url_override,
1260 timeout_secs,
1261 max_retries,
1262 retry_delay_ms,
1263 retry_delay_max_ms,
1264 proxy_url,
1265 max_requests_per_second,
1266 )?),
1267 instruments_cache: Arc::new(AtomicMap::new()),
1268 cache_initialized: Arc::new(AtomicBool::new(false)),
1269 clock: get_atomic_clock_realtime(),
1270 })
1271 }
1272
1273 #[expect(clippy::too_many_arguments)]
1280 pub fn from_env(
1281 environment: KrakenEnvironment,
1282 base_url_override: Option<String>,
1283 timeout_secs: u64,
1284 max_retries: Option<u32>,
1285 retry_delay_ms: Option<u64>,
1286 retry_delay_max_ms: Option<u64>,
1287 proxy_url: Option<String>,
1288 max_requests_per_second: u32,
1289 ) -> anyhow::Result<Self> {
1290 let demo = environment == KrakenEnvironment::Demo;
1291
1292 if let Some(credential) = KrakenCredential::from_env_futures(demo) {
1293 let (api_key, api_secret) = credential.into_parts();
1294 Self::with_credentials(
1295 api_key,
1296 api_secret,
1297 environment,
1298 base_url_override,
1299 timeout_secs,
1300 max_retries,
1301 retry_delay_ms,
1302 retry_delay_max_ms,
1303 proxy_url,
1304 max_requests_per_second,
1305 )
1306 } else {
1307 Self::new(
1308 environment,
1309 base_url_override,
1310 timeout_secs,
1311 max_retries,
1312 retry_delay_ms,
1313 retry_delay_max_ms,
1314 proxy_url,
1315 max_requests_per_second,
1316 )
1317 }
1318 }
1319
1320 pub fn cancel_all_requests(&self) {
1322 self.inner.cancel_all_requests();
1323 }
1324
1325 pub fn reset_cancellation_token(&self) {
1327 self.inner.reset_cancellation_token();
1328 }
1329
1330 pub fn cancellation_token(&self) -> CancellationToken {
1332 self.inner.cancellation_token()
1333 }
1334
1335 pub fn cache_instrument(&self, instrument: InstrumentAny) {
1337 self.instruments_cache
1338 .insert(instrument.symbol().inner(), instrument);
1339 self.cache_initialized.store(true, Ordering::Release);
1340 }
1341
1342 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1344 self.instruments_cache.rcu(|m| {
1345 for instrument in instruments {
1346 m.insert(instrument.symbol().inner(), instrument.clone());
1347 }
1348 });
1349 self.cache_initialized.store(true, Ordering::Release);
1350 }
1351
1352 pub fn get_cached_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1354 self.instruments_cache.get_cloned(symbol)
1355 }
1356
1357 fn get_instrument_by_raw_symbol(&self, raw_symbol: &str) -> Option<InstrumentAny> {
1358 self.instruments_cache
1359 .load()
1360 .values()
1361 .find(|inst| inst.raw_symbol().as_str() == raw_symbol)
1362 .cloned()
1363 }
1364
1365 fn get_instrument_by_history_tradeable(&self, tradeable: &str) -> Option<InstrumentAny> {
1369 self.get_instrument_by_raw_symbol(tradeable).or_else(|| {
1370 self.instruments_cache
1371 .load()
1372 .values()
1373 .find(|inst| inst.raw_symbol().as_str().eq_ignore_ascii_case(tradeable))
1374 .cloned()
1375 })
1376 }
1377
1378 fn generate_ts_init(&self) -> UnixNanos {
1379 self.clock.get_time_ns()
1380 }
1381
1382 pub async fn request_instruments(&self) -> anyhow::Result<Vec<InstrumentAny>, KrakenHttpError> {
1390 let ts_init = self.generate_ts_init();
1391 let response = self.inner.get_instruments().await?;
1392
1393 response
1394 .instruments
1395 .iter()
1396 .map(|fut_instrument| {
1397 parse_futures_instrument(fut_instrument, ts_init, ts_init)
1398 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))
1399 })
1400 .collect()
1401 }
1402
1403 pub async fn request_instrument_statuses(
1405 &self,
1406 ) -> anyhow::Result<AHashMap<InstrumentId, MarketStatusAction>, KrakenHttpError> {
1407 let response = self.inner.get_instruments().await?;
1408
1409 Ok(response
1410 .instruments
1411 .iter()
1412 .map(|instrument| {
1413 let instrument_id =
1414 InstrumentId::new(Symbol::new(&instrument.symbol), *KRAKEN_VENUE);
1415 let action = if instrument.tradeable {
1416 MarketStatusAction::Trading
1417 } else {
1418 MarketStatusAction::NotAvailableForTrading
1419 };
1420
1421 (instrument_id, action)
1422 })
1423 .collect())
1424 }
1425
1426 pub async fn request_mark_price(
1428 &self,
1429 instrument_id: InstrumentId,
1430 ) -> anyhow::Result<Decimal, KrakenHttpError> {
1431 let instrument = self
1432 .get_cached_instrument(&instrument_id.symbol.inner())
1433 .ok_or_else(|| {
1434 KrakenHttpError::ParseError(
1435 InstrumentLookupError::not_found(instrument_id).to_string(),
1436 )
1437 })?;
1438
1439 let raw_symbol = instrument.raw_symbol().to_string();
1440 let tickers = self.inner.get_tickers().await?;
1441
1442 tickers
1443 .tickers
1444 .iter()
1445 .find(|t| t.symbol == raw_symbol)
1446 .ok_or_else(|| {
1447 KrakenHttpError::ParseError(format!("Symbol {raw_symbol} not found in tickers"))
1448 })
1449 .and_then(|t| {
1450 t.mark_price.ok_or_else(|| {
1451 KrakenHttpError::ParseError(format!(
1452 "Mark price not available for {raw_symbol} (may not be available in testnet)"
1453 ))
1454 })
1455 })
1456 }
1457
1458 pub async fn request_index_price(
1459 &self,
1460 instrument_id: InstrumentId,
1461 ) -> anyhow::Result<Decimal, KrakenHttpError> {
1462 let instrument = self
1463 .get_cached_instrument(&instrument_id.symbol.inner())
1464 .ok_or_else(|| {
1465 KrakenHttpError::ParseError(
1466 InstrumentLookupError::not_found(instrument_id).to_string(),
1467 )
1468 })?;
1469
1470 let raw_symbol = instrument.raw_symbol().to_string();
1471 let tickers = self.inner.get_tickers().await?;
1472
1473 tickers
1474 .tickers
1475 .iter()
1476 .find(|t| t.symbol == raw_symbol)
1477 .ok_or_else(|| {
1478 KrakenHttpError::ParseError(format!("Symbol {raw_symbol} not found in tickers"))
1479 })
1480 .and_then(|t| {
1481 t.index_price.ok_or_else(|| {
1482 KrakenHttpError::ParseError(format!(
1483 "Index price not available for {raw_symbol} (may not be available in testnet)"
1484 ))
1485 })
1486 })
1487 }
1488
1489 pub async fn request_trades(
1490 &self,
1491 instrument_id: InstrumentId,
1492 start: Option<Timestamp>,
1493 end: Option<Timestamp>,
1494 limit: Option<u64>,
1495 ) -> anyhow::Result<Vec<TradeTick>, KrakenHttpError> {
1496 let instrument = self
1497 .get_cached_instrument(&instrument_id.symbol.inner())
1498 .ok_or_else(|| {
1499 KrakenHttpError::ParseError(
1500 InstrumentLookupError::not_found(instrument_id).to_string(),
1501 )
1502 })?;
1503
1504 let raw_symbol = instrument.raw_symbol().to_string();
1505 let ts_init = self.generate_ts_init();
1506
1507 let since = start.map(|dt| dt.as_millisecond());
1508 let before = end.map(|dt| dt.as_millisecond());
1509
1510 let sort = if start.is_some() { "asc" } else { "desc" };
1513
1514 let response = self
1515 .inner
1516 .get_public_executions(&raw_symbol, since, before, Some(sort), None)
1517 .await?;
1518
1519 let mut trades = Vec::new();
1520
1521 for element in &response.elements {
1522 let execution = &element.event.execution.execution;
1523 match parse_futures_public_execution(execution, &instrument, ts_init) {
1524 Ok(trade_tick) => trades.push(trade_tick),
1525 Err(e) => {
1526 log::warn!("Failed to parse futures trade tick: {e}");
1527 }
1528 }
1529 }
1530
1531 if start.is_none() {
1532 trades.reverse();
1533 }
1534
1535 apply_count_limit(&mut trades, start, limit);
1536
1537 Ok(trades)
1538 }
1539
1540 pub async fn request_bars(
1541 &self,
1542 bar_type: BarType,
1543 start: Option<Timestamp>,
1544 end: Option<Timestamp>,
1545 limit: Option<u64>,
1546 ) -> anyhow::Result<Vec<Bar>, KrakenHttpError> {
1547 let instrument_id = bar_type.instrument_id();
1548 let instrument = self
1549 .get_cached_instrument(&instrument_id.symbol.inner())
1550 .ok_or_else(|| {
1551 KrakenHttpError::ParseError(
1552 InstrumentLookupError::not_found(instrument_id).to_string(),
1553 )
1554 })?;
1555
1556 let raw_symbol = instrument.raw_symbol().to_string();
1557 let ts_init = self.generate_ts_init();
1558 let tick_type = "trade";
1559 let resolution = bar_type_to_futures_resolution(bar_type)
1560 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1561
1562 let from = start.map(|dt| dt.as_second());
1564 let to = end.map(|dt| dt.as_second());
1565 let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1566
1567 let response = self
1568 .inner
1569 .get_ohlc(tick_type, &raw_symbol, resolution, from, to)
1570 .await?;
1571
1572 let mut bars = Vec::new();
1573
1574 for candle in response.candles {
1575 let ohlc = OhlcData {
1576 time: candle.time / 1000,
1577 open: candle.open,
1578 high: candle.high,
1579 low: candle.low,
1580 close: candle.close,
1581 vwap: "0".to_string(),
1582 volume: candle.volume,
1583 count: 0,
1584 };
1585
1586 match parse_bar(&ohlc, &instrument, bar_type, ts_init) {
1587 Ok(bar) => {
1588 if let Some(end_nanos) = end_ns
1589 && bar.ts_event.as_u64() > end_nanos
1590 {
1591 continue;
1592 }
1593 bars.push(bar);
1594 }
1595 Err(e) => {
1596 log::warn!("Failed to parse futures bar: {e}");
1597 }
1598 }
1599 }
1600
1601 apply_count_limit(&mut bars, start, limit);
1604
1605 Ok(bars)
1606 }
1607
1608 pub async fn request_book_snapshot(
1610 &self,
1611 instrument_id: InstrumentId,
1612 depth: Option<u32>,
1613 ) -> anyhow::Result<OrderBook, KrakenHttpError> {
1614 let instrument = self
1615 .get_cached_instrument(&instrument_id.symbol.inner())
1616 .ok_or_else(|| {
1617 KrakenHttpError::ParseError(
1618 InstrumentLookupError::not_found(instrument_id).to_string(),
1619 )
1620 })?;
1621
1622 let raw_symbol = instrument.raw_symbol().to_string();
1623 let price_precision = instrument.price_precision();
1624 let size_precision = instrument.size_precision();
1625 let ts_event = self.generate_ts_init();
1626
1627 let response = self.inner.get_orderbook(&raw_symbol).await?;
1628 let book_data = &response.order_book;
1629
1630 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
1631
1632 let bid_limit = depth.map_or(book_data.bids.len(), |d| {
1633 (d as usize).min(book_data.bids.len())
1634 });
1635 let ask_limit = depth.map_or(book_data.asks.len(), |d| {
1636 (d as usize).min(book_data.asks.len())
1637 });
1638
1639 for (i, level) in book_data.bids.iter().take(bid_limit).enumerate() {
1642 let price = Price::from_decimal_dp(level.price, price_precision)
1643 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1644 let size = Quantity::from_decimal_dp(level.qty, size_precision)
1645 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1646 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
1647 book.add(order, 0, 0, ts_event);
1648 }
1649
1650 for (i, level) in book_data.asks.iter().take(ask_limit).enumerate() {
1651 let price = Price::from_decimal_dp(level.price, price_precision)
1652 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1653 let size = Quantity::from_decimal_dp(level.qty, size_precision)
1654 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1655 let order = BookOrder::new(OrderSide::Sell, price, size, (bid_limit + i) as u64);
1656 book.add(order, 0, 0, ts_event);
1657 }
1658
1659 Ok(book)
1660 }
1661
1662 pub async fn request_funding_rates(
1667 &self,
1668 instrument_id: InstrumentId,
1669 start: Option<Timestamp>,
1670 end: Option<Timestamp>,
1671 limit: Option<usize>,
1672 ) -> anyhow::Result<Vec<FundingRateUpdate>, KrakenHttpError> {
1673 let instrument = self
1674 .get_cached_instrument(&instrument_id.symbol.inner())
1675 .ok_or_else(|| {
1676 KrakenHttpError::ParseError(
1677 InstrumentLookupError::not_found(instrument_id).to_string(),
1678 )
1679 })?;
1680
1681 let raw_symbol = instrument.raw_symbol().to_string();
1682 let ts_init = self.generate_ts_init();
1683 let start_ns = start.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1684 let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1685
1686 let response = self.inner.get_historical_funding_rates(&raw_symbol).await?;
1687
1688 let mut rates = Vec::new();
1689
1690 for entry in &response.rates {
1691 let ts_event = entry.timestamp.parse::<Timestamp>().map_or(ts_init, |dt| {
1692 UnixNanos::from(u64::try_from(dt.as_nanosecond()).unwrap_or(0))
1693 });
1694
1695 if let Some(s) = start_ns
1696 && ts_event.as_u64() < s
1697 {
1698 continue;
1699 }
1700
1701 if let Some(e) = end_ns
1702 && ts_event.as_u64() > e
1703 {
1704 continue;
1705 }
1706
1707 rates.push(FundingRateUpdate::new(
1708 instrument_id,
1709 entry.relative_funding_rate,
1710 None,
1711 None,
1712 ts_event,
1713 ts_init,
1714 ));
1715
1716 if let Some(lim) = limit
1717 && rates.len() >= lim
1718 {
1719 break;
1720 }
1721 }
1722
1723 rates.reverse();
1725
1726 Ok(rates)
1727 }
1728
1729 pub async fn request_account_state(
1741 &self,
1742 account_id: AccountId,
1743 ) -> anyhow::Result<AccountState> {
1744 let accounts_response = self.inner.get_accounts().await?;
1745
1746 if accounts_response.result != KrakenApiResult::Success {
1747 let error_msg = accounts_response
1748 .error
1749 .unwrap_or_else(|| "Unknown error".to_string());
1750 anyhow::bail!("Failed to get futures accounts: {error_msg}");
1751 }
1752
1753 let ts_init = self.generate_ts_init();
1754 let (balances, margins) = parse_account_entries(&accounts_response.accounts);
1755
1756 Ok(AccountState::new(
1757 account_id,
1758 AccountType::Margin,
1759 balances,
1760 margins,
1761 true,
1762 UUID4::new(),
1763 ts_init,
1764 ts_init,
1765 None,
1766 ))
1767 }
1768
1769 pub async fn request_order_status_reports(
1770 &self,
1771 account_id: AccountId,
1772 instrument_id: Option<InstrumentId>,
1773 start: Option<Timestamp>,
1774 end: Option<Timestamp>,
1775 open_only: bool,
1776 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1777 self.request_order_status_reports_checked(account_id, instrument_id, start, end, open_only)
1778 .await
1779 .map(|(reports, _)| reports)
1780 }
1781
1782 pub(crate) async fn request_order_status_reports_checked(
1796 &self,
1797 account_id: AccountId,
1798 instrument_id: Option<InstrumentId>,
1799 start: Option<Timestamp>,
1800 end: Option<Timestamp>,
1801 open_only: bool,
1802 ) -> anyhow::Result<(Vec<OrderStatusReport>, ReportCoverage)> {
1803 let mut coverage = ReportCoverage::default();
1804
1805 if let Some(ref target_id) = instrument_id
1810 && self
1811 .get_cached_instrument(&target_id.symbol.inner())
1812 .is_none()
1813 {
1814 if product_type_from_symbol(target_id.symbol.as_str()) == KrakenProductType::Futures {
1815 coverage.truncate(None);
1816 }
1817 return Ok((Vec::new(), coverage));
1818 }
1819 let ts_init = self.generate_ts_init();
1820 let mut all_reports = Vec::new();
1821
1822 let response = self
1823 .inner
1824 .get_open_orders()
1825 .await
1826 .map_err(|e| anyhow::anyhow!("get_open_orders failed: {e}"))?;
1827
1828 if response.result != KrakenApiResult::Success {
1829 let error_msg = response
1830 .error
1831 .unwrap_or_else(|| "Unknown error".to_string());
1832 anyhow::bail!("Failed to get open orders: {error_msg}");
1833 }
1834
1835 let position_sizes = if response
1836 .open_orders
1837 .iter()
1838 .any(|order| order.unfilled_size.is_none())
1839 {
1840 match self.inner.get_open_positions().await {
1841 Ok(response) if response.result == KrakenApiResult::Success => Ok(response
1842 .open_positions
1843 .into_iter()
1844 .map(|position| (position.symbol, position.size))
1845 .collect::<AHashMap<_, _>>()),
1846 Ok(response) => {
1847 let reason = response
1848 .error
1849 .unwrap_or_else(|| "Unknown error".to_string());
1850 log::warn!("Failed to get open positions for order quantities: {reason}");
1851 Err(reason)
1852 }
1853 Err(e) => {
1854 log::warn!("Failed to get open positions for order quantities: {e}");
1855 Err(e.to_string())
1856 }
1857 }
1858 } else {
1859 Ok(AHashMap::new())
1860 };
1861 let mut skipped_open_order_ids = AHashSet::new();
1862
1863 for order in &response.open_orders {
1864 let resolved = self.get_instrument_by_raw_symbol(&order.symbol);
1867 if let Some(ref target_id) = instrument_id {
1868 match &resolved {
1869 Some(inst) if inst.id() != *target_id => continue,
1870 None => {
1872 coverage.skip(&order.order_id);
1873 continue;
1874 }
1875 Some(_) => {}
1876 }
1877 }
1878
1879 if let Some(instrument) = resolved {
1880 let sized_by_position = order.unfilled_size.is_none()
1881 && matches!(
1882 order.order_type,
1883 KrakenFuturesOrderType::Stop
1884 | KrakenFuturesOrderType::StopLower
1885 | KrakenFuturesOrderType::StopLoss
1886 | KrakenFuturesOrderType::TakeProfit
1887 )
1888 && order.status == KrakenFuturesOrderStatus::Untouched
1889 && order.reduce_only == Some(true);
1890 let position_size = match &position_sizes {
1891 Ok(sizes) if sized_by_position => sizes.get(&order.symbol).copied(),
1892 _ => None,
1893 };
1894
1895 if sized_by_position && position_size.is_none() {
1900 if let Err(reason) = &position_sizes {
1901 anyhow::bail!(
1902 "OpenOrders: futures order {} lists no unfilled size and the open \
1903 positions read failed: {reason}",
1904 order.order_id
1905 );
1906 }
1907 log::warn!(
1908 "Skipping futures order {} on {}: the venue lists no unfilled size and no \
1909 open position sizes it; the set is incomplete",
1910 order.order_id,
1911 order.symbol
1912 );
1913 coverage.skip(&order.order_id);
1914 skipped_open_order_ids.insert(VenueOrderId::new(order.order_id.as_str()));
1915 continue;
1916 }
1917
1918 let report = parse_futures_order_status_report(
1921 order,
1922 &instrument,
1923 account_id,
1924 position_size,
1925 ts_init,
1926 )
1927 .map_err(|e| {
1928 anyhow::anyhow!(
1929 "OpenOrders: failed to parse futures order {}: {e}",
1930 order.order_id
1931 )
1932 })?;
1933 all_reports.push(report);
1934 } else {
1935 anyhow::bail!(
1938 "OpenOrders: instrument not in cache for futures symbol {}",
1939 order.symbol
1940 );
1941 }
1942 }
1943
1944 if !open_only {
1945 let start_ms = start.map(|dt| dt.as_millisecond());
1947 let end_ms = end.map(|dt| dt.as_millisecond());
1948 let response = self
1951 .inner
1952 .get_order_events(end_ms, start_ms, None)
1953 .await
1954 .map_err(|e| {
1955 let message = format!("get_order_events failed: {e}");
1956 anyhow::Error::new(e).context(message)
1957 })?;
1958
1959 if response
1961 .continuation_token
1962 .as_deref()
1963 .is_some_and(|token| !token.is_empty())
1964 {
1965 log::warn!(
1966 "Order history window since={start_ms:?} before={end_ms:?} holds more events than one page; the events beyond it are not read, marking the set incomplete"
1967 );
1968 coverage.truncate(
1971 response
1972 .oldest_event_ms
1973 .and_then(UnixNanos::from_millis_checked),
1974 );
1975 }
1976
1977 let open_order_ids: AHashSet<VenueOrderId> = all_reports
1982 .iter()
1983 .map(|r| r.venue_order_id)
1984 .chain(skipped_open_order_ids.iter().copied())
1985 .collect();
1986 let mut latest: IndexMap<VenueOrderId, OrderStatusReport> = IndexMap::new();
1987
1988 if response.skipped_rows > 0 {
1989 log::warn!(
1990 "Order history page had {} row(s) the adapter could not represent; marking the set incomplete",
1991 response.skipped_rows
1992 );
1993
1994 for order_id in &response.skipped_order_ids {
1995 coverage.skip(order_id);
1996 }
1997
1998 if response.skipped_rows > response.skipped_order_ids.len() {
1999 coverage.skip_unidentified();
2000 }
2001 }
2002
2003 for event_wrapper in response.order_events {
2004 let event = &event_wrapper.order;
2005
2006 let resolved = self.get_instrument_by_history_tradeable(&event.symbol);
2009 if let Some(ref target_id) = instrument_id {
2010 match &resolved {
2011 Some(inst) if inst.id() != *target_id => continue,
2012 None => {
2014 coverage.skip(&event.order_id);
2015 continue;
2016 }
2017 Some(_) => {}
2018 }
2019 }
2020
2021 if let Some(instrument) = resolved {
2022 match parse_futures_order_event_status_report(
2023 event,
2024 Some(event_wrapper.event_type),
2025 &instrument,
2026 account_id,
2027 ts_init,
2028 ) {
2029 Ok(report) => {
2030 if open_order_ids.contains(&report.venue_order_id) {
2031 continue;
2032 }
2033
2034 match latest.get(&report.venue_order_id) {
2035 Some(existing) if !supersedes(&report, existing) => {}
2036 _ => {
2037 latest.insert(report.venue_order_id, report);
2038 }
2039 }
2040 }
2041 Err(e) => {
2042 let order_id = &event.order_id;
2043 log::warn!("Failed to parse futures order event {order_id}: {e}");
2044 coverage.skip(order_id);
2045 }
2046 }
2047 } else {
2048 log::warn!(
2049 "Instrument not in cache for futures symbol {}, skipping order event",
2050 event.symbol
2051 );
2052 coverage.skip(&event.order_id);
2053 }
2054 }
2055
2056 all_reports.extend(latest.into_values());
2057 }
2058
2059 Ok((all_reports, coverage))
2060 }
2061
2062 pub async fn request_orders_status_reports(
2073 &self,
2074 account_id: AccountId,
2075 order_ids: &[String],
2076 cli_ord_ids: &[String],
2077 ) -> anyhow::Result<Vec<OrderStatusReport>> {
2078 let ts_init = self.generate_ts_init();
2079
2080 let response = self
2081 .inner
2082 .get_orders_status(order_ids, cli_ord_ids)
2083 .await
2084 .map_err(|e| anyhow::anyhow!("get_orders_status failed: {e}"))?;
2085
2086 if response.result != KrakenApiResult::Success {
2087 let error_msg = response
2088 .error
2089 .unwrap_or_else(|| "Unknown error".to_string());
2090 anyhow::bail!("Failed to get orders status: {error_msg}");
2091 }
2092
2093 let mut reports = Vec::with_capacity(response.orders.len());
2094 for details in &response.orders {
2095 let Some(instrument) = self.get_instrument_by_raw_symbol(&details.order.symbol) else {
2096 anyhow::bail!(
2097 "No cached instrument for symbol: {}, order {} cannot be reported; \
2098 treating the lookup as failed rather than the order as absent",
2099 details.order.symbol,
2100 details.order.order_id,
2101 );
2102 };
2103
2104 match parse_futures_order_status_details_report(
2105 details,
2106 &instrument,
2107 account_id,
2108 ts_init,
2109 ) {
2110 Ok(report) => reports.push(report),
2111 Err(e) => anyhow::bail!(
2112 "Failed to parse futures order status {}: {e}; treating the lookup as \
2113 failed rather than the order as absent",
2114 details.order.order_id,
2115 ),
2116 }
2117 }
2118
2119 Ok(reports)
2120 }
2121
2122 pub async fn request_fill_reports(
2123 &self,
2124 account_id: AccountId,
2125 instrument_id: Option<InstrumentId>,
2126 start: Option<Timestamp>,
2127 end: Option<Timestamp>,
2128 ) -> anyhow::Result<Vec<FillReport>> {
2129 self.request_fill_reports_checked(account_id, instrument_id, start, end)
2130 .await
2131 .map(|(reports, _)| reports)
2132 }
2133
2134 pub(crate) async fn request_fill_reports_checked(
2139 &self,
2140 account_id: AccountId,
2141 instrument_id: Option<InstrumentId>,
2142 start: Option<Timestamp>,
2143 end: Option<Timestamp>,
2144 ) -> anyhow::Result<(Vec<FillReport>, bool)> {
2145 let mut complete = true;
2146 let ts_init = self.generate_ts_init();
2147 let mut all_reports = Vec::new();
2148
2149 if let Some(ref target_id) = instrument_id
2151 && self
2152 .get_cached_instrument(&target_id.symbol.inner())
2153 .is_none()
2154 {
2155 return Ok((all_reports, complete));
2156 }
2157
2158 let response = self.inner.get_fills(None).await?;
2159 if response.result != KrakenApiResult::Success {
2160 let error_msg = response
2161 .error
2162 .unwrap_or_else(|| "Unknown error".to_string());
2163 anyhow::bail!("Failed to get fills: {error_msg}");
2164 }
2165
2166 let start_ms = start.map(|dt| dt.as_millisecond());
2167 let end_ms = end.map(|dt| dt.as_millisecond());
2168
2169 for fill in response.fills {
2170 if let Some(start_threshold) = start_ms
2171 && let Ok(fill_ts) = fill.fill_time.parse::<Timestamp>()
2172 {
2173 let fill_ms = fill_ts.as_millisecond();
2174 if fill_ms < start_threshold {
2175 continue;
2176 }
2177 }
2178
2179 if let Some(end_threshold) = end_ms
2180 && let Ok(fill_ts) = fill.fill_time.parse::<Timestamp>()
2181 {
2182 let fill_ms = fill_ts.as_millisecond();
2183 if fill_ms > end_threshold {
2184 continue;
2185 }
2186 }
2187
2188 let resolved = self.get_instrument_by_raw_symbol(&fill.symbol);
2191 if let Some(ref target_id) = instrument_id
2192 && resolved.as_ref().is_none_or(|inst| inst.id() != *target_id)
2193 {
2194 continue;
2195 }
2196
2197 if let Some(instrument) = resolved {
2198 match parse_futures_fill_report(&fill, &instrument, account_id, ts_init) {
2199 Ok(report) => all_reports.push(report),
2200 Err(e) => {
2201 let fill_id = &fill.fill_id;
2202 log::warn!("Failed to parse futures fill {fill_id}: {e}");
2203 complete = false;
2204 }
2205 }
2206 } else {
2207 log::warn!(
2208 "Instrument not in cache for futures symbol {}, skipping fill",
2209 fill.symbol
2210 );
2211 complete = false;
2212 }
2213 }
2214
2215 Ok((all_reports, complete))
2216 }
2217
2218 pub async fn request_position_status_reports(
2219 &self,
2220 account_id: AccountId,
2221 instrument_id: Option<InstrumentId>,
2222 ) -> anyhow::Result<Vec<PositionStatusReport>> {
2223 let ts_init = self.generate_ts_init();
2224 let mut all_reports = Vec::new();
2225
2226 if let Some(ref target_id) = instrument_id
2228 && self
2229 .get_cached_instrument(&target_id.symbol.inner())
2230 .is_none()
2231 {
2232 return Ok(all_reports);
2233 }
2234
2235 let response = self.inner.get_open_positions().await?;
2236 if response.result != KrakenApiResult::Success {
2237 let error_msg = response
2238 .error
2239 .unwrap_or_else(|| "Unknown error".to_string());
2240 anyhow::bail!("Failed to get open positions: {error_msg}");
2241 }
2242
2243 for position in response.open_positions {
2244 let resolved = self.get_instrument_by_raw_symbol(&position.symbol);
2247 if let Some(ref target_id) = instrument_id
2248 && resolved.as_ref().is_none_or(|inst| inst.id() != *target_id)
2249 {
2250 continue;
2251 }
2252
2253 let Some(instrument) = resolved else {
2256 anyhow::bail!(
2257 "OpenPositions: instrument not in cache for futures symbol {}",
2258 position.symbol
2259 );
2260 };
2261
2262 let report =
2263 parse_futures_position_status_report(&position, &instrument, account_id, ts_init)
2264 .map_err(|e| {
2265 anyhow::anyhow!(
2266 "OpenPositions: failed to parse futures position {}: {e}",
2267 position.symbol
2268 )
2269 })?;
2270 all_reports.push(report);
2271 }
2272
2273 Ok(all_reports)
2274 }
2275
2276 #[expect(clippy::too_many_arguments)]
2277 fn build_send_order_params(
2278 &self,
2279 instrument_id: InstrumentId,
2280 client_order_id: ClientOrderId,
2281 order_side: OrderSide,
2282 order_type: OrderType,
2283 quantity: Quantity,
2284 time_in_force: TimeInForce,
2285 price: Option<Price>,
2286 trigger_price: Option<Price>,
2287 trigger_type: Option<TriggerType>,
2288 reduce_only: bool,
2289 post_only: bool,
2290 ) -> anyhow::Result<KrakenFuturesSendOrderParams> {
2291 let instrument = self
2292 .get_cached_instrument(&instrument_id.symbol.inner())
2293 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2294
2295 let raw_symbol = instrument.raw_symbol().inner();
2296
2297 let kraken_order_type = match order_type {
2304 OrderType::Market => KrakenFuturesOrderType::Market,
2305 OrderType::Limit => {
2306 if post_only {
2307 KrakenFuturesOrderType::Post
2308 } else {
2309 match time_in_force {
2310 TimeInForce::Ioc => KrakenFuturesOrderType::Ioc,
2311 TimeInForce::Fok => {
2312 anyhow::bail!("FOK not supported by Kraken Futures, use IOC instead")
2313 }
2314 TimeInForce::Gtd => {
2315 anyhow::bail!("GTD not supported by Kraken Futures, use GTC instead")
2316 }
2317 _ => KrakenFuturesOrderType::Limit, }
2319 }
2320 }
2321 OrderType::StopMarket | OrderType::StopLimit => KrakenFuturesOrderType::Stop,
2322 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2323 KrakenFuturesOrderType::TakeProfit
2324 }
2325 _ => anyhow::bail!("Unsupported order type: {order_type:?}"),
2326 };
2327
2328 let kraken_side = KrakenOrderSide::from(order_side);
2329
2330 let mut builder = KrakenFuturesSendOrderParamsBuilder::default();
2331 builder
2332 .cli_ord_id(truncate_cl_ord_id(&client_order_id))
2333 .broker(NAUTILUS_KRAKEN_BROKER_ID)
2334 .symbol(raw_symbol)
2335 .side(kraken_side)
2336 .size(quantity.to_string())
2337 .order_type(kraken_order_type);
2338
2339 if matches!(
2340 order_type,
2341 OrderType::StopMarket
2342 | OrderType::StopLimit
2343 | OrderType::MarketIfTouched
2344 | OrderType::LimitIfTouched
2345 ) && let Some(signal) = map_futures_trigger_signal(trigger_type)?
2346 {
2347 builder.trigger_signal(signal);
2348 }
2349
2350 match order_type {
2351 OrderType::StopMarket => {
2352 if let Some(trigger) = trigger_price {
2353 builder.stop_price(trigger.to_string());
2354 }
2355 }
2356 OrderType::StopLimit => {
2357 if let Some(trigger) = trigger_price {
2358 builder.stop_price(trigger.to_string());
2359 }
2360
2361 if let Some(limit) = price {
2362 builder.limit_price(limit.to_string());
2363 }
2364 }
2365 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2366 if let Some(trigger) = trigger_price {
2367 builder.stop_price(trigger.to_string());
2368 }
2369
2370 if let Some(limit) = price {
2371 builder.limit_price(limit.to_string());
2372 }
2373 }
2374 _ => {
2375 if let Some(limit) = price {
2376 builder.limit_price(limit.to_string());
2377 }
2378 }
2379 }
2380
2381 if reduce_only {
2382 builder.reduce_only(true);
2383 }
2384
2385 builder
2386 .build()
2387 .map_err(|e| anyhow::anyhow!("Failed to build order params: {e}"))
2388 }
2389
2390 #[expect(clippy::too_many_arguments)]
2401 pub async fn submit_order(
2402 &self,
2403 account_id: AccountId,
2404 instrument_id: InstrumentId,
2405 client_order_id: ClientOrderId,
2406 order_side: OrderSide,
2407 order_type: OrderType,
2408 quantity: Quantity,
2409 time_in_force: TimeInForce,
2410 price: Option<Price>,
2411 trigger_price: Option<Price>,
2412 trigger_type: Option<TriggerType>,
2413 reduce_only: bool,
2414 post_only: bool,
2415 ) -> anyhow::Result<OrderStatusReport> {
2416 let instrument = self
2417 .get_cached_instrument(&instrument_id.symbol.inner())
2418 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2419
2420 let params = self.build_send_order_params(
2421 instrument_id,
2422 client_order_id,
2423 order_side,
2424 order_type,
2425 quantity,
2426 time_in_force,
2427 price,
2428 trigger_price,
2429 trigger_type,
2430 reduce_only,
2431 post_only,
2432 )?;
2433
2434 let response = self.inner.send_order_params(¶ms).await?;
2435
2436 if response.result != KrakenApiResult::Success {
2437 return Err(KrakenSubmitOrderError::Rejected {
2438 reason: response
2439 .error
2440 .unwrap_or_else(|| "Unknown error".to_string()),
2441 }
2442 .into());
2443 }
2444
2445 let send_status = response
2446 .send_status
2447 .ok_or(KrakenSubmitOrderError::MissingStatus)?;
2448
2449 match send_status.status.as_str() {
2450 "placed" | "filled" => {}
2451 "postWouldExecute" => {
2452 let reason = send_status
2453 .order_events
2454 .as_ref()
2455 .and_then(|events| events.first())
2456 .and_then(|event| event.reason.clone())
2457 .unwrap_or_else(|| "Post-only order would have crossed".to_string());
2458 return Err(KrakenSubmitOrderError::Rejected {
2459 reason: format!("POST_ONLY_REJECTED: {reason}"),
2460 }
2461 .into());
2462 }
2463 status if is_futures_submit_rejection(status) => {
2464 return Err(KrakenSubmitOrderError::Rejected {
2465 reason: status.to_string(),
2466 }
2467 .into());
2468 }
2469 status => {
2470 return Err(KrakenSubmitOrderError::UnknownStatus {
2471 status: status.to_string(),
2472 }
2473 .into());
2474 }
2475 }
2476
2477 let venue_order_id =
2478 send_status
2479 .order_id
2480 .clone()
2481 .ok_or_else(|| KrakenSubmitOrderError::MissingOrderId {
2482 detail: format!("send status was {}", send_status.status),
2483 })?;
2484
2485 let report: anyhow::Result<OrderStatusReport> = async {
2486 let ts_init = self.generate_ts_init();
2487
2488 let open_orders_response = self.inner.get_open_orders().await?;
2489 if let Some(order) = open_orders_response
2490 .open_orders
2491 .iter()
2492 .find(|o| o.order_id == venue_order_id)
2493 {
2494 return parse_futures_order_status_report(
2495 order,
2496 &instrument,
2497 account_id,
2498 Some(quantity.as_decimal()),
2499 ts_init,
2500 );
2501 }
2502
2503 if let Some(order_events) = &send_status.order_events
2506 && let Some(send_event) = order_events.first()
2507 {
2508 let event = if let Some(order_data) = &send_event.order {
2510 FuturesOrderEvent {
2511 order_id: order_data.order_id.clone(),
2512 cli_ord_id: order_data.cli_ord_id.clone(),
2513 order_type: order_data.order_type,
2514 symbol: order_data.symbol.clone(),
2515 side: order_data.side,
2516 quantity: order_data.quantity,
2517 filled: order_data.filled,
2518 limit_price: order_data.limit_price,
2519 stop_price: order_data.stop_price,
2520 timestamp: order_data.timestamp.clone(),
2521 last_update_timestamp: order_data.last_update_timestamp.clone(),
2522 reduce_only: order_data.reduce_only,
2523 }
2524 } else if let Some(trigger_data) = &send_event.order_trigger {
2525 FuturesOrderEvent {
2526 order_id: trigger_data.uid.clone(),
2527 cli_ord_id: trigger_data.client_id.clone(),
2528 order_type: trigger_data.order_type,
2529 symbol: trigger_data.symbol.clone(),
2530 side: trigger_data.side,
2531 quantity: trigger_data.quantity,
2532 filled: Decimal::ZERO,
2533 limit_price: trigger_data.limit_price,
2534 stop_price: Some(trigger_data.trigger_price),
2535 timestamp: trigger_data.timestamp.clone(),
2536 last_update_timestamp: trigger_data.last_update_timestamp.clone(),
2537 reduce_only: trigger_data.reduce_only,
2538 }
2539 } else if let Some(prior_exec) = &send_event.order_prior_execution {
2540 FuturesOrderEvent {
2542 order_id: prior_exec.order_id.clone(),
2543 cli_ord_id: prior_exec.cli_ord_id.clone(),
2544 order_type: prior_exec.order_type,
2545 symbol: prior_exec.symbol.clone(),
2546 side: prior_exec.side,
2547 quantity: prior_exec.quantity,
2548 filled: send_event.amount.unwrap_or(prior_exec.quantity), limit_price: prior_exec.limit_price,
2550 stop_price: prior_exec.stop_price,
2551 timestamp: prior_exec.timestamp.clone(),
2552 last_update_timestamp: prior_exec.last_update_timestamp.clone(),
2553 reduce_only: prior_exec.reduce_only,
2554 }
2555 } else {
2556 anyhow::bail!("No order, orderTrigger, or orderPriorExecution data in event");
2557 };
2558 return parse_futures_order_event_status_report(
2559 &event,
2560 Some(send_event.event_type),
2561 &instrument,
2562 account_id,
2563 ts_init,
2564 );
2565 }
2566
2567 let events_response = self.inner.get_order_events(None, None, None).await?;
2569 let event_wrapper = events_response
2570 .order_events
2571 .iter()
2572 .find(|e| e.order.order_id == venue_order_id)
2573 .ok_or_else(|| {
2574 anyhow::anyhow!("Order not found in open orders or events: {venue_order_id}")
2575 })?;
2576
2577 parse_futures_order_event_status_report(
2578 &event_wrapper.order,
2579 Some(event_wrapper.event_type),
2580 &instrument,
2581 account_id,
2582 ts_init,
2583 )
2584 }
2585 .await;
2586
2587 report.map_err(|source| KrakenSubmitOrderError::PostSubmitLookup { source }.into())
2588 }
2589
2590 pub async fn modify_order(
2602 &self,
2603 instrument_id: InstrumentId,
2604 client_order_id: Option<ClientOrderId>,
2605 venue_order_id: Option<VenueOrderId>,
2606 quantity: Option<Quantity>,
2607 price: Option<Price>,
2608 trigger_price: Option<Price>,
2609 ) -> anyhow::Result<VenueOrderId> {
2610 let params = self.build_edit_order_params(
2611 instrument_id,
2612 client_order_id,
2613 venue_order_id,
2614 quantity,
2615 price,
2616 trigger_price,
2617 )?;
2618 let original_order_id = params.order_id.clone();
2619
2620 let response = self.inner.edit_order(¶ms).await?;
2621 let status = response.edit_status.status.as_str();
2622
2623 if response.result != KrakenApiResult::Success {
2624 return Err(KrakenModifyOrderError::Rejected {
2625 reason: status.to_string(),
2626 }
2627 .into());
2628 }
2629
2630 match status {
2631 "edited" => {}
2632 status if is_futures_modify_rejection(status) => {
2633 return Err(KrakenModifyOrderError::Rejected {
2634 reason: status.to_string(),
2635 }
2636 .into());
2637 }
2638 status => {
2639 return Err(KrakenModifyOrderError::UnknownStatus {
2640 status: status.to_string(),
2641 }
2642 .into());
2643 }
2644 }
2645
2646 let new_venue_order_id = response
2648 .edit_status
2649 .order_id
2650 .or(original_order_id)
2651 .ok_or(KrakenModifyOrderError::MissingOrderId)?;
2652
2653 Ok(VenueOrderId::new(&new_venue_order_id))
2654 }
2655
2656 pub async fn cancel_order(
2666 &self,
2667 _account_id: AccountId,
2668 instrument_id: InstrumentId,
2669 client_order_id: Option<ClientOrderId>,
2670 venue_order_id: Option<VenueOrderId>,
2671 ) -> anyhow::Result<()> {
2672 let _ = self
2673 .get_cached_instrument(&instrument_id.symbol.inner())
2674 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2675
2676 let order_id = venue_order_id.as_ref().map(|id| id.to_string());
2677 let cli_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
2678
2679 if order_id.is_none() && cli_ord_id.is_none() {
2680 anyhow::bail!("Either client_order_id or venue_order_id must be provided");
2681 }
2682
2683 let response = self.inner.cancel_order(order_id, cli_ord_id).await?;
2684
2685 if response.result != KrakenApiResult::Success {
2686 let status = &response.cancel_status.status;
2687 anyhow::bail!("Order cancellation failed: {status}");
2688 }
2689
2690 Ok(())
2691 }
2692
2693 pub async fn cancel_orders_batch(
2703 &self,
2704 venue_order_ids: Vec<VenueOrderId>,
2705 ) -> anyhow::Result<usize> {
2706 if venue_order_ids.is_empty() {
2707 return Ok(0);
2708 }
2709
2710 let mut total_cancelled = 0;
2711
2712 for chunk in venue_order_ids.chunks(BATCH_CANCEL_LIMIT) {
2713 let order_ids: Vec<String> = chunk.iter().map(|id| id.to_string()).collect();
2714 let response = self.inner.cancel_orders_batch(order_ids).await?;
2715
2716 if response.result != KrakenApiResult::Success {
2717 let error_msg = response.error.as_deref().unwrap_or("Unknown error");
2718 anyhow::bail!("Batch cancel failed: {error_msg}");
2719 }
2720
2721 let success_count = response
2722 .batch_status
2723 .iter()
2724 .filter(|s| {
2725 s.status == Some(KrakenSendStatus::Cancelled)
2726 || s.cancel_status
2727 .as_ref()
2728 .is_some_and(|cs| cs.status == KrakenSendStatus::Cancelled)
2729 })
2730 .count();
2731
2732 total_cancelled += success_count;
2733 }
2734
2735 Ok(total_cancelled)
2736 }
2737
2738 #[expect(clippy::type_complexity)]
2747 pub async fn submit_orders_batch(
2748 &self,
2749 orders: Vec<(
2750 InstrumentId,
2751 ClientOrderId,
2752 OrderSide,
2753 OrderType,
2754 Quantity,
2755 TimeInForce,
2756 Option<Price>,
2757 Option<Price>,
2758 Option<TriggerType>,
2759 bool,
2760 bool,
2761 )>,
2762 ) -> anyhow::Result<Vec<FuturesSendStatus>> {
2763 Ok(self
2764 .send_order_batches(orders)
2765 .await
2766 .into_iter()
2767 .map(|result| match result {
2768 Ok(item) if item.result == KrakenApiResult::Success => item.status,
2769 Ok(mut item) => {
2770 item.status.status = format!("api_error: {}", item.status.status);
2771 item.status
2772 }
2773 Err(e) => FuturesSendStatus {
2774 order_id: None,
2775 order_tag: None,
2776 status: if matches!(
2777 e.downcast_ref::<KrakenBatchOrderError>(),
2778 Some(KrakenBatchOrderError::Validation { .. })
2779 ) {
2780 format!("validation_error: {e}")
2781 } else {
2782 format!("batch_error: {e}")
2783 },
2784 order_events: None,
2785 cli_ord_id: None,
2786 received_time: None,
2787 },
2788 })
2789 .collect())
2790 }
2791
2792 pub(crate) async fn send_order_batches(
2793 &self,
2794 orders: Vec<FuturesBatchOrder>,
2795 ) -> Vec<anyhow::Result<FuturesBatchSubmitItem>> {
2796 let count = orders.len();
2797 if count == 0 {
2798 return Vec::new();
2799 }
2800
2801 let mut results: Vec<Option<anyhow::Result<FuturesBatchSubmitItem>>> =
2802 (0..count).map(|_| None).collect();
2803 let mut valid_items = Vec::with_capacity(count);
2804
2805 for (
2806 idx,
2807 (
2808 instrument_id,
2809 client_order_id,
2810 order_side,
2811 order_type,
2812 quantity,
2813 time_in_force,
2814 price,
2815 trigger_price,
2816 trigger_type,
2817 reduce_only,
2818 post_only,
2819 ),
2820 ) in orders.into_iter().enumerate()
2821 {
2822 match self.build_send_order_params(
2823 instrument_id,
2824 client_order_id,
2825 order_side,
2826 order_type,
2827 quantity,
2828 time_in_force,
2829 price,
2830 trigger_price,
2831 trigger_type,
2832 reduce_only,
2833 post_only,
2834 ) {
2835 Ok(params) => {
2836 valid_items.push((
2837 idx,
2838 KrakenFuturesBatchSendItem::from_params(params, idx.to_string()),
2839 ));
2840 }
2841 Err(e) => {
2842 results[idx] = Some(Err(KrakenBatchOrderError::Validation {
2843 reason: e.to_string(),
2844 }
2845 .into()));
2846 }
2847 }
2848 }
2849
2850 if valid_items.is_empty() {
2851 return results.into_iter().flatten().collect();
2852 }
2853
2854 let chunks: Vec<_> = valid_items.chunks(BATCH_ORDER_LIMIT).collect();
2855 for (chunk_index, chunk) in chunks.iter().enumerate() {
2856 let items = chunk.iter().map(|(_, item)| item.clone()).collect();
2857 match self.inner.submit_orders_batch(items).await {
2858 Ok(response) => {
2859 let mut by_tag: HashMap<String, Option<FuturesSendStatus>> = HashMap::new();
2860 let response_result = response.result;
2861
2862 for status in response.batch_status {
2863 if let Some(tag) = status.order_tag.clone() {
2864 by_tag
2865 .entry(tag)
2866 .and_modify(|entry| *entry = None)
2867 .or_insert(Some(status));
2868 }
2869 }
2870
2871 for (idx, item) in *chunk {
2872 let result = match by_tag.remove(&item.order_tag) {
2873 Some(Some(status)) => Ok(FuturesBatchSubmitItem {
2874 result: response_result,
2875 status,
2876 }),
2877 Some(None) => Err(KrakenBatchOrderError::DuplicateResponse {
2878 key: format!("order_tag {}", item.order_tag),
2879 }
2880 .into()),
2881 None => Err(KrakenBatchOrderError::MissingResponse {
2882 key: format!("order_tag {}", item.order_tag),
2883 }
2884 .into()),
2885 };
2886 results[*idx] = Some(result);
2887 }
2888 }
2889 Err(e) => {
2890 for (idx, _) in *chunk {
2891 results[*idx] = Some(Err(anyhow::Error::new(e.clone())));
2892 }
2893
2894 for later_chunk in &chunks[chunk_index + 1..] {
2895 for (idx, _) in *later_chunk {
2896 results[*idx] = Some(Err(KrakenBatchOrderError::NotAttempted.into()));
2897 }
2898 }
2899 break;
2900 }
2901 }
2902 }
2903
2904 results
2905 .into_iter()
2906 .map(|result| result.unwrap_or_else(|| Err(KrakenBatchOrderError::NotAttempted.into())))
2907 .collect()
2908 }
2909
2910 #[expect(clippy::type_complexity)]
2912 pub async fn edit_orders_batch(
2913 &self,
2914 orders: Vec<(
2915 InstrumentId,
2916 Option<ClientOrderId>,
2917 Option<VenueOrderId>,
2918 Option<Quantity>,
2919 Option<Price>,
2920 Option<Price>,
2921 )>,
2922 ) -> anyhow::Result<Vec<String>> {
2923 let count = orders.len();
2924 if count == 0 {
2925 return Ok(Vec::new());
2926 }
2927
2928 let mut all_statuses: Vec<Option<String>> = vec![None; count];
2929 let mut valid_items = Vec::with_capacity(count);
2930 let mut valid_indices = Vec::with_capacity(count);
2931
2932 for (
2933 idx,
2934 (instrument_id, client_order_id, venue_order_id, quantity, price, trigger_price),
2935 ) in orders.into_iter().enumerate()
2936 {
2937 match self.build_edit_order_params(
2938 instrument_id,
2939 client_order_id,
2940 venue_order_id,
2941 quantity,
2942 price,
2943 trigger_price,
2944 ) {
2945 Ok(params) => {
2946 valid_items.push(KrakenFuturesBatchEditItem::from_params(
2947 params,
2948 idx.to_string(),
2949 ));
2950 valid_indices.push(idx);
2951 }
2952 Err(e) => {
2953 all_statuses[idx] = Some(format!("validation_error: {e}"));
2954 }
2955 }
2956 }
2957
2958 if valid_items.is_empty() {
2959 return Ok(all_statuses.into_iter().flatten().collect());
2960 }
2961
2962 let mut batch_statuses: Vec<String> = Vec::with_capacity(valid_items.len());
2963
2964 for chunk in valid_items.chunks(BATCH_ORDER_LIMIT) {
2965 match self.inner.edit_orders_batch(chunk.to_vec()).await {
2966 Ok(response) => {
2967 if response.result == KrakenApiResult::Success {
2968 batch_statuses.extend(response.batch_status.into_iter().map(|s| s.status));
2969 } else {
2970 let error_msg = response
2971 .batch_status
2972 .first()
2973 .map_or("Unknown error", |s| s.status.as_str());
2974
2975 for _ in 0..chunk.len() {
2976 batch_statuses.push(format!("api_error: {error_msg}"));
2977 }
2978 }
2979 }
2980 Err(e) => {
2981 let remaining = valid_items.len() - batch_statuses.len();
2982 for _ in 0..remaining {
2983 batch_statuses.push(format!("batch_error: {e}"));
2984 }
2985 break;
2986 }
2987 }
2988 }
2989
2990 for (batch_idx, &original_idx) in valid_indices.iter().enumerate() {
2991 if let Some(status) = batch_statuses.get(batch_idx) {
2992 all_statuses[original_idx] = Some(status.clone());
2993 }
2994 }
2995
2996 Ok(all_statuses.into_iter().flatten().collect())
2997 }
2998
2999 fn build_edit_order_params(
3000 &self,
3001 instrument_id: InstrumentId,
3002 client_order_id: Option<ClientOrderId>,
3003 venue_order_id: Option<VenueOrderId>,
3004 quantity: Option<Quantity>,
3005 price: Option<Price>,
3006 trigger_price: Option<Price>,
3007 ) -> anyhow::Result<KrakenFuturesEditOrderParams> {
3008 let _ = self
3009 .get_cached_instrument(&instrument_id.symbol.inner())
3010 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
3011
3012 let order_id = venue_order_id.as_ref().map(|id| id.to_string());
3013 let cli_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
3014
3015 if order_id.is_none() && cli_ord_id.is_none() {
3016 anyhow::bail!("Either client_order_id or venue_order_id must be provided");
3017 }
3018
3019 let mut builder = KrakenFuturesEditOrderParamsBuilder::default();
3020
3021 if let Some(ref id) = order_id {
3022 builder.order_id(id.clone());
3023 }
3024
3025 if let Some(ref id) = cli_ord_id {
3026 builder.cli_ord_id(id.clone());
3027 }
3028
3029 if let Some(qty) = quantity {
3032 builder.size(qty.to_string());
3033 builder.qty_mode(KrakenFuturesQtyMode::Absolute);
3034 }
3035
3036 if let Some(p) = price {
3037 builder.limit_price(p.to_string());
3038 }
3039
3040 if let Some(tp) = trigger_price {
3041 builder.stop_price(tp.to_string());
3042 }
3043
3044 builder
3045 .build()
3046 .map_err(|e| anyhow::anyhow!("Failed to build edit order params: {e}"))
3047 }
3048}
3049
3050pub(crate) fn is_futures_submit_rejection(status: &str) -> bool {
3051 matches!(
3052 status.parse::<KrakenSendStatus>(),
3053 Ok(KrakenSendStatus::InsufficientAvailableFunds
3054 | KrakenSendStatus::InvalidOrderType
3055 | KrakenSendStatus::InvalidSize
3056 | KrakenSendStatus::WouldCauseLiquidation
3057 | KrakenSendStatus::PostWouldExecute
3058 | KrakenSendStatus::IocWouldNotExecute
3059 | KrakenSendStatus::ReduceOnlyWouldIncreasePosition)
3060 )
3061}
3062
3063fn is_futures_modify_rejection(status: &str) -> bool {
3064 status.parse::<KrakenSendStatus>().is_ok_and(|status| {
3065 status == KrakenSendStatus::NotFound || is_futures_submit_rejection(status.as_ref())
3066 })
3067}
3068
3069fn map_futures_trigger_signal(
3070 trigger_type: Option<TriggerType>,
3071) -> anyhow::Result<Option<KrakenTriggerSignal>> {
3072 match trigger_type {
3073 None => Ok(None),
3074 Some(TriggerType::Default | TriggerType::LastPrice) => Ok(Some(KrakenTriggerSignal::Last)),
3075 Some(TriggerType::MarkPrice) => Ok(Some(KrakenTriggerSignal::Mark)),
3076 Some(TriggerType::IndexPrice) => Ok(Some(KrakenTriggerSignal::Index)),
3077 Some(other) => anyhow::bail!(
3078 "Unsupported trigger type for Kraken Futures: {other:?} (only LastPrice, MarkPrice, and IndexPrice supported)"
3079 ),
3080 }
3081}
3082
3083type AmountsByCode = AHashMap<Ustr, (Decimal, Decimal)>;
3085
3086fn parse_account_entries(
3102 accounts: &AHashMap<String, FuturesAccount>,
3103) -> (Vec<AccountBalance>, Vec<MarginBalance>) {
3104 let mut balances: AmountsByCode = AHashMap::new();
3105 let mut margins: AmountsByCode = AHashMap::new();
3106
3107 for account in accounts.values() {
3108 match account.account_type {
3109 KrakenFuturesAccountType::MultiCollateralMarginAccount => {
3110 parse_multi_collateral_balances(account, &mut balances);
3111 parse_multi_collateral_margins(account, &mut margins);
3112 }
3113 KrakenFuturesAccountType::MarginAccount => {
3114 let currency = margin_account_currency(account);
3115
3116 if currency.is_none() {
3117 log::warn!(
3120 "Single-collateral wallet currency unresolved (currency {:?}): its margin requirement is skipped and none of its assets is reported as locked; balances {:?}",
3121 account.currency,
3122 account.balances.keys().collect::<Vec<_>>()
3123 );
3124 }
3125
3126 parse_margin_account_balances(account, currency.as_deref(), &mut balances);
3127 parse_margin_account_margins(account, currency.as_deref(), &mut margins);
3128 }
3129 KrakenFuturesAccountType::CashAccount => {
3130 parse_cash_account_balances(account, &mut balances);
3131 }
3132 KrakenFuturesAccountType::Unknown => {
3133 log::debug!("Unknown account type: {:?}", account.account_type);
3134 }
3135 }
3136 }
3137
3138 (emit_balances(&balances), emit_margins(&margins))
3139}
3140
3141fn futures_balance_currency(code: &str) -> anyhow::Result<Currency> {
3147 Currency::new_checked(code, 8, 0, code, CurrencyType::Crypto)
3148 .map_err(|e| anyhow::anyhow!("Invalid currency code {code:?}: {e}"))
3149}
3150
3151fn sorted_codes(amounts: &AmountsByCode) -> Vec<Ustr> {
3153 let mut codes: Vec<Ustr> = amounts.keys().copied().collect();
3154 codes.sort_unstable();
3155 codes
3156}
3157
3158fn emit_balances(amounts: &AmountsByCode) -> Vec<AccountBalance> {
3159 let mut balances = Vec::with_capacity(amounts.len());
3160
3161 for code in sorted_codes(amounts) {
3162 let (total, locked) = amounts[&code];
3163 match futures_balance_currency(code.as_str())
3164 .and_then(|currency| aggregate_balance(total, locked, currency))
3165 {
3166 Ok(balance) => balances.push(balance),
3167 Err(e) => log::warn!("Skipping {code} balance: {e}"),
3168 }
3169 }
3170
3171 balances
3172}
3173
3174fn aggregate_balance(
3182 total: Decimal,
3183 locked: Decimal,
3184 currency: Currency,
3185) -> anyhow::Result<AccountBalance> {
3186 let total = Money::from_decimal(total, currency)?;
3187 let locked = Money::from_decimal(locked, currency)?;
3188 let free = total
3189 .checked_sub(locked)
3190 .ok_or_else(|| anyhow::anyhow!("Derived `free` for {currency} exceeds Money bounds"))?;
3191
3192 AccountBalance::new_checked(total, locked, free).map_err(Into::into)
3193}
3194
3195fn accumulate_balance(amounts: &mut AmountsByCode, code: &str, total: Decimal, locked: Decimal) {
3204 let locked = if total.is_sign_negative() {
3205 locked
3206 } else {
3207 locked.clamp(Decimal::ZERO, total)
3208 };
3209
3210 let entry = amounts
3211 .entry(Ustr::from(code))
3212 .or_insert((Decimal::ZERO, Decimal::ZERO));
3213 entry.0 += total;
3214 entry.1 += locked;
3215}
3216
3217fn parse_multi_collateral_balances(account: &FuturesAccount, balances: &mut AmountsByCode) {
3218 let emits_portfolio_value = account
3221 .portfolio_value
3222 .is_some_and(|value| value > Decimal::ZERO);
3223
3224 for (currency_code, currency_info) in &account.currencies {
3225 let code = normalize_asset_key(currency_code.as_str());
3227
3228 if emits_portfolio_value && code == "USD" {
3229 continue;
3230 }
3231
3232 let total_amount = currency_info.quantity;
3233 let available_amount = currency_info.available.unwrap_or(total_amount);
3234 let locked_amount = total_amount - available_amount;
3235
3236 accumulate_balance(balances, &code, total_amount, locked_amount);
3237 }
3238
3239 if let Some(portfolio_value) = account.portfolio_value
3242 && portfolio_value > Decimal::ZERO
3243 {
3244 let available_usd = account.available_margin.unwrap_or(portfolio_value);
3245 let locked_usd = portfolio_value - available_usd;
3246
3247 accumulate_balance(balances, "USD", portfolio_value, locked_usd);
3248 }
3249}
3250
3251fn parse_multi_collateral_margins(account: &FuturesAccount, margins: &mut AmountsByCode) {
3252 let initial_margin = account.initial_margin.unwrap_or(Decimal::ZERO);
3255 let maintenance = account.maintenance_margin.unwrap_or(Decimal::ZERO);
3256
3257 if initial_margin > Decimal::ZERO || maintenance > Decimal::ZERO {
3260 accumulate_margin(margins, "USD", initial_margin, maintenance);
3261 }
3262}
3263
3264fn parse_margin_account_balances(
3266 account: &FuturesAccount,
3267 wallet_currency: Option<&str>,
3268 balances: &mut AmountsByCode,
3269) {
3270 let available = account.auxiliary.as_ref().and_then(|aux| aux.af);
3275
3276 for (currency_code, &amount) in &account.balances {
3277 if currency_code.contains('_') {
3281 continue;
3282 }
3283
3284 let code = normalize_asset_key(currency_code.as_str());
3286
3287 let locked = match available {
3288 Some(af) if wallet_currency == Some(code.as_str()) => amount - af,
3289 _ => Decimal::ZERO,
3290 };
3291
3292 accumulate_balance(balances, &code, amount, locked);
3293 }
3294}
3295
3296fn margin_account_currency(account: &FuturesAccount) -> Option<String> {
3302 account
3303 .currency
3304 .as_deref()
3305 .map(str::trim)
3306 .filter(|code| !code.is_empty())
3307 .map(normalize_asset_key)
3308}
3309
3310fn parse_margin_account_margins(
3313 account: &FuturesAccount,
3314 wallet_currency: Option<&str>,
3315 margins: &mut AmountsByCode,
3316) {
3317 if let (Some(mr), Some(code)) = (account.margin_requirements.as_ref(), wallet_currency) {
3318 let im = mr.im.unwrap_or(Decimal::ZERO);
3319 let mm = mr.mm.unwrap_or(Decimal::ZERO);
3320
3321 if im > Decimal::ZERO || mm > Decimal::ZERO {
3322 accumulate_margin(margins, code, im, mm);
3323 }
3324 }
3325}
3326
3327fn accumulate_margin(
3329 margins: &mut AmountsByCode,
3330 code: &str,
3331 initial: Decimal,
3332 maintenance: Decimal,
3333) {
3334 let entry = margins
3335 .entry(Ustr::from(code))
3336 .or_insert((Decimal::ZERO, Decimal::ZERO));
3337 entry.0 += initial;
3338 entry.1 += maintenance;
3339}
3340
3341fn emit_margins(amounts: &AmountsByCode) -> Vec<MarginBalance> {
3342 let mut margins = Vec::with_capacity(amounts.len());
3343
3344 for code in sorted_codes(amounts) {
3345 let (initial, maintenance) = amounts[&code];
3346 let margin = futures_balance_currency(code.as_str()).and_then(|currency| {
3347 let initial = Money::from_decimal(initial, currency)?;
3348 let maintenance = Money::from_decimal(maintenance, currency)?;
3349 Ok(MarginBalance::new(initial, maintenance, None))
3350 });
3351
3352 match margin {
3353 Ok(margin) => margins.push(margin),
3354 Err(e) => log::warn!("Skipping {code} margin: {e}"),
3355 }
3356 }
3357
3358 margins
3359}
3360
3361fn parse_cash_account_balances(account: &FuturesAccount, balances: &mut AmountsByCode) {
3362 for (currency_code, &amount) in &account.balances {
3363 let code = normalize_asset_key(currency_code.as_str());
3365
3366 accumulate_balance(balances, &code, amount, Decimal::ZERO);
3367 }
3368}
3369
3370pub(crate) fn supersedes(candidate: &OrderStatusReport, existing: &OrderStatusReport) -> bool {
3377 match (
3378 candidate.order_status.is_closed(),
3379 existing.order_status.is_closed(),
3380 ) {
3381 (true, false) => true,
3382 (false, true) => false,
3383 _ if candidate.ts_last != existing.ts_last => candidate.ts_last > existing.ts_last,
3384 _ => candidate.filled_qty > existing.filled_qty,
3385 }
3386}
3387
3388#[cfg(test)]
3389mod tests {
3390 use std::{sync::Arc, time::Duration};
3391
3392 use ahash::AHashMap;
3393 use nautilus_model::{enums::OrderStatus, instruments::CryptoPerpetual};
3394 use nautilus_testkit::http::assert_http_redirect_rejected;
3395 use rstest::rstest;
3396 use rust_decimal_macros::dec;
3397
3398 use super::*;
3399
3400 #[tokio::test]
3401 async fn test_authenticated_client_rejects_redirects() {
3402 let client = KrakenFuturesRawHttpClient::with_credentials(
3403 "key".into(),
3404 "secret".into(),
3405 KrakenEnvironment::Live,
3406 None,
3407 3,
3408 Some(0),
3409 None,
3410 None,
3411 None,
3412 10,
3413 )
3414 .unwrap()
3415 .client;
3416 assert_http_redirect_rejected(|url| async move {
3417 client
3418 .get(url, None, None, Some(3), None)
3419 .await
3420 .unwrap()
3421 .status
3422 .as_u16()
3423 })
3424 .await;
3425 }
3426
3427 #[rstest]
3428 fn test_raw_client_creation() {
3429 let client = KrakenFuturesRawHttpClient::default();
3430 assert!(client.credential.is_none());
3431 assert!(client.base_url().contains("futures"));
3432 }
3433
3434 #[rstest]
3435 fn test_raw_client_with_credentials() {
3436 let client = KrakenFuturesRawHttpClient::with_credentials(
3437 "test_key".to_string(),
3438 "test_secret".to_string(),
3439 KrakenEnvironment::Live,
3440 None,
3441 60,
3442 None,
3443 None,
3444 None,
3445 None,
3446 KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND,
3447 )
3448 .unwrap();
3449 assert!(client.credential.is_some());
3450 }
3451
3452 #[rstest]
3453 #[tokio::test]
3454 async fn test_order_request_cancellation_before_transport() {
3455 let client = Arc::new(KrakenFuturesRawHttpClient::default());
3456 let guard = client.auth_mutex.lock().await;
3457 let waiting_client = Arc::clone(&client);
3458 let waiting = tokio::spawn(async move {
3459 waiting_client
3460 .send_authenticated_post::<serde_json::Value>(
3461 "/derivatives/api/v3/sendorder",
3462 "orderType=lmt".to_string(),
3463 )
3464 .await
3465 });
3466
3467 tokio::task::yield_now().await;
3468 client.cancel_all_requests();
3469 let waiting_result = tokio::time::timeout(Duration::from_secs(1), waiting)
3470 .await
3471 .expect("auth lock wait should stop on cancellation")
3472 .expect("auth request task should complete");
3473 drop(guard);
3474 client.reset_cancellation_token();
3475 let reset_token = client.cancellation_token();
3476
3477 assert!(matches!(
3478 waiting_result,
3479 Err(KrakenHttpError::RequestNotStarted(ref message))
3480 if message == "Request cancelled"
3481 ));
3482 assert!(!reset_token.is_cancelled());
3483 }
3484
3485 #[rstest]
3486 fn test_client_creation() {
3487 let client = KrakenFuturesHttpClient::default();
3488 assert!(client.instruments_cache.is_empty());
3489 }
3490
3491 #[rstest]
3492 fn test_client_with_credentials() {
3493 let client = KrakenFuturesHttpClient::with_credentials(
3494 "test_key".to_string(),
3495 "test_secret".to_string(),
3496 KrakenEnvironment::Live,
3497 None,
3498 60,
3499 None,
3500 None,
3501 None,
3502 None,
3503 KRAKEN_FUTURES_DEFAULT_RATE_LIMIT_PER_SECOND,
3504 )
3505 .unwrap();
3506 assert!(client.instruments_cache.is_empty());
3507 }
3508
3509 fn entries_for(
3510 accounts: &[(&str, FuturesAccount)],
3511 ) -> (Vec<AccountBalance>, Vec<MarginBalance>) {
3512 let map: AHashMap<String, FuturesAccount> = accounts
3513 .iter()
3514 .map(|(name, account)| ((*name).to_string(), account.clone()))
3515 .collect();
3516
3517 parse_account_entries(&map)
3518 }
3519
3520 fn flex_wallet(
3521 currencies: &[(&str, Decimal)],
3522 portfolio_value: Option<Decimal>,
3523 ) -> FuturesAccount {
3524 FuturesAccount {
3525 account_type: KrakenFuturesAccountType::MultiCollateralMarginAccount,
3526 currency: None,
3527 balances: AHashMap::new(),
3528 currencies: currencies
3529 .iter()
3530 .map(|(code, quantity)| {
3531 (
3532 (*code).to_string(),
3533 FuturesFlexCurrency {
3534 quantity: *quantity,
3535 value: None,
3536 collateral: None,
3537 available: Some(*quantity),
3538 },
3539 )
3540 })
3541 .collect(),
3542 auxiliary: None,
3543 margin_requirements: None,
3544 portfolio_value,
3545 available_margin: portfolio_value,
3546 initial_margin: None,
3547 maintenance_margin: None,
3548 pnl: None,
3549 }
3550 }
3551
3552 fn cash_wallet(balances: &[(&str, Decimal)]) -> FuturesAccount {
3553 FuturesAccount {
3554 account_type: KrakenFuturesAccountType::CashAccount,
3555 currency: None,
3556 balances: balances
3557 .iter()
3558 .map(|(code, amount)| ((*code).to_string(), *amount))
3559 .collect(),
3560 currencies: AHashMap::new(),
3561 auxiliary: None,
3562 margin_requirements: None,
3563 portfolio_value: None,
3564 available_margin: None,
3565 initial_margin: None,
3566 maintenance_margin: None,
3567 pnl: None,
3568 }
3569 }
3570
3571 #[rstest]
3577 fn test_parse_account_entries_combines_one_asset_across_wallets() {
3578 let (balances, _) = entries_for(&[
3579 ("cash", cash_wallet(&[("xbt", dec!(1.5))])),
3580 ("flex", flex_wallet(&[("XBT", dec!(0.25))], None)),
3581 ]);
3582
3583 let btc: Vec<_> = balances
3584 .iter()
3585 .filter(|b| b.currency.code.as_str() == "BTC")
3586 .collect();
3587 assert_eq!(btc.len(), 1, "expected one BTC balance: {balances:?}");
3588 assert_eq!(btc[0].total.as_decimal(), dec!(1.75));
3589 }
3590
3591 #[rstest]
3593 fn test_parse_account_entries_emits_balances_in_code_order() {
3594 let (balances, _) = entries_for(&[(
3595 "cash",
3596 cash_wallet(&[("xrp", dec!(10)), ("xbt", dec!(1)), ("eth", dec!(2))]),
3597 )]);
3598
3599 let codes: Vec<&str> = balances.iter().map(|b| b.currency.code.as_str()).collect();
3600 assert_eq!(codes, vec!["BTC", "ETH", "XRP"]);
3601 }
3602
3603 #[rstest]
3605 fn test_parse_account_entries_does_not_double_count_flex_usd() {
3606 let (balances, _) = entries_for(&[(
3607 "flex",
3608 flex_wallet(&[("USD", dec!(5000))], Some(dec!(34995.52))),
3609 )]);
3610
3611 let usd: Vec<_> = balances
3612 .iter()
3613 .filter(|b| b.currency.code.as_str() == "USD")
3614 .collect();
3615 assert_eq!(usd.len(), 1, "expected one USD balance: {balances:?}");
3616 assert_eq!(usd[0].total.as_decimal(), dec!(34995.52));
3617 }
3618
3619 #[rstest]
3625 fn test_parse_account_entries_bounds_locked_per_wallet() {
3626 let single = FuturesAccount {
3627 account_type: KrakenFuturesAccountType::MarginAccount,
3628 currency: Some("xbt".to_string()),
3629 balances: [("xbt".to_string(), dec!(1))].into_iter().collect(),
3630 currencies: AHashMap::new(),
3631 auxiliary: Some(FuturesAuxiliary {
3632 usd: None,
3633 pv: None,
3634 pnl: None,
3635 af: Some(dec!(0.4)),
3636 funding: None,
3637 }),
3638 margin_requirements: None,
3639 portfolio_value: None,
3640 available_margin: None,
3641 initial_margin: None,
3642 maintenance_margin: None,
3643 pnl: None,
3644 };
3645
3646 let mut flex = flex_wallet(&[], None);
3647 flex.currencies.insert(
3648 "XBT".to_string(),
3649 FuturesFlexCurrency {
3650 quantity: dec!(1),
3651 value: None,
3652 collateral: None,
3653 available: Some(dec!(1.6)),
3654 },
3655 );
3656
3657 let (balances, _) = entries_for(&[("fi_xbtusd", single), ("flex", flex)]);
3658
3659 let btc = balances
3660 .iter()
3661 .find(|b| b.currency.code.as_str() == "BTC")
3662 .expect("one BTC balance");
3663 assert_eq!(btc.total.as_decimal(), dec!(2));
3664 assert_eq!(
3665 btc.locked.as_decimal(),
3666 dec!(0.6),
3667 "the flex wallet's negative raw locked must not cancel the reservation"
3668 );
3669 assert_eq!(btc.free.as_decimal(), dec!(1.4));
3670 }
3671
3672 #[rstest]
3678 fn test_parse_account_entries_preserves_summed_amounts_across_mixed_sign_wallets() {
3679 let margin = FuturesAccount {
3680 account_type: KrakenFuturesAccountType::MarginAccount,
3681 currency: Some("xbt".to_string()),
3682 balances: [("xbt".to_string(), dec!(-1))].into_iter().collect(),
3683 currencies: AHashMap::new(),
3684 auxiliary: Some(FuturesAuxiliary {
3685 usd: None,
3686 pv: None,
3687 pnl: None,
3688 af: Some(dec!(-2)),
3689 funding: None,
3690 }),
3691 margin_requirements: None,
3692 portfolio_value: None,
3693 available_margin: None,
3694 initial_margin: None,
3695 maintenance_margin: None,
3696 pnl: None,
3697 };
3698
3699 let (balances, _) = entries_for(&[
3700 ("fi_xbtusd", margin),
3701 ("cash", cash_wallet(&[("xbt", dec!(1.5))])),
3702 ]);
3703
3704 let btc = balances
3705 .iter()
3706 .find(|b| b.currency.code.as_str() == "BTC")
3707 .expect("one BTC balance");
3708 assert_eq!(btc.total.as_decimal(), dec!(0.5));
3709 assert_eq!(
3710 btc.locked.as_decimal(),
3711 dec!(1),
3712 "the margin wallet's reservation must survive the aggregate"
3713 );
3714 assert_eq!(
3715 btc.free.as_decimal(),
3716 dec!(-0.5),
3717 "the shortfall must stay visible rather than clamp to zero"
3718 );
3719 }
3720
3721 #[rstest]
3723 fn test_parse_account_entries_passes_a_negative_total_through() {
3724 let account = FuturesAccount {
3725 account_type: KrakenFuturesAccountType::MarginAccount,
3726 currency: Some("xbt".to_string()),
3727 balances: [("xbt".to_string(), dec!(-1))].into_iter().collect(),
3728 currencies: AHashMap::new(),
3729 auxiliary: Some(FuturesAuxiliary {
3730 usd: None,
3731 pv: None,
3732 pnl: None,
3733 af: Some(dec!(-0.4)),
3734 funding: None,
3735 }),
3736 margin_requirements: None,
3737 portfolio_value: None,
3738 available_margin: None,
3739 initial_margin: None,
3740 maintenance_margin: None,
3741 pnl: None,
3742 };
3743
3744 let (balances, _) = entries_for(&[("fi_xbtusd", account)]);
3745
3746 assert_eq!(balances.len(), 1);
3747 assert_eq!(balances[0].total.as_decimal(), dec!(-1));
3748 assert_eq!(balances[0].locked.as_decimal(), dec!(-0.6));
3749 assert_eq!(balances[0].free.as_decimal(), dec!(-0.4));
3750 }
3751
3752 #[rstest]
3754 fn test_parse_account_entries_keeps_usd_balance_at_eight_decimals() {
3755 let (balances, _) = entries_for(&[("cash", cash_wallet(&[("usd", dec!(1234.56789012))]))]);
3756
3757 let usd = balances
3758 .iter()
3759 .find(|b| b.currency.code.as_str() == "USD")
3760 .expect("one USD balance");
3761 assert_eq!(usd.currency.precision, 8);
3762 assert_eq!(usd.total.as_decimal(), dec!(1234.56789012));
3763 }
3764
3765 #[rstest]
3768 fn test_parse_account_entries_keeps_margins_per_currency() {
3769 let mut flex = flex_wallet(&[], Some(dec!(10000)));
3770 flex.initial_margin = Some(dec!(500));
3771 flex.maintenance_margin = Some(dec!(250));
3772
3773 let single = FuturesAccount {
3774 account_type: KrakenFuturesAccountType::MarginAccount,
3775 currency: Some("xbt".to_string()),
3776 balances: [("xbt".to_string(), dec!(2))].into_iter().collect(),
3777 currencies: AHashMap::new(),
3778 auxiliary: None,
3779 margin_requirements: Some(FuturesMarginRequirements {
3780 im: Some(dec!(0.1)),
3781 mm: Some(dec!(0.05)),
3782 lt: None,
3783 tt: None,
3784 }),
3785 portfolio_value: None,
3786 available_margin: None,
3787 initial_margin: None,
3788 maintenance_margin: None,
3789 pnl: None,
3790 };
3791
3792 let (_, margins) = entries_for(&[("flex", flex), ("fi_xbtusd", single)]);
3793
3794 assert_eq!(
3795 margins.len(),
3796 2,
3797 "one margin entry per currency: {margins:?}"
3798 );
3799 let mut entries: Vec<(String, Decimal)> = margins
3800 .iter()
3801 .map(|m| (m.currency.code.to_string(), m.initial.as_decimal()))
3802 .collect();
3803 entries.sort();
3804 assert_eq!(
3805 entries,
3806 vec![
3807 ("BTC".to_string(), dec!(0.1)),
3808 ("USD".to_string(), dec!(500))
3809 ],
3810 "each currency keeps its own entry"
3811 );
3812 }
3813
3814 #[rstest]
3817 fn test_parse_margin_account_balances_bound_locked_to_the_wallet_currency() {
3818 let wallet = FuturesAccount {
3819 account_type: KrakenFuturesAccountType::MarginAccount,
3820 currency: Some("xbt".to_string()),
3821 balances: [("xbt".to_string(), dec!(0.3)), ("xrp".to_string(), dec!(5))]
3822 .into_iter()
3823 .collect(),
3824 currencies: AHashMap::new(),
3825 auxiliary: Some(FuturesAuxiliary {
3826 usd: None,
3827 pv: None,
3828 pnl: None,
3829 af: Some(dec!(0.1)),
3830 funding: None,
3831 }),
3832 margin_requirements: None,
3833 portfolio_value: None,
3834 available_margin: None,
3835 initial_margin: None,
3836 maintenance_margin: None,
3837 pnl: None,
3838 };
3839
3840 let (balances, _) = entries_for(&[("fi_xbtusd", wallet)]);
3841
3842 let by_code = |code: &str| {
3843 balances
3844 .iter()
3845 .find(|b| b.currency.code.as_str() == code)
3846 .unwrap_or_else(|| panic!("{code} balance"))
3847 };
3848 assert_eq!(by_code("BTC").locked.as_decimal(), dec!(0.2));
3849 assert_eq!(by_code("XRP").locked.as_decimal(), dec!(0));
3850 assert_eq!(by_code("XRP").free.as_decimal(), dec!(5));
3851 }
3852
3853 #[rstest]
3855 fn test_parse_margin_account_balances_skip_contract_symbol_keys() {
3856 let wallet = FuturesAccount {
3857 account_type: KrakenFuturesAccountType::MarginAccount,
3858 currency: Some("xbt".to_string()),
3859 balances: [
3860 ("xbt".to_string(), dec!(0.5)),
3861 ("FI_XBTUSD_171215".to_string(), dec!(50000)),
3862 ("FI_XBTUSD_180615".to_string(), dec!(-15000)),
3863 ]
3864 .into_iter()
3865 .collect(),
3866 currencies: AHashMap::new(),
3867 auxiliary: None,
3868 margin_requirements: None,
3869 portfolio_value: None,
3870 available_margin: None,
3871 initial_margin: None,
3872 maintenance_margin: None,
3873 pnl: None,
3874 };
3875
3876 let (balances, _) = entries_for(&[("fi_xbtusd", wallet)]);
3877
3878 assert_eq!(balances.len(), 1, "{balances:?}");
3879 assert_eq!(balances[0].currency.code.as_str(), "BTC");
3880 }
3881
3882 #[rstest]
3887 fn test_parse_multi_collateral_margins_read_the_documented_fields() {
3888 let json = r#"{
3889 "type": "multiCollateralMarginAccount",
3890 "currencies": {
3891 "XBT": {"quantity": 0.1185308247, "value": 4998.721054420551, "collateral": 4886.49976674881, "available": 0.1185308247},
3892 "USD": {"quantity": 5000, "value": 5000, "collateral": 5000, "available": 5000}
3893 },
3894 "balanceValue": 9998.72,
3895 "portfolioValue": 9998.72,
3896 "collateralValue": 9886.5,
3897 "initialMargin": 500,
3898 "initialMarginWithOrders": 500,
3899 "maintenanceMargin": 250,
3900 "pnl": 0,
3901 "unrealizedFunding": 0,
3902 "totalUnrealized": 0,
3903 "totalUnrealizedAsMargin": 0,
3904 "marginEquity": 9886.5,
3905 "availableMargin": 9386.5
3906 }"#;
3907 let account: FuturesAccount = serde_json::from_str(json).unwrap();
3908
3909 let (_, margins) = entries_for(&[("flex", account)]);
3910
3911 assert_eq!(margins.len(), 1, "{margins:?}");
3912 assert_eq!(margins[0].currency.code.as_str(), "USD");
3913 assert_eq!(margins[0].initial.as_decimal(), dec!(500));
3914 assert_eq!(margins[0].maintenance.as_decimal(), dec!(250));
3915 assert_eq!(margins[0].instrument_id, None);
3916 }
3917
3918 #[rstest]
3922 #[case::none(None, None, 0)]
3923 #[case::initial_only(Some(dec!(100)), None, 1)]
3924 #[case::maintenance_only(None, Some(dec!(40)), 1)]
3925 fn test_parse_multi_collateral_margins_follow_the_requirement_gate(
3926 #[case] initial_margin: Option<Decimal>,
3927 #[case] maintenance: Option<Decimal>,
3928 #[case] expected: usize,
3929 ) {
3930 let mut flex = flex_wallet(&[("USD", dec!(1000))], Some(dec!(1000)));
3931 flex.initial_margin = initial_margin;
3932 flex.maintenance_margin = maintenance;
3933
3934 let (_, margins) = entries_for(&[("flex", flex)]);
3935
3936 assert_eq!(margins.len(), expected, "{margins:?}");
3937 for margin in &margins {
3938 assert_eq!(margin.currency.code.as_str(), "USD");
3939 assert_eq!(
3940 margin.instrument_id, None,
3941 "the requirement is account-wide"
3942 );
3943 }
3944 }
3945
3946 #[rstest]
3951 fn test_parse_margin_account_margins_use_the_wallet_currency() {
3952 let json = r#"{
3953 "auxiliary": {"af": 100.73891563, "funding": 100.73891563, "pnl": 12.42134766, "pv": 153.73891563, "usd": 0},
3954 "balances": {"FI_XBTUSD_171215": "50000", "FI_XBTUSD_180615": "-15000", "xbt": "141.31756797", "xrp": "0"},
3955 "currency": "xbt",
3956 "marginRequirements": {"im": 52.8, "lt": 39.6, "mm": 23.76, "tt": 15.84},
3957 "triggerEstimates": {"im": 3110, "lt": 2890, "mm": 3000, "tt": 2830},
3958 "type": "marginAccount"
3959 }"#;
3960 let account: FuturesAccount = serde_json::from_str(json).unwrap();
3961
3962 let (balances, margins) = entries_for(&[("fi_xbtusd", account)]);
3963
3964 assert_eq!(margins.len(), 1, "{margins:?}");
3965 assert_eq!(margins[0].currency.code.as_str(), "BTC");
3966 assert_eq!(margins[0].currency.precision, 8);
3967 assert_eq!(margins[0].initial.as_decimal(), dec!(52.8));
3968 assert_eq!(margins[0].maintenance.as_decimal(), dec!(23.76));
3969 let btc = balances
3970 .iter()
3971 .find(|b| b.currency.code.as_str() == "BTC")
3972 .expect("BTC balance");
3973 assert_eq!(btc.total.as_decimal(), dec!(141.31756797));
3974 }
3975
3976 #[rstest]
3982 #[case::absent(None)]
3983 #[case::empty(Some(""))]
3984 #[case::blank(Some(" "))]
3985 fn test_parse_margin_account_margins_skip_a_wallet_whose_currency_is_unresolved(
3986 #[case] currency: Option<&str>,
3987 ) {
3988 let mut wallet = cash_wallet(&[("xbt", dec!(1.5)), ("xrp", Decimal::ZERO)]);
3989 wallet.account_type = KrakenFuturesAccountType::MarginAccount;
3990 wallet.currency = currency.map(str::to_string);
3991 wallet.margin_requirements = Some(FuturesMarginRequirements {
3992 im: Some(dec!(0.05)),
3993 mm: Some(dec!(0.025)),
3994 lt: None,
3995 tt: None,
3996 });
3997
3998 let (_, margins) = entries_for(&[("fi_xbtusd", wallet)]);
3999
4000 assert!(margins.is_empty(), "no guessed denomination: {margins:?}");
4001 }
4002
4003 #[rstest]
4008 fn test_parse_account_entries_sums_margins_of_wallets_sharing_a_currency() {
4009 let requirement = |im: Decimal, mm: Decimal| {
4010 Some(FuturesMarginRequirements {
4011 im: Some(im),
4012 mm: Some(mm),
4013 lt: None,
4014 tt: None,
4015 })
4016 };
4017 let mut first = cash_wallet(&[("xbt", dec!(2))]);
4018 first.account_type = KrakenFuturesAccountType::MarginAccount;
4019 first.currency = Some("xbt".to_string());
4020 first.margin_requirements = requirement(dec!(0.1), dec!(0.05));
4021 let mut second = cash_wallet(&[("xbt", dec!(1))]);
4022 second.account_type = KrakenFuturesAccountType::MarginAccount;
4023 second.currency = Some("XBT".to_string());
4024 second.margin_requirements = requirement(dec!(0.02), dec!(0.01));
4025
4026 let (_, margins) = entries_for(&[("fi_a", first), ("fi_b", second)]);
4027
4028 assert_eq!(margins.len(), 1, "one entry per currency: {margins:?}");
4029 assert_eq!(margins[0].currency.code.as_str(), "BTC");
4030 assert_eq!(margins[0].initial.as_decimal(), dec!(0.12));
4031 assert_eq!(margins[0].maintenance.as_decimal(), dec!(0.06));
4032 }
4033
4034 #[rstest]
4036 fn test_parse_account_entries_skips_an_empty_asset_key() {
4037 let (balances, _) =
4038 entries_for(&[("cash", cash_wallet(&[("", dec!(1)), ("xbt", dec!(2))]))]);
4039
4040 assert_eq!(balances.len(), 1, "{balances:?}");
4041 assert_eq!(balances[0].currency.code.as_str(), "BTC");
4042 }
4043
4044 #[rstest]
4045 fn test_parse_margin_account_margins() {
4046 let account = FuturesAccount {
4047 account_type: KrakenFuturesAccountType::MarginAccount,
4048 currency: Some("xbt".to_string()),
4049 balances: [("xbt".to_string(), dec!(2))].into_iter().collect(),
4050 currencies: AHashMap::new(),
4051 auxiliary: None,
4052 margin_requirements: Some(FuturesMarginRequirements {
4053 im: Some(dec!(100)),
4054 mm: Some(dec!(50)),
4055 lt: None,
4056 tt: None,
4057 }),
4058 portfolio_value: None,
4059 available_margin: None,
4060 initial_margin: None,
4061 maintenance_margin: None,
4062 pnl: None,
4063 };
4064
4065 let (_, margins) = entries_for(&[("wallet", account)]);
4066
4067 assert_eq!(margins.len(), 1);
4068 let margin = &margins[0];
4069 assert_eq!(margin.currency.code.as_str(), "BTC");
4070 assert_eq!(margin.initial.as_decimal(), dec!(100));
4071 assert_eq!(margin.maintenance.as_decimal(), dec!(50));
4072 }
4073
4074 #[rstest]
4075 fn test_parse_margin_account_margins_no_requirements() {
4076 let account = FuturesAccount {
4077 account_type: KrakenFuturesAccountType::MarginAccount,
4078 currency: None,
4079 balances: AHashMap::new(),
4080 currencies: AHashMap::new(),
4081 auxiliary: None,
4082 margin_requirements: None,
4083 portfolio_value: None,
4084 available_margin: None,
4085 initial_margin: None,
4086 maintenance_margin: None,
4087 pnl: None,
4088 };
4089
4090 let (_, margins) = entries_for(&[("wallet", account)]);
4091
4092 assert_eq!(margins.len(), 0);
4093 }
4094
4095 #[rstest]
4096 fn test_parse_multi_collateral_balances() {
4097 let mut currencies = AHashMap::new();
4098 currencies.insert(
4099 "BTC".to_string(),
4100 FuturesFlexCurrency {
4101 quantity: dec!(1.5),
4102 value: None,
4103 collateral: None,
4104 available: Some(dec!(1.2)),
4105 },
4106 );
4107
4108 let account = FuturesAccount {
4109 account_type: KrakenFuturesAccountType::MultiCollateralMarginAccount,
4110 currency: None,
4111 balances: AHashMap::new(),
4112 currencies,
4113 auxiliary: None,
4114 margin_requirements: None,
4115 portfolio_value: Some(dec!(50000)),
4116 available_margin: Some(dec!(45000)),
4117 initial_margin: None,
4118 maintenance_margin: None,
4119 pnl: None,
4120 };
4121
4122 let (balances, _) = entries_for(&[("wallet", account)]);
4123
4124 assert_eq!(balances.len(), 2);
4126 }
4127
4128 #[rstest]
4129 fn test_parse_margin_account_balances_preserves_exact_values() {
4130 let mut bals = AHashMap::new();
4131 bals.insert("XBT".to_string(), dec!(10.00000003));
4132
4133 let account = FuturesAccount {
4134 account_type: KrakenFuturesAccountType::MarginAccount,
4135 currency: Some("xbt".to_string()),
4136 balances: bals,
4137 currencies: AHashMap::new(),
4138 auxiliary: Some(FuturesAuxiliary {
4139 usd: None,
4140 pv: None,
4141 pnl: None,
4142 af: Some(dec!(0.00000004)),
4143 funding: None,
4144 }),
4145 margin_requirements: None,
4146 portfolio_value: None,
4147 available_margin: None,
4148 initial_margin: None,
4149 maintenance_margin: None,
4150 pnl: None,
4151 };
4152
4153 let (balances, _) = entries_for(&[("wallet", account)]);
4154
4155 assert_eq!(balances.len(), 1);
4156 let balance = &balances[0];
4157 assert_eq!(balance.total.as_decimal(), dec!(10.00000003));
4158 assert_eq!(balance.locked.as_decimal(), dec!(9.99999999));
4159 assert_eq!(balance.free.as_decimal(), dec!(0.00000004));
4160 assert_eq!(balance.total, balance.locked + balance.free);
4161 }
4162
4163 #[rstest]
4164 fn test_parse_cash_account_balances() {
4165 let mut bals = AHashMap::new();
4166 bals.insert("ETH".to_string(), dec!(10));
4167 bals.insert("BTC".to_string(), Decimal::ZERO);
4168
4169 let account = FuturesAccount {
4170 account_type: KrakenFuturesAccountType::CashAccount,
4171 currency: None,
4172 balances: bals,
4173 currencies: AHashMap::new(),
4174 auxiliary: None,
4175 margin_requirements: None,
4176 portfolio_value: None,
4177 available_margin: None,
4178 initial_margin: None,
4179 maintenance_margin: None,
4180 pnl: None,
4181 };
4182
4183 let (balances, _) = entries_for(&[("wallet", account)]);
4184
4185 assert_eq!(
4186 balances.len(),
4187 2,
4188 "a wallet listed at zero is reported: {balances:?}"
4189 );
4190 let eth = balances
4191 .iter()
4192 .find(|b| b.currency.code.as_str() == "ETH")
4193 .expect("ETH balance");
4194 assert_eq!(eth.total.as_decimal(), dec!(10));
4195 assert_eq!(eth.locked.as_decimal(), Decimal::ZERO);
4196 let btc = balances
4197 .iter()
4198 .find(|b| b.currency.code.as_str() == "BTC")
4199 .expect("BTC balance");
4200 assert_eq!(btc.total.as_decimal(), Decimal::ZERO);
4201 assert_eq!(btc.free.as_decimal(), Decimal::ZERO);
4202 }
4203
4204 #[rstest]
4206 fn test_parse_multi_collateral_balances_reports_zero_quantity() {
4207 let (balances, _) = entries_for(&[("flex", flex_wallet(&[("XBT", Decimal::ZERO)], None))]);
4208
4209 assert_eq!(balances.len(), 1, "{balances:?}");
4210 assert_eq!(balances[0].currency.code.as_str(), "BTC");
4211 assert_eq!(balances[0].total.as_decimal(), Decimal::ZERO);
4212 assert_eq!(balances[0].free.as_decimal(), Decimal::ZERO);
4213 }
4214
4215 #[rstest]
4217 fn test_parse_margin_account_balances_reports_zero_wallet() {
4218 let mut wallet = cash_wallet(&[("xbt", Decimal::ZERO)]);
4219 wallet.account_type = KrakenFuturesAccountType::MarginAccount;
4220
4221 let (balances, _) = entries_for(&[("fi_xbtusd", wallet)]);
4222
4223 assert_eq!(balances.len(), 1, "{balances:?}");
4224 assert_eq!(balances[0].currency.code.as_str(), "BTC");
4225 assert_eq!(balances[0].total.as_decimal(), Decimal::ZERO);
4226 assert_eq!(balances[0].free.as_decimal(), Decimal::ZERO);
4227 }
4228
4229 #[rstest]
4231 fn test_parse_account_entries_zero_wallet_does_not_displace_a_funded_one() {
4232 let mut margin = cash_wallet(&[("xbt", dec!(1.5))]);
4233 margin.account_type = KrakenFuturesAccountType::MarginAccount;
4234
4235 let (balances, _) = entries_for(&[
4236 ("cash", cash_wallet(&[("xbt", Decimal::ZERO)])),
4237 ("fi_xbtusd", margin),
4238 ("flex", flex_wallet(&[("XBT", Decimal::ZERO)], None)),
4239 ]);
4240
4241 assert_eq!(balances.len(), 1, "one BTC balance: {balances:?}");
4242 assert_eq!(balances[0].currency.code.as_str(), "BTC");
4243 assert_eq!(balances[0].total.as_decimal(), dec!(1.5));
4244 assert_eq!(balances[0].free.as_decimal(), dec!(1.5));
4245 }
4246
4247 #[rstest]
4249 fn test_parse_account_entries_reports_an_asset_all_wallets_hold_at_zero() {
4250 let mut margin = cash_wallet(&[("xbt", Decimal::ZERO)]);
4251 margin.account_type = KrakenFuturesAccountType::MarginAccount;
4252
4253 let (balances, _) = entries_for(&[
4254 ("cash", cash_wallet(&[("xbt", Decimal::ZERO)])),
4255 ("fi_xbtusd", margin),
4256 ("flex", flex_wallet(&[("XBT", Decimal::ZERO)], None)),
4257 ]);
4258
4259 assert_eq!(balances.len(), 1, "one BTC balance: {balances:?}");
4260 assert_eq!(balances[0].currency.code.as_str(), "BTC");
4261 assert_eq!(balances[0].total.as_decimal(), Decimal::ZERO);
4262 assert_eq!(balances[0].locked.as_decimal(), Decimal::ZERO);
4263 assert_eq!(balances[0].free.as_decimal(), Decimal::ZERO);
4264 }
4265
4266 #[rstest]
4267 #[case(None, None)]
4268 #[case(Some(TriggerType::Default), Some(KrakenTriggerSignal::Last))]
4269 #[case(Some(TriggerType::LastPrice), Some(KrakenTriggerSignal::Last))]
4270 #[case(Some(TriggerType::MarkPrice), Some(KrakenTriggerSignal::Mark))]
4271 #[case(Some(TriggerType::IndexPrice), Some(KrakenTriggerSignal::Index))]
4272 fn test_build_send_order_params_maps_supported_trigger_signals(
4273 #[case] trigger_type: Option<TriggerType>,
4274 #[case] expected_signal: Option<KrakenTriggerSignal>,
4275 ) {
4276 let client = KrakenFuturesHttpClient::default();
4277 let instrument_id = cache_test_futures_instrument(&client);
4278
4279 let params = client
4280 .build_send_order_params(
4281 instrument_id,
4282 ClientOrderId::new("futures-trigger"),
4283 OrderSide::Buy,
4284 OrderType::StopMarket,
4285 Quantity::from("1"),
4286 TimeInForce::Gtc,
4287 None,
4288 Some(Price::from("45000")),
4289 trigger_type,
4290 false,
4291 false,
4292 )
4293 .unwrap();
4294
4295 assert_eq!(params.trigger_signal, expected_signal);
4296 }
4297
4298 #[rstest]
4299 fn test_build_send_order_params_rejects_unsupported_trigger_signal() {
4300 let client = KrakenFuturesHttpClient::default();
4301 let instrument_id = cache_test_futures_instrument(&client);
4302
4303 let error = client
4304 .build_send_order_params(
4305 instrument_id,
4306 ClientOrderId::new("futures-trigger-invalid"),
4307 OrderSide::Buy,
4308 OrderType::StopMarket,
4309 Quantity::from("1"),
4310 TimeInForce::Gtc,
4311 None,
4312 Some(Price::from("45000")),
4313 Some(TriggerType::BidAsk),
4314 false,
4315 false,
4316 )
4317 .unwrap_err();
4318
4319 assert!(
4320 error
4321 .to_string()
4322 .contains("Unsupported trigger type for Kraken Futures")
4323 );
4324 }
4325
4326 fn history_page(first_order_type: &str, continuation_token: Option<&str>) -> String {
4329 let token = continuation_token
4330 .map(|token| format!(r#","continuationToken":"{token}""#))
4331 .unwrap_or_default();
4332 format!(
4333 r#"{{"elements":[{{"uid":"e1","timestamp":1680876930250,"event":{{"OrderPlaced":{{"order":{{"uid":"H-SKIP-1","tradeable":"PF_XBTUSD","direction":"Sell","quantity":"3","filled":"1","limitPrice":"70000","orderType":"{first_order_type}","clientId":"cl-1","reduceOnly":false,"timestamp":1680876930250,"lastUpdateTimestamp":1680876930250}}}}}}}},{{"uid":"e2","timestamp":1680877245500,"event":{{"OrderPlaced":{{"order":{{"uid":"H-KEEP-2","tradeable":"PF_XBTUSD","direction":"Buy","quantity":"2","filled":"0.5","limitPrice":"69500","orderType":"Limit","clientId":"cl-2","reduceOnly":false,"timestamp":1680877245500,"lastUpdateTimestamp":1680877245500}}}}}}}}]{token}}}"#
4334 )
4335 }
4336
4337 async fn history_test_client(
4340 history: Arc<RwLock<String>>,
4341 header_token: Option<&'static str>,
4342 ) -> KrakenFuturesHttpClient {
4343 use axum::{
4344 Router, body::Body, extract::State, http::header, response::Response, routing::any,
4345 };
4346
4347 let app = Router::new()
4348 .route(
4349 "/derivatives/api/v3/openorders",
4350 any(|| async {
4351 (
4352 [(header::CONTENT_TYPE, "application/json")],
4353 r#"{"result":"success","openOrders":[]}"#,
4354 )
4355 }),
4356 )
4357 .route(
4358 "/api/history/v3/orders",
4359 any(
4360 move |State(history): State<Arc<RwLock<String>>>| async move {
4361 let mut response =
4362 Response::builder().header(header::CONTENT_TYPE, "application/json");
4363
4364 if let Some(token) = header_token {
4365 response = response.header(NEXT_CONTINUATION_TOKEN_HEADER, token);
4366 }
4367 response.body(Body::from(history.read().clone())).unwrap()
4368 },
4369 ),
4370 )
4371 .with_state(history);
4372 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4373 let addr = listener.local_addr().unwrap();
4374 tokio::spawn(async move {
4375 axum::serve(listener, app).await.unwrap();
4376 });
4377
4378 KrakenFuturesHttpClient::with_credentials(
4379 "test".to_string(),
4380 "test".to_string(),
4381 KrakenEnvironment::Live,
4382 Some(format!("http://{addr}")),
4383 10,
4384 None,
4385 None,
4386 None,
4387 None,
4388 10,
4389 )
4390 .unwrap()
4391 }
4392
4393 #[rstest]
4397 #[case::body_token(Some("c2ltYjE3OA=="), None, false)]
4398 #[case::header_token(None, Some("c2ltYjE3OA=="), false)]
4399 #[case::no_token(None, None, true)]
4400 #[tokio::test]
4401 async fn test_request_order_status_reports_marks_a_page_with_a_continuation_token_incomplete(
4402 #[case] body_token: Option<&str>,
4403 #[case] header_token: Option<&'static str>,
4404 #[case] expected_complete: bool,
4405 ) {
4406 let history = Arc::new(RwLock::new(history_page("Limit", body_token)));
4407 let client = history_test_client(history, header_token).await;
4408 cache_test_futures_instrument(&client);
4409
4410 let (reports, coverage) = client
4411 .request_order_status_reports_checked(
4412 AccountId::from("KRAKEN-001"),
4413 None,
4414 None,
4415 None,
4416 false,
4417 )
4418 .await
4419 .unwrap();
4420
4421 assert_eq!(coverage.is_complete(), expected_complete);
4422 assert_eq!(
4423 reports
4424 .iter()
4425 .map(|report| report.venue_order_id.as_str())
4426 .collect::<Vec<_>>(),
4427 vec!["H-SKIP-1", "H-KEEP-2"],
4428 "the page's rows are reported either way"
4429 );
4430 }
4431
4432 #[rstest]
4436 #[case::resize(Some("5"), Some("ABSOLUTE"))]
4437 #[case::price_only(None, None)]
4438 #[tokio::test]
4439 async fn test_build_edit_order_params_sends_a_size_as_the_total(
4440 #[case] size: Option<&str>,
4441 #[case] expected_qty_mode: Option<&str>,
4442 ) {
4443 let client = history_test_client(Arc::new(RwLock::new(String::new())), None).await;
4444 let instrument_id = cache_test_futures_instrument(&client);
4445
4446 let params = client
4447 .build_edit_order_params(
4448 instrument_id,
4449 None,
4450 Some(VenueOrderId::from("V-EDIT-1")),
4451 size.map(Quantity::from),
4452 Some(Price::from("70000")),
4453 None,
4454 )
4455 .unwrap();
4456
4457 let form: Vec<(String, String)> =
4458 serde_urlencoded::from_str(&serde_urlencoded::to_string(¶ms).unwrap()).unwrap();
4459 let form_field = |name: &str| {
4460 form.iter()
4461 .find(|(key, _)| key == name)
4462 .map(|(_, value)| value.as_str())
4463 };
4464 assert_eq!(form_field("size"), size, "{form:?}");
4465 assert_eq!(form_field("qtyMode"), expected_qty_mode, "{form:?}");
4466
4467 let item =
4468 serde_json::to_value(KrakenFuturesBatchEditItem::from_params(params, "tag-1")).unwrap();
4469 let item_field = |name: &str| item.get(name).and_then(|value| value.as_str());
4470 assert_eq!(item_field("size"), size, "{item}");
4471 assert_eq!(item_field("qtyMode"), expected_qty_mode, "{item}");
4472 }
4473
4474 #[rstest]
4478 #[tokio::test]
4479 async fn test_request_order_status_reports_keep_a_closed_order_closed_after_a_refused_edit() {
4480 let order = |last_update_ms: u64| {
4481 format!(
4482 r#"{{"uid":"H-CLOSED","tradeable":"PF_XBTUSD","direction":"Buy","quantity":"1","filled":"0","limitPrice":"70000","orderType":"Limit","clientId":"","reduceOnly":false,"timestamp":1680876930250,"lastUpdateTimestamp":{last_update_ms}}}"#
4483 )
4484 };
4485 let page = format!(
4488 r#"{{"elements":[{{"uid":"e3","timestamp":1680877400000,"event":{{"OrderEditRejected":{{"oldOrder":{},"reason":"order_for_edit_not_found"}}}}}},{{"uid":"e2","timestamp":1680877300000,"event":{{"OrderCancelled":{{"order":{},"reason":"cancelled_by_user"}}}}}},{{"uid":"e1","timestamp":1680876930250,"event":{{"OrderPlaced":{{"order":{},"reason":"new_user_order"}}}}}}]}}"#,
4489 order(1680876930250),
4490 order(1680877300000),
4491 order(1680876930250),
4492 );
4493 let client = history_test_client(Arc::new(RwLock::new(page)), None).await;
4494 cache_test_futures_instrument(&client);
4495
4496 let (reports, coverage) = client
4497 .request_order_status_reports_checked(
4498 AccountId::from("KRAKEN-001"),
4499 None,
4500 None,
4501 None,
4502 false,
4503 )
4504 .await
4505 .unwrap();
4506
4507 assert!(coverage.is_complete());
4508 assert_eq!(reports.len(), 1, "{reports:?}");
4509 assert_eq!(reports[0].venue_order_id, VenueOrderId::from("H-CLOSED"));
4510 assert_eq!(reports[0].order_status, OrderStatus::Canceled);
4511 }
4512
4513 #[rstest]
4516 #[tokio::test]
4517 async fn test_request_order_status_reports_marks_the_set_incomplete_for_a_skipped_history_row()
4518 {
4519 let history = Arc::new(RwLock::new(history_page("Limit", None)));
4520 let client = history_test_client(history.clone(), None).await;
4521 let instrument_id = cache_test_futures_instrument(&client);
4522 let account_id = AccountId::from("KRAKEN-001");
4523
4524 let (control, coverage) = client
4527 .request_order_status_reports_checked(account_id, None, None, None, false)
4528 .await
4529 .unwrap();
4530 assert!(
4531 coverage.is_complete(),
4532 "the control must be complete, or the skip proves nothing"
4533 );
4534 assert_eq!(control.len(), 2);
4535
4536 *history.write() = history_page("Unknown", None);
4537 let (reports, coverage) = client
4538 .request_order_status_reports_checked(account_id, None, None, None, false)
4539 .await
4540 .unwrap();
4541
4542 assert!(
4543 !coverage.is_complete(),
4544 "a skipped history row must leave the set incomplete"
4545 );
4546 assert_eq!(reports.len(), 1);
4547 let report = &reports[0];
4548 assert_eq!(report.venue_order_id, VenueOrderId::from("H-KEEP-2"));
4549 assert_eq!(report.instrument_id, instrument_id);
4550 assert_eq!(report.account_id, account_id);
4551 assert_eq!(report.client_order_id, Some(ClientOrderId::from("cl-2")));
4552 assert_eq!(report.order_side, Some(OrderSide::Buy));
4553 assert_eq!(report.order_type, OrderType::Limit);
4554 assert_eq!(report.quantity, Quantity::from("2"));
4555 assert_eq!(report.filled_qty, Quantity::from("0.5"));
4556 assert_eq!(report.price, Some(Price::from("69500")));
4557 }
4558
4559 #[rstest]
4563 #[tokio::test]
4564 async fn test_get_order_events_asks_for_the_newest_events_first() {
4565 use axum::{Router, extract::RawQuery, routing::any};
4566
4567 let query: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
4568 let recorded = query.clone();
4569 let app = Router::new().route(
4570 "/api/history/v3/orders",
4571 any(move |RawQuery(raw): RawQuery| {
4572 let recorded = recorded.clone();
4573 async move {
4574 *recorded.write() = raw;
4575 r#"{"elements":[]}"#
4576 }
4577 }),
4578 );
4579 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4580 let addr = listener.local_addr().unwrap();
4581 tokio::spawn(async move {
4582 axum::serve(listener, app).await.unwrap();
4583 });
4584 let client = KrakenFuturesHttpClient::with_credentials(
4585 "test".to_string(),
4586 "test".to_string(),
4587 KrakenEnvironment::Live,
4588 Some(format!("http://{addr}")),
4589 10,
4590 None,
4591 None,
4592 None,
4593 None,
4594 10,
4595 )
4596 .unwrap();
4597
4598 client
4599 .inner
4600 .get_order_events(None, None, None)
4601 .await
4602 .unwrap();
4603
4604 let query = query.read().clone().unwrap_or_default();
4605 assert!(
4606 query.split('&').any(|param| param == "sort=desc"),
4607 "{query}"
4608 );
4609 }
4610
4611 fn cache_test_futures_instrument(client: &KrakenFuturesHttpClient) -> InstrumentId {
4612 let instrument_id = InstrumentId::from("PF_XBTUSD.KRAKEN");
4613
4614 client.cache_instrument(InstrumentAny::CryptoPerpetual(
4615 CryptoPerpetual::builder()
4616 .instrument_id(instrument_id)
4617 .raw_symbol(Symbol::new("PF_XBTUSD"))
4618 .base_currency(Currency::BTC())
4619 .quote_currency(Currency::USD())
4620 .settlement_currency(Currency::USD())
4621 .is_inverse(false)
4622 .price_precision(0)
4623 .size_precision(4)
4624 .price_increment(Price::from("1"))
4625 .size_increment(Quantity::from("0.0001"))
4626 .ts_event(0.into())
4627 .ts_init(0.into())
4628 .build()
4629 .unwrap(),
4630 ));
4631
4632 instrument_id
4633 }
4634}