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, datetime::NANOSECONDS_IN_SECOND, nanos::UnixNanos,
35 time::get_atomic_clock_realtime,
36};
37use nautilus_model::{
38 data::{Bar, BarType, BookOrder, TradeTick},
39 enums::{
40 AccountType, AvgPxReconciliation, BookType, CurrencyType, MarketStatusAction, OrderSide,
41 OrderType, 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 ConnectionUrl,
52 http::{
53 HttpClient, HttpRedirectPolicy, HttpResponse, Method, create_standard_nautilus_headers,
54 },
55 ratelimiter::quota::Quota,
56 retry::{RetryConfig, RetryError, RetryManager},
57};
58use parking_lot::RwLock;
59use rust_decimal::Decimal;
60use serde::{Serialize, de::DeserializeOwned};
61use tokio_util::sync::CancellationToken;
62use ustr::Ustr;
63
64use super::{models::*, query::*};
65use crate::{
66 common::{
67 consts::{
68 KRAKEN_ALTNAME_KEY, KRAKEN_OFLAG_POST_ONLY, KRAKEN_OFLAG_QUOTE_QUANTITY, KRAKEN_VENUE,
69 NAUTILUS_KRAKEN_BROKER_ID,
70 },
71 credential::KrakenCredential,
72 enums::{
73 KrakenAssetClass, KrakenEnvironment, KrakenOrderSide, KrakenOrderType,
74 KrakenProductType, product_type_from_symbol,
75 },
76 parse::{
77 bar_type_to_spot_interval, normalize_currency_code, normalize_spot_symbol, parse_bar,
78 parse_fill_report, parse_order_status_report, parse_spot_instrument,
79 parse_tokenized_instrument, parse_trade_tick_from_array, truncate_cl_ord_id,
80 },
81 urls::get_kraken_http_base_url,
82 },
83 http::{
84 apply_count_limit,
85 coverage::ReportCoverage,
86 error::{
87 KrakenBatchOrderError, KrakenHttpError, KrakenModifyOrderError, KrakenSubmitOrderError,
88 kraken_http_should_retry,
89 },
90 },
91};
92
93pub const KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND: u32 = 5;
95
96const MAX_REPORT_PAGES: usize = 500;
102
103const KRAKEN_GLOBAL_RATE_KEY: &str = "kraken:spot:global";
104
105const BATCH_CANCEL_LIMIT: usize = 50;
107
108const BATCH_SUBMIT_LIMIT: usize = 15;
110
111pub struct KrakenSpotRawHttpClient {
116 base_url: ConnectionUrl,
117 client: HttpClient,
118 credential: Option<KrakenCredential>,
119 retry_manager: RetryManager<KrakenHttpError>,
120 cancellation_token: RwLock<CancellationToken>,
121 clock: &'static AtomicTime,
122 auth_mutex: tokio::sync::Mutex<()>,
124}
125
126impl Default for KrakenSpotRawHttpClient {
127 fn default() -> Self {
128 Self::new(
129 KrakenEnvironment::Live,
130 None,
131 60,
132 None,
133 None,
134 None,
135 None,
136 KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
137 )
138 .expect("Failed to create default KrakenSpotRawHttpClient")
139 }
140}
141
142impl Debug for KrakenSpotRawHttpClient {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 f.debug_struct(stringify!(KrakenSpotRawHttpClient))
145 .field("base_url", &self.base_url)
146 .field("has_credentials", &self.credential.is_some())
147 .finish()
148 }
149}
150
151impl KrakenSpotRawHttpClient {
152 #[expect(clippy::too_many_arguments)]
154 pub fn new(
155 environment: KrakenEnvironment,
156 base_url_override: Option<String>,
157 timeout_secs: u64,
158 max_retries: Option<u32>,
159 retry_delay_ms: Option<u64>,
160 retry_delay_max_ms: Option<u64>,
161 proxy_url: Option<String>,
162 max_requests_per_second: u32,
163 ) -> anyhow::Result<Self> {
164 let retry_config = RetryConfig {
165 max_retries: max_retries.unwrap_or(3),
166 initial_delay_ms: retry_delay_ms.unwrap_or(1000),
167 max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
168 backoff_factor: 2.0,
169 jitter_ms: 1000,
170 operation_timeout_ms: Some(60_000),
171 immediate_first: false,
172 max_elapsed_ms: Some(180_000),
173 };
174
175 let retry_manager = RetryManager::new(retry_config);
176 let base_url = base_url_override.unwrap_or_else(|| {
177 get_kraken_http_base_url(KrakenProductType::Spot, environment).to_string()
178 });
179
180 Ok(Self {
181 base_url: base_url.into(),
182 client: HttpClient::builder()
183 .headers(Self::default_headers())
184 .keyed_quotas(Self::rate_limiter_quotas(max_requests_per_second)?)
185 .default_quota(Self::default_quota(max_requests_per_second)?)
186 .timeout_secs(timeout_secs)
187 .maybe_proxy_url(proxy_url)
188 .build()
189 .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
190 credential: None,
191 retry_manager,
192 cancellation_token: RwLock::new(CancellationToken::new()),
193 clock: get_atomic_clock_realtime(),
194 auth_mutex: tokio::sync::Mutex::new(()),
195 })
196 }
197
198 #[expect(clippy::too_many_arguments)]
200 pub fn with_credentials(
201 api_key: String,
202 api_secret: String,
203 environment: KrakenEnvironment,
204 base_url_override: Option<String>,
205 timeout_secs: u64,
206 max_retries: Option<u32>,
207 retry_delay_ms: Option<u64>,
208 retry_delay_max_ms: Option<u64>,
209 proxy_url: Option<String>,
210 max_requests_per_second: u32,
211 ) -> anyhow::Result<Self> {
212 let retry_config = RetryConfig {
213 max_retries: max_retries.unwrap_or(3),
214 initial_delay_ms: retry_delay_ms.unwrap_or(1000),
215 max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
216 backoff_factor: 2.0,
217 jitter_ms: 1000,
218 operation_timeout_ms: Some(60_000),
219 immediate_first: false,
220 max_elapsed_ms: Some(180_000),
221 };
222
223 let retry_manager = RetryManager::new(retry_config);
224 let base_url = base_url_override.unwrap_or_else(|| {
225 get_kraken_http_base_url(KrakenProductType::Spot, environment).to_string()
226 });
227
228 Ok(Self {
229 base_url: base_url.into(),
230 client: HttpClient::builder()
231 .redirect_policy(HttpRedirectPolicy::Reject)
232 .headers(Self::default_headers())
233 .keyed_quotas(Self::rate_limiter_quotas(max_requests_per_second)?)
234 .default_quota(Self::default_quota(max_requests_per_second)?)
235 .timeout_secs(timeout_secs)
236 .maybe_proxy_url(proxy_url)
237 .build()
238 .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
239 credential: Some(KrakenCredential::new(api_key, api_secret)),
240 retry_manager,
241 cancellation_token: RwLock::new(CancellationToken::new()),
242 clock: get_atomic_clock_realtime(),
243 auth_mutex: tokio::sync::Mutex::new(()),
244 })
245 }
246
247 fn generate_nonce(&self) -> u64 {
252 self.clock.get_time_ns().as_u64()
253 }
254
255 pub fn base_url(&self) -> &str {
257 self.base_url.expose()
258 }
259
260 pub fn credential(&self) -> Option<&KrakenCredential> {
262 self.credential.as_ref()
263 }
264
265 pub fn cancel_all_requests(&self) {
267 self.cancellation_token.read().cancel();
268 }
269
270 pub fn reset_cancellation_token(&self) {
272 *self.cancellation_token.write() = CancellationToken::new();
273 }
274
275 pub fn cancellation_token(&self) -> CancellationToken {
277 self.cancellation_token.read().clone()
278 }
279
280 fn default_headers() -> HashMap<String, String> {
281 create_standard_nautilus_headers().into_iter().collect()
282 }
283
284 fn default_quota(max_requests_per_second: u32) -> anyhow::Result<Quota> {
285 let burst = NonZeroU32::new(max_requests_per_second).unwrap_or(
286 NonZeroU32::new(KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND).expect("non-zero"),
287 );
288 Quota::per_second(burst).ok_or_else(|| {
289 anyhow::anyhow!(
290 "Invalid max_requests_per_second: {max_requests_per_second} exceeds maximum"
291 )
292 })
293 }
294
295 fn rate_limiter_quotas(max_requests_per_second: u32) -> anyhow::Result<Vec<(String, Quota)>> {
296 Ok(vec![(
297 KRAKEN_GLOBAL_RATE_KEY.to_string(),
298 Self::default_quota(max_requests_per_second)?,
299 )])
300 }
301
302 fn rate_limit_keys(endpoint: &str) -> Vec<String> {
303 let normalized = endpoint.split('?').next().unwrap_or(endpoint);
304 let route = format!("kraken:spot:{normalized}");
305 vec![KRAKEN_GLOBAL_RATE_KEY.to_string(), route]
306 }
307
308 fn sign_spot(
309 &self,
310 path: &str,
311 nonce: u64,
312 params: &HashMap<String, String>,
313 ) -> anyhow::Result<(HashMap<String, String>, String)> {
314 let credential = self
315 .credential
316 .as_ref()
317 .ok_or_else(|| anyhow::anyhow!("Missing credentials"))?;
318
319 let (signature, post_data) = credential.sign_spot(path, nonce, params)?;
320
321 let mut headers = HashMap::new();
322 headers.insert("API-Key".to_string(), credential.api_key().to_string());
323 headers.insert("API-Sign".to_string(), signature);
324
325 Ok((headers, post_data))
326 }
327
328 async fn send_request<T: DeserializeOwned>(
329 &self,
330 method: Method,
331 endpoint: &str,
332 body: Option<Vec<u8>>,
333 authenticate: bool,
334 ) -> anyhow::Result<KrakenResponse<T>, KrakenHttpError> {
335 self.send_request_with_body(method, endpoint, RequestBody::Form(body), authenticate)
336 .await
337 }
338
339 #[allow(
340 dead_code,
341 reason = "TradeVolume transport retained for account fee-rate follow-up"
342 )]
343 async fn send_json_request<T: DeserializeOwned, P: Serialize>(
344 &self,
345 method: Method,
346 endpoint: &str,
347 params: &P,
348 authenticate: bool,
349 ) -> anyhow::Result<KrakenResponse<T>, KrakenHttpError> {
350 let body = serde_json::to_value(params).map_err(|e| {
351 KrakenHttpError::RequestNotStarted(format!("Failed to encode request params: {e}"))
352 })?;
353 self.send_request_with_body(method, endpoint, RequestBody::Json(body), authenticate)
354 .await
355 }
356
357 async fn send_request_with_body<T: DeserializeOwned>(
358 &self,
359 method: Method,
360 endpoint: &str,
361 body: RequestBody,
362 authenticate: bool,
363 ) -> anyhow::Result<KrakenResponse<T>, KrakenHttpError> {
364 let _guard = if authenticate {
368 Some(self.auth_mutex.lock().await)
369 } else {
370 None
371 };
372
373 let endpoint = endpoint.to_string();
374 let method_clone = method.clone();
375 let body_clone = body.clone();
376
377 let operation = || {
378 let method = method_clone.clone();
379 let body = body_clone.clone();
380 let endpoint = endpoint.clone();
381
382 async move {
383 self.send_request_attempt(method, &endpoint, body, authenticate, None)
384 .await
385 }
386 };
387
388 let should_retry = kraken_http_should_retry;
389 let create_error = |error: RetryError| KrakenHttpError::NetworkError(error.to_string());
390
391 let cancellation_token = self.cancellation_token();
392
393 self.retry_manager
394 .invocation(&endpoint, operation, should_retry, create_error)
395 .cancellation_token(&cancellation_token)
396 .execute()
397 .await
398 }
399
400 async fn send_request_once<T: DeserializeOwned>(
401 &self,
402 method: Method,
403 endpoint: &str,
404 body: Option<Vec<u8>>,
405 authenticate: bool,
406 ) -> anyhow::Result<KrakenResponse<T>, KrakenHttpError> {
407 let cancellation_token = self.cancellation_token();
408 let _guard = if authenticate {
409 Some(self.lock_order_request(&cancellation_token).await?)
410 } else {
411 None
412 };
413
414 self.send_request_attempt(
415 method,
416 endpoint,
417 RequestBody::Form(body),
418 authenticate,
419 Some(&cancellation_token),
420 )
421 .await
422 }
423
424 async fn lock_order_request(
425 &self,
426 cancellation_token: &CancellationToken,
427 ) -> anyhow::Result<tokio::sync::MutexGuard<'_, ()>, KrakenHttpError> {
428 if cancellation_token.is_cancelled() {
429 return Err(KrakenHttpError::RequestNotStarted(
430 "Request canceled before transport invocation".to_string(),
431 ));
432 }
433
434 tokio::select! {
435 biased;
436 () = cancellation_token.cancelled() => Err(KrakenHttpError::RequestNotStarted(
437 "Request canceled before transport invocation".to_string(),
438 )),
439 guard = self.auth_mutex.lock() => Ok(guard),
440 }
441 }
442
443 async fn send_request_attempt<T: DeserializeOwned>(
444 &self,
445 method: Method,
446 endpoint: &str,
447 body: RequestBody,
448 authenticate: bool,
449 cancellation_token: Option<&CancellationToken>,
450 ) -> anyhow::Result<KrakenResponse<T>, KrakenHttpError> {
451 let mut headers = Self::default_headers();
452 let (final_body, content_type) = match (authenticate, body) {
453 (true, RequestBody::Form(body)) => {
454 let nonce = self.generate_nonce();
455 log::debug!("Generated nonce {nonce} for {endpoint}");
456
457 let params: HashMap<String, String> = if let Some(ref body_bytes) = body {
458 let body_str = std::str::from_utf8(body_bytes).map_err(|e| {
459 KrakenHttpError::RequestNotStarted(format!(
460 "Invalid UTF-8 in request body: {e}"
461 ))
462 })?;
463 serde_urlencoded::from_str(body_str).map_err(|e| {
464 KrakenHttpError::RequestNotStarted(format!(
465 "Failed to parse request params: {e}"
466 ))
467 })?
468 } else {
469 HashMap::new()
470 };
471
472 let (auth_headers, post_data) =
473 self.sign_spot(endpoint, nonce, ¶ms).map_err(|e| {
474 KrakenHttpError::RequestNotStarted(format!("Failed to sign request: {e}"))
475 })?;
476 headers.extend(auth_headers);
477 (
478 Some(post_data.into_bytes()),
479 "application/x-www-form-urlencoded",
480 )
481 }
482 (true, RequestBody::Json(mut body)) => {
483 let nonce = self.generate_nonce();
484 log::debug!("Generated nonce {nonce} for {endpoint}");
485
486 let body = body.as_object_mut().ok_or_else(|| {
487 KrakenHttpError::RequestNotStarted(
488 "Authenticated JSON request body must be an object".to_string(),
489 )
490 })?;
491 body.insert("nonce".to_string(), serde_json::json!(nonce.to_string()));
492 let body = serde_json::to_string(body).map_err(|e| {
493 KrakenHttpError::RequestNotStarted(format!(
494 "Failed to serialize request body: {e}"
495 ))
496 })?;
497 let credential = self.credential.as_ref().ok_or_else(|| {
498 KrakenHttpError::RequestNotStarted("Missing credentials".to_string())
499 })?;
500 let signature = credential
501 .sign_spot_json(endpoint, nonce, &body)
502 .map_err(|e| {
503 KrakenHttpError::RequestNotStarted(format!("Failed to sign request: {e}"))
504 })?;
505 headers.insert("API-Key".to_string(), credential.api_key().to_string());
506 headers.insert("API-Sign".to_string(), signature);
507 (Some(body.into_bytes()), "application/json")
508 }
509 (false, RequestBody::Form(body)) => (body, "application/x-www-form-urlencoded"),
510 (false, RequestBody::Json(body)) => {
511 let body = serde_json::to_vec(&body).map_err(|e| {
512 KrakenHttpError::RequestNotStarted(format!(
513 "Failed to serialize request body: {e}"
514 ))
515 })?;
516 (Some(body), "application/json")
517 }
518 };
519
520 if method == Method::POST {
521 headers.insert("Content-Type".to_string(), content_type.to_string());
522 }
523
524 let response = if let Some(cancellation_token) = cancellation_token {
525 self.send_order_request(method, endpoint, headers, final_body, cancellation_token)
526 .await?
527 } else {
528 let url = format!("{}{endpoint}", self.base_url.expose());
529 let rate_limit_keys = Self::rate_limit_keys(endpoint);
530 self.client
531 .request(
532 method,
533 url,
534 None,
535 Some(headers),
536 final_body,
537 None,
538 Some(rate_limit_keys),
539 )
540 .await
541 .map_err(|e| {
542 KrakenHttpError::NetworkError(format!(
543 "Request to {} failed: {e}",
544 self.base_url.origin()
545 ))
546 })?
547 };
548
549 let status = response.status.as_u16();
550 if status >= 400 {
551 let body = String::from_utf8_lossy(&response.body).to_string();
552 let http_error = if status == 401 || status == 403 {
553 KrakenHttpError::AuthenticationError(format!("HTTP error {status}: {body}"))
554 } else {
555 KrakenHttpError::NetworkError(format!("HTTP error {status}: {body}"))
556 };
557 return Err(http_error);
558 }
559
560 let response_text = String::from_utf8(response.body.to_vec()).map_err(|e| {
561 KrakenHttpError::ParseError(format!("Failed to parse response as UTF-8: {e}"))
562 })?;
563 let kraken_response: KrakenResponse<T> =
564 serde_json::from_str(&response_text).map_err(|e| {
565 KrakenHttpError::ParseError(format!("Failed to deserialize response: {e}"))
566 })?;
567
568 if !kraken_response.error.is_empty() {
569 return Err(KrakenHttpError::ApiError(kraken_response.error));
570 }
571
572 Ok(kraken_response)
573 }
574
575 async fn send_order_request(
576 &self,
577 method: Method,
578 endpoint: &str,
579 headers: HashMap<String, String>,
580 body: Option<Vec<u8>>,
581 cancellation_token: &CancellationToken,
582 ) -> anyhow::Result<HttpResponse, KrakenHttpError> {
583 if cancellation_token.is_cancelled() {
584 return Err(KrakenHttpError::RequestNotStarted(
585 "Request canceled before transport invocation".to_string(),
586 ));
587 }
588
589 let request_started = AtomicBool::new(false);
590 let url = format!("{}{endpoint}", self.base_url.expose());
591 let rate_limit_keys = Self::rate_limit_keys(endpoint);
592 let request = async {
593 request_started.store(true, Ordering::Relaxed);
594 self.client
595 .request(
596 method,
597 url,
598 None,
599 Some(headers),
600 body,
601 None,
602 Some(rate_limit_keys),
603 )
604 .await
605 };
606 tokio::pin!(request);
607
608 tokio::select! {
609 biased;
610 () = cancellation_token.cancelled() => {
611 if request_started.load(Ordering::Relaxed) {
612 Err(KrakenHttpError::NetworkError(
613 "Request canceled after transport invocation".to_string(),
614 ))
615 } else {
616 Err(KrakenHttpError::RequestNotStarted(
617 "Request canceled before transport invocation".to_string(),
618 ))
619 }
620 }
621 response = &mut request => response
622 .map_err(|e| KrakenHttpError::NetworkError(format!("Request to {} failed: {e}", self.base_url.origin()))),
623 }
624 }
625
626 pub async fn get_server_time(&self) -> anyhow::Result<ServerTime, KrakenHttpError> {
628 let response: KrakenResponse<ServerTime> = self
629 .send_request(Method::GET, "/0/public/Time", None, false)
630 .await?;
631
632 response.result.ok_or_else(|| {
633 KrakenHttpError::ParseError("Missing result in server time response".to_string())
634 })
635 }
636
637 pub async fn get_system_status(&self) -> anyhow::Result<SystemStatus, KrakenHttpError> {
639 let response: KrakenResponse<SystemStatus> = self
640 .send_request(Method::GET, "/0/public/SystemStatus", None, false)
641 .await?;
642
643 response.result.ok_or_else(|| {
644 KrakenHttpError::ParseError("Missing result in system status response".to_string())
645 })
646 }
647
648 pub async fn get_asset_pairs(
653 &self,
654 pairs: Option<Vec<String>>,
655 aclass_base: Option<&str>,
656 ) -> anyhow::Result<AssetPairsResponse, KrakenHttpError> {
657 let mut params = Vec::new();
658
659 if let Some(pairs) = pairs {
660 params.push(format!("pair={}", pairs.join(",")));
661 }
662
663 if let Some(aclass) = aclass_base {
664 params.push(format!("aclass_base={aclass}"));
665 }
666
667 let endpoint = if params.is_empty() {
668 "/0/public/AssetPairs".to_string()
669 } else {
670 format!("/0/public/AssetPairs?{}", params.join("&"))
671 };
672
673 let response: KrakenResponse<AssetPairsResponse> = self
674 .send_request(Method::GET, &endpoint, None, false)
675 .await?;
676
677 response.result.ok_or_else(|| {
678 KrakenHttpError::ParseError("Missing result in asset pairs response".to_string())
679 })
680 }
681
682 pub async fn get_ticker(
684 &self,
685 pairs: Vec<String>,
686 asset_class: Option<KrakenAssetClass>,
687 ) -> anyhow::Result<TickerResponse, KrakenHttpError> {
688 let mut endpoint = format!("/0/public/Ticker?pair={}", pairs.join(","));
689
690 if let Some(aclass) = asset_class {
691 endpoint.push_str(&format!("&asset_class={aclass}"));
692 }
693
694 let response: KrakenResponse<TickerResponse> = self
695 .send_request(Method::GET, &endpoint, None, false)
696 .await?;
697
698 response.result.ok_or_else(|| {
699 KrakenHttpError::ParseError("Missing result in ticker response".to_string())
700 })
701 }
702
703 pub async fn get_ohlc(
705 &self,
706 pair: &str,
707 interval: Option<u32>,
708 since: Option<i64>,
709 asset_class: Option<KrakenAssetClass>,
710 ) -> anyhow::Result<OhlcResponse, KrakenHttpError> {
711 let mut endpoint = format!("/0/public/OHLC?pair={pair}");
712
713 if let Some(aclass) = asset_class {
714 endpoint.push_str(&format!("&asset_class={aclass}"));
715 }
716
717 if let Some(interval) = interval {
718 endpoint.push_str(&format!("&interval={interval}"));
719 }
720
721 if let Some(since) = since {
722 endpoint.push_str(&format!("&since={since}"));
723 }
724
725 let response: KrakenResponse<OhlcResponse> = self
726 .send_request(Method::GET, &endpoint, None, false)
727 .await?;
728
729 response.result.ok_or_else(|| {
730 KrakenHttpError::ParseError("Missing result in OHLC response".to_string())
731 })
732 }
733
734 pub async fn get_book_depth(
736 &self,
737 pair: &str,
738 count: Option<u32>,
739 asset_class: Option<KrakenAssetClass>,
740 ) -> anyhow::Result<OrderBookResponse, KrakenHttpError> {
741 let mut endpoint = format!("/0/public/Depth?pair={pair}");
742
743 if let Some(aclass) = asset_class {
744 endpoint.push_str(&format!("&asset_class={aclass}"));
745 }
746
747 if let Some(count) = count {
748 endpoint.push_str(&format!("&count={count}"));
749 }
750
751 let response: KrakenResponse<OrderBookResponse> = self
752 .send_request(Method::GET, &endpoint, None, false)
753 .await?;
754
755 response.result.ok_or_else(|| {
756 KrakenHttpError::ParseError("Missing result in book depth response".to_string())
757 })
758 }
759
760 pub async fn get_trades(
762 &self,
763 pair: &str,
764 since: Option<String>,
765 asset_class: Option<KrakenAssetClass>,
766 ) -> anyhow::Result<TradesResponse, KrakenHttpError> {
767 let mut endpoint = format!("/0/public/Trades?pair={pair}");
768
769 if let Some(aclass) = asset_class {
770 endpoint.push_str(&format!("&asset_class={aclass}"));
771 }
772
773 if let Some(since) = since {
774 endpoint.push_str(&format!("&since={since}"));
775 }
776
777 let response: KrakenResponse<TradesResponse> = self
778 .send_request(Method::GET, &endpoint, None, false)
779 .await?;
780
781 response.result.ok_or_else(|| {
782 KrakenHttpError::ParseError("Missing result in trades response".to_string())
783 })
784 }
785
786 pub async fn get_websockets_token(&self) -> anyhow::Result<WebSocketToken, KrakenHttpError> {
788 if self.credential.is_none() {
789 return Err(KrakenHttpError::AuthenticationError(
790 "API credentials required for GetWebSocketsToken".to_string(),
791 ));
792 }
793
794 let response: KrakenResponse<WebSocketToken> = self
795 .send_request(Method::POST, "/0/private/GetWebSocketsToken", None, true)
796 .await?;
797
798 response.result.ok_or_else(|| {
799 KrakenHttpError::ParseError("Missing result in websockets token response".to_string())
800 })
801 }
802
803 pub async fn get_open_orders(
805 &self,
806 trades: Option<bool>,
807 userref: Option<i64>,
808 ) -> anyhow::Result<IndexMap<String, SpotOrder>, KrakenHttpError> {
809 if self.credential.is_none() {
810 return Err(KrakenHttpError::AuthenticationError(
811 "API credentials required for OpenOrders".to_string(),
812 ));
813 }
814
815 let mut params = vec![];
816
817 if let Some(trades_flag) = trades {
818 params.push(format!("trades={trades_flag}"));
819 }
820
821 if let Some(userref_val) = userref {
822 params.push(format!("userref={userref_val}"));
823 }
824
825 let body = if params.is_empty() {
826 None
827 } else {
828 Some(params.join("&").into_bytes())
829 };
830
831 let response: KrakenResponse<SpotOpenOrdersResult> = self
832 .send_request(Method::POST, "/0/private/OpenOrders", body, true)
833 .await?;
834
835 let result = response.result.ok_or_else(|| {
836 KrakenHttpError::ParseError("Missing result in open orders response".to_string())
837 })?;
838
839 Ok(result.open)
840 }
841
842 pub async fn get_closed_orders(
844 &self,
845 trades: Option<bool>,
846 userref: Option<i64>,
847 start: Option<i64>,
848 end: Option<i64>,
849 ofs: Option<i32>,
850 closetime: Option<String>,
851 ) -> anyhow::Result<IndexMap<String, SpotOrder>, KrakenHttpError> {
852 if self.credential.is_none() {
853 return Err(KrakenHttpError::AuthenticationError(
854 "API credentials required for ClosedOrders".to_string(),
855 ));
856 }
857
858 let mut params = vec![];
859
860 if let Some(trades_flag) = trades {
861 params.push(format!("trades={trades_flag}"));
862 }
863
864 if let Some(userref_val) = userref {
865 params.push(format!("userref={userref_val}"));
866 }
867
868 if let Some(start_val) = start {
869 params.push(format!("start={start_val}"));
870 }
871
872 if let Some(end_val) = end {
873 params.push(format!("end={end_val}"));
874 }
875
876 if let Some(ofs_val) = ofs {
877 params.push(format!("ofs={ofs_val}"));
878 }
879
880 if let Some(closetime_val) = closetime {
881 params.push(format!("closetime={closetime_val}"));
882 }
883
884 let body = if params.is_empty() {
885 None
886 } else {
887 Some(params.join("&").into_bytes())
888 };
889
890 let response: KrakenResponse<SpotClosedOrdersResult> = self
891 .send_request(Method::POST, "/0/private/ClosedOrders", body, true)
892 .await?;
893
894 let result = response.result.ok_or_else(|| {
895 KrakenHttpError::ParseError("Missing result in closed orders response".to_string())
896 })?;
897
898 Ok(result.closed)
899 }
900
901 pub async fn get_trades_history(
903 &self,
904 trade_type: Option<String>,
905 trades: Option<bool>,
906 start: Option<i64>,
907 end: Option<i64>,
908 ofs: Option<i32>,
909 ) -> anyhow::Result<IndexMap<String, SpotTrade>, KrakenHttpError> {
910 if self.credential.is_none() {
911 return Err(KrakenHttpError::AuthenticationError(
912 "API credentials required for TradesHistory".to_string(),
913 ));
914 }
915
916 let mut params = vec![];
917
918 if let Some(type_val) = trade_type {
919 params.push(format!("type={type_val}"));
920 }
921
922 if let Some(trades_flag) = trades {
923 params.push(format!("trades={trades_flag}"));
924 }
925
926 if let Some(start_val) = start {
927 params.push(format!("start={start_val}"));
928 }
929
930 if let Some(end_val) = end {
931 params.push(format!("end={end_val}"));
932 }
933
934 if let Some(ofs_val) = ofs {
935 params.push(format!("ofs={ofs_val}"));
936 }
937
938 let body = if params.is_empty() {
939 None
940 } else {
941 Some(params.join("&").into_bytes())
942 };
943
944 let response: KrakenResponse<SpotTradesHistoryResult> = self
945 .send_request(Method::POST, "/0/private/TradesHistory", body, true)
946 .await?;
947
948 let result = response.result.ok_or_else(|| {
949 KrakenHttpError::ParseError("Missing result in trades history response".to_string())
950 })?;
951
952 Ok(result.trades)
953 }
954
955 pub async fn add_order(
957 &self,
958 params: &KrakenSpotAddOrderParams,
959 ) -> anyhow::Result<SpotAddOrderResponse, KrakenHttpError> {
960 if self.credential.is_none() {
961 return Err(KrakenHttpError::MissingCredentials);
962 }
963
964 let param_string = serde_urlencoded::to_string(params).map_err(|e| {
965 KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
966 })?;
967 let body = Some(param_string.into_bytes());
968
969 let response: KrakenResponse<SpotAddOrderResponse> = self
970 .send_request_once(Method::POST, "/0/private/AddOrder", body, true)
971 .await?;
972
973 response
974 .result
975 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
976 }
977
978 pub async fn add_order_batch(
980 &self,
981 params: &KrakenSpotAddOrderBatchParams,
982 ) -> anyhow::Result<SpotAddOrderBatchResponse, KrakenHttpError> {
983 let credential = self
984 .credential
985 .as_ref()
986 .ok_or(KrakenHttpError::MissingCredentials)?;
987
988 let cancellation_token = self.cancellation_token();
989 let _guard = self.lock_order_request(&cancellation_token).await?;
990
991 let endpoint = "/0/private/AddOrderBatch";
992 let nonce = self.generate_nonce();
993
994 let mut json_body = serde_json::json!({
995 "nonce": nonce.to_string(),
996 "pair": params.pair,
997 "orders": params.orders,
998 });
999
1000 if let Some(aclass) = ¶ms.asset_class {
1001 json_body["asset_class"] = serde_json::json!(aclass);
1002 }
1003 let json_str = serde_json::to_string(&json_body)
1004 .map_err(|e| KrakenHttpError::RequestNotStarted(format!("Failed to serialize: {e}")))?;
1005
1006 let signature = credential
1007 .sign_spot_json(endpoint, nonce, &json_str)
1008 .map_err(|e| KrakenHttpError::RequestNotStarted(format!("Failed to sign: {e}")))?;
1009
1010 let mut headers = Self::default_headers();
1011 headers.insert("API-Key".to_string(), credential.api_key().to_string());
1012 headers.insert("API-Sign".to_string(), signature);
1013 headers.insert("Content-Type".to_string(), "application/json".to_string());
1014
1015 let response = self
1016 .send_order_request(
1017 Method::POST,
1018 endpoint,
1019 headers,
1020 Some(json_str.into_bytes()),
1021 &cancellation_token,
1022 )
1023 .await?;
1024
1025 if !response.status.is_success() {
1026 return Err(KrakenHttpError::NetworkError(format!(
1027 "HTTP {:?} for {}",
1028 response.status, endpoint
1029 )));
1030 }
1031
1032 let parsed: KrakenResponse<SpotAddOrderBatchResponse> =
1033 serde_json::from_slice(&response.body).map_err(|e| {
1034 KrakenHttpError::ParseError(format!("Failed to parse JSON response: {e}"))
1035 })?;
1036
1037 if !parsed.error.is_empty() {
1038 return Err(KrakenHttpError::ApiError(parsed.error));
1039 }
1040
1041 parsed
1042 .result
1043 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1044 }
1045
1046 pub async fn cancel_order(
1048 &self,
1049 params: &KrakenSpotCancelOrderParams,
1050 ) -> anyhow::Result<SpotCancelOrderResponse, KrakenHttpError> {
1051 if self.credential.is_none() {
1052 return Err(KrakenHttpError::AuthenticationError(
1053 "API credentials required for canceling orders".to_string(),
1054 ));
1055 }
1056
1057 let param_string = serde_urlencoded::to_string(params)
1058 .map_err(|e| KrakenHttpError::ParseError(format!("Failed to encode params: {e}")))?;
1059
1060 let body = Some(param_string.into_bytes());
1061
1062 let response: KrakenResponse<SpotCancelOrderResponse> = self
1063 .send_request(Method::POST, "/0/private/CancelOrder", body, true)
1064 .await?;
1065
1066 response
1067 .result
1068 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1069 }
1070
1071 pub async fn cancel_order_batch(
1073 &self,
1074 params: &KrakenSpotCancelOrderBatchParams,
1075 ) -> anyhow::Result<SpotCancelOrderBatchResponse, KrakenHttpError> {
1076 let credential = self.credential.as_ref().ok_or_else(|| {
1077 KrakenHttpError::AuthenticationError(
1078 "API credentials required for canceling orders".to_string(),
1079 )
1080 })?;
1081
1082 let _guard = self.auth_mutex.lock().await;
1084
1085 let endpoint = "/0/private/CancelOrderBatch";
1086 let nonce = self.generate_nonce();
1087
1088 let mut json_body = serde_json::json!({ "nonce": nonce.to_string() });
1090
1091 if !params.orders.is_empty() {
1093 json_body["orders"] = serde_json::json!(params.orders);
1094 }
1095
1096 if !params.cl_ord_ids.is_empty() {
1097 json_body["cl_ord_ids"] = serde_json::json!(params.cl_ord_ids);
1098 }
1099 let json_str = serde_json::to_string(&json_body)
1100 .map_err(|e| KrakenHttpError::ParseError(format!("Failed to serialize: {e}")))?;
1101
1102 let signature = credential
1103 .sign_spot_json(endpoint, nonce, &json_str)
1104 .map_err(|e| KrakenHttpError::AuthenticationError(format!("Failed to sign: {e}")))?;
1105
1106 let mut headers = Self::default_headers();
1107 headers.insert("API-Key".to_string(), credential.api_key().to_string());
1108 headers.insert("API-Sign".to_string(), signature);
1109 headers.insert("Content-Type".to_string(), "application/json".to_string());
1110
1111 let url = format!("{}{endpoint}", self.base_url.expose());
1112 let rate_limit_keys = Self::rate_limit_keys(endpoint);
1113
1114 let response = self
1115 .client
1116 .request(
1117 Method::POST,
1118 url,
1119 None,
1120 Some(headers),
1121 Some(json_str.into_bytes()),
1122 None,
1123 Some(rate_limit_keys),
1124 )
1125 .await
1126 .map_err(|e| {
1127 KrakenHttpError::NetworkError(format!(
1128 "Request to {} failed: {e}",
1129 self.base_url.origin()
1130 ))
1131 })?;
1132
1133 if response.status.as_u16() >= 400 {
1134 let status = response.status.as_u16();
1135 let body = String::from_utf8_lossy(&response.body).to_string();
1136
1137 if status == 401 || status == 403 {
1138 return Err(KrakenHttpError::AuthenticationError(format!(
1139 "HTTP error {status}: {body}"
1140 )));
1141 }
1142 return Err(KrakenHttpError::NetworkError(format!(
1143 "HTTP error {status}: {body}"
1144 )));
1145 }
1146
1147 let response_text = String::from_utf8(response.body.to_vec())
1148 .map_err(|e| KrakenHttpError::ParseError(format!("Invalid UTF-8: {e}")))?;
1149
1150 let kraken_response: KrakenResponse<SpotCancelOrderBatchResponse> =
1151 serde_json::from_str(&response_text).map_err(|e| {
1152 KrakenHttpError::ParseError(format!("Failed to parse response: {e}"))
1153 })?;
1154
1155 if !kraken_response.error.is_empty() {
1156 return Err(KrakenHttpError::ApiError(kraken_response.error));
1157 }
1158
1159 kraken_response
1160 .result
1161 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1162 }
1163
1164 pub async fn cancel_all_orders(
1166 &self,
1167 ) -> anyhow::Result<SpotCancelOrderResponse, KrakenHttpError> {
1168 if self.credential.is_none() {
1169 return Err(KrakenHttpError::AuthenticationError(
1170 "API credentials required for canceling orders".to_string(),
1171 ));
1172 }
1173
1174 let response: KrakenResponse<SpotCancelOrderResponse> = self
1175 .send_request(Method::POST, "/0/private/CancelAll", None, true)
1176 .await?;
1177
1178 response
1179 .result
1180 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1181 }
1182
1183 pub async fn edit_order(
1185 &self,
1186 params: &KrakenSpotEditOrderParams,
1187 ) -> anyhow::Result<SpotEditOrderResponse, KrakenHttpError> {
1188 if self.credential.is_none() {
1189 return Err(KrakenHttpError::AuthenticationError(
1190 "API credentials required for editing orders".to_string(),
1191 ));
1192 }
1193
1194 let param_string = serde_urlencoded::to_string(params)
1195 .map_err(|e| KrakenHttpError::ParseError(format!("Failed to encode params: {e}")))?;
1196
1197 let body = Some(param_string.into_bytes());
1198
1199 let response: KrakenResponse<SpotEditOrderResponse> = self
1200 .send_request(Method::POST, "/0/private/EditOrder", body, true)
1201 .await?;
1202
1203 response
1204 .result
1205 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1206 }
1207
1208 pub async fn amend_order(
1210 &self,
1211 params: &KrakenSpotAmendOrderParams,
1212 ) -> anyhow::Result<SpotAmendOrderResponse, KrakenHttpError> {
1213 if self.credential.is_none() {
1214 return Err(KrakenHttpError::MissingCredentials);
1215 }
1216
1217 let param_string = serde_urlencoded::to_string(params).map_err(|e| {
1218 KrakenHttpError::RequestNotStarted(format!("Failed to encode params: {e}"))
1219 })?;
1220
1221 let body = Some(param_string.into_bytes());
1222
1223 let response: KrakenResponse<SpotAmendOrderResponse> = self
1224 .send_request_once(Method::POST, "/0/private/AmendOrder", body, true)
1225 .await?;
1226
1227 response
1228 .result
1229 .ok_or_else(|| KrakenHttpError::ParseError("Missing result in response".to_string()))
1230 }
1231
1232 pub async fn get_balance(&self) -> anyhow::Result<BalanceResponse, KrakenHttpError> {
1234 if self.credential.is_none() {
1235 return Err(KrakenHttpError::AuthenticationError(
1236 "API credentials required for Balance".to_string(),
1237 ));
1238 }
1239
1240 let response: KrakenResponse<BalanceResponse> = self
1241 .send_request(Method::POST, "/0/private/Balance", None, true)
1242 .await?;
1243
1244 response.result.ok_or_else(|| {
1245 KrakenHttpError::ParseError("Missing result in balance response".to_string())
1246 })
1247 }
1248
1249 pub async fn get_balance_ex(&self) -> anyhow::Result<BalanceExResponse, KrakenHttpError> {
1255 if self.credential.is_none() {
1256 return Err(KrakenHttpError::AuthenticationError(
1257 "API credentials required for BalanceEx".to_string(),
1258 ));
1259 }
1260
1261 let response: KrakenResponse<BalanceExResponse> = self
1262 .send_request(Method::POST, "/0/private/BalanceEx", None, true)
1263 .await?;
1264
1265 response.result.ok_or_else(|| {
1266 KrakenHttpError::ParseError("Missing result in extended balance response".to_string())
1267 })
1268 }
1269
1270 pub async fn get_trade_balance(
1276 &self,
1277 asset: Option<&str>,
1278 ) -> anyhow::Result<TradeBalanceResponse, KrakenHttpError> {
1279 if self.credential.is_none() {
1280 return Err(KrakenHttpError::AuthenticationError(
1281 "API credentials required for TradeBalance".to_string(),
1282 ));
1283 }
1284
1285 let params = asset.map(|a| SpotTradeBalanceParams {
1286 asset: Some(a.to_string()),
1287 });
1288
1289 let body = params
1290 .as_ref()
1291 .and_then(|p| serde_urlencoded::to_string(p).ok())
1292 .map(|s| s.into_bytes());
1293
1294 let response: KrakenResponse<TradeBalanceResponse> = self
1295 .send_request(Method::POST, "/0/private/TradeBalance", body, true)
1296 .await?;
1297
1298 response.result.ok_or_else(|| {
1299 KrakenHttpError::ParseError("Missing result in TradeBalance response".to_string())
1300 })
1301 }
1302
1303 #[allow(
1304 dead_code,
1305 reason = "TradeVolume endpoint retained for account fee-rate follow-up"
1306 )]
1307 async fn get_trade_volume(
1308 &self,
1309 params: &SpotTradeVolumeParams,
1310 ) -> anyhow::Result<SpotTradeVolumeResponse, KrakenHttpError> {
1311 if self.credential.is_none() {
1312 return Err(KrakenHttpError::MissingCredentials);
1313 }
1314
1315 let response: KrakenResponse<SpotTradeVolumeResponse> = self
1316 .send_json_request(Method::POST, "/0/private/TradeVolume", params, true)
1317 .await?;
1318
1319 response.result.ok_or_else(|| {
1320 KrakenHttpError::ParseError("Missing result in TradeVolume response".to_string())
1321 })
1322 }
1323
1324 pub async fn get_open_positions(
1326 &self,
1327 params: &SpotOpenPositionsParams,
1328 ) -> anyhow::Result<SpotOpenPositionsResponse, KrakenHttpError> {
1329 if self.credential.is_none() {
1330 return Err(KrakenHttpError::AuthenticationError(
1331 "API credentials required for OpenPositions".to_string(),
1332 ));
1333 }
1334
1335 let body = serde_urlencoded::to_string(params)
1336 .ok()
1337 .filter(|s| !s.is_empty())
1338 .map(|s| s.into_bytes());
1339
1340 let response: KrakenResponse<SpotOpenPositionsResponse> = self
1341 .send_request(Method::POST, "/0/private/OpenPositions", body, true)
1342 .await?;
1343
1344 response.result.ok_or_else(|| {
1345 KrakenHttpError::ParseError("Missing result in OpenPositions response".to_string())
1346 })
1347 }
1348}
1349
1350#[derive(Clone)]
1351enum RequestBody {
1352 Form(Option<Vec<u8>>),
1353 Json(serde_json::Value),
1354}
1355
1356#[allow(
1357 dead_code,
1358 reason = "TradeVolume request types retained for account fee-rate follow-up"
1359)]
1360#[derive(Clone, Serialize)]
1361struct SpotTradeVolumeParams {
1362 pair: SpotTradeVolumePairs,
1363}
1364
1365#[allow(
1366 dead_code,
1367 reason = "TradeVolume request types retained for account fee-rate follow-up"
1368)]
1369#[derive(Clone, Serialize)]
1370#[serde(untagged)]
1371enum SpotTradeVolumePairs {
1372 Names(String),
1373 Classified(Vec<SpotTradeVolumePair>),
1374}
1375
1376#[allow(
1377 dead_code,
1378 reason = "TradeVolume request types retained for account fee-rate follow-up"
1379)]
1380#[derive(Clone, Serialize)]
1381struct SpotTradeVolumePair {
1382 asset: String,
1383 aclass: SpotTradeVolumeAssetClass,
1384}
1385
1386#[allow(
1387 dead_code,
1388 reason = "TradeVolume request types retained for account fee-rate follow-up"
1389)]
1390#[derive(Clone, Copy, Serialize)]
1391#[serde(rename_all = "snake_case")]
1392enum SpotTradeVolumeAssetClass {
1393 EquityPair,
1394}
1395
1396pub(crate) type SpotBatchOrder = (
1397 InstrumentId,
1398 ClientOrderId,
1399 OrderSide,
1400 OrderType,
1401 Quantity,
1402 TimeInForce,
1403 Option<UnixNanos>,
1404 Option<Price>,
1405 Option<Price>,
1406 Option<TriggerType>,
1407 Option<Decimal>,
1408 Option<Decimal>,
1409 bool,
1410 bool,
1411 bool,
1412 Option<Quantity>,
1413 Option<u16>,
1414);
1415
1416#[cfg_attr(
1422 feature = "python",
1423 pyo3::pyclass(module = "nautilus_trader.adapters.kraken", from_py_object)
1424)]
1425#[cfg_attr(
1426 feature = "python",
1427 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.kraken")
1428)]
1429pub struct KrakenSpotHttpClient {
1430 pub(crate) inner: Arc<KrakenSpotRawHttpClient>,
1431 pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
1432 pair_aliases: Arc<AtomicMap<Ustr, Ustr>>,
1439 leverage_tiers_cache: LeverageTiersCache,
1440 clock: &'static AtomicTime,
1441 cache_initialized: Arc<AtomicBool>,
1442}
1443
1444impl Clone for KrakenSpotHttpClient {
1445 fn clone(&self) -> Self {
1446 Self {
1447 inner: self.inner.clone(),
1448 instruments_cache: self.instruments_cache.clone(),
1449 pair_aliases: self.pair_aliases.clone(),
1450 leverage_tiers_cache: self.leverage_tiers_cache.clone(),
1451 cache_initialized: self.cache_initialized.clone(),
1452 clock: self.clock,
1453 }
1454 }
1455}
1456
1457impl Default for KrakenSpotHttpClient {
1458 fn default() -> Self {
1459 Self::new(
1460 KrakenEnvironment::Live,
1461 None,
1462 60,
1463 None,
1464 None,
1465 None,
1466 None,
1467 KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
1468 )
1469 .expect("Failed to create default KrakenSpotHttpClient")
1470 }
1471}
1472
1473impl Debug for KrakenSpotHttpClient {
1474 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1475 f.debug_struct(stringify!(KrakenSpotHttpClient))
1476 .field("inner", &self.inner)
1477 .finish()
1478 }
1479}
1480
1481impl KrakenSpotHttpClient {
1482 #[expect(clippy::too_many_arguments)]
1484 pub fn new(
1485 environment: KrakenEnvironment,
1486 base_url_override: Option<String>,
1487 timeout_secs: u64,
1488 max_retries: Option<u32>,
1489 retry_delay_ms: Option<u64>,
1490 retry_delay_max_ms: Option<u64>,
1491 proxy_url: Option<String>,
1492 max_requests_per_second: u32,
1493 ) -> anyhow::Result<Self> {
1494 Ok(Self {
1495 inner: Arc::new(KrakenSpotRawHttpClient::new(
1496 environment,
1497 base_url_override,
1498 timeout_secs,
1499 max_retries,
1500 retry_delay_ms,
1501 retry_delay_max_ms,
1502 proxy_url,
1503 max_requests_per_second,
1504 )?),
1505 instruments_cache: Arc::new(AtomicMap::new()),
1506 pair_aliases: Arc::new(AtomicMap::new()),
1507 leverage_tiers_cache: Arc::new(AtomicMap::new()),
1508 cache_initialized: Arc::new(AtomicBool::new(false)),
1509 clock: get_atomic_clock_realtime(),
1510 })
1511 }
1512
1513 #[expect(clippy::too_many_arguments)]
1515 pub fn with_credentials(
1516 api_key: String,
1517 api_secret: String,
1518 environment: KrakenEnvironment,
1519 base_url_override: Option<String>,
1520 timeout_secs: u64,
1521 max_retries: Option<u32>,
1522 retry_delay_ms: Option<u64>,
1523 retry_delay_max_ms: Option<u64>,
1524 proxy_url: Option<String>,
1525 max_requests_per_second: u32,
1526 ) -> anyhow::Result<Self> {
1527 Ok(Self {
1528 inner: Arc::new(KrakenSpotRawHttpClient::with_credentials(
1529 api_key,
1530 api_secret,
1531 environment,
1532 base_url_override,
1533 timeout_secs,
1534 max_retries,
1535 retry_delay_ms,
1536 retry_delay_max_ms,
1537 proxy_url,
1538 max_requests_per_second,
1539 )?),
1540 instruments_cache: Arc::new(AtomicMap::new()),
1541 pair_aliases: Arc::new(AtomicMap::new()),
1542 leverage_tiers_cache: Arc::new(AtomicMap::new()),
1543 cache_initialized: Arc::new(AtomicBool::new(false)),
1544 clock: get_atomic_clock_realtime(),
1545 })
1546 }
1547
1548 #[expect(clippy::too_many_arguments)]
1556 pub fn from_env(
1557 environment: KrakenEnvironment,
1558 base_url_override: Option<String>,
1559 timeout_secs: u64,
1560 max_retries: Option<u32>,
1561 retry_delay_ms: Option<u64>,
1562 retry_delay_max_ms: Option<u64>,
1563 proxy_url: Option<String>,
1564 max_requests_per_second: u32,
1565 ) -> anyhow::Result<Self> {
1566 if let Some(credential) = KrakenCredential::from_env_spot() {
1567 let (api_key, api_secret) = credential.into_parts();
1568 Self::with_credentials(
1569 api_key,
1570 api_secret,
1571 environment,
1572 base_url_override,
1573 timeout_secs,
1574 max_retries,
1575 retry_delay_ms,
1576 retry_delay_max_ms,
1577 proxy_url,
1578 max_requests_per_second,
1579 )
1580 } else {
1581 Self::new(
1582 environment,
1583 base_url_override,
1584 timeout_secs,
1585 max_retries,
1586 retry_delay_ms,
1587 retry_delay_max_ms,
1588 proxy_url,
1589 max_requests_per_second,
1590 )
1591 }
1592 }
1593
1594 pub fn cancel_all_requests(&self) {
1596 self.inner.cancel_all_requests();
1597 }
1598
1599 pub fn reset_cancellation_token(&self) {
1601 self.inner.reset_cancellation_token();
1602 }
1603
1604 pub fn cancellation_token(&self) -> CancellationToken {
1606 self.inner.cancellation_token()
1607 }
1608
1609 pub fn cache_instrument(&self, instrument: InstrumentAny) {
1611 self.record_instrument_aliases(std::slice::from_ref(&instrument));
1612 self.instruments_cache
1613 .insert(instrument.symbol().inner(), instrument);
1614 self.cache_initialized.store(true, Ordering::Release);
1615 }
1616
1617 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1619 self.record_instrument_aliases(instruments);
1620 self.instruments_cache.rcu(|m| {
1621 for instrument in instruments {
1622 m.insert(instrument.symbol().inner(), instrument.clone());
1623 }
1624 });
1625 self.cache_initialized.store(true, Ordering::Release);
1626 }
1627
1628 fn record_instrument_aliases(&self, instruments: &[InstrumentAny]) {
1631 let aliases: Vec<(Ustr, Ustr)> = instruments
1632 .iter()
1633 .filter_map(|instrument| {
1634 let altname = instrument.info()?.get_str(KRAKEN_ALTNAME_KEY)?;
1635 Some((Ustr::from(altname), instrument.raw_symbol().inner()))
1636 })
1637 .collect();
1638
1639 if aliases.is_empty() {
1640 return;
1641 }
1642
1643 self.pair_aliases.rcu(|m| {
1644 for (altname, pair_name) in &aliases {
1645 m.insert(*altname, *pair_name);
1646 }
1647 });
1648 }
1649
1650 pub fn get_cached_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1652 self.instruments_cache.get_cloned(symbol)
1653 }
1654
1655 fn record_pair_aliases(&self, pairs: &AssetPairsResponse) {
1659 let aliases: Vec<(Ustr, Ustr)> = pairs
1660 .iter()
1661 .filter(|(pair_name, definition)| definition.altname.as_str() != pair_name.as_str())
1662 .map(|(pair_name, definition)| (definition.altname, Ustr::from(pair_name)))
1663 .collect();
1664
1665 if aliases.is_empty() {
1666 return;
1667 }
1668
1669 self.pair_aliases.rcu(|m| {
1670 for (altname, pair_name) in &aliases {
1671 m.insert(*altname, *pair_name);
1672 }
1673 });
1674 }
1675
1676 fn get_instrument_by_raw_symbol(&self, raw_symbol: &str) -> Option<InstrumentAny> {
1681 let instruments = self.instruments_cache.load();
1682
1683 if let Some(instrument) = instruments
1684 .values()
1685 .find(|inst| inst.raw_symbol().as_str() == raw_symbol)
1686 {
1687 return Some(instrument.clone());
1688 }
1689
1690 let pair_name = self.pair_aliases.get_cloned(&Ustr::from(raw_symbol))?;
1691
1692 instruments
1693 .values()
1694 .find(|inst| inst.raw_symbol().inner() == pair_name)
1695 .cloned()
1696 }
1697
1698 fn generate_ts_init(&self) -> UnixNanos {
1699 self.clock.get_time_ns()
1700 }
1701
1702 fn asset_class_for(instrument: &InstrumentAny) -> Option<KrakenAssetClass> {
1704 if matches!(instrument, InstrumentAny::TokenizedAsset(_)) {
1705 Some(KrakenAssetClass::TokenizedAsset)
1706 } else {
1707 None
1708 }
1709 }
1710
1711 pub async fn get_websockets_token(&self) -> anyhow::Result<WebSocketToken, KrakenHttpError> {
1713 self.inner.get_websockets_token().await
1714 }
1715
1716 pub async fn request_instruments(
1721 &self,
1722 pairs: Option<Vec<String>>,
1723 ) -> anyhow::Result<Vec<InstrumentAny>, KrakenHttpError> {
1724 let ts_init = self.generate_ts_init();
1725 let asset_pairs = self.inner.get_asset_pairs(pairs.clone(), None).await?;
1726 self.record_pair_aliases(&asset_pairs);
1727 let mut leverage_tiers = AHashMap::new();
1728
1729 let mut instruments: Vec<InstrumentAny> = asset_pairs
1730 .iter()
1731 .filter_map(|(pair_name, definition)| {
1732 match parse_spot_instrument(pair_name, definition, ts_init, ts_init) {
1733 Ok(instrument) => Some((instrument, definition)),
1734 Err(e) => {
1735 log::warn!("Failed to parse instrument {pair_name}: {e}");
1736 None
1737 }
1738 }
1739 })
1740 .map(|(instrument, definition)| {
1741 let key = Ustr::from(instrument.raw_symbol().as_str());
1742 let tiers = (
1743 definition.leverage_buy.clone(),
1744 definition.leverage_sell.clone(),
1745 );
1746 leverage_tiers.insert(key, tiers);
1747 instrument
1748 })
1749 .collect();
1750
1751 if !leverage_tiers.is_empty() {
1752 self.leverage_tiers_cache.rcu(|cache| {
1753 cache.extend(
1754 leverage_tiers
1755 .iter()
1756 .map(|(key, tiers)| (*key, tiers.clone())),
1757 );
1758 });
1759 }
1760
1761 {
1765 match self
1766 .inner
1767 .get_asset_pairs(pairs, Some("tokenized_asset"))
1768 .await
1769 {
1770 Ok(tokenized_pairs) => {
1771 if !tokenized_pairs.is_empty() {
1772 log::debug!("Fetched {} tokenized asset pairs", tokenized_pairs.len());
1773 }
1774 self.record_pair_aliases(&tokenized_pairs);
1775 let tokenized_instruments: Vec<InstrumentAny> =
1776 tokenized_pairs
1777 .iter()
1778 .filter_map(|(pair_name, definition)| match parse_tokenized_instrument(
1779 pair_name, definition, ts_init, ts_init,
1780 ) {
1781 Ok(instrument) => Some(instrument),
1782 Err(e) => {
1783 log::warn!(
1784 "Failed to parse tokenized instrument {pair_name}: {e}"
1785 );
1786 None
1787 }
1788 })
1789 .collect();
1790 instruments.extend(tokenized_instruments);
1791 }
1792 Err(e) => {
1793 log::warn!("Failed to fetch tokenized asset pairs: {e}");
1794 }
1795 }
1796 }
1797
1798 Ok(instruments)
1799 }
1800
1801 pub async fn request_instrument_statuses(
1807 &self,
1808 pairs: Option<Vec<String>>,
1809 ) -> anyhow::Result<AHashMap<InstrumentId, MarketStatusAction>, KrakenHttpError> {
1810 let asset_pairs = self.inner.get_asset_pairs(pairs.clone(), None).await?;
1811 let mut statuses = collect_spot_statuses(&asset_pairs);
1812
1813 let tokenized_pairs = self
1814 .inner
1815 .get_asset_pairs(pairs, Some("tokenized_asset"))
1816 .await?;
1817 statuses.extend(collect_spot_statuses(&tokenized_pairs));
1818
1819 Ok(statuses)
1820 }
1821
1822 pub async fn request_trades(
1824 &self,
1825 instrument_id: InstrumentId,
1826 start: Option<Timestamp>,
1827 end: Option<Timestamp>,
1828 limit: Option<u64>,
1829 ) -> anyhow::Result<Vec<TradeTick>, KrakenHttpError> {
1830 let instrument = self
1831 .get_cached_instrument(&instrument_id.symbol.inner())
1832 .ok_or_else(|| {
1833 KrakenHttpError::ParseError(
1834 InstrumentLookupError::not_found(instrument_id).to_string(),
1835 )
1836 })?;
1837
1838 let raw_symbol = instrument.raw_symbol().to_string();
1839 let asset_class = Self::asset_class_for(&instrument);
1840 let ts_init = self.generate_ts_init();
1841
1842 let since = start.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0).to_string());
1844 let response = self
1845 .inner
1846 .get_trades(&raw_symbol, since, asset_class)
1847 .await?;
1848
1849 let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1850 let mut trades = Vec::new();
1851
1852 for (_pair_name, trade_arrays) in &response.data {
1853 for trade_array in trade_arrays {
1854 match parse_trade_tick_from_array(trade_array, &instrument, ts_init) {
1855 Ok(trade_tick) => {
1856 if let Some(end_nanos) = end_ns
1857 && trade_tick.ts_event.as_u64() > end_nanos
1858 {
1859 continue;
1860 }
1861 trades.push(trade_tick);
1862 }
1863 Err(e) => {
1864 log::warn!("Failed to parse trade tick: {e}");
1865 }
1866 }
1867 }
1868 }
1869
1870 apply_count_limit(&mut trades, start, limit);
1872
1873 Ok(trades)
1874 }
1875
1876 pub async fn request_bars(
1878 &self,
1879 bar_type: BarType,
1880 start: Option<Timestamp>,
1881 end: Option<Timestamp>,
1882 limit: Option<u64>,
1883 ) -> anyhow::Result<Vec<Bar>, KrakenHttpError> {
1884 let instrument_id = bar_type.instrument_id();
1885 let instrument = self
1886 .get_cached_instrument(&instrument_id.symbol.inner())
1887 .ok_or_else(|| {
1888 KrakenHttpError::ParseError(
1889 InstrumentLookupError::not_found(instrument_id).to_string(),
1890 )
1891 })?;
1892
1893 let raw_symbol = instrument.raw_symbol().to_string();
1894 let asset_class = Self::asset_class_for(&instrument);
1895 let ts_init = self.generate_ts_init();
1896
1897 let interval = Some(
1898 bar_type_to_spot_interval(bar_type)
1899 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?,
1900 );
1901
1902 let since = start.map(|dt| dt.as_second());
1904 let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1905 let response = self
1906 .inner
1907 .get_ohlc(&raw_symbol, interval, since, asset_class)
1908 .await?;
1909
1910 let mut bars = Vec::new();
1911
1912 for (_pair_name, ohlc_arrays) in &response.data {
1913 for ohlc_array in ohlc_arrays {
1914 if ohlc_array.len() < 8 {
1915 let len = ohlc_array.len();
1916 log::warn!("OHLC array too short: {len}");
1917 continue;
1918 }
1919
1920 let ohlc = OhlcData {
1921 time: ohlc_array[0].as_i64().unwrap_or(0),
1922 open: ohlc_array[1].as_str().unwrap_or("0").to_string(),
1923 high: ohlc_array[2].as_str().unwrap_or("0").to_string(),
1924 low: ohlc_array[3].as_str().unwrap_or("0").to_string(),
1925 close: ohlc_array[4].as_str().unwrap_or("0").to_string(),
1926 vwap: ohlc_array[5].as_str().unwrap_or("0").to_string(),
1927 volume: ohlc_array[6].as_str().unwrap_or("0").to_string(),
1928 count: ohlc_array[7].as_i64().unwrap_or(0),
1929 };
1930
1931 match parse_bar(&ohlc, &instrument, bar_type, ts_init) {
1932 Ok(bar) => {
1933 if let Some(end_nanos) = end_ns
1934 && bar.ts_event.as_u64() > end_nanos
1935 {
1936 continue;
1937 }
1938 bars.push(bar);
1939 }
1940 Err(e) => {
1941 log::warn!("Failed to parse bar: {e}");
1942 }
1943 }
1944 }
1945 }
1946
1947 apply_count_limit(&mut bars, start, limit);
1950
1951 Ok(bars)
1952 }
1953
1954 pub async fn request_book_snapshot(
1956 &self,
1957 instrument_id: InstrumentId,
1958 depth: Option<u32>,
1959 ) -> anyhow::Result<OrderBook, KrakenHttpError> {
1960 let instrument = self
1961 .get_cached_instrument(&instrument_id.symbol.inner())
1962 .ok_or_else(|| {
1963 KrakenHttpError::ParseError(
1964 InstrumentLookupError::not_found(instrument_id).to_string(),
1965 )
1966 })?;
1967
1968 let raw_symbol = instrument.raw_symbol().to_string();
1969 let asset_class = Self::asset_class_for(&instrument);
1970 let price_precision = instrument.price_precision();
1971 let size_precision = instrument.size_precision();
1972 let ts_event = self.generate_ts_init();
1973
1974 let response = self
1975 .inner
1976 .get_book_depth(&raw_symbol, depth, asset_class)
1977 .await?;
1978
1979 let book_data = response.values().next().ok_or_else(|| {
1980 KrakenHttpError::ParseError(format!("No book data returned for {instrument_id}"))
1981 })?;
1982
1983 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
1984
1985 for (i, level) in book_data.bids.iter().enumerate() {
1988 let price_str = level.first().and_then(|v| v.as_str()).unwrap_or("0");
1989 let size_str = level.get(1).and_then(|v| v.as_str()).unwrap_or("0");
1990 let price = Price::from_decimal_dp(
1991 Decimal::from_str_exact(price_str).unwrap_or(Decimal::ZERO),
1992 price_precision,
1993 )
1994 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1995 let size = Quantity::from_decimal_dp(
1996 Decimal::from_str_exact(size_str).unwrap_or(Decimal::ZERO),
1997 size_precision,
1998 )
1999 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
2000 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
2001 book.add(order, 0, 0, ts_event);
2002 }
2003
2004 let bids_len = book_data.bids.len();
2005
2006 for (i, level) in book_data.asks.iter().enumerate() {
2007 let price_str = level.first().and_then(|v| v.as_str()).unwrap_or("0");
2008 let size_str = level.get(1).and_then(|v| v.as_str()).unwrap_or("0");
2009 let price = Price::from_decimal_dp(
2010 Decimal::from_str_exact(price_str).unwrap_or(Decimal::ZERO),
2011 price_precision,
2012 )
2013 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
2014 let size = Quantity::from_decimal_dp(
2015 Decimal::from_str_exact(size_str).unwrap_or(Decimal::ZERO),
2016 size_precision,
2017 )
2018 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
2019 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
2020 book.add(order, 0, 0, ts_event);
2021 }
2022
2023 Ok(book)
2024 }
2025
2026 pub async fn request_account_state(
2037 &self,
2038 account_id: AccountId,
2039 account_type: AccountType,
2040 margin_balance_asset: Option<&str>,
2041 ) -> anyhow::Result<AccountState> {
2042 self.request_account_state_with_metrics(account_id, account_type, margin_balance_asset)
2043 .await
2044 .map(|(state, _)| state)
2045 }
2046
2047 pub async fn request_account_state_with_metrics(
2069 &self,
2070 account_id: AccountId,
2071 account_type: AccountType,
2072 margin_balance_asset: Option<&str>,
2073 ) -> anyhow::Result<(AccountState, IndexMap<String, String>)> {
2074 let balances_raw = self.inner.get_balance_ex().await?;
2075 let ts_init = self.generate_ts_init();
2076
2077 let (margins, metrics, margin_entry, target_code) = if account_type == AccountType::Margin {
2078 let snapshot = self
2079 .fetch_trade_balance_snapshot(margin_balance_asset)
2080 .await?;
2081 let target_code = normalize_currency_code(margin_balance_asset.unwrap_or("ZUSD"));
2082 let currency = Currency::new(target_code, 8, 0, "0", CurrencyType::Crypto);
2083 let margin_entry = AccountBalance::from_total_and_free(
2084 snapshot.equity,
2085 snapshot.free_margin,
2086 currency,
2087 )
2088 .context("Failed to build synthetic margin AccountBalance from TradeBalance")?;
2089
2090 (
2091 snapshot.margins,
2092 snapshot.metrics,
2093 Some(margin_entry),
2094 target_code,
2095 )
2096 } else {
2097 (Vec::new(), IndexMap::new(), None, "")
2098 };
2099
2100 let skip_margin_wallet = margin_entry.is_some();
2101
2102 let balances: Vec<AccountBalance> = balances_raw
2103 .iter()
2104 .filter_map(|(currency_code, entry)| {
2105 let balance = Decimal::from_str_exact(&entry.balance).ok()?;
2106 let credit = optional_credit_amount(entry.credit.as_deref())?;
2107 let credit_used = optional_credit_amount(entry.credit_used.as_deref())?;
2108
2109 let total = balance + credit - credit_used;
2112
2113 let normalized_code = normalize_currency_code(currency_code);
2114
2115 if skip_margin_wallet && normalized_code == target_code {
2116 return None;
2117 }
2118
2119 let locked = Decimal::from_str_exact(&entry.hold_trade).ok()?;
2120 let currency =
2121 Currency::new(normalized_code, 8, 0, normalized_code, CurrencyType::Crypto);
2122 AccountBalance::from_total_and_locked(total, locked, currency).ok()
2123 })
2124 .chain(margin_entry)
2125 .collect();
2126
2127 let state = AccountState::new(
2128 account_id,
2129 account_type,
2130 balances,
2131 margins,
2132 true,
2133 UUID4::new(),
2134 ts_init,
2135 ts_init,
2136 None,
2137 );
2138
2139 Ok((state, metrics))
2140 }
2141
2142 async fn fetch_trade_balance_snapshot(
2157 &self,
2158 asset: Option<&str>,
2159 ) -> anyhow::Result<TradeBalanceSnapshot> {
2160 let tb = self.inner.get_trade_balance(asset).await?;
2161
2162 let used_margin = Decimal::from_str_exact(&tb.m)
2163 .with_context(|| format!("Failed to parse TradeBalance 'm' field {:?}", tb.m))?;
2164 let free_margin = Decimal::from_str_exact(&tb.mf)
2165 .with_context(|| format!("Failed to parse TradeBalance 'mf' field {:?}", tb.mf))?;
2166 let equity = Decimal::from_str_exact(&tb.e)
2167 .with_context(|| format!("Failed to parse TradeBalance 'e' field {:?}", tb.e))?;
2168
2169 let margins = if used_margin.is_zero() {
2170 Vec::new()
2171 } else {
2172 let currency = trade_balance_currency(asset);
2173 let initial = Money::from_decimal(used_margin, currency)
2174 .context("Failed to build initial margin from TradeBalance 'm'")?;
2175 let maintenance = Money::zero(currency);
2176 vec![MarginBalance::new(initial, maintenance, None)]
2177 };
2178
2179 let mut metrics = IndexMap::new();
2180 metrics.insert("equivalent_balance".to_string(), tb.eb);
2181 metrics.insert("trade_balance".to_string(), tb.tb);
2182 metrics.insert("used_margin".to_string(), tb.m);
2183 metrics.insert("unexecuted_value".to_string(), tb.uv);
2184 metrics.insert("unrealized_pnl".to_string(), tb.n);
2185 metrics.insert("cost_basis".to_string(), tb.c);
2186 metrics.insert("valuation".to_string(), tb.v);
2187 metrics.insert("equity".to_string(), tb.e);
2188 metrics.insert("free_margin".to_string(), tb.mf);
2189 if let Some(ml) = tb.ml {
2190 metrics.insert("margin_level".to_string(), ml);
2191 }
2192 metrics.insert(
2193 "asset".to_string(),
2194 normalize_currency_code(asset.unwrap_or("ZUSD")).to_string(),
2195 );
2196
2197 Ok(TradeBalanceSnapshot {
2198 margins,
2199 metrics,
2200 free_margin,
2201 equity,
2202 })
2203 }
2204
2205 pub async fn request_margin_metrics(
2218 &self,
2219 asset: Option<&str>,
2220 ) -> anyhow::Result<IndexMap<String, String>> {
2221 self.fetch_trade_balance_snapshot(asset)
2222 .await
2223 .map(|snapshot| snapshot.metrics)
2224 }
2225
2226 pub async fn request_order_status_reports(
2228 &self,
2229 account_id: AccountId,
2230 instrument_id: Option<InstrumentId>,
2231 start: Option<Timestamp>,
2232 end: Option<Timestamp>,
2233 open_only: bool,
2234 ) -> anyhow::Result<Vec<OrderStatusReport>> {
2235 self.request_order_status_reports_checked(account_id, instrument_id, start, end, open_only)
2236 .await
2237 .map(|(reports, _)| reports)
2238 }
2239
2240 pub(crate) async fn request_order_status_reports_checked(
2251 &self,
2252 account_id: AccountId,
2253 instrument_id: Option<InstrumentId>,
2254 start: Option<Timestamp>,
2255 end: Option<Timestamp>,
2256 open_only: bool,
2257 ) -> anyhow::Result<(Vec<OrderStatusReport>, ReportCoverage)> {
2258 const PAGE_SIZE: i32 = 50;
2259
2260 let ts_init = self.generate_ts_init();
2261 let mut all_reports = Vec::new();
2262 let mut coverage = ReportCoverage::default();
2263
2264 if let Some(ref target_id) = instrument_id
2269 && self
2270 .get_cached_instrument(&target_id.symbol.inner())
2271 .is_none()
2272 {
2273 if product_type_from_symbol(target_id.symbol.as_str()) == KrakenProductType::Spot {
2274 coverage.truncate(None);
2275 }
2276 return Ok((all_reports, coverage));
2277 }
2278
2279 let open_orders = self.inner.get_open_orders(Some(true), None).await?;
2280
2281 for (order_id, order) in &open_orders {
2282 let resolved = self.get_instrument_by_raw_symbol(order.descr.pair.as_str());
2285 if let Some(ref target_id) = instrument_id {
2286 match &resolved {
2287 Some(inst) if inst.id() != *target_id => continue,
2288 None => {
2290 coverage.skip(order_id);
2291 continue;
2292 }
2293 Some(_) => {}
2294 }
2295 }
2296
2297 let instrument = resolved.ok_or_else(|| {
2298 anyhow::anyhow!(
2299 "OpenOrders: instrument not in cache for pair {}",
2300 order.descr.pair
2301 )
2302 })?;
2303
2304 let report =
2307 parse_order_status_report(order_id, order, &instrument, account_id, ts_init)
2308 .map_err(|e| {
2309 anyhow::anyhow!("OpenOrders: failed to parse order {order_id}: {e}")
2310 })?;
2311 all_reports.push(report);
2312 }
2313
2314 if open_only {
2315 return Ok((all_reports, coverage));
2316 }
2317
2318 let start_ts = start.map(|dt| dt.as_second());
2320 let end_ts = end.map(|dt| dt.as_second());
2321
2322 let mut offset = 0;
2323 let mut pages = 0;
2324
2325 loop {
2326 if pages >= MAX_REPORT_PAGES {
2327 log::warn!(
2328 "ClosedOrders pagination hit the cap of {MAX_REPORT_PAGES} pages; returning a truncated set and marking it incomplete"
2329 );
2330 coverage.truncate(None);
2331 break;
2332 }
2333
2334 let closed_orders = self
2335 .inner
2336 .get_closed_orders(Some(true), None, start_ts, end_ts, Some(offset), None)
2337 .await?;
2338
2339 if closed_orders.is_empty() {
2340 break;
2341 }
2342
2343 pages += 1;
2344
2345 for (order_id, order) in &closed_orders {
2346 let resolved = self.get_instrument_by_raw_symbol(order.descr.pair.as_str());
2347 if let Some(ref target_id) = instrument_id {
2348 match &resolved {
2349 Some(inst) if inst.id() != *target_id => continue,
2350 None => {
2352 coverage.skip(order_id);
2353 continue;
2354 }
2355 Some(_) => {}
2356 }
2357 }
2358
2359 let Some(instrument) = resolved else {
2362 log::warn!(
2363 "ClosedOrders: instrument not in cache for pair {}, skipping order {order_id}",
2364 order.descr.pair
2365 );
2366 coverage.skip(order_id);
2367 continue;
2368 };
2369
2370 match parse_order_status_report(order_id, order, &instrument, account_id, ts_init) {
2371 Ok(report) => all_reports.push(report),
2372 Err(e) => {
2373 log::warn!("Failed to parse order {order_id}: {e}");
2374 coverage.skip(order_id);
2375 }
2376 }
2377 }
2378
2379 offset += PAGE_SIZE;
2380 }
2381
2382 Ok((all_reports, coverage))
2383 }
2384
2385 pub async fn request_fill_reports(
2387 &self,
2388 account_id: AccountId,
2389 instrument_id: Option<InstrumentId>,
2390 start: Option<Timestamp>,
2391 end: Option<Timestamp>,
2392 ) -> anyhow::Result<Vec<FillReport>> {
2393 self.request_fill_reports_checked(account_id, instrument_id, start, end)
2394 .await
2395 .map(|(reports, _)| reports)
2396 }
2397
2398 pub(crate) async fn request_fill_reports_checked(
2403 &self,
2404 account_id: AccountId,
2405 instrument_id: Option<InstrumentId>,
2406 start: Option<Timestamp>,
2407 end: Option<Timestamp>,
2408 ) -> anyhow::Result<(Vec<FillReport>, bool)> {
2409 const PAGE_SIZE: i32 = 50;
2410
2411 let ts_init = self.generate_ts_init();
2412 let mut all_reports = Vec::new();
2413 let mut complete = true;
2414
2415 if let Some(ref target_id) = instrument_id
2417 && self
2418 .get_cached_instrument(&target_id.symbol.inner())
2419 .is_none()
2420 {
2421 return Ok((all_reports, complete));
2422 }
2423
2424 let start_ts = start.map(|dt| dt.as_second());
2426 let end_ts = end.map(|dt| dt.as_second());
2427
2428 let mut offset = 0;
2429 let mut pages = 0;
2430
2431 loop {
2432 if pages >= MAX_REPORT_PAGES {
2433 log::warn!(
2434 "TradesHistory pagination hit the cap of {MAX_REPORT_PAGES} pages; returning a truncated set and marking it incomplete, which only mass status surfaces"
2435 );
2436 complete = false;
2437 break;
2438 }
2439
2440 let trades = self
2441 .inner
2442 .get_trades_history(None, Some(true), start_ts, end_ts, Some(offset))
2443 .await?;
2444
2445 if trades.is_empty() {
2446 break;
2447 }
2448
2449 pages += 1;
2450
2451 for (trade_id, trade) in &trades {
2452 let resolved = self.get_instrument_by_raw_symbol(trade.pair.as_str());
2453 if let Some(ref target_id) = instrument_id
2454 && resolved.as_ref().is_none_or(|inst| inst.id() != *target_id)
2455 {
2456 continue;
2457 }
2458
2459 let Some(instrument) = resolved else {
2461 log::warn!(
2462 "TradesHistory: instrument not in cache for pair {}, skipping trade {trade_id}",
2463 trade.pair
2464 );
2465 complete = false;
2466 continue;
2467 };
2468
2469 match parse_fill_report(trade_id, trade, &instrument, account_id, ts_init) {
2470 Ok(report) => all_reports.push(report),
2471 Err(e) => {
2472 log::warn!("Failed to parse trade {trade_id}: {e}");
2473 complete = false;
2474 }
2475 }
2476 }
2477
2478 offset += PAGE_SIZE;
2479 }
2480
2481 Ok((all_reports, complete))
2482 }
2483
2484 pub async fn request_position_status_reports(
2490 &self,
2491 account_id: AccountId,
2492 instrument_id: Option<InstrumentId>,
2493 account_type: AccountType,
2494 use_spot_position_reports: bool,
2495 quote_currency: Ustr,
2496 ) -> anyhow::Result<Vec<PositionStatusReport>> {
2497 if account_type == AccountType::Margin {
2498 self.generate_margin_position_reports(account_id, instrument_id)
2499 .await
2500 } else if use_spot_position_reports {
2501 self.generate_spot_position_reports_from_wallet(
2502 account_id,
2503 instrument_id,
2504 quote_currency,
2505 )
2506 .await
2507 } else {
2508 Ok(Vec::new())
2509 }
2510 }
2511
2512 pub fn covers_bulk_position_reports(
2521 &self,
2522 instrument_id: InstrumentId,
2523 account_type: AccountType,
2524 use_spot_position_reports: bool,
2525 quote_currency: Ustr,
2526 ) -> bool {
2527 if account_type == AccountType::Margin || !use_spot_position_reports {
2528 return false;
2529 }
2530
2531 self.get_cached_instrument(&instrument_id.symbol.inner())
2532 .is_some_and(|instrument| {
2533 wallet_report_base_currency(&instrument, quote_currency).is_some()
2534 })
2535 }
2536
2537 async fn generate_margin_position_reports(
2539 &self,
2540 account_id: AccountId,
2541 instrument_id: Option<InstrumentId>,
2542 ) -> anyhow::Result<Vec<PositionStatusReport>> {
2543 let open_positions = self
2544 .inner
2545 .get_open_positions(&SpotOpenPositionsParams::default())
2546 .await?;
2547
2548 let ts_init = self.generate_ts_init();
2549
2550 let mut agg: IndexMap<String, OpenPositionAggregate> = IndexMap::new();
2558
2559 let target_pair: Option<Ustr> = match &instrument_id {
2560 Some(target_id) => match self.get_cached_instrument(&target_id.symbol.inner()) {
2561 Some(inst) => Some(Ustr::from(inst.raw_symbol().as_str())),
2562 None => return Ok(Vec::new()),
2563 },
2564 None => None,
2565 };
2566
2567 for (_pos_id, pos) in open_positions.iter() {
2568 if let Some(pair) = target_pair
2569 && pair.as_str() != pos.pair
2570 {
2571 continue;
2572 }
2573
2574 if let Some(status) = pos.posstatus.as_deref()
2575 && status != "open"
2576 {
2577 log::debug!(
2578 "Skipping non-open OpenPositions entry for {}: posstatus={status}",
2579 pos.pair,
2580 );
2581 continue;
2582 }
2583
2584 let instrument = self
2585 .get_instrument_by_raw_symbol(pos.pair.as_str())
2586 .ok_or_else(|| {
2587 anyhow::anyhow!(
2588 "OpenPositions: instrument not in cache for pair {}",
2589 pos.pair
2590 )
2591 })?;
2592
2593 let vol = Decimal::from_str_exact(&pos.vol)
2594 .with_context(|| format!("OpenPositions: failed to parse vol for {}", pos.pair))?;
2595 let vol_closed = Decimal::from_str_exact(&pos.vol_closed).with_context(|| {
2596 format!("OpenPositions: failed to parse vol_closed for {}", pos.pair)
2597 })?;
2598
2599 let lot_net = (vol - vol_closed).max(Decimal::ZERO);
2600 let signed_lot = match pos.side {
2601 KrakenOrderSide::Buy => lot_net,
2602 KrakenOrderSide::Sell => -lot_net,
2603 };
2604
2605 let cost = Decimal::from_str_exact(&pos.cost)
2609 .with_context(|| format!("OpenPositions: failed to parse cost for {}", pos.pair))?;
2610
2611 let entry = agg
2612 .entry(pos.pair.clone())
2613 .or_insert_with(|| OpenPositionAggregate::new(instrument.id()));
2614 entry.signed_qty += signed_lot;
2615
2616 if !vol.is_zero() {
2617 let entry_px = cost / vol;
2618
2619 match pos.side {
2620 KrakenOrderSide::Buy => {
2621 entry.long_qty += lot_net;
2622 entry.long_notional += lot_net * entry_px;
2623 }
2624 KrakenOrderSide::Sell => {
2625 entry.short_qty += lot_net;
2626 entry.short_notional += lot_net * entry_px;
2627 }
2628 }
2629 }
2630 }
2631
2632 let mut reports = Vec::new();
2633
2634 for (_, aggregate) in agg {
2635 let signed_qty = aggregate.signed_qty;
2636 let inst_id = aggregate.instrument_id;
2637 let instrument = self
2638 .get_cached_instrument(&inst_id.symbol.inner())
2639 .ok_or_else(|| InstrumentLookupError::not_found(inst_id))?;
2640
2641 let side = if signed_qty.is_sign_positive() && !signed_qty.is_zero() {
2642 PositionSide::Long
2643 } else if signed_qty.is_sign_negative() && !signed_qty.is_zero() {
2644 PositionSide::Short
2645 } else {
2646 PositionSide::Flat
2647 };
2648 let quantity = Quantity::from_decimal_dp(signed_qty.abs(), instrument.size_precision())
2649 .map_err(|e| {
2650 anyhow::anyhow!("OpenPositions: failed to build Quantity for {inst_id}: {e:?}")
2651 })?;
2652 let avg_px_open = aggregate.avg_px_open(side);
2653 let report = PositionStatusReport::new(
2654 account_id,
2655 inst_id,
2656 side,
2657 quantity,
2658 ts_init,
2659 ts_init,
2660 None,
2661 None,
2662 avg_px_open,
2663 )
2664 .with_avg_px_open_reconciliation(AvgPxReconciliation::OpeningOnly);
2668 reports.push(report);
2669 }
2670
2671 if let Some(target_id) = instrument_id {
2678 let already_reported = reports.iter().any(|r| r.instrument_id == target_id);
2679
2680 if !already_reported
2681 && let Some(instrument) = self.get_cached_instrument(&target_id.symbol.inner())
2682 {
2683 let precision = instrument.size_precision();
2684 reports.push(PositionStatusReport::new(
2685 account_id,
2686 target_id,
2687 PositionSide::Flat,
2688 Quantity::zero(precision),
2689 ts_init,
2690 ts_init,
2691 None,
2692 None,
2693 None,
2694 ));
2695 }
2696 }
2697
2698 Ok(reports)
2699 }
2700
2701 async fn generate_spot_position_reports_from_wallet(
2707 &self,
2708 account_id: AccountId,
2709 instrument_id: Option<InstrumentId>,
2710 quote_currency: Ustr,
2711 ) -> anyhow::Result<Vec<PositionStatusReport>> {
2712 let balances_raw = self.inner.get_balance().await?;
2713 let ts_init = self.generate_ts_init();
2714 let mut wallet_by_coin: HashMap<Ustr, Decimal> = HashMap::new();
2715
2716 for (currency_code, amount_str) in &balances_raw {
2717 let balance = match Decimal::from_str_exact(amount_str) {
2718 Ok(b) => b,
2719 Err(_) => continue,
2720 };
2721
2722 if balance.is_zero() {
2723 continue;
2724 }
2725
2726 wallet_by_coin.insert(Ustr::from(normalize_currency_code(currency_code)), balance);
2727 }
2728
2729 let mut reports = Vec::new();
2730
2731 if let Some(instrument_id) = instrument_id {
2732 if let Some(instrument) = self.get_cached_instrument(&instrument_id.symbol.inner()) {
2733 let base_currency = match instrument.base_currency() {
2734 Some(currency) => currency,
2735 None => return Ok(reports),
2736 };
2737
2738 let coin = Ustr::from(base_currency.code.as_str());
2739 let wallet_balance = wallet_by_coin.get(&coin).copied().unwrap_or(Decimal::ZERO);
2740
2741 let side = if wallet_balance > Decimal::ZERO {
2742 PositionSide::Long
2743 } else {
2744 PositionSide::Flat
2745 };
2746
2747 let abs_balance = wallet_balance.abs();
2748 let quantity = Quantity::from_decimal_dp(abs_balance, instrument.size_precision())?;
2749
2750 let report = PositionStatusReport::new(
2751 account_id,
2752 instrument_id,
2753 side,
2754 quantity,
2755 ts_init,
2756 ts_init,
2757 None,
2758 None,
2759 None,
2760 );
2761
2762 reports.push(report);
2763 }
2764 } else {
2765 let instruments_guard = self.instruments_cache.load();
2766 for instrument in instruments_guard.values() {
2767 let Some(base_currency) = wallet_report_base_currency(instrument, quote_currency)
2768 else {
2769 continue;
2770 };
2771
2772 let coin = Ustr::from(base_currency.code.as_str());
2773 let wallet_balance = wallet_by_coin.get(&coin).copied().unwrap_or(Decimal::ZERO);
2774
2775 if wallet_balance.is_zero() {
2776 continue;
2777 }
2778
2779 let side = PositionSide::Long;
2780 let quantity =
2781 Quantity::from_decimal_dp(wallet_balance, instrument.size_precision())?;
2782
2783 if quantity.is_zero() {
2784 continue;
2785 }
2786
2787 log::debug!(
2788 "Spot position: {} {} (quote: {})",
2789 quantity,
2790 base_currency.code,
2791 instrument.quote_currency().code
2792 );
2793
2794 let report = PositionStatusReport::new(
2795 account_id,
2796 instrument.id(),
2797 side,
2798 quantity,
2799 ts_init,
2800 ts_init,
2801 None,
2802 None,
2803 None,
2804 );
2805
2806 reports.push(report);
2807 }
2808 }
2809
2810 Ok(reports)
2811 }
2812
2813 #[expect(clippy::too_many_arguments)]
2826 pub async fn submit_order(
2827 &self,
2828 _account_id: AccountId,
2829 instrument_id: InstrumentId,
2830 client_order_id: ClientOrderId,
2831 order_side: OrderSide,
2832 order_type: OrderType,
2833 quantity: Quantity,
2834 time_in_force: TimeInForce,
2835 expire_time: Option<UnixNanos>,
2836 price: Option<Price>,
2837 trigger_price: Option<Price>,
2838 trigger_type: Option<TriggerType>,
2839 trailing_offset: Option<Decimal>,
2840 limit_offset: Option<Decimal>,
2841 reduce_only: bool,
2842 post_only: bool,
2843 quote_quantity: bool,
2844 display_qty: Option<Quantity>,
2845 leverage: Option<u16>,
2846 account_type: AccountType,
2847 ) -> anyhow::Result<VenueOrderId> {
2848 let params = self.build_add_order_params(
2849 instrument_id,
2850 client_order_id,
2851 order_side,
2852 order_type,
2853 quantity,
2854 time_in_force,
2855 expire_time,
2856 price,
2857 trigger_price,
2858 trigger_type,
2859 trailing_offset,
2860 limit_offset,
2861 reduce_only,
2862 post_only,
2863 quote_quantity,
2864 display_qty,
2865 leverage,
2866 account_type,
2867 )?;
2868 let response = self.inner.add_order(¶ms).await?;
2869
2870 let venue_order_id =
2871 response
2872 .txid
2873 .first()
2874 .ok_or_else(|| KrakenSubmitOrderError::MissingOrderId {
2875 detail: "transaction ID was absent".to_string(),
2876 })?;
2877
2878 Ok(VenueOrderId::new(venue_order_id))
2879 }
2880
2881 #[expect(clippy::type_complexity)]
2886 pub async fn submit_orders_batch(
2887 &self,
2888 orders: Vec<(
2889 InstrumentId,
2890 ClientOrderId,
2891 OrderSide,
2892 OrderType,
2893 Quantity,
2894 TimeInForce,
2895 Option<UnixNanos>,
2896 Option<Price>,
2897 Option<Price>,
2898 Option<TriggerType>,
2899 Option<Decimal>,
2900 Option<Decimal>,
2901 bool,
2902 bool,
2903 bool,
2904 Option<Quantity>,
2905 Option<u16>,
2906 )>,
2907 account_type: AccountType,
2908 ) -> anyhow::Result<Vec<String>> {
2909 Ok(self
2910 .send_order_batches(orders, account_type)
2911 .await
2912 .into_iter()
2913 .map(|result| match result {
2914 Ok(order) if order.txid.is_some() => "placed".to_string(),
2915 Ok(order) => order.error.unwrap_or_else(|| "Unknown error".to_string()),
2916 Err(e)
2917 if matches!(
2918 e.downcast_ref::<KrakenBatchOrderError>(),
2919 Some(KrakenBatchOrderError::Validation { .. })
2920 ) =>
2921 {
2922 format!("validation_error: {e}")
2923 }
2924 Err(e) => format!("batch_error: {e}"),
2925 })
2926 .collect())
2927 }
2928
2929 pub(crate) async fn send_order_batches(
2930 &self,
2931 orders: Vec<SpotBatchOrder>,
2932 account_type: AccountType,
2933 ) -> Vec<anyhow::Result<SpotBatchOrderResponse>> {
2934 let count = orders.len();
2935 if count == 0 {
2936 return Vec::new();
2937 }
2938
2939 let mut results: Vec<Option<anyhow::Result<SpotBatchOrderResponse>>> =
2940 (0..count).map(|_| None).collect();
2941 let mut grouped: AHashMap<Ustr, Vec<(usize, KrakenSpotAddOrderParams)>> = AHashMap::new();
2942
2943 for (
2944 idx,
2945 (
2946 instrument_id,
2947 client_order_id,
2948 order_side,
2949 order_type,
2950 quantity,
2951 time_in_force,
2952 expire_time,
2953 price,
2954 trigger_price,
2955 trigger_type,
2956 trailing_offset,
2957 limit_offset,
2958 reduce_only,
2959 post_only,
2960 quote_quantity,
2961 display_qty,
2962 leverage,
2963 ),
2964 ) in orders.into_iter().enumerate()
2965 {
2966 match self.build_add_order_params(
2967 instrument_id,
2968 client_order_id,
2969 order_side,
2970 order_type,
2971 quantity,
2972 time_in_force,
2973 expire_time,
2974 price,
2975 trigger_price,
2976 trigger_type,
2977 trailing_offset,
2978 limit_offset,
2979 reduce_only,
2980 post_only,
2981 quote_quantity,
2982 display_qty,
2983 leverage,
2984 account_type,
2985 ) {
2986 Ok(params) => {
2987 grouped.entry(params.pair).or_default().push((idx, params));
2988 }
2989 Err(e) => {
2990 results[idx] = Some(Err(KrakenBatchOrderError::Validation {
2991 reason: e.to_string(),
2992 }
2993 .into()));
2994 }
2995 }
2996 }
2997
2998 let mut batches = Vec::new();
2999 let mut grouped_orders: Vec<_> = grouped.into_values().collect();
3000 grouped_orders.sort_by_key(|group| group.first().map_or(usize::MAX, |(idx, _)| *idx));
3001 for group in grouped_orders {
3002 batches.extend(group.chunks(BATCH_SUBMIT_LIMIT).map(|chunk| chunk.to_vec()));
3003 }
3004
3005 for (batch_index, batch) in batches.iter().enumerate() {
3006 let response = if batch.len() == 1 {
3007 self.inner
3008 .add_order(&batch[0].1)
3009 .await
3010 .map(|response| SpotAddOrderBatchResponse {
3011 orders: vec![SpotBatchOrderResponse {
3012 descr: response.descr,
3013 error: None,
3014 txid: response.txid.into_iter().next(),
3015 }],
3016 })
3017 } else {
3018 let params = KrakenSpotAddOrderBatchParams {
3019 pair: batch[0].1.pair,
3020 orders: batch
3021 .iter()
3022 .map(|(_, params)| params.clone().into())
3023 .collect(),
3024 asset_class: batch[0].1.asset_class,
3025 };
3026 self.inner.add_order_batch(¶ms).await
3027 };
3028
3029 match response {
3030 Ok(response) if response.orders.len() == batch.len() => {
3033 for ((idx, _), order) in batch.iter().zip(response.orders) {
3034 results[*idx] = Some(Ok(order));
3035 }
3036 }
3037 Ok(response) => {
3038 for (idx, _) in batch {
3039 results[*idx] = Some(Err(KrakenBatchOrderError::ResponseCount {
3040 expected: batch.len(),
3041 actual: response.orders.len(),
3042 }
3043 .into()));
3044 }
3045 }
3046 Err(e) => {
3047 for (idx, _) in batch {
3048 results[*idx] = Some(Err(anyhow::Error::new(e.clone())));
3049 }
3050
3051 for later_batch in &batches[batch_index + 1..] {
3052 for (idx, _) in later_batch {
3053 results[*idx] = Some(Err(KrakenBatchOrderError::NotAttempted.into()));
3054 }
3055 }
3056 break;
3057 }
3058 }
3059 }
3060
3061 results
3062 .into_iter()
3063 .map(|result| result.unwrap_or_else(|| Err(KrakenBatchOrderError::NotAttempted.into())))
3064 .collect()
3065 }
3066
3067 pub async fn modify_order(
3079 &self,
3080 instrument_id: InstrumentId,
3081 client_order_id: Option<ClientOrderId>,
3082 venue_order_id: Option<VenueOrderId>,
3083 quantity: Option<Quantity>,
3084 price: Option<Price>,
3085 trigger_price: Option<Price>,
3086 ) -> anyhow::Result<VenueOrderId> {
3087 let _ = self
3088 .get_cached_instrument(&instrument_id.symbol.inner())
3089 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
3090
3091 let txid = venue_order_id.as_ref().map(|id| id.to_string());
3092 let cl_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
3093
3094 if txid.is_none() && cl_ord_id.is_none() {
3095 anyhow::bail!("Either client_order_id or venue_order_id must be provided");
3096 }
3097
3098 let mut builder = KrakenSpotAmendOrderParamsBuilder::default();
3099 if let Some(ref id) = txid {
3100 builder.txid(id.clone());
3101 } else if let Some(ref id) = cl_ord_id {
3102 builder.cl_ord_id(id.clone());
3103 }
3104
3105 if let Some(qty) = quantity {
3106 builder.order_qty(qty.to_string());
3107 }
3108
3109 if let Some(p) = price {
3110 builder.limit_price(p.to_string());
3111 }
3112
3113 if let Some(tp) = trigger_price {
3114 builder.trigger_price(tp.to_string());
3115 }
3116
3117 let params = builder
3118 .build()
3119 .map_err(|e| anyhow::anyhow!("Failed to build amend order params: {e}"))?;
3120
3121 let _response = self.inner.amend_order(¶ms).await?;
3122
3123 let order_id = venue_order_id.ok_or(KrakenModifyOrderError::MissingOrderId)?;
3125
3126 Ok(order_id)
3127 }
3128
3129 pub async fn cancel_order(
3139 &self,
3140 _account_id: AccountId,
3141 instrument_id: InstrumentId,
3142 client_order_id: Option<ClientOrderId>,
3143 venue_order_id: Option<VenueOrderId>,
3144 ) -> anyhow::Result<()> {
3145 let _ = self
3146 .get_cached_instrument(&instrument_id.symbol.inner())
3147 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
3148
3149 let txid = venue_order_id.as_ref().map(|id| id.to_string());
3150 let cl_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
3151
3152 if txid.is_none() && cl_ord_id.is_none() {
3153 anyhow::bail!("Either client_order_id or venue_order_id must be provided");
3154 }
3155
3156 let mut builder = KrakenSpotCancelOrderParamsBuilder::default();
3159
3160 if let Some(ref id) = txid {
3161 builder.txid(id.clone());
3162 } else if let Some(ref id) = cl_ord_id {
3163 builder.cl_ord_id(id.clone());
3164 }
3165 let params = builder
3166 .build()
3167 .map_err(|e| anyhow::anyhow!("Failed to build cancel params: {e}"))?;
3168
3169 self.inner.cancel_order(¶ms).await?;
3170
3171 Ok(())
3172 }
3173
3174 pub async fn cancel_orders_batch(
3176 &self,
3177 venue_order_ids: Vec<VenueOrderId>,
3178 ) -> anyhow::Result<i32> {
3179 if venue_order_ids.is_empty() {
3180 return Ok(0);
3181 }
3182
3183 let mut total_cancelled = 0;
3184
3185 for chunk in venue_order_ids.chunks(BATCH_CANCEL_LIMIT) {
3186 let orders: Vec<String> = chunk.iter().map(|id| id.to_string()).collect();
3187 let params = KrakenSpotCancelOrderBatchParams {
3188 orders,
3189 cl_ord_ids: Vec::new(),
3190 };
3191
3192 let response = self.inner.cancel_order_batch(¶ms).await?;
3193 total_cancelled += response.count;
3194 }
3195
3196 Ok(total_cancelled)
3197 }
3198
3199 #[expect(clippy::too_many_arguments)]
3200 fn build_add_order_params(
3201 &self,
3202 instrument_id: InstrumentId,
3203 client_order_id: ClientOrderId,
3204 order_side: OrderSide,
3205 order_type: OrderType,
3206 quantity: Quantity,
3207 time_in_force: TimeInForce,
3208 expire_time: Option<UnixNanos>,
3209 price: Option<Price>,
3210 trigger_price: Option<Price>,
3211 trigger_type: Option<TriggerType>,
3212 trailing_offset: Option<Decimal>,
3213 limit_offset: Option<Decimal>,
3214 reduce_only: bool,
3215 post_only: bool,
3216 quote_quantity: bool,
3217 display_qty: Option<Quantity>,
3218 leverage: Option<u16>,
3219 account_type: AccountType,
3220 ) -> anyhow::Result<KrakenSpotAddOrderParams> {
3221 let instrument = self
3222 .get_cached_instrument(&instrument_id.symbol.inner())
3223 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
3224
3225 let raw_symbol = instrument.raw_symbol().inner();
3226 let asset_class = Self::asset_class_for(&instrument);
3227
3228 let kraken_side = match order_side {
3229 OrderSide::Buy => KrakenOrderSide::Buy,
3230 OrderSide::Sell => KrakenOrderSide::Sell,
3231 };
3232
3233 let kraken_order_type = match order_type {
3234 OrderType::Market => KrakenOrderType::Market,
3235 OrderType::Limit => KrakenOrderType::Limit,
3236 OrderType::StopMarket => KrakenOrderType::StopLoss,
3237 OrderType::StopLimit => KrakenOrderType::StopLossLimit,
3238 OrderType::MarketIfTouched => KrakenOrderType::TakeProfit,
3239 OrderType::LimitIfTouched => KrakenOrderType::TakeProfitLimit,
3240 OrderType::TrailingStopMarket => KrakenOrderType::TrailingStop,
3241 OrderType::TrailingStopLimit => KrakenOrderType::TrailingStopLimit,
3242 _ => anyhow::bail!("Unsupported order type: {order_type:?}"),
3243 };
3244
3245 let mut oflags = Vec::new();
3246 let is_limit_order = matches!(
3247 order_type,
3248 OrderType::Limit
3249 | OrderType::StopLimit
3250 | OrderType::LimitIfTouched
3251 | OrderType::TrailingStopLimit
3252 );
3253
3254 if time_in_force == TimeInForce::Fok && order_type != OrderType::Limit {
3255 anyhow::bail!("FOK time in force only supported for LIMIT orders on Kraken Spot");
3256 }
3257
3258 let (timeinforce, expiretm) =
3259 compute_time_in_force(is_limit_order, time_in_force, expire_time)?;
3260
3261 if post_only {
3262 oflags.push(KRAKEN_OFLAG_POST_ONLY);
3263 }
3264
3265 if quote_quantity {
3266 oflags.push(KRAKEN_OFLAG_QUOTE_QUANTITY);
3267 }
3268
3269 let mut builder = KrakenSpotAddOrderParamsBuilder::default();
3270 builder
3271 .cl_ord_id(truncate_cl_ord_id(&client_order_id))
3272 .broker(NAUTILUS_KRAKEN_BROKER_ID)
3273 .pair(raw_symbol)
3274 .side(kraken_side)
3275 .volume(quantity.to_string())
3276 .order_type(kraken_order_type);
3277
3278 let is_conditional = matches!(
3279 order_type,
3280 OrderType::StopMarket
3281 | OrderType::StopLimit
3282 | OrderType::MarketIfTouched
3283 | OrderType::LimitIfTouched
3284 | OrderType::TrailingStopMarket
3285 | OrderType::TrailingStopLimit
3286 );
3287
3288 let is_trailing = matches!(
3289 order_type,
3290 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
3291 );
3292
3293 if is_trailing {
3294 if trigger_price.is_some() {
3295 anyhow::bail!(
3296 "Kraken Spot trailing stops do not support activation trigger prices"
3297 );
3298 }
3299
3300 if let Some(offset) = trailing_offset {
3301 builder.price(offset.to_string());
3302 }
3303
3304 if let Some(offset) = limit_offset {
3305 builder.price2(offset.to_string());
3306 }
3307 } else if is_conditional {
3308 if let Some(trigger) = trigger_price {
3309 builder.price(trigger.to_string());
3310 }
3311
3312 if let Some(limit) = price {
3313 builder.price2(limit.to_string());
3314 }
3315 } else if let Some(limit) = price {
3316 builder.price(limit.to_string());
3317 }
3318
3319 if is_conditional {
3320 match trigger_type {
3321 Some(TriggerType::IndexPrice) => {
3322 builder.trigger("index".to_string());
3323 }
3324 Some(TriggerType::LastPrice | TriggerType::Default) | None => {}
3325 Some(other) => {
3326 anyhow::bail!(
3327 "Unsupported trigger type for Kraken Spot: {other:?} (only LastPrice and IndexPrice supported)"
3328 );
3329 }
3330 }
3331 }
3332
3333 if !oflags.is_empty() {
3334 builder.oflags(oflags.join(","));
3335 }
3336
3337 if let Some(tif) = timeinforce {
3338 builder.timeinforce(tif);
3339 }
3340
3341 if let Some(expire) = expiretm {
3342 builder.expiretm(expire);
3343 }
3344
3345 if let Some(dq) = display_qty {
3346 builder.displayvol(dq.to_string());
3347 }
3348
3349 if let Some(ac) = asset_class {
3350 builder.asset_class(ac);
3351 }
3352
3353 if leverage.is_some() && account_type != AccountType::Margin {
3354 anyhow::bail!("leverage requires spot_account_type=Margin (current: Cash)");
3355 }
3356
3357 if let Some(n) = leverage {
3358 let tiers = self.leverage_tiers_cache.get_cloned(&raw_symbol);
3359 let (buy_tiers, sell_tiers) = tiers.ok_or_else(|| {
3360 anyhow::anyhow!(
3361 "Leverage tiers not loaded for {raw_symbol}; cannot validate leverage {n}:1 (instruments must be initialized before submitting margin orders)"
3362 )
3363 })?;
3364 let valid_tiers = match order_side {
3365 OrderSide::Buy => buy_tiers,
3366 _ => sell_tiers,
3367 };
3368 let side_label = match order_side {
3369 OrderSide::Buy => "buy",
3370 _ => "sell",
3371 };
3372
3373 if valid_tiers.is_empty() {
3374 anyhow::bail!("Leverage not supported for {raw_symbol} on {side_label} side");
3375 }
3376
3377 if !valid_tiers.contains(&(n as i32)) {
3378 anyhow::bail!(
3379 "Leverage {n}:1 not supported for {raw_symbol} on {side_label} side (valid: {valid_tiers:?})"
3380 );
3381 }
3382 builder.leverage(format!("{n}:1"));
3383 }
3384
3385 if reduce_only {
3386 if account_type != AccountType::Margin {
3387 anyhow::bail!("reduce_only requires spot_account_type=Margin (current: Cash)");
3388 }
3389 builder.reduce_only(true);
3390 }
3391
3392 builder
3393 .build()
3394 .map_err(|e| anyhow::anyhow!("Failed to build order params: {e}"))
3395 }
3396}
3397
3398fn collect_spot_statuses(
3399 asset_pairs: &AssetPairsResponse,
3400) -> AHashMap<InstrumentId, MarketStatusAction> {
3401 asset_pairs
3402 .iter()
3403 .map(|(_, definition)| {
3404 let symbol_str = definition.wsname.as_ref().unwrap_or(&definition.altname);
3405 let normalized_symbol = normalize_spot_symbol(symbol_str.as_str());
3406 let instrument_id = InstrumentId::new(Symbol::new(&normalized_symbol), *KRAKEN_VENUE);
3407 let action = definition
3408 .status
3409 .map_or(MarketStatusAction::Trading, MarketStatusAction::from);
3410
3411 (instrument_id, action)
3412 })
3413 .collect()
3414}
3415
3416type LeverageTiersCache = Arc<AtomicMap<Ustr, (Vec<i32>, Vec<i32>)>>;
3418
3419struct OpenPositionAggregate {
3424 instrument_id: InstrumentId,
3425 signed_qty: Decimal,
3426 long_qty: Decimal,
3427 long_notional: Decimal,
3428 short_qty: Decimal,
3429 short_notional: Decimal,
3430}
3431
3432impl OpenPositionAggregate {
3433 fn new(instrument_id: InstrumentId) -> Self {
3434 Self {
3435 instrument_id,
3436 signed_qty: Decimal::ZERO,
3437 long_qty: Decimal::ZERO,
3438 long_notional: Decimal::ZERO,
3439 short_qty: Decimal::ZERO,
3440 short_notional: Decimal::ZERO,
3441 }
3442 }
3443
3444 fn avg_px_open(&self, side: PositionSide) -> Option<Decimal> {
3446 match side {
3447 PositionSide::Long if !self.long_qty.is_zero() => {
3448 Some(self.long_notional / self.long_qty)
3449 }
3450 PositionSide::Short if !self.short_qty.is_zero() => {
3451 Some(self.short_notional / self.short_qty)
3452 }
3453 _ => None,
3454 }
3455 }
3456}
3457
3458struct TradeBalanceSnapshot {
3459 margins: Vec<MarginBalance>,
3460 metrics: IndexMap<String, String>,
3461 free_margin: Decimal,
3462 equity: Decimal,
3463}
3464
3465fn wallet_report_base_currency(
3475 instrument: &InstrumentAny,
3476 quote_currency: Ustr,
3477) -> Option<Currency> {
3478 let quote_filter = Ustr::from(normalize_currency_code(quote_currency.as_str()));
3479 if instrument.quote_currency().code != quote_filter {
3480 return None;
3481 }
3482
3483 instrument.base_currency()
3484}
3485
3486fn optional_credit_amount(value: Option<&str>) -> Option<Decimal> {
3492 match value {
3493 Some(amount) => Decimal::from_str_exact(amount).ok(),
3494 None => Some(Decimal::ZERO),
3495 }
3496}
3497
3498fn trade_balance_currency(asset: Option<&str>) -> Currency {
3504 let raw = asset.unwrap_or("ZUSD");
3505 let normalized = normalize_currency_code(raw);
3506 Currency::try_from_str(normalized)
3507 .unwrap_or_else(|| Currency::new(normalized, 2, 0, normalized, CurrencyType::Fiat))
3508}
3509
3510fn compute_time_in_force(
3511 is_limit_order: bool,
3512 time_in_force: TimeInForce,
3513 expire_time: Option<UnixNanos>,
3514) -> anyhow::Result<(Option<String>, Option<String>)> {
3515 if !is_limit_order {
3516 return Ok((None, None));
3517 }
3518
3519 match time_in_force {
3520 TimeInForce::Gtc => Ok((None, None)),
3521 TimeInForce::Ioc => Ok((Some("IOC".to_string()), None)),
3522 TimeInForce::Fok => Ok((Some("FOK".to_string()), None)),
3523 TimeInForce::Gtd => {
3524 let expire = expire_time.ok_or_else(|| {
3525 anyhow::anyhow!("GTD time in force requires expire_time parameter")
3526 })?;
3527 let expire_secs = expire.as_u64() / NANOSECONDS_IN_SECOND;
3528 Ok((Some("GTD".to_string()), Some(expire_secs.to_string())))
3529 }
3530 _ => anyhow::bail!("Unsupported time in force: {time_in_force:?}"),
3531 }
3532}
3533
3534#[cfg(test)]
3535mod tests {
3536 use std::{sync::Arc, time::Duration};
3537
3538 use nautilus_model::instruments::CurrencyPair;
3539 use nautilus_testkit::http::assert_http_redirect_rejected;
3540 use rstest::rstest;
3541
3542 use super::*;
3543
3544 #[tokio::test]
3545 async fn test_authenticated_client_rejects_redirects() {
3546 let client = KrakenSpotRawHttpClient::with_credentials(
3547 "key".into(),
3548 "secret".into(),
3549 KrakenEnvironment::Live,
3550 None,
3551 3,
3552 Some(0),
3553 None,
3554 None,
3555 None,
3556 10,
3557 )
3558 .unwrap()
3559 .client;
3560 assert_http_redirect_rejected(|url| async move {
3561 client
3562 .get(url, None, None, Some(3), None)
3563 .await
3564 .unwrap()
3565 .status
3566 .as_u16()
3567 })
3568 .await;
3569 }
3570
3571 #[rstest]
3572 fn test_raw_client_creation() {
3573 let client = KrakenSpotRawHttpClient::default();
3574 assert!(client.credential.is_none());
3575 }
3576
3577 #[rstest]
3578 fn test_raw_client_with_credentials() {
3579 let client = KrakenSpotRawHttpClient::with_credentials(
3580 "test_key".to_string(),
3581 "test_secret".to_string(),
3582 KrakenEnvironment::Live,
3583 None,
3584 60,
3585 None,
3586 None,
3587 None,
3588 None,
3589 KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3590 )
3591 .unwrap();
3592 assert!(client.credential.is_some());
3593 }
3594
3595 #[rstest]
3596 #[tokio::test]
3597 async fn test_order_request_cancellation_before_transport() {
3598 let client = Arc::new(KrakenSpotRawHttpClient::default());
3599 let guard = client.auth_mutex.lock().await;
3600 let cancellation_token = client.cancellation_token();
3601 let waiting_token = cancellation_token.clone();
3602 let waiting_client = Arc::clone(&client);
3603 let waiting = tokio::spawn(async move {
3604 waiting_client
3605 .lock_order_request(&waiting_token)
3606 .await
3607 .map(drop)
3608 });
3609
3610 tokio::task::yield_now().await;
3611 client.cancel_all_requests();
3612 let waiting_result = tokio::time::timeout(Duration::from_secs(1), waiting)
3613 .await
3614 .expect("auth lock wait should stop on cancellation")
3615 .expect("auth lock task should complete");
3616 let request_result = client
3617 .send_order_request(
3618 Method::POST,
3619 "/0/private/AddOrder",
3620 KrakenSpotRawHttpClient::default_headers(),
3621 None,
3622 &cancellation_token,
3623 )
3624 .await;
3625 drop(guard);
3626 client.reset_cancellation_token();
3627 let reset_token = client.cancellation_token();
3628
3629 assert!(matches!(
3630 waiting_result,
3631 Err(KrakenHttpError::RequestNotStarted(ref message))
3632 if message == "Request canceled before transport invocation"
3633 ));
3634 assert!(matches!(
3635 request_result,
3636 Err(KrakenHttpError::RequestNotStarted(ref message))
3637 if message == "Request canceled before transport invocation"
3638 ));
3639 assert!(!reset_token.is_cancelled());
3640 }
3641
3642 #[rstest]
3643 #[tokio::test]
3644 async fn test_trade_volume_request_cancellation_before_transport() {
3645 let client = KrakenSpotRawHttpClient::with_credentials(
3646 "test_key".to_string(),
3647 "dGVzdF9hcGlfc2VjcmV0X2Jhc2U2NA==".to_string(),
3648 KrakenEnvironment::Live,
3649 Some("http://127.0.0.1:1".to_string()),
3650 1,
3651 None,
3652 None,
3653 None,
3654 None,
3655 KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3656 )
3657 .unwrap();
3658 let params = SpotTradeVolumeParams {
3659 pair: SpotTradeVolumePairs::Names("XBTUSDT".to_string()),
3660 };
3661 client.cancel_all_requests();
3662
3663 let result = client.get_trade_volume(¶ms).await;
3664
3665 assert!(matches!(
3666 result,
3667 Err(KrakenHttpError::NetworkError(ref message))
3668 if message.contains("canceled") || message.contains("cancelled")
3669 ));
3670 }
3671
3672 #[rstest]
3673 fn test_client_creation() {
3674 let client = KrakenSpotHttpClient::default();
3675 assert!(client.instruments_cache.is_empty());
3676 }
3677
3678 #[rstest]
3679 fn test_client_with_credentials() {
3680 let client = KrakenSpotHttpClient::with_credentials(
3681 "test_key".to_string(),
3682 "test_secret".to_string(),
3683 KrakenEnvironment::Live,
3684 None,
3685 60,
3686 None,
3687 None,
3688 None,
3689 None,
3690 KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3691 )
3692 .unwrap();
3693 assert!(client.instruments_cache.is_empty());
3694 }
3695
3696 #[rstest]
3697 fn test_nonce_generation_strictly_increasing() {
3698 let client = KrakenSpotRawHttpClient::default();
3699
3700 let nonce1 = client.generate_nonce();
3701 let nonce2 = client.generate_nonce();
3702 let nonce3 = client.generate_nonce();
3703
3704 assert!(
3705 nonce2 > nonce1,
3706 "nonce2 ({nonce2}) should be > nonce1 ({nonce1})"
3707 );
3708 assert!(
3709 nonce3 > nonce2,
3710 "nonce3 ({nonce3}) should be > nonce2 ({nonce2})"
3711 );
3712 }
3713
3714 #[rstest]
3715 fn test_nonce_is_nanosecond_timestamp() {
3716 let client = KrakenSpotRawHttpClient::default();
3717
3718 let nonce = client.generate_nonce();
3719
3720 assert!(
3723 nonce > 1_500_000_000_000_000_000,
3724 "Nonce should be nanosecond timestamp"
3725 );
3726 }
3727
3728 #[rstest]
3729 #[case::gtc_limit(true, TimeInForce::Gtc, None, None, None)]
3730 #[case::ioc_limit(true, TimeInForce::Ioc, None, Some("IOC"), None)]
3731 #[case::fok_limit(true, TimeInForce::Fok, None, Some("FOK"), None)]
3732 #[case::gtd_limit_with_expire(
3733 true,
3734 TimeInForce::Gtd,
3735 Some(1_704_067_200_000_000_000u64),
3736 Some("GTD"),
3737 Some("1704067200")
3738 )]
3739 #[case::gtc_market(false, TimeInForce::Gtc, None, None, None)]
3740 #[case::ioc_market(false, TimeInForce::Ioc, None, None, None)]
3741 fn test_compute_time_in_force_success(
3742 #[case] is_limit: bool,
3743 #[case] tif: TimeInForce,
3744 #[case] expire_nanos: Option<u64>,
3745 #[case] expected_tif: Option<&str>,
3746 #[case] expected_expire: Option<&str>,
3747 ) {
3748 let expire_time = expire_nanos.map(UnixNanos::from);
3749 let result = compute_time_in_force(is_limit, tif, expire_time).unwrap();
3750 assert_eq!(result.0, expected_tif.map(String::from));
3751 assert_eq!(result.1, expected_expire.map(String::from));
3752 }
3753
3754 #[rstest]
3755 #[case::gtd_missing_expire(TimeInForce::Gtd, None, "expire_time")]
3756 fn test_compute_time_in_force_errors(
3757 #[case] tif: TimeInForce,
3758 #[case] expire_nanos: Option<u64>,
3759 #[case] expected_error: &str,
3760 ) {
3761 let expire_time = expire_nanos.map(UnixNanos::from);
3762 let result = compute_time_in_force(true, tif, expire_time);
3763 assert!(result.is_err());
3764 assert!(result.unwrap_err().to_string().contains(expected_error));
3765 }
3766
3767 #[rstest]
3768 fn test_build_add_order_params_sets_index_trigger_for_conditional_orders() {
3769 let client = KrakenSpotHttpClient::default();
3770 let instrument_id = cache_test_spot_instrument(&client);
3771
3772 let params = client
3773 .build_add_order_params(
3774 instrument_id,
3775 ClientOrderId::new("spot-trigger-index"),
3776 OrderSide::Buy,
3777 OrderType::StopMarket,
3778 Quantity::from("0.01"),
3779 TimeInForce::Gtc,
3780 None,
3781 None,
3782 Some(Price::from("50000")),
3783 Some(TriggerType::IndexPrice),
3784 None,
3785 None,
3786 false,
3787 false,
3788 false,
3789 None,
3790 None,
3791 AccountType::Cash,
3792 )
3793 .unwrap();
3794
3795 assert_eq!(params.trigger, Some("index".to_string()));
3796 assert_eq!(params.price, Some("50000".to_string()));
3797 }
3798
3799 #[rstest]
3800 fn test_build_add_order_params_sets_trailing_offsets() {
3801 let client = KrakenSpotHttpClient::default();
3802 let instrument_id = cache_test_spot_instrument(&client);
3803
3804 let params = client
3805 .build_add_order_params(
3806 instrument_id,
3807 ClientOrderId::new("spot-trailing"),
3808 OrderSide::Sell,
3809 OrderType::TrailingStopLimit,
3810 Quantity::from("0.01"),
3811 TimeInForce::Gtc,
3812 None,
3813 Some(Price::from("49900")),
3814 None,
3815 Some(TriggerType::LastPrice),
3816 Some(Decimal::from(50)),
3817 Some(Decimal::from(25)),
3818 false,
3819 false,
3820 false,
3821 Some(Quantity::from("0.005")),
3822 None,
3823 AccountType::Cash,
3824 )
3825 .unwrap();
3826
3827 assert_eq!(params.price, Some("50".to_string()));
3828 assert_eq!(params.price2, Some("25".to_string()));
3829 assert_eq!(params.trigger, None);
3830 assert_eq!(params.displayvol, Some("0.005".to_string()));
3831 }
3832
3833 #[rstest]
3834 fn test_build_add_order_params_rejects_unsupported_trigger_type() {
3835 let client = KrakenSpotHttpClient::default();
3836 let instrument_id = cache_test_spot_instrument(&client);
3837
3838 let error = client
3839 .build_add_order_params(
3840 instrument_id,
3841 ClientOrderId::new("spot-trigger-invalid"),
3842 OrderSide::Buy,
3843 OrderType::StopMarket,
3844 Quantity::from("0.01"),
3845 TimeInForce::Gtc,
3846 None,
3847 None,
3848 Some(Price::from("50000")),
3849 Some(TriggerType::MarkPrice),
3850 None,
3851 None,
3852 false,
3853 false,
3854 false,
3855 None,
3856 None,
3857 AccountType::Cash,
3858 )
3859 .unwrap_err();
3860
3861 assert!(
3862 error
3863 .to_string()
3864 .contains("Unsupported trigger type for Kraken Spot")
3865 );
3866 }
3867
3868 fn cache_spot_pair(
3869 client: &KrakenSpotHttpClient,
3870 symbol: &str,
3871 quote: Currency,
3872 ) -> InstrumentId {
3873 let instrument_id = InstrumentId::from(symbol);
3874
3875 client.cache_instrument(InstrumentAny::CurrencyPair(
3876 CurrencyPair::builder()
3877 .instrument_id(instrument_id)
3878 .raw_symbol(Symbol::new(instrument_id.symbol.as_str()))
3879 .base_currency(Currency::BTC())
3880 .quote_currency(quote)
3881 .price_precision(1)
3882 .size_precision(8)
3883 .price_increment(Price::from("0.1"))
3884 .size_increment(Quantity::from("0.00000001"))
3885 .ts_event(0.into())
3886 .ts_init(0.into())
3887 .build()
3888 .unwrap(),
3889 ));
3890
3891 instrument_id
3892 }
3893
3894 #[rstest]
3900 #[case(AccountType::Margin, true, "XBT/USDT.KRAKEN", false)]
3901 #[case(AccountType::Margin, false, "XBT/USDT.KRAKEN", false)]
3902 #[case(AccountType::Cash, false, "XBT/USDT.KRAKEN", false)]
3903 #[case(AccountType::Cash, true, "XBT/USDT.KRAKEN", true)]
3904 #[case(AccountType::Cash, true, "XBT/USD.KRAKEN", false)]
3905 #[case(AccountType::Cash, true, "XBT/EUR.KRAKEN", false)]
3906 fn test_covers_bulk_position_reports(
3907 #[case] account_type: AccountType,
3908 #[case] use_spot_position_reports: bool,
3909 #[case] instrument: &str,
3910 #[case] expected: bool,
3911 ) {
3912 let client = KrakenSpotHttpClient::default();
3913 cache_spot_pair(&client, "XBT/USDT.KRAKEN", Currency::USDT());
3914 cache_spot_pair(&client, "XBT/USD.KRAKEN", Currency::USD());
3915
3916 let covered = client.covers_bulk_position_reports(
3918 InstrumentId::from(instrument),
3919 account_type,
3920 use_spot_position_reports,
3921 Ustr::from("USDT"),
3922 );
3923
3924 assert_eq!(covered, expected);
3925 }
3926
3927 #[rstest]
3932 fn test_covers_bulk_position_reports_accepts_legacy_quote_code() {
3933 let client = KrakenSpotHttpClient::default();
3934 let eur = cache_spot_pair(&client, "XBT/EUR.KRAKEN", Currency::EUR());
3935 cache_spot_pair(&client, "XBT/USDT.KRAKEN", Currency::USDT());
3936
3937 let legacy =
3938 client.covers_bulk_position_reports(eur, AccountType::Cash, true, Ustr::from("ZEUR"));
3939 let standard =
3940 client.covers_bulk_position_reports(eur, AccountType::Cash, true, Ustr::from("EUR"));
3941 let other = client.covers_bulk_position_reports(
3942 InstrumentId::from("XBT/USDT.KRAKEN"),
3943 AccountType::Cash,
3944 true,
3945 Ustr::from("ZEUR"),
3946 );
3947
3948 assert!(legacy);
3949 assert!(standard);
3950 assert!(!other);
3951 }
3952
3953 fn cache_test_spot_instrument(client: &KrakenSpotHttpClient) -> InstrumentId {
3954 let instrument_id = InstrumentId::from("XBT/USD.KRAKEN");
3955
3956 client.cache_instrument(InstrumentAny::CurrencyPair(
3957 CurrencyPair::builder()
3958 .instrument_id(instrument_id)
3959 .raw_symbol(Symbol::new("XBTUSD"))
3960 .base_currency(Currency::BTC())
3961 .quote_currency(Currency::USD())
3962 .price_precision(1)
3963 .size_precision(8)
3964 .price_increment(Price::from("0.1"))
3965 .size_increment(Quantity::from("0.00000001"))
3966 .ts_event(0.into())
3967 .ts_init(0.into())
3968 .build()
3969 .unwrap(),
3970 ));
3971
3972 instrument_id
3973 }
3974
3975 fn cache_test_spot_instrument_with_leverage(
3976 client: &KrakenSpotHttpClient,
3977 leverage_buy: &[i32],
3978 leverage_sell: &[i32],
3979 ) -> InstrumentId {
3980 let instrument_id = cache_test_spot_instrument(client);
3981 let raw_symbol = Ustr::from("XBTUSD");
3982 client.leverage_tiers_cache.rcu(|m| {
3983 m.insert(raw_symbol, (leverage_buy.to_vec(), leverage_sell.to_vec()));
3984 });
3985 instrument_id
3986 }
3987
3988 #[rstest]
3989 fn test_build_add_order_params_leverage_serialized_as_ratio() {
3990 let client = KrakenSpotHttpClient::default();
3991 let instrument_id =
3992 cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
3993
3994 let params = client
3995 .build_add_order_params(
3996 instrument_id,
3997 ClientOrderId::new("spot-margin-buy"),
3998 OrderSide::Buy,
3999 OrderType::Limit,
4000 Quantity::from("0.01"),
4001 TimeInForce::Gtc,
4002 None,
4003 Some(Price::from("50000")),
4004 None,
4005 None,
4006 None,
4007 None,
4008 false,
4009 false,
4010 false,
4011 None,
4012 Some(3),
4013 AccountType::Margin,
4014 )
4015 .unwrap();
4016
4017 assert_eq!(params.leverage, Some("3:1".to_string()));
4018 }
4019
4020 #[rstest]
4021 fn test_build_add_order_params_invalid_leverage_rejected() {
4022 let client = KrakenSpotHttpClient::default();
4023 let instrument_id =
4024 cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
4025
4026 let err = client
4027 .build_add_order_params(
4028 instrument_id,
4029 ClientOrderId::new("spot-margin-bad"),
4030 OrderSide::Buy,
4031 OrderType::Limit,
4032 Quantity::from("0.01"),
4033 TimeInForce::Gtc,
4034 None,
4035 Some(Price::from("50000")),
4036 None,
4037 None,
4038 None,
4039 None,
4040 false,
4041 false,
4042 false,
4043 None,
4044 Some(7),
4045 AccountType::Margin,
4046 )
4047 .unwrap_err();
4048
4049 assert!(
4050 err.to_string().contains("not supported"),
4051 "Expected tier-validation error: {err}"
4052 );
4053 }
4054
4055 #[rstest]
4056 fn test_build_add_order_params_no_leverage_is_cash() {
4057 let client = KrakenSpotHttpClient::default();
4058 let instrument_id =
4059 cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
4060
4061 let params = client
4062 .build_add_order_params(
4063 instrument_id,
4064 ClientOrderId::new("spot-cash"),
4065 OrderSide::Buy,
4066 OrderType::Limit,
4067 Quantity::from("0.01"),
4068 TimeInForce::Gtc,
4069 None,
4070 Some(Price::from("50000")),
4071 None,
4072 None,
4073 None,
4074 None,
4075 false,
4076 false,
4077 false,
4078 None,
4079 None,
4080 AccountType::Cash,
4081 )
4082 .unwrap();
4083
4084 assert_eq!(
4085 params.leverage, None,
4086 "Cash order should not have leverage field"
4087 );
4088 }
4089
4090 #[rstest]
4091 fn test_build_add_order_params_rejects_per_order_leverage_in_cash_mode() {
4092 let client = KrakenSpotHttpClient::default();
4093 let instrument_id = cache_test_spot_instrument(&client);
4094
4095 let err = client
4096 .build_add_order_params(
4097 instrument_id,
4098 ClientOrderId::new("cash-with-leverage"),
4099 OrderSide::Buy,
4100 OrderType::Limit,
4101 Quantity::from("0.01"),
4102 TimeInForce::Gtc,
4103 None,
4104 Some(Price::from("50000")),
4105 None,
4106 None,
4107 None,
4108 None,
4109 false,
4110 false,
4111 false,
4112 None,
4113 Some(3),
4114 AccountType::Cash,
4115 )
4116 .unwrap_err();
4117
4118 assert!(
4119 err.to_string().contains("Margin"),
4120 "Expected Margin mode rejection: {err}"
4121 );
4122 }
4123
4124 #[rstest]
4125 fn test_build_add_order_params_reduce_only_forwarded_in_margin_mode() {
4126 let client = KrakenSpotHttpClient::default();
4127 let instrument_id = cache_test_spot_instrument(&client);
4128
4129 let params = client
4130 .build_add_order_params(
4131 instrument_id,
4132 ClientOrderId::new("margin-reduce-only"),
4133 OrderSide::Sell,
4134 OrderType::Limit,
4135 Quantity::from("0.01"),
4136 TimeInForce::Gtc,
4137 None,
4138 Some(Price::from("50000")),
4139 None,
4140 None,
4141 None,
4142 None,
4143 true,
4144 false,
4145 false,
4146 None,
4147 None,
4148 AccountType::Margin,
4149 )
4150 .unwrap();
4151
4152 assert_eq!(params.reduce_only, Some(true));
4153 }
4154
4155 #[rstest]
4156 fn test_build_add_order_params_rejects_reduce_only_in_cash_mode() {
4157 let client = KrakenSpotHttpClient::default();
4158 let instrument_id = cache_test_spot_instrument(&client);
4159
4160 let err = client
4161 .build_add_order_params(
4162 instrument_id,
4163 ClientOrderId::new("cash-reduce-only"),
4164 OrderSide::Sell,
4165 OrderType::Limit,
4166 Quantity::from("0.01"),
4167 TimeInForce::Gtc,
4168 None,
4169 Some(Price::from("50000")),
4170 None,
4171 None,
4172 None,
4173 None,
4174 true,
4175 false,
4176 false,
4177 None,
4178 None,
4179 AccountType::Cash,
4180 )
4181 .unwrap_err();
4182
4183 assert!(
4184 err.to_string().contains("reduce_only requires"),
4185 "expected reduce_only Margin rejection: {err}"
4186 );
4187 }
4188
4189 #[rstest]
4190 fn test_build_add_order_params_rejects_leverage_when_tiers_not_loaded() {
4191 let client = KrakenSpotHttpClient::default();
4192 let instrument_id = cache_test_spot_instrument(&client);
4193
4194 let err = client
4195 .build_add_order_params(
4196 instrument_id,
4197 ClientOrderId::new("missing-tiers"),
4198 OrderSide::Buy,
4199 OrderType::Limit,
4200 Quantity::from("0.01"),
4201 TimeInForce::Gtc,
4202 None,
4203 Some(Price::from("50000")),
4204 None,
4205 None,
4206 None,
4207 None,
4208 false,
4209 false,
4210 false,
4211 None,
4212 Some(3),
4213 AccountType::Margin,
4214 )
4215 .unwrap_err();
4216
4217 assert!(
4218 err.to_string().contains("Leverage tiers not loaded"),
4219 "expected cache-miss rejection: {err}"
4220 );
4221 }
4222}