1use std::{
19 collections::HashMap,
20 fmt::Debug,
21 num::NonZeroU32,
22 sync::{
23 Arc,
24 atomic::{AtomicBool, Ordering},
25 },
26};
27
28use ahash::AHashMap;
29use anyhow::Context;
30use indexmap::IndexMap;
31use jiff::Timestamp;
32use nautilus_common::cache::InstrumentLookupError;
33use nautilus_core::{
34 AtomicMap, AtomicTime, UUID4, consts::NAUTILUS_USER_AGENT, datetime::NANOSECONDS_IN_SECOND,
35 nanos::UnixNanos, time::get_atomic_clock_realtime,
36};
37use nautilus_model::{
38 data::{Bar, BarType, BookOrder, TradeTick},
39 enums::{
40 AccountType, BookType, CurrencyType, MarketStatusAction, OrderSide, OrderType,
41 PositionSide, TimeInForce, TriggerType,
42 },
43 events::AccountState,
44 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
45 instruments::{Instrument, InstrumentAny},
46 orderbook::OrderBook,
47 reports::{FillReport, OrderStatusReport, PositionStatusReport},
48 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
49};
50use nautilus_network::{
51 http::{HttpClient, HttpResponse, Method, USER_AGENT},
52 ratelimiter::quota::Quota,
53 retry::{RetryConfig, RetryError, RetryManager},
54};
55use parking_lot::RwLock;
56use rust_decimal::Decimal;
57use serde::de::DeserializeOwned;
58use tokio_util::sync::CancellationToken;
59use ustr::Ustr;
60
61use super::{models::*, query::*};
62use crate::{
63 common::{
64 consts::{
65 KRAKEN_OFLAG_POST_ONLY, KRAKEN_OFLAG_QUOTE_QUANTITY, KRAKEN_VENUE,
66 NAUTILUS_KRAKEN_BROKER_ID,
67 },
68 credential::KrakenCredential,
69 enums::{
70 KrakenAssetClass, KrakenEnvironment, KrakenOrderSide, KrakenOrderType,
71 KrakenProductType,
72 },
73 parse::{
74 bar_type_to_spot_interval, normalize_currency_code, normalize_spot_symbol, parse_bar,
75 parse_fill_report, parse_order_status_report, parse_spot_instrument,
76 parse_tokenized_instrument, parse_trade_tick_from_array, truncate_cl_ord_id,
77 },
78 urls::get_kraken_http_base_url,
79 },
80 http::{
81 apply_count_limit,
82 error::{
83 KrakenBatchOrderError, KrakenHttpError, KrakenModifyOrderError, KrakenSubmitOrderError,
84 kraken_http_should_retry,
85 },
86 },
87};
88
89pub const KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND: u32 = 5;
91
92const KRAKEN_GLOBAL_RATE_KEY: &str = "kraken:spot:global";
93
94const BATCH_CANCEL_LIMIT: usize = 50;
96
97const BATCH_SUBMIT_LIMIT: usize = 15;
99
100pub struct KrakenSpotRawHttpClient {
105 base_url: String,
106 client: HttpClient,
107 credential: Option<KrakenCredential>,
108 retry_manager: RetryManager<KrakenHttpError>,
109 cancellation_token: RwLock<CancellationToken>,
110 clock: &'static AtomicTime,
111 auth_mutex: tokio::sync::Mutex<()>,
113}
114
115impl Default for KrakenSpotRawHttpClient {
116 fn default() -> Self {
117 Self::new(
118 KrakenEnvironment::Live,
119 None,
120 60,
121 None,
122 None,
123 None,
124 None,
125 KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
126 )
127 .expect("Failed to create default KrakenSpotRawHttpClient")
128 }
129}
130
131impl Debug for KrakenSpotRawHttpClient {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 f.debug_struct(stringify!(KrakenSpotRawHttpClient))
134 .field("base_url", &self.base_url)
135 .field("has_credentials", &self.credential.is_some())
136 .finish()
137 }
138}
139
140impl KrakenSpotRawHttpClient {
141 #[expect(clippy::too_many_arguments)]
143 pub fn new(
144 environment: KrakenEnvironment,
145 base_url_override: Option<String>,
146 timeout_secs: u64,
147 max_retries: Option<u32>,
148 retry_delay_ms: Option<u64>,
149 retry_delay_max_ms: Option<u64>,
150 proxy_url: Option<String>,
151 max_requests_per_second: u32,
152 ) -> anyhow::Result<Self> {
153 let retry_config = RetryConfig {
154 max_retries: max_retries.unwrap_or(3),
155 initial_delay_ms: retry_delay_ms.unwrap_or(1000),
156 max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
157 backoff_factor: 2.0,
158 jitter_ms: 1000,
159 operation_timeout_ms: Some(60_000),
160 immediate_first: false,
161 max_elapsed_ms: Some(180_000),
162 };
163
164 let retry_manager = RetryManager::new(retry_config);
165 let base_url = base_url_override.unwrap_or_else(|| {
166 get_kraken_http_base_url(KrakenProductType::Spot, environment).to_string()
167 });
168
169 Ok(Self {
170 base_url,
171 client: HttpClient::builder()
172 .headers(Self::default_headers())
173 .keyed_quotas(Self::rate_limiter_quotas(max_requests_per_second)?)
174 .default_quota(Self::default_quota(max_requests_per_second)?)
175 .timeout_secs(timeout_secs)
176 .maybe_proxy_url(proxy_url)
177 .build()
178 .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
179 credential: None,
180 retry_manager,
181 cancellation_token: RwLock::new(CancellationToken::new()),
182 clock: get_atomic_clock_realtime(),
183 auth_mutex: tokio::sync::Mutex::new(()),
184 })
185 }
186
187 #[expect(clippy::too_many_arguments)]
189 pub fn with_credentials(
190 api_key: String,
191 api_secret: String,
192 environment: KrakenEnvironment,
193 base_url_override: Option<String>,
194 timeout_secs: u64,
195 max_retries: Option<u32>,
196 retry_delay_ms: Option<u64>,
197 retry_delay_max_ms: Option<u64>,
198 proxy_url: Option<String>,
199 max_requests_per_second: u32,
200 ) -> anyhow::Result<Self> {
201 let retry_config = RetryConfig {
202 max_retries: max_retries.unwrap_or(3),
203 initial_delay_ms: retry_delay_ms.unwrap_or(1000),
204 max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
205 backoff_factor: 2.0,
206 jitter_ms: 1000,
207 operation_timeout_ms: Some(60_000),
208 immediate_first: false,
209 max_elapsed_ms: Some(180_000),
210 };
211
212 let retry_manager = RetryManager::new(retry_config);
213 let base_url = base_url_override.unwrap_or_else(|| {
214 get_kraken_http_base_url(KrakenProductType::Spot, environment).to_string()
215 });
216
217 Ok(Self {
218 base_url,
219 client: HttpClient::builder()
220 .headers(Self::default_headers())
221 .keyed_quotas(Self::rate_limiter_quotas(max_requests_per_second)?)
222 .default_quota(Self::default_quota(max_requests_per_second)?)
223 .timeout_secs(timeout_secs)
224 .maybe_proxy_url(proxy_url)
225 .build()
226 .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
227 credential: Some(KrakenCredential::new(api_key, api_secret)),
228 retry_manager,
229 cancellation_token: RwLock::new(CancellationToken::new()),
230 clock: get_atomic_clock_realtime(),
231 auth_mutex: tokio::sync::Mutex::new(()),
232 })
233 }
234
235 fn generate_nonce(&self) -> u64 {
240 self.clock.get_time_ns().as_u64()
241 }
242
243 pub fn base_url(&self) -> &str {
245 &self.base_url
246 }
247
248 pub fn credential(&self) -> Option<&KrakenCredential> {
250 self.credential.as_ref()
251 }
252
253 pub fn cancel_all_requests(&self) {
255 self.cancellation_token.read().cancel();
256 }
257
258 pub fn reset_cancellation_token(&self) {
260 *self.cancellation_token.write() = CancellationToken::new();
261 }
262
263 pub fn cancellation_token(&self) -> CancellationToken {
265 self.cancellation_token.read().clone()
266 }
267
268 fn default_headers() -> HashMap<String, String> {
269 HashMap::from([(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string())])
270 }
271
272 fn default_quota(max_requests_per_second: u32) -> anyhow::Result<Quota> {
273 let burst = NonZeroU32::new(max_requests_per_second).unwrap_or(
274 NonZeroU32::new(KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND).expect("non-zero"),
275 );
276 Quota::per_second(burst).ok_or_else(|| {
277 anyhow::anyhow!(
278 "Invalid max_requests_per_second: {max_requests_per_second} exceeds maximum"
279 )
280 })
281 }
282
283 fn rate_limiter_quotas(max_requests_per_second: u32) -> anyhow::Result<Vec<(String, Quota)>> {
284 Ok(vec![(
285 KRAKEN_GLOBAL_RATE_KEY.to_string(),
286 Self::default_quota(max_requests_per_second)?,
287 )])
288 }
289
290 fn rate_limit_keys(endpoint: &str) -> Vec<String> {
291 let normalized = endpoint.split('?').next().unwrap_or(endpoint);
292 let route = format!("kraken:spot:{normalized}");
293 vec![KRAKEN_GLOBAL_RATE_KEY.to_string(), route]
294 }
295
296 fn sign_spot(
297 &self,
298 path: &str,
299 nonce: u64,
300 params: &HashMap<String, String>,
301 ) -> anyhow::Result<(HashMap<String, String>, String)> {
302 let credential = self
303 .credential
304 .as_ref()
305 .ok_or_else(|| anyhow::anyhow!("Missing credentials"))?;
306
307 let (signature, post_data) = credential.sign_spot(path, nonce, params)?;
308
309 let mut headers = HashMap::new();
310 headers.insert("API-Key".to_string(), credential.api_key().to_string());
311 headers.insert("API-Sign".to_string(), signature);
312
313 Ok((headers, post_data))
314 }
315
316 async fn send_request<T: DeserializeOwned>(
317 &self,
318 method: Method,
319 endpoint: &str,
320 body: Option<Vec<u8>>,
321 authenticate: bool,
322 ) -> anyhow::Result<KrakenResponse<T>, KrakenHttpError> {
323 let _guard = if authenticate {
327 Some(self.auth_mutex.lock().await)
328 } else {
329 None
330 };
331
332 let endpoint = endpoint.to_string();
333 let method_clone = method.clone();
334 let body_clone = body.clone();
335
336 let operation = || {
337 let method = method_clone.clone();
338 let body = body_clone.clone();
339 let endpoint = endpoint.clone();
340
341 async move {
342 self.send_request_attempt(method, &endpoint, body, authenticate, None)
343 .await
344 }
345 };
346
347 let should_retry = kraken_http_should_retry;
348 let create_error = |error: RetryError| KrakenHttpError::NetworkError(error.to_string());
349
350 let cancellation_token = self.cancellation_token();
351
352 self.retry_manager
353 .execute_with_retry_with_cancel(
354 &endpoint,
355 operation,
356 should_retry,
357 create_error,
358 &cancellation_token,
359 )
360 .await
361 }
362
363 async fn send_request_once<T: DeserializeOwned>(
364 &self,
365 method: Method,
366 endpoint: &str,
367 body: Option<Vec<u8>>,
368 authenticate: bool,
369 ) -> anyhow::Result<KrakenResponse<T>, KrakenHttpError> {
370 let cancellation_token = self.cancellation_token();
371 let _guard = if authenticate {
372 Some(self.lock_order_request(&cancellation_token).await?)
373 } else {
374 None
375 };
376
377 self.send_request_attempt(
378 method,
379 endpoint,
380 body,
381 authenticate,
382 Some(&cancellation_token),
383 )
384 .await
385 }
386
387 async fn lock_order_request(
388 &self,
389 cancellation_token: &CancellationToken,
390 ) -> anyhow::Result<tokio::sync::MutexGuard<'_, ()>, KrakenHttpError> {
391 if cancellation_token.is_cancelled() {
392 return Err(KrakenHttpError::RequestNotStarted(
393 "Request canceled before transport invocation".to_string(),
394 ));
395 }
396
397 tokio::select! {
398 biased;
399 () = cancellation_token.cancelled() => Err(KrakenHttpError::RequestNotStarted(
400 "Request canceled before transport invocation".to_string(),
401 )),
402 guard = self.auth_mutex.lock() => Ok(guard),
403 }
404 }
405
406 async fn send_request_attempt<T: DeserializeOwned>(
407 &self,
408 method: Method,
409 endpoint: &str,
410 body: Option<Vec<u8>>,
411 authenticate: bool,
412 cancellation_token: Option<&CancellationToken>,
413 ) -> anyhow::Result<KrakenResponse<T>, KrakenHttpError> {
414 let mut headers = Self::default_headers();
415 let final_body = if authenticate {
416 let nonce = self.generate_nonce();
417 log::debug!("Generated nonce {nonce} for {endpoint}");
418
419 let params: HashMap<String, String> = if let Some(ref body_bytes) = body {
420 let body_str = std::str::from_utf8(body_bytes).map_err(|e| {
421 KrakenHttpError::RequestNotStarted(format!(
422 "Invalid UTF-8 in request body: {e}"
423 ))
424 })?;
425 serde_urlencoded::from_str(body_str).map_err(|e| {
426 KrakenHttpError::RequestNotStarted(format!(
427 "Failed to parse request params: {e}"
428 ))
429 })?
430 } else {
431 HashMap::new()
432 };
433
434 let (auth_headers, post_data) =
435 self.sign_spot(endpoint, nonce, ¶ms).map_err(|e| {
436 KrakenHttpError::RequestNotStarted(format!("Failed to sign request: {e}"))
437 })?;
438 headers.extend(auth_headers);
439 Some(post_data.into_bytes())
440 } else {
441 body
442 };
443
444 if method == Method::POST {
445 headers.insert(
446 "Content-Type".to_string(),
447 "application/x-www-form-urlencoded".to_string(),
448 );
449 }
450
451 let response = if let Some(cancellation_token) = cancellation_token {
452 self.send_order_request(method, endpoint, headers, final_body, cancellation_token)
453 .await?
454 } else {
455 let url = format!("{}{endpoint}", self.base_url);
456 let rate_limit_keys = Self::rate_limit_keys(endpoint);
457 self.client
458 .request(
459 method,
460 url,
461 None,
462 Some(headers),
463 final_body,
464 None,
465 Some(rate_limit_keys),
466 )
467 .await
468 .map_err(|e| KrakenHttpError::NetworkError(e.to_string()))?
469 };
470
471 let status = response.status.as_u16();
472 if status >= 400 {
473 let body = String::from_utf8_lossy(&response.body).to_string();
474 let http_error = if status == 401 || status == 403 {
475 KrakenHttpError::AuthenticationError(format!("HTTP error {status}: {body}"))
476 } else {
477 KrakenHttpError::NetworkError(format!("HTTP error {status}: {body}"))
478 };
479 return Err(http_error);
480 }
481
482 let response_text = String::from_utf8(response.body.to_vec()).map_err(|e| {
483 KrakenHttpError::ParseError(format!("Failed to parse response as UTF-8: {e}"))
484 })?;
485 let kraken_response: KrakenResponse<T> =
486 serde_json::from_str(&response_text).map_err(|e| {
487 KrakenHttpError::ParseError(format!("Failed to deserialize response: {e}"))
488 })?;
489
490 if !kraken_response.error.is_empty() {
491 return Err(KrakenHttpError::ApiError(kraken_response.error));
492 }
493
494 Ok(kraken_response)
495 }
496
497 async fn send_order_request(
498 &self,
499 method: Method,
500 endpoint: &str,
501 headers: HashMap<String, String>,
502 body: Option<Vec<u8>>,
503 cancellation_token: &CancellationToken,
504 ) -> anyhow::Result<HttpResponse, KrakenHttpError> {
505 if cancellation_token.is_cancelled() {
506 return Err(KrakenHttpError::RequestNotStarted(
507 "Request canceled before transport invocation".to_string(),
508 ));
509 }
510
511 let request_started = AtomicBool::new(false);
512 let url = format!("{}{endpoint}", self.base_url);
513 let rate_limit_keys = Self::rate_limit_keys(endpoint);
514 let request = async {
515 request_started.store(true, Ordering::Relaxed);
516 self.client
517 .request(
518 method,
519 url,
520 None,
521 Some(headers),
522 body,
523 None,
524 Some(rate_limit_keys),
525 )
526 .await
527 };
528 tokio::pin!(request);
529
530 tokio::select! {
531 biased;
532 () = cancellation_token.cancelled() => {
533 if request_started.load(Ordering::Relaxed) {
534 Err(KrakenHttpError::NetworkError(
535 "Request canceled after transport invocation".to_string(),
536 ))
537 } else {
538 Err(KrakenHttpError::RequestNotStarted(
539 "Request canceled before transport invocation".to_string(),
540 ))
541 }
542 }
543 response = &mut request => response
544 .map_err(|e| KrakenHttpError::NetworkError(e.to_string())),
545 }
546 }
547
548 pub async fn get_server_time(&self) -> anyhow::Result<ServerTime, KrakenHttpError> {
550 let response: KrakenResponse<ServerTime> = self
551 .send_request(Method::GET, "/0/public/Time", None, false)
552 .await?;
553
554 response.result.ok_or_else(|| {
555 KrakenHttpError::ParseError("Missing result in server time response".to_string())
556 })
557 }
558
559 pub async fn get_system_status(&self) -> anyhow::Result<SystemStatus, KrakenHttpError> {
561 let response: KrakenResponse<SystemStatus> = self
562 .send_request(Method::GET, "/0/public/SystemStatus", None, false)
563 .await?;
564
565 response.result.ok_or_else(|| {
566 KrakenHttpError::ParseError("Missing result in system status response".to_string())
567 })
568 }
569
570 pub async fn get_asset_pairs(
575 &self,
576 pairs: Option<Vec<String>>,
577 aclass_base: Option<&str>,
578 ) -> anyhow::Result<AssetPairsResponse, KrakenHttpError> {
579 let mut params = Vec::new();
580
581 if let Some(pairs) = pairs {
582 params.push(format!("pair={}", pairs.join(",")));
583 }
584
585 if let Some(aclass) = aclass_base {
586 params.push(format!("aclass_base={aclass}"));
587 }
588
589 let endpoint = if params.is_empty() {
590 "/0/public/AssetPairs".to_string()
591 } else {
592 format!("/0/public/AssetPairs?{}", params.join("&"))
593 };
594
595 let response: KrakenResponse<AssetPairsResponse> = self
596 .send_request(Method::GET, &endpoint, None, false)
597 .await?;
598
599 response.result.ok_or_else(|| {
600 KrakenHttpError::ParseError("Missing result in asset pairs response".to_string())
601 })
602 }
603
604 pub async fn get_ticker(
606 &self,
607 pairs: Vec<String>,
608 asset_class: Option<KrakenAssetClass>,
609 ) -> anyhow::Result<TickerResponse, KrakenHttpError> {
610 let mut endpoint = format!("/0/public/Ticker?pair={}", pairs.join(","));
611
612 if let Some(aclass) = asset_class {
613 endpoint.push_str(&format!("&asset_class={aclass}"));
614 }
615
616 let response: KrakenResponse<TickerResponse> = self
617 .send_request(Method::GET, &endpoint, None, false)
618 .await?;
619
620 response.result.ok_or_else(|| {
621 KrakenHttpError::ParseError("Missing result in ticker response".to_string())
622 })
623 }
624
625 pub async fn get_ohlc(
627 &self,
628 pair: &str,
629 interval: Option<u32>,
630 since: Option<i64>,
631 asset_class: Option<KrakenAssetClass>,
632 ) -> anyhow::Result<OhlcResponse, KrakenHttpError> {
633 let mut endpoint = format!("/0/public/OHLC?pair={pair}");
634
635 if let Some(aclass) = asset_class {
636 endpoint.push_str(&format!("&asset_class={aclass}"));
637 }
638
639 if let Some(interval) = interval {
640 endpoint.push_str(&format!("&interval={interval}"));
641 }
642
643 if let Some(since) = since {
644 endpoint.push_str(&format!("&since={since}"));
645 }
646
647 let response: KrakenResponse<OhlcResponse> = self
648 .send_request(Method::GET, &endpoint, None, false)
649 .await?;
650
651 response.result.ok_or_else(|| {
652 KrakenHttpError::ParseError("Missing result in OHLC response".to_string())
653 })
654 }
655
656 pub async fn get_book_depth(
658 &self,
659 pair: &str,
660 count: Option<u32>,
661 asset_class: Option<KrakenAssetClass>,
662 ) -> anyhow::Result<OrderBookResponse, KrakenHttpError> {
663 let mut endpoint = format!("/0/public/Depth?pair={pair}");
664
665 if let Some(aclass) = asset_class {
666 endpoint.push_str(&format!("&asset_class={aclass}"));
667 }
668
669 if let Some(count) = count {
670 endpoint.push_str(&format!("&count={count}"));
671 }
672
673 let response: KrakenResponse<OrderBookResponse> = self
674 .send_request(Method::GET, &endpoint, None, false)
675 .await?;
676
677 response.result.ok_or_else(|| {
678 KrakenHttpError::ParseError("Missing result in book depth response".to_string())
679 })
680 }
681
682 pub async fn get_trades(
684 &self,
685 pair: &str,
686 since: Option<String>,
687 asset_class: Option<KrakenAssetClass>,
688 ) -> anyhow::Result<TradesResponse, KrakenHttpError> {
689 let mut endpoint = format!("/0/public/Trades?pair={pair}");
690
691 if let Some(aclass) = asset_class {
692 endpoint.push_str(&format!("&asset_class={aclass}"));
693 }
694
695 if let Some(since) = since {
696 endpoint.push_str(&format!("&since={since}"));
697 }
698
699 let response: KrakenResponse<TradesResponse> = self
700 .send_request(Method::GET, &endpoint, None, false)
701 .await?;
702
703 response.result.ok_or_else(|| {
704 KrakenHttpError::ParseError("Missing result in trades response".to_string())
705 })
706 }
707
708 pub async fn get_websockets_token(&self) -> anyhow::Result<WebSocketToken, KrakenHttpError> {
710 if self.credential.is_none() {
711 return Err(KrakenHttpError::AuthenticationError(
712 "API credentials required for GetWebSocketsToken".to_string(),
713 ));
714 }
715
716 let response: KrakenResponse<WebSocketToken> = self
717 .send_request(Method::POST, "/0/private/GetWebSocketsToken", None, true)
718 .await?;
719
720 response.result.ok_or_else(|| {
721 KrakenHttpError::ParseError("Missing result in websockets token response".to_string())
722 })
723 }
724
725 pub async fn get_open_orders(
727 &self,
728 trades: Option<bool>,
729 userref: Option<i64>,
730 ) -> anyhow::Result<IndexMap<String, SpotOrder>, KrakenHttpError> {
731 if self.credential.is_none() {
732 return Err(KrakenHttpError::AuthenticationError(
733 "API credentials required for OpenOrders".to_string(),
734 ));
735 }
736
737 let mut params = vec![];
738
739 if let Some(trades_flag) = trades {
740 params.push(format!("trades={trades_flag}"));
741 }
742
743 if let Some(userref_val) = userref {
744 params.push(format!("userref={userref_val}"));
745 }
746
747 let body = if params.is_empty() {
748 None
749 } else {
750 Some(params.join("&").into_bytes())
751 };
752
753 let response: KrakenResponse<SpotOpenOrdersResult> = self
754 .send_request(Method::POST, "/0/private/OpenOrders", body, true)
755 .await?;
756
757 let result = response.result.ok_or_else(|| {
758 KrakenHttpError::ParseError("Missing result in open orders response".to_string())
759 })?;
760
761 Ok(result.open)
762 }
763
764 pub async fn get_closed_orders(
766 &self,
767 trades: Option<bool>,
768 userref: Option<i64>,
769 start: Option<i64>,
770 end: Option<i64>,
771 ofs: Option<i32>,
772 closetime: Option<String>,
773 ) -> anyhow::Result<IndexMap<String, SpotOrder>, KrakenHttpError> {
774 if self.credential.is_none() {
775 return Err(KrakenHttpError::AuthenticationError(
776 "API credentials required for ClosedOrders".to_string(),
777 ));
778 }
779
780 let mut params = vec![];
781
782 if let Some(trades_flag) = trades {
783 params.push(format!("trades={trades_flag}"));
784 }
785
786 if let Some(userref_val) = userref {
787 params.push(format!("userref={userref_val}"));
788 }
789
790 if let Some(start_val) = start {
791 params.push(format!("start={start_val}"));
792 }
793
794 if let Some(end_val) = end {
795 params.push(format!("end={end_val}"));
796 }
797
798 if let Some(ofs_val) = ofs {
799 params.push(format!("ofs={ofs_val}"));
800 }
801
802 if let Some(closetime_val) = closetime {
803 params.push(format!("closetime={closetime_val}"));
804 }
805
806 let body = if params.is_empty() {
807 None
808 } else {
809 Some(params.join("&").into_bytes())
810 };
811
812 let response: KrakenResponse<SpotClosedOrdersResult> = self
813 .send_request(Method::POST, "/0/private/ClosedOrders", body, true)
814 .await?;
815
816 let result = response.result.ok_or_else(|| {
817 KrakenHttpError::ParseError("Missing result in closed orders response".to_string())
818 })?;
819
820 Ok(result.closed)
821 }
822
823 pub async fn get_trades_history(
825 &self,
826 trade_type: Option<String>,
827 trades: Option<bool>,
828 start: Option<i64>,
829 end: Option<i64>,
830 ofs: Option<i32>,
831 ) -> anyhow::Result<IndexMap<String, SpotTrade>, KrakenHttpError> {
832 if self.credential.is_none() {
833 return Err(KrakenHttpError::AuthenticationError(
834 "API credentials required for TradesHistory".to_string(),
835 ));
836 }
837
838 let mut params = vec![];
839
840 if let Some(type_val) = trade_type {
841 params.push(format!("type={type_val}"));
842 }
843
844 if let Some(trades_flag) = trades {
845 params.push(format!("trades={trades_flag}"));
846 }
847
848 if let Some(start_val) = start {
849 params.push(format!("start={start_val}"));
850 }
851
852 if let Some(end_val) = end {
853 params.push(format!("end={end_val}"));
854 }
855
856 if let Some(ofs_val) = ofs {
857 params.push(format!("ofs={ofs_val}"));
858 }
859
860 let body = if params.is_empty() {
861 None
862 } else {
863 Some(params.join("&").into_bytes())
864 };
865
866 let response: KrakenResponse<SpotTradesHistoryResult> = self
867 .send_request(Method::POST, "/0/private/TradesHistory", body, true)
868 .await?;
869
870 let result = response.result.ok_or_else(|| {
871 KrakenHttpError::ParseError("Missing result in trades history response".to_string())
872 })?;
873
874 Ok(result.trades)
875 }
876
877 pub async fn add_order(
879 &self,
880 params: &KrakenSpotAddOrderParams,
881 ) -> anyhow::Result<SpotAddOrderResponse, KrakenHttpError> {
882 if self.credential.is_none() {
883 return Err(KrakenHttpError::MissingCredentials);
884 }
885
886 let param_string = serde_urlencoded::to_string(params).map_err(|e| {
887 KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
888 })?;
889 let body = Some(param_string.into_bytes());
890
891 let response: KrakenResponse<SpotAddOrderResponse> = self
892 .send_request_once(Method::POST, "/0/private/AddOrder", body, true)
893 .await?;
894
895 response
896 .result
897 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
898 }
899
900 pub async fn add_order_batch(
902 &self,
903 params: &KrakenSpotAddOrderBatchParams,
904 ) -> anyhow::Result<SpotAddOrderBatchResponse, KrakenHttpError> {
905 let credential = self
906 .credential
907 .as_ref()
908 .ok_or(KrakenHttpError::MissingCredentials)?;
909
910 let cancellation_token = self.cancellation_token();
911 let _guard = self.lock_order_request(&cancellation_token).await?;
912
913 let endpoint = "/0/private/AddOrderBatch";
914 let nonce = self.generate_nonce();
915
916 let mut json_body = serde_json::json!({
917 "nonce": nonce.to_string(),
918 "pair": params.pair,
919 "orders": params.orders,
920 });
921
922 if let Some(aclass) = ¶ms.asset_class {
923 json_body["asset_class"] = serde_json::json!(aclass);
924 }
925 let json_str = serde_json::to_string(&json_body)
926 .map_err(|e| KrakenHttpError::RequestNotStarted(format!("Failed to serialize: {e}")))?;
927
928 let signature = credential
929 .sign_spot_json(endpoint, nonce, &json_str)
930 .map_err(|e| KrakenHttpError::RequestNotStarted(format!("Failed to sign: {e}")))?;
931
932 let mut headers = Self::default_headers();
933 headers.insert("API-Key".to_string(), credential.api_key().to_string());
934 headers.insert("API-Sign".to_string(), signature);
935 headers.insert("Content-Type".to_string(), "application/json".to_string());
936
937 let response = self
938 .send_order_request(
939 Method::POST,
940 endpoint,
941 headers,
942 Some(json_str.into_bytes()),
943 &cancellation_token,
944 )
945 .await?;
946
947 if !response.status.is_success() {
948 return Err(KrakenHttpError::NetworkError(format!(
949 "HTTP {:?} for {}",
950 response.status, endpoint
951 )));
952 }
953
954 let parsed: KrakenResponse<SpotAddOrderBatchResponse> =
955 serde_json::from_slice(&response.body).map_err(|e| {
956 KrakenHttpError::ParseError(format!("Failed to parse JSON response: {e}"))
957 })?;
958
959 if !parsed.error.is_empty() {
960 return Err(KrakenHttpError::ApiError(parsed.error));
961 }
962
963 parsed
964 .result
965 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
966 }
967
968 pub async fn cancel_order(
970 &self,
971 params: &KrakenSpotCancelOrderParams,
972 ) -> anyhow::Result<SpotCancelOrderResponse, KrakenHttpError> {
973 if self.credential.is_none() {
974 return Err(KrakenHttpError::AuthenticationError(
975 "API credentials required for canceling orders".to_string(),
976 ));
977 }
978
979 let param_string = serde_urlencoded::to_string(params)
980 .map_err(|e| KrakenHttpError::ParseError(format!("Failed to encode params: {e}")))?;
981
982 let body = Some(param_string.into_bytes());
983
984 let response: KrakenResponse<SpotCancelOrderResponse> = self
985 .send_request(Method::POST, "/0/private/CancelOrder", body, true)
986 .await?;
987
988 response
989 .result
990 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
991 }
992
993 pub async fn cancel_order_batch(
995 &self,
996 params: &KrakenSpotCancelOrderBatchParams,
997 ) -> anyhow::Result<SpotCancelOrderBatchResponse, KrakenHttpError> {
998 let credential = self.credential.as_ref().ok_or_else(|| {
999 KrakenHttpError::AuthenticationError(
1000 "API credentials required for canceling orders".to_string(),
1001 )
1002 })?;
1003
1004 let _guard = self.auth_mutex.lock().await;
1006
1007 let endpoint = "/0/private/CancelOrderBatch";
1008 let nonce = self.generate_nonce();
1009
1010 let json_body = serde_json::json!({
1012 "nonce": nonce.to_string(),
1013 "orders": params.orders
1014 });
1015 let json_str = serde_json::to_string(&json_body)
1016 .map_err(|e| KrakenHttpError::ParseError(format!("Failed to serialize: {e}")))?;
1017
1018 let signature = credential
1019 .sign_spot_json(endpoint, nonce, &json_str)
1020 .map_err(|e| KrakenHttpError::AuthenticationError(format!("Failed to sign: {e}")))?;
1021
1022 let mut headers = Self::default_headers();
1023 headers.insert("API-Key".to_string(), credential.api_key().to_string());
1024 headers.insert("API-Sign".to_string(), signature);
1025 headers.insert("Content-Type".to_string(), "application/json".to_string());
1026
1027 let url = format!("{}{endpoint}", self.base_url);
1028 let rate_limit_keys = Self::rate_limit_keys(endpoint);
1029
1030 let response = self
1031 .client
1032 .request(
1033 Method::POST,
1034 url,
1035 None,
1036 Some(headers),
1037 Some(json_str.into_bytes()),
1038 None,
1039 Some(rate_limit_keys),
1040 )
1041 .await
1042 .map_err(|e| KrakenHttpError::NetworkError(e.to_string()))?;
1043
1044 if response.status.as_u16() >= 400 {
1045 let status = response.status.as_u16();
1046 let body = String::from_utf8_lossy(&response.body).to_string();
1047
1048 if status == 401 || status == 403 {
1049 return Err(KrakenHttpError::AuthenticationError(format!(
1050 "HTTP error {status}: {body}"
1051 )));
1052 }
1053 return Err(KrakenHttpError::NetworkError(format!(
1054 "HTTP error {status}: {body}"
1055 )));
1056 }
1057
1058 let response_text = String::from_utf8(response.body.to_vec())
1059 .map_err(|e| KrakenHttpError::ParseError(format!("Invalid UTF-8: {e}")))?;
1060
1061 let kraken_response: KrakenResponse<SpotCancelOrderBatchResponse> =
1062 serde_json::from_str(&response_text).map_err(|e| {
1063 KrakenHttpError::ParseError(format!("Failed to parse response: {e}"))
1064 })?;
1065
1066 if !kraken_response.error.is_empty() {
1067 return Err(KrakenHttpError::ApiError(kraken_response.error));
1068 }
1069
1070 kraken_response
1071 .result
1072 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1073 }
1074
1075 pub async fn cancel_all_orders(
1077 &self,
1078 ) -> anyhow::Result<SpotCancelOrderResponse, KrakenHttpError> {
1079 if self.credential.is_none() {
1080 return Err(KrakenHttpError::AuthenticationError(
1081 "API credentials required for canceling orders".to_string(),
1082 ));
1083 }
1084
1085 let response: KrakenResponse<SpotCancelOrderResponse> = self
1086 .send_request(Method::POST, "/0/private/CancelAll", None, true)
1087 .await?;
1088
1089 response
1090 .result
1091 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1092 }
1093
1094 pub async fn edit_order(
1096 &self,
1097 params: &KrakenSpotEditOrderParams,
1098 ) -> anyhow::Result<SpotEditOrderResponse, KrakenHttpError> {
1099 if self.credential.is_none() {
1100 return Err(KrakenHttpError::AuthenticationError(
1101 "API credentials required for editing orders".to_string(),
1102 ));
1103 }
1104
1105 let param_string = serde_urlencoded::to_string(params)
1106 .map_err(|e| KrakenHttpError::ParseError(format!("Failed to encode params: {e}")))?;
1107
1108 let body = Some(param_string.into_bytes());
1109
1110 let response: KrakenResponse<SpotEditOrderResponse> = self
1111 .send_request(Method::POST, "/0/private/EditOrder", body, true)
1112 .await?;
1113
1114 response
1115 .result
1116 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1117 }
1118
1119 pub async fn amend_order(
1121 &self,
1122 params: &KrakenSpotAmendOrderParams,
1123 ) -> anyhow::Result<SpotAmendOrderResponse, KrakenHttpError> {
1124 if self.credential.is_none() {
1125 return Err(KrakenHttpError::MissingCredentials);
1126 }
1127
1128 let param_string = serde_urlencoded::to_string(params).map_err(|e| {
1129 KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
1130 })?;
1131
1132 let body = Some(param_string.into_bytes());
1133
1134 let response: KrakenResponse<SpotAmendOrderResponse> = self
1135 .send_request_once(Method::POST, "/0/private/AmendOrder", body, true)
1136 .await?;
1137
1138 response
1139 .result
1140 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1141 }
1142
1143 pub async fn get_balance(&self) -> anyhow::Result<BalanceResponse, KrakenHttpError> {
1145 if self.credential.is_none() {
1146 return Err(KrakenHttpError::AuthenticationError(
1147 "API credentials required for Balance".to_string(),
1148 ));
1149 }
1150
1151 let response: KrakenResponse<BalanceResponse> = self
1152 .send_request(Method::POST, "/0/private/Balance", None, true)
1153 .await?;
1154
1155 response.result.ok_or_else(|| {
1156 KrakenHttpError::ParseError("Missing result in balance response".to_string())
1157 })
1158 }
1159
1160 pub async fn get_trade_balance(
1166 &self,
1167 asset: Option<&str>,
1168 ) -> anyhow::Result<TradeBalanceResponse, KrakenHttpError> {
1169 if self.credential.is_none() {
1170 return Err(KrakenHttpError::AuthenticationError(
1171 "API credentials required for TradeBalance".to_string(),
1172 ));
1173 }
1174
1175 let params = asset.map(|a| SpotTradeBalanceParams {
1176 asset: Some(a.to_string()),
1177 });
1178
1179 let body = params
1180 .as_ref()
1181 .and_then(|p| serde_urlencoded::to_string(p).ok())
1182 .map(|s| s.into_bytes());
1183
1184 let response: KrakenResponse<TradeBalanceResponse> = self
1185 .send_request(Method::POST, "/0/private/TradeBalance", body, true)
1186 .await?;
1187
1188 response.result.ok_or_else(|| {
1189 KrakenHttpError::ParseError("Missing result in TradeBalance response".to_string())
1190 })
1191 }
1192
1193 pub async fn get_open_positions(
1195 &self,
1196 params: &SpotOpenPositionsParams,
1197 ) -> anyhow::Result<SpotOpenPositionsResponse, KrakenHttpError> {
1198 if self.credential.is_none() {
1199 return Err(KrakenHttpError::AuthenticationError(
1200 "API credentials required for OpenPositions".to_string(),
1201 ));
1202 }
1203
1204 let body = serde_urlencoded::to_string(params)
1205 .ok()
1206 .filter(|s| !s.is_empty())
1207 .map(|s| s.into_bytes());
1208
1209 let response: KrakenResponse<SpotOpenPositionsResponse> = self
1210 .send_request(Method::POST, "/0/private/OpenPositions", body, true)
1211 .await?;
1212
1213 response.result.ok_or_else(|| {
1214 KrakenHttpError::ParseError("Missing result in OpenPositions response".to_string())
1215 })
1216 }
1217}
1218
1219pub(crate) type SpotBatchOrder = (
1220 InstrumentId,
1221 ClientOrderId,
1222 OrderSide,
1223 OrderType,
1224 Quantity,
1225 TimeInForce,
1226 Option<UnixNanos>,
1227 Option<Price>,
1228 Option<Price>,
1229 Option<TriggerType>,
1230 Option<Decimal>,
1231 Option<Decimal>,
1232 bool,
1233 bool,
1234 bool,
1235 Option<Quantity>,
1236 Option<u16>,
1237);
1238
1239#[cfg_attr(
1245 feature = "python",
1246 pyo3::pyclass(module = "nautilus_trader.adapters.kraken", from_py_object)
1247)]
1248#[cfg_attr(
1249 feature = "python",
1250 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.kraken")
1251)]
1252pub struct KrakenSpotHttpClient {
1253 pub(crate) inner: Arc<KrakenSpotRawHttpClient>,
1254 pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
1255 leverage_tiers_cache: LeverageTiersCache,
1256 clock: &'static AtomicTime,
1257 cache_initialized: Arc<AtomicBool>,
1258}
1259
1260impl Clone for KrakenSpotHttpClient {
1261 fn clone(&self) -> Self {
1262 Self {
1263 inner: self.inner.clone(),
1264 instruments_cache: self.instruments_cache.clone(),
1265 leverage_tiers_cache: self.leverage_tiers_cache.clone(),
1266 cache_initialized: self.cache_initialized.clone(),
1267 clock: self.clock,
1268 }
1269 }
1270}
1271
1272impl Default for KrakenSpotHttpClient {
1273 fn default() -> Self {
1274 Self::new(
1275 KrakenEnvironment::Live,
1276 None,
1277 60,
1278 None,
1279 None,
1280 None,
1281 None,
1282 KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
1283 )
1284 .expect("Failed to create default KrakenSpotHttpClient")
1285 }
1286}
1287
1288impl Debug for KrakenSpotHttpClient {
1289 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1290 f.debug_struct(stringify!(KrakenSpotHttpClient))
1291 .field("inner", &self.inner)
1292 .finish()
1293 }
1294}
1295
1296impl KrakenSpotHttpClient {
1297 #[expect(clippy::too_many_arguments)]
1299 pub fn new(
1300 environment: KrakenEnvironment,
1301 base_url_override: Option<String>,
1302 timeout_secs: u64,
1303 max_retries: Option<u32>,
1304 retry_delay_ms: Option<u64>,
1305 retry_delay_max_ms: Option<u64>,
1306 proxy_url: Option<String>,
1307 max_requests_per_second: u32,
1308 ) -> anyhow::Result<Self> {
1309 Ok(Self {
1310 inner: Arc::new(KrakenSpotRawHttpClient::new(
1311 environment,
1312 base_url_override,
1313 timeout_secs,
1314 max_retries,
1315 retry_delay_ms,
1316 retry_delay_max_ms,
1317 proxy_url,
1318 max_requests_per_second,
1319 )?),
1320 instruments_cache: Arc::new(AtomicMap::new()),
1321 leverage_tiers_cache: Arc::new(AtomicMap::new()),
1322 cache_initialized: Arc::new(AtomicBool::new(false)),
1323 clock: get_atomic_clock_realtime(),
1324 })
1325 }
1326
1327 #[expect(clippy::too_many_arguments)]
1329 pub fn with_credentials(
1330 api_key: String,
1331 api_secret: String,
1332 environment: KrakenEnvironment,
1333 base_url_override: Option<String>,
1334 timeout_secs: u64,
1335 max_retries: Option<u32>,
1336 retry_delay_ms: Option<u64>,
1337 retry_delay_max_ms: Option<u64>,
1338 proxy_url: Option<String>,
1339 max_requests_per_second: u32,
1340 ) -> anyhow::Result<Self> {
1341 Ok(Self {
1342 inner: Arc::new(KrakenSpotRawHttpClient::with_credentials(
1343 api_key,
1344 api_secret,
1345 environment,
1346 base_url_override,
1347 timeout_secs,
1348 max_retries,
1349 retry_delay_ms,
1350 retry_delay_max_ms,
1351 proxy_url,
1352 max_requests_per_second,
1353 )?),
1354 instruments_cache: Arc::new(AtomicMap::new()),
1355 leverage_tiers_cache: Arc::new(AtomicMap::new()),
1356 cache_initialized: Arc::new(AtomicBool::new(false)),
1357 clock: get_atomic_clock_realtime(),
1358 })
1359 }
1360
1361 #[expect(clippy::too_many_arguments)]
1369 pub fn from_env(
1370 environment: KrakenEnvironment,
1371 base_url_override: Option<String>,
1372 timeout_secs: u64,
1373 max_retries: Option<u32>,
1374 retry_delay_ms: Option<u64>,
1375 retry_delay_max_ms: Option<u64>,
1376 proxy_url: Option<String>,
1377 max_requests_per_second: u32,
1378 ) -> anyhow::Result<Self> {
1379 if let Some(credential) = KrakenCredential::from_env_spot() {
1380 let (api_key, api_secret) = credential.into_parts();
1381 Self::with_credentials(
1382 api_key,
1383 api_secret,
1384 environment,
1385 base_url_override,
1386 timeout_secs,
1387 max_retries,
1388 retry_delay_ms,
1389 retry_delay_max_ms,
1390 proxy_url,
1391 max_requests_per_second,
1392 )
1393 } else {
1394 Self::new(
1395 environment,
1396 base_url_override,
1397 timeout_secs,
1398 max_retries,
1399 retry_delay_ms,
1400 retry_delay_max_ms,
1401 proxy_url,
1402 max_requests_per_second,
1403 )
1404 }
1405 }
1406
1407 pub fn cancel_all_requests(&self) {
1409 self.inner.cancel_all_requests();
1410 }
1411
1412 pub fn reset_cancellation_token(&self) {
1414 self.inner.reset_cancellation_token();
1415 }
1416
1417 pub fn cancellation_token(&self) -> CancellationToken {
1419 self.inner.cancellation_token()
1420 }
1421
1422 pub fn cache_instrument(&self, instrument: InstrumentAny) {
1424 self.instruments_cache
1425 .insert(instrument.symbol().inner(), instrument);
1426 self.cache_initialized.store(true, Ordering::Release);
1427 }
1428
1429 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1431 self.instruments_cache.rcu(|m| {
1432 for instrument in instruments {
1433 m.insert(instrument.symbol().inner(), instrument.clone());
1434 }
1435 });
1436 self.cache_initialized.store(true, Ordering::Release);
1437 }
1438
1439 pub fn get_cached_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1441 self.instruments_cache.get_cloned(symbol)
1442 }
1443
1444 fn get_instrument_by_raw_symbol(&self, raw_symbol: &str) -> Option<InstrumentAny> {
1445 self.instruments_cache
1446 .load()
1447 .values()
1448 .find(|inst| inst.raw_symbol().as_str() == raw_symbol)
1449 .cloned()
1450 }
1451
1452 fn generate_ts_init(&self) -> UnixNanos {
1453 self.clock.get_time_ns()
1454 }
1455
1456 fn asset_class_for(instrument: &InstrumentAny) -> Option<KrakenAssetClass> {
1458 if matches!(instrument, InstrumentAny::TokenizedAsset(_)) {
1459 Some(KrakenAssetClass::TokenizedAsset)
1460 } else {
1461 None
1462 }
1463 }
1464
1465 pub async fn get_websockets_token(&self) -> anyhow::Result<WebSocketToken, KrakenHttpError> {
1467 self.inner.get_websockets_token().await
1468 }
1469
1470 pub async fn request_instruments(
1475 &self,
1476 pairs: Option<Vec<String>>,
1477 ) -> anyhow::Result<Vec<InstrumentAny>, KrakenHttpError> {
1478 let ts_init = self.generate_ts_init();
1479 let asset_pairs = self.inner.get_asset_pairs(pairs.clone(), None).await?;
1480
1481 let mut instruments: Vec<InstrumentAny> = asset_pairs
1482 .iter()
1483 .filter_map(|(pair_name, definition)| {
1484 match parse_spot_instrument(pair_name, definition, ts_init, ts_init) {
1485 Ok(instrument) => Some((instrument, definition)),
1486 Err(e) => {
1487 log::warn!("Failed to parse instrument {pair_name}: {e}");
1488 None
1489 }
1490 }
1491 })
1492 .map(|(instrument, definition)| {
1493 let key = Ustr::from(instrument.raw_symbol().as_str());
1494 let tiers = (
1495 definition.leverage_buy.clone(),
1496 definition.leverage_sell.clone(),
1497 );
1498 self.leverage_tiers_cache.rcu(|m| {
1499 m.insert(key, tiers.clone());
1500 });
1501 instrument
1502 })
1503 .collect();
1504
1505 {
1509 match self
1510 .inner
1511 .get_asset_pairs(pairs, Some("tokenized_asset"))
1512 .await
1513 {
1514 Ok(tokenized_pairs) => {
1515 if !tokenized_pairs.is_empty() {
1516 log::debug!("Fetched {} tokenized asset pairs", tokenized_pairs.len());
1517 }
1518 let tokenized_instruments: Vec<InstrumentAny> =
1519 tokenized_pairs
1520 .iter()
1521 .filter_map(|(pair_name, definition)| match parse_tokenized_instrument(
1522 pair_name, definition, ts_init, ts_init,
1523 ) {
1524 Ok(instrument) => Some(instrument),
1525 Err(e) => {
1526 log::warn!(
1527 "Failed to parse tokenized instrument {pair_name}: {e}"
1528 );
1529 None
1530 }
1531 })
1532 .collect();
1533 instruments.extend(tokenized_instruments);
1534 }
1535 Err(e) => {
1536 log::warn!("Failed to fetch tokenized asset pairs: {e}");
1537 }
1538 }
1539 }
1540
1541 Ok(instruments)
1542 }
1543
1544 pub async fn request_instrument_statuses(
1550 &self,
1551 pairs: Option<Vec<String>>,
1552 ) -> anyhow::Result<AHashMap<InstrumentId, MarketStatusAction>, KrakenHttpError> {
1553 let asset_pairs = self.inner.get_asset_pairs(pairs.clone(), None).await?;
1554 let mut statuses = collect_spot_statuses(&asset_pairs);
1555
1556 let tokenized_pairs = self
1557 .inner
1558 .get_asset_pairs(pairs, Some("tokenized_asset"))
1559 .await?;
1560 statuses.extend(collect_spot_statuses(&tokenized_pairs));
1561
1562 Ok(statuses)
1563 }
1564
1565 pub async fn request_trades(
1567 &self,
1568 instrument_id: InstrumentId,
1569 start: Option<Timestamp>,
1570 end: Option<Timestamp>,
1571 limit: Option<u64>,
1572 ) -> anyhow::Result<Vec<TradeTick>, KrakenHttpError> {
1573 let instrument = self
1574 .get_cached_instrument(&instrument_id.symbol.inner())
1575 .ok_or_else(|| {
1576 KrakenHttpError::ParseError(
1577 InstrumentLookupError::not_found(instrument_id).to_string(),
1578 )
1579 })?;
1580
1581 let raw_symbol = instrument.raw_symbol().to_string();
1582 let asset_class = Self::asset_class_for(&instrument);
1583 let ts_init = self.generate_ts_init();
1584
1585 let since = start.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0).to_string());
1587 let response = self
1588 .inner
1589 .get_trades(&raw_symbol, since, asset_class)
1590 .await?;
1591
1592 let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1593 let mut trades = Vec::new();
1594
1595 for (_pair_name, trade_arrays) in &response.data {
1596 for trade_array in trade_arrays {
1597 match parse_trade_tick_from_array(trade_array, &instrument, ts_init) {
1598 Ok(trade_tick) => {
1599 if let Some(end_nanos) = end_ns
1600 && trade_tick.ts_event.as_u64() > end_nanos
1601 {
1602 continue;
1603 }
1604 trades.push(trade_tick);
1605 }
1606 Err(e) => {
1607 log::warn!("Failed to parse trade tick: {e}");
1608 }
1609 }
1610 }
1611 }
1612
1613 apply_count_limit(&mut trades, start, limit);
1615
1616 Ok(trades)
1617 }
1618
1619 pub async fn request_bars(
1621 &self,
1622 bar_type: BarType,
1623 start: Option<Timestamp>,
1624 end: Option<Timestamp>,
1625 limit: Option<u64>,
1626 ) -> anyhow::Result<Vec<Bar>, KrakenHttpError> {
1627 let instrument_id = bar_type.instrument_id();
1628 let instrument = self
1629 .get_cached_instrument(&instrument_id.symbol.inner())
1630 .ok_or_else(|| {
1631 KrakenHttpError::ParseError(
1632 InstrumentLookupError::not_found(instrument_id).to_string(),
1633 )
1634 })?;
1635
1636 let raw_symbol = instrument.raw_symbol().to_string();
1637 let asset_class = Self::asset_class_for(&instrument);
1638 let ts_init = self.generate_ts_init();
1639
1640 let interval = Some(
1641 bar_type_to_spot_interval(bar_type)
1642 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?,
1643 );
1644
1645 let since = start.map(|dt| dt.as_second());
1647 let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1648 let response = self
1649 .inner
1650 .get_ohlc(&raw_symbol, interval, since, asset_class)
1651 .await?;
1652
1653 let mut bars = Vec::new();
1654
1655 for (_pair_name, ohlc_arrays) in &response.data {
1656 for ohlc_array in ohlc_arrays {
1657 if ohlc_array.len() < 8 {
1658 let len = ohlc_array.len();
1659 log::warn!("OHLC array too short: {len}");
1660 continue;
1661 }
1662
1663 let ohlc = OhlcData {
1664 time: ohlc_array[0].as_i64().unwrap_or(0),
1665 open: ohlc_array[1].as_str().unwrap_or("0").to_string(),
1666 high: ohlc_array[2].as_str().unwrap_or("0").to_string(),
1667 low: ohlc_array[3].as_str().unwrap_or("0").to_string(),
1668 close: ohlc_array[4].as_str().unwrap_or("0").to_string(),
1669 vwap: ohlc_array[5].as_str().unwrap_or("0").to_string(),
1670 volume: ohlc_array[6].as_str().unwrap_or("0").to_string(),
1671 count: ohlc_array[7].as_i64().unwrap_or(0),
1672 };
1673
1674 match parse_bar(&ohlc, &instrument, bar_type, ts_init) {
1675 Ok(bar) => {
1676 if let Some(end_nanos) = end_ns
1677 && bar.ts_event.as_u64() > end_nanos
1678 {
1679 continue;
1680 }
1681 bars.push(bar);
1682 }
1683 Err(e) => {
1684 log::warn!("Failed to parse bar: {e}");
1685 }
1686 }
1687 }
1688 }
1689
1690 apply_count_limit(&mut bars, start, limit);
1693
1694 Ok(bars)
1695 }
1696
1697 pub async fn request_book_snapshot(
1699 &self,
1700 instrument_id: InstrumentId,
1701 depth: Option<u32>,
1702 ) -> anyhow::Result<OrderBook, KrakenHttpError> {
1703 let instrument = self
1704 .get_cached_instrument(&instrument_id.symbol.inner())
1705 .ok_or_else(|| {
1706 KrakenHttpError::ParseError(
1707 InstrumentLookupError::not_found(instrument_id).to_string(),
1708 )
1709 })?;
1710
1711 let raw_symbol = instrument.raw_symbol().to_string();
1712 let asset_class = Self::asset_class_for(&instrument);
1713 let price_precision = instrument.price_precision();
1714 let size_precision = instrument.size_precision();
1715 let ts_event = self.generate_ts_init();
1716
1717 let response = self
1718 .inner
1719 .get_book_depth(&raw_symbol, depth, asset_class)
1720 .await?;
1721
1722 let book_data = response.values().next().ok_or_else(|| {
1723 KrakenHttpError::ParseError(format!("No book data returned for {instrument_id}"))
1724 })?;
1725
1726 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
1727
1728 for (i, level) in book_data.bids.iter().enumerate() {
1731 let price_str = level.first().and_then(|v| v.as_str()).unwrap_or("0");
1732 let size_str = level.get(1).and_then(|v| v.as_str()).unwrap_or("0");
1733 let price = Price::from_decimal_dp(
1734 Decimal::from_str_exact(price_str).unwrap_or(Decimal::ZERO),
1735 price_precision,
1736 )
1737 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1738 let size = Quantity::from_decimal_dp(
1739 Decimal::from_str_exact(size_str).unwrap_or(Decimal::ZERO),
1740 size_precision,
1741 )
1742 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1743 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
1744 book.add(order, 0, 0, ts_event);
1745 }
1746
1747 let bids_len = book_data.bids.len();
1748
1749 for (i, level) in book_data.asks.iter().enumerate() {
1750 let price_str = level.first().and_then(|v| v.as_str()).unwrap_or("0");
1751 let size_str = level.get(1).and_then(|v| v.as_str()).unwrap_or("0");
1752 let price = Price::from_decimal_dp(
1753 Decimal::from_str_exact(price_str).unwrap_or(Decimal::ZERO),
1754 price_precision,
1755 )
1756 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1757 let size = Quantity::from_decimal_dp(
1758 Decimal::from_str_exact(size_str).unwrap_or(Decimal::ZERO),
1759 size_precision,
1760 )
1761 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1762 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
1763 book.add(order, 0, 0, ts_event);
1764 }
1765
1766 Ok(book)
1767 }
1768
1769 pub async fn request_account_state(
1780 &self,
1781 account_id: AccountId,
1782 account_type: AccountType,
1783 margin_balance_asset: Option<&str>,
1784 ) -> anyhow::Result<AccountState> {
1785 self.request_account_state_with_metrics(account_id, account_type, margin_balance_asset)
1786 .await
1787 .map(|(state, _)| state)
1788 }
1789
1790 pub async fn request_account_state_with_metrics(
1806 &self,
1807 account_id: AccountId,
1808 account_type: AccountType,
1809 margin_balance_asset: Option<&str>,
1810 ) -> anyhow::Result<(AccountState, IndexMap<String, String>)> {
1811 let balances_raw = self.inner.get_balance().await?;
1812 let ts_init = self.generate_ts_init();
1813
1814 let (margins, metrics, margin_entry, target_code) = if account_type == AccountType::Margin {
1815 let snapshot = self
1816 .fetch_trade_balance_snapshot(margin_balance_asset)
1817 .await?;
1818 let target_code = normalize_currency_code(margin_balance_asset.unwrap_or("ZUSD"));
1819 let currency = Currency::new(target_code, 8, 0, "0", CurrencyType::Crypto);
1820 let margin_entry = AccountBalance::from_total_and_free(
1821 snapshot.equity,
1822 snapshot.free_margin,
1823 currency,
1824 )
1825 .context("Failed to build synthetic margin AccountBalance from TradeBalance")?;
1826
1827 (
1828 snapshot.margins,
1829 snapshot.metrics,
1830 Some(margin_entry),
1831 target_code,
1832 )
1833 } else {
1834 (Vec::new(), IndexMap::new(), None, "")
1835 };
1836
1837 let skip_margin_wallet = margin_entry.is_some();
1838
1839 let balances: Vec<AccountBalance> = balances_raw
1840 .iter()
1841 .filter_map(|(currency_code, amount_str)| {
1842 let amount = Decimal::from_str_exact(amount_str).ok()?;
1843 if amount.is_zero() {
1844 return None;
1845 }
1846
1847 let normalized_code = currency_code
1848 .strip_prefix("X")
1849 .or_else(|| currency_code.strip_prefix("Z"))
1850 .unwrap_or(currency_code);
1851
1852 if skip_margin_wallet && normalized_code == target_code {
1853 return None;
1854 }
1855
1856 let currency = Currency::new(normalized_code, 8, 0, "0", CurrencyType::Crypto);
1857 AccountBalance::from_total_and_locked(amount, Decimal::ZERO, currency).ok()
1858 })
1859 .chain(margin_entry)
1860 .collect();
1861
1862 let state = AccountState::new(
1863 account_id,
1864 account_type,
1865 balances,
1866 margins,
1867 true,
1868 UUID4::new(),
1869 ts_init,
1870 ts_init,
1871 None,
1872 );
1873
1874 Ok((state, metrics))
1875 }
1876
1877 async fn fetch_trade_balance_snapshot(
1892 &self,
1893 asset: Option<&str>,
1894 ) -> anyhow::Result<TradeBalanceSnapshot> {
1895 let tb = self.inner.get_trade_balance(asset).await?;
1896
1897 let used_margin = Decimal::from_str_exact(&tb.m)
1898 .with_context(|| format!("Failed to parse TradeBalance 'm' field {:?}", tb.m))?;
1899 let free_margin = Decimal::from_str_exact(&tb.mf)
1900 .with_context(|| format!("Failed to parse TradeBalance 'mf' field {:?}", tb.mf))?;
1901 let equity = Decimal::from_str_exact(&tb.e)
1902 .with_context(|| format!("Failed to parse TradeBalance 'e' field {:?}", tb.e))?;
1903
1904 let margins = if used_margin.is_zero() {
1905 Vec::new()
1906 } else {
1907 let currency = trade_balance_currency(asset);
1908 let initial = Money::from_decimal(used_margin, currency)
1909 .context("Failed to build initial margin from TradeBalance 'm'")?;
1910 let maintenance = Money::zero(currency);
1911 vec![MarginBalance::new(initial, maintenance, None)]
1912 };
1913
1914 let mut metrics = IndexMap::new();
1915 metrics.insert("equivalent_balance".to_string(), tb.eb);
1916 metrics.insert("trade_balance".to_string(), tb.tb);
1917 metrics.insert("used_margin".to_string(), tb.m);
1918 metrics.insert("unexecuted_value".to_string(), tb.uv);
1919 metrics.insert("unrealized_pnl".to_string(), tb.n);
1920 metrics.insert("cost_basis".to_string(), tb.c);
1921 metrics.insert("valuation".to_string(), tb.v);
1922 metrics.insert("equity".to_string(), tb.e);
1923 metrics.insert("free_margin".to_string(), tb.mf);
1924 if let Some(ml) = tb.ml {
1925 metrics.insert("margin_level".to_string(), ml);
1926 }
1927 metrics.insert(
1928 "asset".to_string(),
1929 normalize_currency_code(asset.unwrap_or("ZUSD")).to_string(),
1930 );
1931
1932 Ok(TradeBalanceSnapshot {
1933 margins,
1934 metrics,
1935 free_margin,
1936 equity,
1937 })
1938 }
1939
1940 pub async fn request_margin_metrics(
1953 &self,
1954 asset: Option<&str>,
1955 ) -> anyhow::Result<IndexMap<String, String>> {
1956 self.fetch_trade_balance_snapshot(asset)
1957 .await
1958 .map(|snapshot| snapshot.metrics)
1959 }
1960
1961 pub async fn request_order_status_reports(
1963 &self,
1964 account_id: AccountId,
1965 instrument_id: Option<InstrumentId>,
1966 start: Option<Timestamp>,
1967 end: Option<Timestamp>,
1968 open_only: bool,
1969 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1970 const PAGE_SIZE: i32 = 50;
1971
1972 let ts_init = self.generate_ts_init();
1973 let mut all_reports = Vec::new();
1974
1975 let open_orders = self.inner.get_open_orders(Some(true), None).await?;
1976
1977 for (order_id, order) in &open_orders {
1978 if let Some(ref target_id) = instrument_id {
1979 let instrument = self.get_cached_instrument(&target_id.symbol.inner());
1980 if let Some(inst) = instrument
1981 && inst.raw_symbol().as_str() != order.descr.pair
1982 {
1983 continue;
1984 }
1985 }
1986
1987 if let Some(instrument) = self.get_instrument_by_raw_symbol(order.descr.pair.as_str()) {
1988 match parse_order_status_report(order_id, order, &instrument, account_id, ts_init) {
1989 Ok(report) => all_reports.push(report),
1990 Err(e) => {
1991 log::warn!("Failed to parse order {order_id}: {e}");
1992 }
1993 }
1994 }
1995 }
1996
1997 if open_only {
1998 return Ok(all_reports);
1999 }
2000
2001 let start_ts = start.map(|dt| dt.as_second());
2003 let end_ts = end.map(|dt| dt.as_second());
2004
2005 let mut offset = 0;
2006
2007 loop {
2008 let closed_orders = self
2009 .inner
2010 .get_closed_orders(Some(true), None, start_ts, end_ts, Some(offset), None)
2011 .await?;
2012
2013 if closed_orders.is_empty() {
2014 break;
2015 }
2016
2017 for (order_id, order) in &closed_orders {
2018 if let Some(ref target_id) = instrument_id {
2019 let instrument = self.get_cached_instrument(&target_id.symbol.inner());
2020 if let Some(inst) = instrument
2021 && inst.raw_symbol().as_str() != order.descr.pair
2022 {
2023 continue;
2024 }
2025 }
2026
2027 if let Some(instrument) =
2028 self.get_instrument_by_raw_symbol(order.descr.pair.as_str())
2029 {
2030 match parse_order_status_report(
2031 order_id,
2032 order,
2033 &instrument,
2034 account_id,
2035 ts_init,
2036 ) {
2037 Ok(report) => all_reports.push(report),
2038 Err(e) => {
2039 log::warn!("Failed to parse order {order_id}: {e}");
2040 }
2041 }
2042 }
2043 }
2044
2045 offset += PAGE_SIZE;
2046 }
2047
2048 Ok(all_reports)
2049 }
2050
2051 pub async fn request_fill_reports(
2053 &self,
2054 account_id: AccountId,
2055 instrument_id: Option<InstrumentId>,
2056 start: Option<Timestamp>,
2057 end: Option<Timestamp>,
2058 ) -> anyhow::Result<Vec<FillReport>> {
2059 const PAGE_SIZE: i32 = 50;
2060
2061 let ts_init = self.generate_ts_init();
2062 let mut all_reports = Vec::new();
2063
2064 let start_ts = start.map(|dt| dt.as_second());
2066 let end_ts = end.map(|dt| dt.as_second());
2067
2068 let mut offset = 0;
2069
2070 loop {
2071 let trades = self
2072 .inner
2073 .get_trades_history(None, Some(true), start_ts, end_ts, Some(offset))
2074 .await?;
2075
2076 if trades.is_empty() {
2077 break;
2078 }
2079
2080 for (trade_id, trade) in &trades {
2081 if let Some(ref target_id) = instrument_id {
2082 let instrument = self.get_cached_instrument(&target_id.symbol.inner());
2083 if let Some(inst) = instrument
2084 && inst.raw_symbol().as_str() != trade.pair
2085 {
2086 continue;
2087 }
2088 }
2089
2090 if let Some(instrument) = self.get_instrument_by_raw_symbol(trade.pair.as_str()) {
2091 match parse_fill_report(trade_id, trade, &instrument, account_id, ts_init) {
2092 Ok(report) => all_reports.push(report),
2093 Err(e) => {
2094 log::warn!("Failed to parse trade {trade_id}: {e}");
2095 }
2096 }
2097 }
2098 }
2099
2100 offset += PAGE_SIZE;
2101 }
2102
2103 Ok(all_reports)
2104 }
2105
2106 pub async fn request_position_status_reports(
2112 &self,
2113 account_id: AccountId,
2114 instrument_id: Option<InstrumentId>,
2115 account_type: AccountType,
2116 use_spot_position_reports: bool,
2117 quote_currency: Ustr,
2118 ) -> anyhow::Result<Vec<PositionStatusReport>> {
2119 if account_type == AccountType::Margin {
2120 self.generate_margin_position_reports(account_id, instrument_id)
2121 .await
2122 } else if use_spot_position_reports {
2123 self.generate_spot_position_reports_from_wallet(
2124 account_id,
2125 instrument_id,
2126 quote_currency,
2127 )
2128 .await
2129 } else {
2130 Ok(Vec::new())
2131 }
2132 }
2133
2134 async fn generate_margin_position_reports(
2136 &self,
2137 account_id: AccountId,
2138 instrument_id: Option<InstrumentId>,
2139 ) -> anyhow::Result<Vec<PositionStatusReport>> {
2140 let open_positions = self
2141 .inner
2142 .get_open_positions(&SpotOpenPositionsParams::default())
2143 .await?;
2144
2145 let ts_init = self.generate_ts_init();
2146
2147 let mut agg: IndexMap<String, (Decimal, InstrumentId)> = IndexMap::new();
2155
2156 let target_pair: Option<Ustr> = match &instrument_id {
2157 Some(target_id) => match self.get_cached_instrument(&target_id.symbol.inner()) {
2158 Some(inst) => Some(Ustr::from(inst.raw_symbol().as_str())),
2159 None => return Ok(Vec::new()),
2160 },
2161 None => None,
2162 };
2163
2164 for (_pos_id, pos) in open_positions.iter() {
2165 if let Some(pair) = target_pair
2166 && pair.as_str() != pos.pair
2167 {
2168 continue;
2169 }
2170
2171 if let Some(status) = pos.posstatus.as_deref()
2172 && status != "open"
2173 {
2174 log::debug!(
2175 "Skipping non-open OpenPositions entry for {}: posstatus={status}",
2176 pos.pair,
2177 );
2178 continue;
2179 }
2180
2181 let instrument = self
2182 .get_instrument_by_raw_symbol(pos.pair.as_str())
2183 .ok_or_else(|| {
2184 anyhow::anyhow!(
2185 "OpenPositions: instrument not in cache for pair {}",
2186 pos.pair
2187 )
2188 })?;
2189
2190 let vol = Decimal::from_str_exact(&pos.vol)
2191 .with_context(|| format!("OpenPositions: failed to parse vol for {}", pos.pair))?;
2192 let vol_closed = Decimal::from_str_exact(&pos.vol_closed).with_context(|| {
2193 format!("OpenPositions: failed to parse vol_closed for {}", pos.pair)
2194 })?;
2195
2196 let lot_net = (vol - vol_closed).max(Decimal::ZERO);
2197 let signed_lot = match pos.side {
2198 KrakenOrderSide::Buy => lot_net,
2199 KrakenOrderSide::Sell => -lot_net,
2200 };
2201
2202 let entry = agg
2203 .entry(pos.pair.clone())
2204 .or_insert((Decimal::ZERO, instrument.id()));
2205 entry.0 += signed_lot;
2206 }
2207
2208 let mut reports = Vec::new();
2209
2210 for (_, (signed_qty, inst_id)) in agg {
2211 let instrument = self
2212 .get_cached_instrument(&inst_id.symbol.inner())
2213 .ok_or_else(|| InstrumentLookupError::not_found(inst_id))?;
2214
2215 let side = if signed_qty.is_sign_positive() && !signed_qty.is_zero() {
2216 PositionSide::Long
2217 } else if signed_qty.is_sign_negative() && !signed_qty.is_zero() {
2218 PositionSide::Short
2219 } else {
2220 PositionSide::Flat
2221 };
2222 let quantity = Quantity::from_decimal_dp(signed_qty.abs(), instrument.size_precision())
2223 .map_err(|e| {
2224 anyhow::anyhow!("OpenPositions: failed to build Quantity for {inst_id}: {e:?}")
2225 })?;
2226 let report = PositionStatusReport::new(
2227 account_id, inst_id, side, quantity, ts_init, ts_init, None, None, None,
2228 );
2229 reports.push(report);
2230 }
2231
2232 if let Some(target_id) = instrument_id {
2239 let already_reported = reports.iter().any(|r| r.instrument_id == target_id);
2240
2241 if !already_reported
2242 && let Some(instrument) = self.get_cached_instrument(&target_id.symbol.inner())
2243 {
2244 let precision = instrument.size_precision();
2245 reports.push(PositionStatusReport::new(
2246 account_id,
2247 target_id,
2248 PositionSide::Flat,
2249 Quantity::zero(precision),
2250 ts_init,
2251 ts_init,
2252 None,
2253 None,
2254 None,
2255 ));
2256 }
2257 }
2258
2259 Ok(reports)
2260 }
2261
2262 async fn generate_spot_position_reports_from_wallet(
2268 &self,
2269 account_id: AccountId,
2270 instrument_id: Option<InstrumentId>,
2271 quote_currency: Ustr,
2272 ) -> anyhow::Result<Vec<PositionStatusReport>> {
2273 let balances_raw = self.inner.get_balance().await?;
2274 let ts_init = self.generate_ts_init();
2275 let mut wallet_by_coin: HashMap<Ustr, Decimal> = HashMap::new();
2276
2277 for (currency_code, amount_str) in &balances_raw {
2278 let balance = match Decimal::from_str_exact(amount_str) {
2279 Ok(b) => b,
2280 Err(_) => continue,
2281 };
2282
2283 if balance.is_zero() {
2284 continue;
2285 }
2286
2287 wallet_by_coin.insert(Ustr::from(normalize_currency_code(currency_code)), balance);
2288 }
2289
2290 let mut reports = Vec::new();
2291
2292 if let Some(instrument_id) = instrument_id {
2293 if let Some(instrument) = self.get_cached_instrument(&instrument_id.symbol.inner()) {
2294 let base_currency = match instrument.base_currency() {
2295 Some(currency) => currency,
2296 None => return Ok(reports),
2297 };
2298
2299 let coin = Ustr::from(normalize_currency_code(base_currency.code.as_str()));
2300 let wallet_balance = wallet_by_coin.get(&coin).copied().unwrap_or(Decimal::ZERO);
2301
2302 let side = if wallet_balance > Decimal::ZERO {
2303 PositionSide::Long
2304 } else {
2305 PositionSide::Flat
2306 };
2307
2308 let abs_balance = wallet_balance.abs();
2309 let quantity = Quantity::from_decimal_dp(abs_balance, instrument.size_precision())?;
2310
2311 let report = PositionStatusReport::new(
2312 account_id,
2313 instrument_id,
2314 side,
2315 quantity,
2316 ts_init,
2317 ts_init,
2318 None,
2319 None,
2320 None,
2321 );
2322
2323 reports.push(report);
2324 }
2325 } else {
2326 let quote_filter = quote_currency;
2327
2328 let instruments_guard = self.instruments_cache.load();
2329 for instrument in instruments_guard.values() {
2330 let quote_currency = match instrument.quote_currency() {
2331 currency if currency.code == quote_filter => currency,
2332 _ => continue,
2333 };
2334
2335 let base_currency = match instrument.base_currency() {
2336 Some(currency) => currency,
2337 None => continue,
2338 };
2339
2340 let coin = Ustr::from(normalize_currency_code(base_currency.code.as_str()));
2341 let wallet_balance = wallet_by_coin.get(&coin).copied().unwrap_or(Decimal::ZERO);
2342
2343 if wallet_balance.is_zero() {
2344 continue;
2345 }
2346
2347 let side = PositionSide::Long;
2348 let quantity =
2349 Quantity::from_decimal_dp(wallet_balance, instrument.size_precision())?;
2350
2351 if quantity.is_zero() {
2352 continue;
2353 }
2354
2355 log::debug!(
2356 "Spot position: {} {} (quote: {})",
2357 quantity,
2358 base_currency.code,
2359 quote_currency.code
2360 );
2361
2362 let report = PositionStatusReport::new(
2363 account_id,
2364 instrument.id(),
2365 side,
2366 quantity,
2367 ts_init,
2368 ts_init,
2369 None,
2370 None,
2371 None,
2372 );
2373
2374 reports.push(report);
2375 }
2376 }
2377
2378 Ok(reports)
2379 }
2380
2381 #[expect(clippy::too_many_arguments)]
2394 pub async fn submit_order(
2395 &self,
2396 _account_id: AccountId,
2397 instrument_id: InstrumentId,
2398 client_order_id: ClientOrderId,
2399 order_side: OrderSide,
2400 order_type: OrderType,
2401 quantity: Quantity,
2402 time_in_force: TimeInForce,
2403 expire_time: Option<UnixNanos>,
2404 price: Option<Price>,
2405 trigger_price: Option<Price>,
2406 trigger_type: Option<TriggerType>,
2407 trailing_offset: Option<Decimal>,
2408 limit_offset: Option<Decimal>,
2409 reduce_only: bool,
2410 post_only: bool,
2411 quote_quantity: bool,
2412 display_qty: Option<Quantity>,
2413 leverage: Option<u16>,
2414 account_type: AccountType,
2415 ) -> anyhow::Result<VenueOrderId> {
2416 let params = self.build_add_order_params(
2417 instrument_id,
2418 client_order_id,
2419 order_side,
2420 order_type,
2421 quantity,
2422 time_in_force,
2423 expire_time,
2424 price,
2425 trigger_price,
2426 trigger_type,
2427 trailing_offset,
2428 limit_offset,
2429 reduce_only,
2430 post_only,
2431 quote_quantity,
2432 display_qty,
2433 leverage,
2434 account_type,
2435 )?;
2436 let response = self.inner.add_order(¶ms).await?;
2437
2438 let venue_order_id =
2439 response
2440 .txid
2441 .first()
2442 .ok_or_else(|| KrakenSubmitOrderError::MissingOrderId {
2443 detail: "transaction ID was absent".to_string(),
2444 })?;
2445
2446 Ok(VenueOrderId::new(venue_order_id))
2447 }
2448
2449 #[expect(clippy::type_complexity)]
2454 pub async fn submit_orders_batch(
2455 &self,
2456 orders: Vec<(
2457 InstrumentId,
2458 ClientOrderId,
2459 OrderSide,
2460 OrderType,
2461 Quantity,
2462 TimeInForce,
2463 Option<UnixNanos>,
2464 Option<Price>,
2465 Option<Price>,
2466 Option<TriggerType>,
2467 Option<Decimal>,
2468 Option<Decimal>,
2469 bool,
2470 bool,
2471 bool,
2472 Option<Quantity>,
2473 Option<u16>,
2474 )>,
2475 account_type: AccountType,
2476 ) -> anyhow::Result<Vec<String>> {
2477 Ok(self
2478 .send_order_batches(orders, account_type)
2479 .await
2480 .into_iter()
2481 .map(|result| match result {
2482 Ok(order) if order.txid.is_some() => "placed".to_string(),
2483 Ok(order) => order.error.unwrap_or_else(|| "Unknown error".to_string()),
2484 Err(e)
2485 if matches!(
2486 e.downcast_ref::<KrakenBatchOrderError>(),
2487 Some(KrakenBatchOrderError::Validation { .. })
2488 ) =>
2489 {
2490 format!("validation_error: {e}")
2491 }
2492 Err(e) => format!("batch_error: {e}"),
2493 })
2494 .collect())
2495 }
2496
2497 pub(crate) async fn send_order_batches(
2498 &self,
2499 orders: Vec<SpotBatchOrder>,
2500 account_type: AccountType,
2501 ) -> Vec<anyhow::Result<SpotBatchOrderResponse>> {
2502 let count = orders.len();
2503 if count == 0 {
2504 return Vec::new();
2505 }
2506
2507 let mut results: Vec<Option<anyhow::Result<SpotBatchOrderResponse>>> =
2508 (0..count).map(|_| None).collect();
2509 let mut grouped: AHashMap<Ustr, Vec<(usize, KrakenSpotAddOrderParams)>> = AHashMap::new();
2510
2511 for (
2512 idx,
2513 (
2514 instrument_id,
2515 client_order_id,
2516 order_side,
2517 order_type,
2518 quantity,
2519 time_in_force,
2520 expire_time,
2521 price,
2522 trigger_price,
2523 trigger_type,
2524 trailing_offset,
2525 limit_offset,
2526 reduce_only,
2527 post_only,
2528 quote_quantity,
2529 display_qty,
2530 leverage,
2531 ),
2532 ) in orders.into_iter().enumerate()
2533 {
2534 match self.build_add_order_params(
2535 instrument_id,
2536 client_order_id,
2537 order_side,
2538 order_type,
2539 quantity,
2540 time_in_force,
2541 expire_time,
2542 price,
2543 trigger_price,
2544 trigger_type,
2545 trailing_offset,
2546 limit_offset,
2547 reduce_only,
2548 post_only,
2549 quote_quantity,
2550 display_qty,
2551 leverage,
2552 account_type,
2553 ) {
2554 Ok(params) => {
2555 grouped.entry(params.pair).or_default().push((idx, params));
2556 }
2557 Err(e) => {
2558 results[idx] = Some(Err(KrakenBatchOrderError::Validation {
2559 reason: e.to_string(),
2560 }
2561 .into()));
2562 }
2563 }
2564 }
2565
2566 let mut batches = Vec::new();
2567 let mut grouped_orders: Vec<_> = grouped.into_values().collect();
2568 grouped_orders.sort_by_key(|group| group.first().map_or(usize::MAX, |(idx, _)| *idx));
2569 for group in grouped_orders {
2570 batches.extend(group.chunks(BATCH_SUBMIT_LIMIT).map(|chunk| chunk.to_vec()));
2571 }
2572
2573 for (batch_index, batch) in batches.iter().enumerate() {
2574 let response = if batch.len() == 1 {
2575 self.inner
2576 .add_order(&batch[0].1)
2577 .await
2578 .map(|response| SpotAddOrderBatchResponse {
2579 orders: vec![SpotBatchOrderResponse {
2580 descr: response.descr,
2581 error: None,
2582 txid: response.txid.into_iter().next(),
2583 }],
2584 })
2585 } else {
2586 let params = KrakenSpotAddOrderBatchParams {
2587 pair: batch[0].1.pair,
2588 orders: batch
2589 .iter()
2590 .map(|(_, params)| params.clone().into())
2591 .collect(),
2592 asset_class: batch[0].1.asset_class,
2593 };
2594 self.inner.add_order_batch(¶ms).await
2595 };
2596
2597 match response {
2598 Ok(response) if response.orders.len() == batch.len() => {
2601 for ((idx, _), order) in batch.iter().zip(response.orders) {
2602 results[*idx] = Some(Ok(order));
2603 }
2604 }
2605 Ok(response) => {
2606 for (idx, _) in batch {
2607 results[*idx] = Some(Err(KrakenBatchOrderError::ResponseCount {
2608 expected: batch.len(),
2609 actual: response.orders.len(),
2610 }
2611 .into()));
2612 }
2613 }
2614 Err(e) => {
2615 for (idx, _) in batch {
2616 results[*idx] = Some(Err(anyhow::Error::new(e.clone())));
2617 }
2618
2619 for later_batch in &batches[batch_index + 1..] {
2620 for (idx, _) in later_batch {
2621 results[*idx] = Some(Err(KrakenBatchOrderError::NotAttempted.into()));
2622 }
2623 }
2624 break;
2625 }
2626 }
2627 }
2628
2629 results
2630 .into_iter()
2631 .map(|result| result.unwrap_or_else(|| Err(KrakenBatchOrderError::NotAttempted.into())))
2632 .collect()
2633 }
2634
2635 pub async fn modify_order(
2647 &self,
2648 instrument_id: InstrumentId,
2649 client_order_id: Option<ClientOrderId>,
2650 venue_order_id: Option<VenueOrderId>,
2651 quantity: Option<Quantity>,
2652 price: Option<Price>,
2653 trigger_price: Option<Price>,
2654 ) -> anyhow::Result<VenueOrderId> {
2655 let _ = self
2656 .get_cached_instrument(&instrument_id.symbol.inner())
2657 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2658
2659 let txid = venue_order_id.as_ref().map(|id| id.to_string());
2660 let cl_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
2661
2662 if txid.is_none() && cl_ord_id.is_none() {
2663 anyhow::bail!("Either client_order_id or venue_order_id must be provided");
2664 }
2665
2666 let mut builder = KrakenSpotAmendOrderParamsBuilder::default();
2667 if let Some(ref id) = txid {
2668 builder.txid(id.clone());
2669 } else if let Some(ref id) = cl_ord_id {
2670 builder.cl_ord_id(id.clone());
2671 }
2672
2673 if let Some(qty) = quantity {
2674 builder.order_qty(qty.to_string());
2675 }
2676
2677 if let Some(p) = price {
2678 builder.limit_price(p.to_string());
2679 }
2680
2681 if let Some(tp) = trigger_price {
2682 builder.trigger_price(tp.to_string());
2683 }
2684
2685 let params = builder
2686 .build()
2687 .map_err(|e| anyhow::anyhow!("Failed to build amend order params: {e}"))?;
2688
2689 let _response = self.inner.amend_order(¶ms).await?;
2690
2691 let order_id = venue_order_id.ok_or(KrakenModifyOrderError::MissingOrderId)?;
2693
2694 Ok(order_id)
2695 }
2696
2697 pub async fn cancel_order(
2707 &self,
2708 _account_id: AccountId,
2709 instrument_id: InstrumentId,
2710 client_order_id: Option<ClientOrderId>,
2711 venue_order_id: Option<VenueOrderId>,
2712 ) -> anyhow::Result<()> {
2713 let _ = self
2714 .get_cached_instrument(&instrument_id.symbol.inner())
2715 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2716
2717 let txid = venue_order_id.as_ref().map(|id| id.to_string());
2718 let cl_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
2719
2720 if txid.is_none() && cl_ord_id.is_none() {
2721 anyhow::bail!("Either client_order_id or venue_order_id must be provided");
2722 }
2723
2724 let mut builder = KrakenSpotCancelOrderParamsBuilder::default();
2727
2728 if let Some(ref id) = txid {
2729 builder.txid(id.clone());
2730 } else if let Some(ref id) = cl_ord_id {
2731 builder.cl_ord_id(id.clone());
2732 }
2733 let params = builder
2734 .build()
2735 .map_err(|e| anyhow::anyhow!("Failed to build cancel params: {e}"))?;
2736
2737 self.inner.cancel_order(¶ms).await?;
2738
2739 Ok(())
2740 }
2741
2742 pub async fn cancel_orders_batch(
2744 &self,
2745 venue_order_ids: Vec<VenueOrderId>,
2746 ) -> anyhow::Result<i32> {
2747 if venue_order_ids.is_empty() {
2748 return Ok(0);
2749 }
2750
2751 let mut total_cancelled = 0;
2752
2753 for chunk in venue_order_ids.chunks(BATCH_CANCEL_LIMIT) {
2754 let orders: Vec<String> = chunk.iter().map(|id| id.to_string()).collect();
2755 let params = KrakenSpotCancelOrderBatchParams { orders };
2756
2757 let response = self.inner.cancel_order_batch(¶ms).await?;
2758 total_cancelled += response.count;
2759 }
2760
2761 Ok(total_cancelled)
2762 }
2763
2764 #[expect(clippy::too_many_arguments)]
2765 fn build_add_order_params(
2766 &self,
2767 instrument_id: InstrumentId,
2768 client_order_id: ClientOrderId,
2769 order_side: OrderSide,
2770 order_type: OrderType,
2771 quantity: Quantity,
2772 time_in_force: TimeInForce,
2773 expire_time: Option<UnixNanos>,
2774 price: Option<Price>,
2775 trigger_price: Option<Price>,
2776 trigger_type: Option<TriggerType>,
2777 trailing_offset: Option<Decimal>,
2778 limit_offset: Option<Decimal>,
2779 reduce_only: bool,
2780 post_only: bool,
2781 quote_quantity: bool,
2782 display_qty: Option<Quantity>,
2783 leverage: Option<u16>,
2784 account_type: AccountType,
2785 ) -> anyhow::Result<KrakenSpotAddOrderParams> {
2786 let instrument = self
2787 .get_cached_instrument(&instrument_id.symbol.inner())
2788 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2789
2790 let raw_symbol = instrument.raw_symbol().inner();
2791 let asset_class = Self::asset_class_for(&instrument);
2792
2793 let kraken_side = match order_side {
2794 OrderSide::Buy => KrakenOrderSide::Buy,
2795 OrderSide::Sell => KrakenOrderSide::Sell,
2796 };
2797
2798 let kraken_order_type = match order_type {
2799 OrderType::Market => KrakenOrderType::Market,
2800 OrderType::Limit => KrakenOrderType::Limit,
2801 OrderType::StopMarket => KrakenOrderType::StopLoss,
2802 OrderType::StopLimit => KrakenOrderType::StopLossLimit,
2803 OrderType::MarketIfTouched => KrakenOrderType::TakeProfit,
2804 OrderType::LimitIfTouched => KrakenOrderType::TakeProfitLimit,
2805 OrderType::TrailingStopMarket => KrakenOrderType::TrailingStop,
2806 OrderType::TrailingStopLimit => KrakenOrderType::TrailingStopLimit,
2807 _ => anyhow::bail!("Unsupported order type: {order_type:?}"),
2808 };
2809
2810 let mut oflags = Vec::new();
2811 let is_limit_order = matches!(
2812 order_type,
2813 OrderType::Limit
2814 | OrderType::StopLimit
2815 | OrderType::LimitIfTouched
2816 | OrderType::TrailingStopLimit
2817 );
2818
2819 if time_in_force == TimeInForce::Fok && order_type != OrderType::Limit {
2820 anyhow::bail!("FOK time in force only supported for LIMIT orders on Kraken Spot");
2821 }
2822
2823 let (timeinforce, expiretm) =
2824 compute_time_in_force(is_limit_order, time_in_force, expire_time)?;
2825
2826 if post_only {
2827 oflags.push(KRAKEN_OFLAG_POST_ONLY);
2828 }
2829
2830 if quote_quantity {
2831 oflags.push(KRAKEN_OFLAG_QUOTE_QUANTITY);
2832 }
2833
2834 let mut builder = KrakenSpotAddOrderParamsBuilder::default();
2835 builder
2836 .cl_ord_id(truncate_cl_ord_id(&client_order_id))
2837 .broker(NAUTILUS_KRAKEN_BROKER_ID)
2838 .pair(raw_symbol)
2839 .side(kraken_side)
2840 .volume(quantity.to_string())
2841 .order_type(kraken_order_type);
2842
2843 let is_conditional = matches!(
2844 order_type,
2845 OrderType::StopMarket
2846 | OrderType::StopLimit
2847 | OrderType::MarketIfTouched
2848 | OrderType::LimitIfTouched
2849 | OrderType::TrailingStopMarket
2850 | OrderType::TrailingStopLimit
2851 );
2852
2853 let is_trailing = matches!(
2854 order_type,
2855 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
2856 );
2857
2858 if is_trailing {
2859 if trigger_price.is_some() {
2860 anyhow::bail!(
2861 "Kraken Spot trailing stops do not support activation trigger prices"
2862 );
2863 }
2864
2865 if let Some(offset) = trailing_offset {
2866 builder.price(offset.to_string());
2867 }
2868
2869 if let Some(offset) = limit_offset {
2870 builder.price2(offset.to_string());
2871 }
2872 } else if is_conditional {
2873 if let Some(trigger) = trigger_price {
2874 builder.price(trigger.to_string());
2875 }
2876
2877 if let Some(limit) = price {
2878 builder.price2(limit.to_string());
2879 }
2880 } else if let Some(limit) = price {
2881 builder.price(limit.to_string());
2882 }
2883
2884 if is_conditional {
2885 match trigger_type {
2886 Some(TriggerType::IndexPrice) => {
2887 builder.trigger("index".to_string());
2888 }
2889 Some(TriggerType::LastPrice | TriggerType::Default) | None => {}
2890 Some(other) => {
2891 anyhow::bail!(
2892 "Unsupported trigger type for Kraken Spot: {other:?} (only LastPrice and IndexPrice supported)"
2893 );
2894 }
2895 }
2896 }
2897
2898 if !oflags.is_empty() {
2899 builder.oflags(oflags.join(","));
2900 }
2901
2902 if let Some(tif) = timeinforce {
2903 builder.timeinforce(tif);
2904 }
2905
2906 if let Some(expire) = expiretm {
2907 builder.expiretm(expire);
2908 }
2909
2910 if let Some(dq) = display_qty {
2911 builder.displayvol(dq.to_string());
2912 }
2913
2914 if let Some(ac) = asset_class {
2915 builder.asset_class(ac);
2916 }
2917
2918 if leverage.is_some() && account_type != AccountType::Margin {
2919 anyhow::bail!("leverage requires spot_account_type=Margin (current: Cash)");
2920 }
2921
2922 if let Some(n) = leverage {
2923 let tiers = self.leverage_tiers_cache.get_cloned(&raw_symbol);
2924 let (buy_tiers, sell_tiers) = tiers.ok_or_else(|| {
2925 anyhow::anyhow!(
2926 "Leverage tiers not loaded for {raw_symbol}; cannot validate leverage {n}:1 (instruments must be initialized before submitting margin orders)"
2927 )
2928 })?;
2929 let valid_tiers = match order_side {
2930 OrderSide::Buy => buy_tiers,
2931 _ => sell_tiers,
2932 };
2933 let side_label = match order_side {
2934 OrderSide::Buy => "buy",
2935 _ => "sell",
2936 };
2937
2938 if valid_tiers.is_empty() {
2939 anyhow::bail!("Leverage not supported for {raw_symbol} on {side_label} side");
2940 }
2941
2942 if !valid_tiers.contains(&(n as i32)) {
2943 anyhow::bail!(
2944 "Leverage {n}:1 not supported for {raw_symbol} on {side_label} side (valid: {valid_tiers:?})"
2945 );
2946 }
2947 builder.leverage(format!("{n}:1"));
2948 }
2949
2950 if reduce_only {
2951 if account_type != AccountType::Margin {
2952 anyhow::bail!("reduce_only requires spot_account_type=Margin (current: Cash)");
2953 }
2954 builder.reduce_only(true);
2955 }
2956
2957 builder
2958 .build()
2959 .map_err(|e| anyhow::anyhow!("Failed to build order params: {e}"))
2960 }
2961}
2962
2963fn collect_spot_statuses(
2964 asset_pairs: &AssetPairsResponse,
2965) -> AHashMap<InstrumentId, MarketStatusAction> {
2966 asset_pairs
2967 .iter()
2968 .map(|(_, definition)| {
2969 let symbol_str = definition.wsname.as_ref().unwrap_or(&definition.altname);
2970 let normalized_symbol = normalize_spot_symbol(symbol_str.as_str());
2971 let instrument_id = InstrumentId::new(Symbol::new(&normalized_symbol), *KRAKEN_VENUE);
2972 let action = definition
2973 .status
2974 .map_or(MarketStatusAction::Trading, MarketStatusAction::from);
2975
2976 (instrument_id, action)
2977 })
2978 .collect()
2979}
2980
2981type LeverageTiersCache = Arc<AtomicMap<Ustr, (Vec<i32>, Vec<i32>)>>;
2983
2984struct TradeBalanceSnapshot {
2985 margins: Vec<MarginBalance>,
2986 metrics: IndexMap<String, String>,
2987 free_margin: Decimal,
2988 equity: Decimal,
2989}
2990
2991fn trade_balance_currency(asset: Option<&str>) -> Currency {
2997 let raw = asset.unwrap_or("ZUSD");
2998 let normalized = normalize_currency_code(raw);
2999 Currency::try_from_str(normalized)
3000 .unwrap_or_else(|| Currency::new(normalized, 2, 0, normalized, CurrencyType::Fiat))
3001}
3002
3003fn compute_time_in_force(
3004 is_limit_order: bool,
3005 time_in_force: TimeInForce,
3006 expire_time: Option<UnixNanos>,
3007) -> anyhow::Result<(Option<String>, Option<String>)> {
3008 if !is_limit_order {
3009 return Ok((None, None));
3010 }
3011
3012 match time_in_force {
3013 TimeInForce::Gtc => Ok((None, None)),
3014 TimeInForce::Ioc => Ok((Some("IOC".to_string()), None)),
3015 TimeInForce::Fok => Ok((Some("FOK".to_string()), None)),
3016 TimeInForce::Gtd => {
3017 let expire = expire_time.ok_or_else(|| {
3018 anyhow::anyhow!("GTD time in force requires expire_time parameter")
3019 })?;
3020 let expire_secs = expire.as_u64() / NANOSECONDS_IN_SECOND;
3021 Ok((Some("GTD".to_string()), Some(expire_secs.to_string())))
3022 }
3023 _ => anyhow::bail!("Unsupported time in force: {time_in_force:?}"),
3024 }
3025}
3026
3027#[cfg(test)]
3028mod tests {
3029 use std::{sync::Arc, time::Duration};
3030
3031 use nautilus_model::instruments::CurrencyPair;
3032 use rstest::rstest;
3033
3034 use super::*;
3035
3036 #[rstest]
3037 fn test_raw_client_creation() {
3038 let client = KrakenSpotRawHttpClient::default();
3039 assert!(client.credential.is_none());
3040 }
3041
3042 #[rstest]
3043 fn test_raw_client_with_credentials() {
3044 let client = KrakenSpotRawHttpClient::with_credentials(
3045 "test_key".to_string(),
3046 "test_secret".to_string(),
3047 KrakenEnvironment::Live,
3048 None,
3049 60,
3050 None,
3051 None,
3052 None,
3053 None,
3054 KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3055 )
3056 .unwrap();
3057 assert!(client.credential.is_some());
3058 }
3059
3060 #[rstest]
3061 #[tokio::test]
3062 async fn test_order_request_cancellation_before_transport() {
3063 let client = Arc::new(KrakenSpotRawHttpClient::default());
3064 let guard = client.auth_mutex.lock().await;
3065 let cancellation_token = client.cancellation_token();
3066 let waiting_token = cancellation_token.clone();
3067 let waiting_client = Arc::clone(&client);
3068 let waiting = tokio::spawn(async move {
3069 waiting_client
3070 .lock_order_request(&waiting_token)
3071 .await
3072 .map(drop)
3073 });
3074
3075 tokio::task::yield_now().await;
3076 client.cancel_all_requests();
3077 let waiting_result = tokio::time::timeout(Duration::from_secs(1), waiting)
3078 .await
3079 .expect("auth lock wait should stop on cancellation")
3080 .expect("auth lock task should complete");
3081 let request_result = client
3082 .send_order_request(
3083 Method::POST,
3084 "/0/private/AddOrder",
3085 KrakenSpotRawHttpClient::default_headers(),
3086 None,
3087 &cancellation_token,
3088 )
3089 .await;
3090 drop(guard);
3091 client.reset_cancellation_token();
3092 let reset_token = client.cancellation_token();
3093
3094 assert!(matches!(
3095 waiting_result,
3096 Err(KrakenHttpError::RequestNotStarted(ref message))
3097 if message == "Request canceled before transport invocation"
3098 ));
3099 assert!(matches!(
3100 request_result,
3101 Err(KrakenHttpError::RequestNotStarted(ref message))
3102 if message == "Request canceled before transport invocation"
3103 ));
3104 assert!(!reset_token.is_cancelled());
3105 }
3106
3107 #[rstest]
3108 fn test_client_creation() {
3109 let client = KrakenSpotHttpClient::default();
3110 assert!(client.instruments_cache.is_empty());
3111 }
3112
3113 #[rstest]
3114 fn test_client_with_credentials() {
3115 let client = KrakenSpotHttpClient::with_credentials(
3116 "test_key".to_string(),
3117 "test_secret".to_string(),
3118 KrakenEnvironment::Live,
3119 None,
3120 60,
3121 None,
3122 None,
3123 None,
3124 None,
3125 KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3126 )
3127 .unwrap();
3128 assert!(client.instruments_cache.is_empty());
3129 }
3130
3131 #[rstest]
3132 fn test_nonce_generation_strictly_increasing() {
3133 let client = KrakenSpotRawHttpClient::default();
3134
3135 let nonce1 = client.generate_nonce();
3136 let nonce2 = client.generate_nonce();
3137 let nonce3 = client.generate_nonce();
3138
3139 assert!(
3140 nonce2 > nonce1,
3141 "nonce2 ({nonce2}) should be > nonce1 ({nonce1})"
3142 );
3143 assert!(
3144 nonce3 > nonce2,
3145 "nonce3 ({nonce3}) should be > nonce2 ({nonce2})"
3146 );
3147 }
3148
3149 #[rstest]
3150 fn test_nonce_is_nanosecond_timestamp() {
3151 let client = KrakenSpotRawHttpClient::default();
3152
3153 let nonce = client.generate_nonce();
3154
3155 assert!(
3158 nonce > 1_500_000_000_000_000_000,
3159 "Nonce should be nanosecond timestamp"
3160 );
3161 }
3162
3163 #[rstest]
3164 #[case::gtc_limit(true, TimeInForce::Gtc, None, None, None)]
3165 #[case::ioc_limit(true, TimeInForce::Ioc, None, Some("IOC"), None)]
3166 #[case::fok_limit(true, TimeInForce::Fok, None, Some("FOK"), None)]
3167 #[case::gtd_limit_with_expire(
3168 true,
3169 TimeInForce::Gtd,
3170 Some(1_704_067_200_000_000_000u64),
3171 Some("GTD"),
3172 Some("1704067200")
3173 )]
3174 #[case::gtc_market(false, TimeInForce::Gtc, None, None, None)]
3175 #[case::ioc_market(false, TimeInForce::Ioc, None, None, None)]
3176 fn test_compute_time_in_force_success(
3177 #[case] is_limit: bool,
3178 #[case] tif: TimeInForce,
3179 #[case] expire_nanos: Option<u64>,
3180 #[case] expected_tif: Option<&str>,
3181 #[case] expected_expire: Option<&str>,
3182 ) {
3183 let expire_time = expire_nanos.map(UnixNanos::from);
3184 let result = compute_time_in_force(is_limit, tif, expire_time).unwrap();
3185 assert_eq!(result.0, expected_tif.map(String::from));
3186 assert_eq!(result.1, expected_expire.map(String::from));
3187 }
3188
3189 #[rstest]
3190 #[case::gtd_missing_expire(TimeInForce::Gtd, None, "expire_time")]
3191 fn test_compute_time_in_force_errors(
3192 #[case] tif: TimeInForce,
3193 #[case] expire_nanos: Option<u64>,
3194 #[case] expected_error: &str,
3195 ) {
3196 let expire_time = expire_nanos.map(UnixNanos::from);
3197 let result = compute_time_in_force(true, tif, expire_time);
3198 assert!(result.is_err());
3199 assert!(result.unwrap_err().to_string().contains(expected_error));
3200 }
3201
3202 #[rstest]
3203 fn test_build_add_order_params_sets_index_trigger_for_conditional_orders() {
3204 let client = KrakenSpotHttpClient::default();
3205 let instrument_id = cache_test_spot_instrument(&client);
3206
3207 let params = client
3208 .build_add_order_params(
3209 instrument_id,
3210 ClientOrderId::new("spot-trigger-index"),
3211 OrderSide::Buy,
3212 OrderType::StopMarket,
3213 Quantity::from("0.01"),
3214 TimeInForce::Gtc,
3215 None,
3216 None,
3217 Some(Price::from("50000")),
3218 Some(TriggerType::IndexPrice),
3219 None,
3220 None,
3221 false,
3222 false,
3223 false,
3224 None,
3225 None,
3226 AccountType::Cash,
3227 )
3228 .unwrap();
3229
3230 assert_eq!(params.trigger, Some("index".to_string()));
3231 assert_eq!(params.price, Some("50000".to_string()));
3232 }
3233
3234 #[rstest]
3235 fn test_build_add_order_params_sets_trailing_offsets() {
3236 let client = KrakenSpotHttpClient::default();
3237 let instrument_id = cache_test_spot_instrument(&client);
3238
3239 let params = client
3240 .build_add_order_params(
3241 instrument_id,
3242 ClientOrderId::new("spot-trailing"),
3243 OrderSide::Sell,
3244 OrderType::TrailingStopLimit,
3245 Quantity::from("0.01"),
3246 TimeInForce::Gtc,
3247 None,
3248 Some(Price::from("49900")),
3249 None,
3250 Some(TriggerType::LastPrice),
3251 Some(Decimal::from(50)),
3252 Some(Decimal::from(25)),
3253 false,
3254 false,
3255 false,
3256 Some(Quantity::from("0.005")),
3257 None,
3258 AccountType::Cash,
3259 )
3260 .unwrap();
3261
3262 assert_eq!(params.price, Some("50".to_string()));
3263 assert_eq!(params.price2, Some("25".to_string()));
3264 assert_eq!(params.trigger, None);
3265 assert_eq!(params.displayvol, Some("0.005".to_string()));
3266 }
3267
3268 #[rstest]
3269 fn test_build_add_order_params_rejects_unsupported_trigger_type() {
3270 let client = KrakenSpotHttpClient::default();
3271 let instrument_id = cache_test_spot_instrument(&client);
3272
3273 let error = client
3274 .build_add_order_params(
3275 instrument_id,
3276 ClientOrderId::new("spot-trigger-invalid"),
3277 OrderSide::Buy,
3278 OrderType::StopMarket,
3279 Quantity::from("0.01"),
3280 TimeInForce::Gtc,
3281 None,
3282 None,
3283 Some(Price::from("50000")),
3284 Some(TriggerType::MarkPrice),
3285 None,
3286 None,
3287 false,
3288 false,
3289 false,
3290 None,
3291 None,
3292 AccountType::Cash,
3293 )
3294 .unwrap_err();
3295
3296 assert!(
3297 error
3298 .to_string()
3299 .contains("Unsupported trigger type for Kraken Spot")
3300 );
3301 }
3302
3303 fn cache_test_spot_instrument(client: &KrakenSpotHttpClient) -> InstrumentId {
3304 let instrument_id = InstrumentId::from("XBT/USD.KRAKEN");
3305
3306 client.cache_instrument(InstrumentAny::CurrencyPair(
3307 CurrencyPair::builder()
3308 .instrument_id(instrument_id)
3309 .raw_symbol(Symbol::new("XBTUSD"))
3310 .base_currency(Currency::BTC())
3311 .quote_currency(Currency::USD())
3312 .price_precision(1)
3313 .size_precision(8)
3314 .price_increment(Price::from("0.1"))
3315 .size_increment(Quantity::from("0.00000001"))
3316 .ts_event(0.into())
3317 .ts_init(0.into())
3318 .build()
3319 .unwrap(),
3320 ));
3321
3322 instrument_id
3323 }
3324
3325 fn cache_test_spot_instrument_with_leverage(
3326 client: &KrakenSpotHttpClient,
3327 leverage_buy: &[i32],
3328 leverage_sell: &[i32],
3329 ) -> InstrumentId {
3330 let instrument_id = cache_test_spot_instrument(client);
3331 let raw_symbol = Ustr::from("XBTUSD");
3332 client.leverage_tiers_cache.rcu(|m| {
3333 m.insert(raw_symbol, (leverage_buy.to_vec(), leverage_sell.to_vec()));
3334 });
3335 instrument_id
3336 }
3337
3338 #[rstest]
3339 fn test_build_add_order_params_leverage_serialised_as_ratio() {
3340 let client = KrakenSpotHttpClient::default();
3341 let instrument_id =
3342 cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
3343
3344 let params = client
3345 .build_add_order_params(
3346 instrument_id,
3347 ClientOrderId::new("spot-margin-buy"),
3348 OrderSide::Buy,
3349 OrderType::Limit,
3350 Quantity::from("0.01"),
3351 TimeInForce::Gtc,
3352 None,
3353 Some(Price::from("50000")),
3354 None,
3355 None,
3356 None,
3357 None,
3358 false,
3359 false,
3360 false,
3361 None,
3362 Some(3),
3363 AccountType::Margin,
3364 )
3365 .unwrap();
3366
3367 assert_eq!(params.leverage, Some("3:1".to_string()));
3368 }
3369
3370 #[rstest]
3371 fn test_build_add_order_params_invalid_leverage_rejected() {
3372 let client = KrakenSpotHttpClient::default();
3373 let instrument_id =
3374 cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
3375
3376 let err = client
3377 .build_add_order_params(
3378 instrument_id,
3379 ClientOrderId::new("spot-margin-bad"),
3380 OrderSide::Buy,
3381 OrderType::Limit,
3382 Quantity::from("0.01"),
3383 TimeInForce::Gtc,
3384 None,
3385 Some(Price::from("50000")),
3386 None,
3387 None,
3388 None,
3389 None,
3390 false,
3391 false,
3392 false,
3393 None,
3394 Some(7),
3395 AccountType::Margin,
3396 )
3397 .unwrap_err();
3398
3399 assert!(
3400 err.to_string().contains("not supported"),
3401 "Expected tier-validation error: {err}"
3402 );
3403 }
3404
3405 #[rstest]
3406 fn test_build_add_order_params_no_leverage_is_cash() {
3407 let client = KrakenSpotHttpClient::default();
3408 let instrument_id =
3409 cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
3410
3411 let params = client
3412 .build_add_order_params(
3413 instrument_id,
3414 ClientOrderId::new("spot-cash"),
3415 OrderSide::Buy,
3416 OrderType::Limit,
3417 Quantity::from("0.01"),
3418 TimeInForce::Gtc,
3419 None,
3420 Some(Price::from("50000")),
3421 None,
3422 None,
3423 None,
3424 None,
3425 false,
3426 false,
3427 false,
3428 None,
3429 None,
3430 AccountType::Cash,
3431 )
3432 .unwrap();
3433
3434 assert_eq!(
3435 params.leverage, None,
3436 "Cash order should not have leverage field"
3437 );
3438 }
3439
3440 #[rstest]
3441 fn test_build_add_order_params_rejects_per_order_leverage_in_cash_mode() {
3442 let client = KrakenSpotHttpClient::default();
3443 let instrument_id = cache_test_spot_instrument(&client);
3444
3445 let err = client
3446 .build_add_order_params(
3447 instrument_id,
3448 ClientOrderId::new("cash-with-leverage"),
3449 OrderSide::Buy,
3450 OrderType::Limit,
3451 Quantity::from("0.01"),
3452 TimeInForce::Gtc,
3453 None,
3454 Some(Price::from("50000")),
3455 None,
3456 None,
3457 None,
3458 None,
3459 false,
3460 false,
3461 false,
3462 None,
3463 Some(3),
3464 AccountType::Cash,
3465 )
3466 .unwrap_err();
3467
3468 assert!(
3469 err.to_string().contains("Margin"),
3470 "Expected Margin mode rejection: {err}"
3471 );
3472 }
3473
3474 #[rstest]
3475 fn test_build_add_order_params_reduce_only_forwarded_in_margin_mode() {
3476 let client = KrakenSpotHttpClient::default();
3477 let instrument_id = cache_test_spot_instrument(&client);
3478
3479 let params = client
3480 .build_add_order_params(
3481 instrument_id,
3482 ClientOrderId::new("margin-reduce-only"),
3483 OrderSide::Sell,
3484 OrderType::Limit,
3485 Quantity::from("0.01"),
3486 TimeInForce::Gtc,
3487 None,
3488 Some(Price::from("50000")),
3489 None,
3490 None,
3491 None,
3492 None,
3493 true,
3494 false,
3495 false,
3496 None,
3497 None,
3498 AccountType::Margin,
3499 )
3500 .unwrap();
3501
3502 assert_eq!(params.reduce_only, Some(true));
3503 }
3504
3505 #[rstest]
3506 fn test_build_add_order_params_rejects_reduce_only_in_cash_mode() {
3507 let client = KrakenSpotHttpClient::default();
3508 let instrument_id = cache_test_spot_instrument(&client);
3509
3510 let err = client
3511 .build_add_order_params(
3512 instrument_id,
3513 ClientOrderId::new("cash-reduce-only"),
3514 OrderSide::Sell,
3515 OrderType::Limit,
3516 Quantity::from("0.01"),
3517 TimeInForce::Gtc,
3518 None,
3519 Some(Price::from("50000")),
3520 None,
3521 None,
3522 None,
3523 None,
3524 true,
3525 false,
3526 false,
3527 None,
3528 None,
3529 AccountType::Cash,
3530 )
3531 .unwrap_err();
3532
3533 assert!(
3534 err.to_string().contains("reduce_only requires"),
3535 "expected reduce_only Margin rejection: {err}"
3536 );
3537 }
3538
3539 #[rstest]
3540 fn test_build_add_order_params_rejects_leverage_when_tiers_not_loaded() {
3541 let client = KrakenSpotHttpClient::default();
3542 let instrument_id = cache_test_spot_instrument(&client);
3543
3544 let err = client
3545 .build_add_order_params(
3546 instrument_id,
3547 ClientOrderId::new("missing-tiers"),
3548 OrderSide::Buy,
3549 OrderType::Limit,
3550 Quantity::from("0.01"),
3551 TimeInForce::Gtc,
3552 None,
3553 Some(Price::from("50000")),
3554 None,
3555 None,
3556 None,
3557 None,
3558 false,
3559 false,
3560 false,
3561 None,
3562 Some(3),
3563 AccountType::Margin,
3564 )
3565 .unwrap_err();
3566
3567 assert!(
3568 err.to_string().contains("Leverage tiers not loaded"),
3569 "expected cache-miss rejection: {err}"
3570 );
3571 }
3572}