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 instruments: Vec<InstrumentAny> = asset_pairs
1728 .iter()
1729 .filter_map(|(pair_name, definition)| {
1730 match parse_spot_instrument(pair_name, definition, ts_init, ts_init) {
1731 Ok(instrument) => Some((instrument, definition)),
1732 Err(e) => {
1733 log::warn!("Failed to parse instrument {pair_name}: {e}");
1734 None
1735 }
1736 }
1737 })
1738 .map(|(instrument, definition)| {
1739 let key = Ustr::from(instrument.raw_symbol().as_str());
1740 let tiers = (
1741 definition.leverage_buy.clone(),
1742 definition.leverage_sell.clone(),
1743 );
1744 self.leverage_tiers_cache.rcu(|m| {
1745 m.insert(key, tiers.clone());
1746 });
1747 instrument
1748 })
1749 .collect();
1750
1751 {
1755 match self
1756 .inner
1757 .get_asset_pairs(pairs, Some("tokenized_asset"))
1758 .await
1759 {
1760 Ok(tokenized_pairs) => {
1761 if !tokenized_pairs.is_empty() {
1762 log::debug!("Fetched {} tokenized asset pairs", tokenized_pairs.len());
1763 }
1764 self.record_pair_aliases(&tokenized_pairs);
1765 let tokenized_instruments: Vec<InstrumentAny> =
1766 tokenized_pairs
1767 .iter()
1768 .filter_map(|(pair_name, definition)| match parse_tokenized_instrument(
1769 pair_name, definition, ts_init, ts_init,
1770 ) {
1771 Ok(instrument) => Some(instrument),
1772 Err(e) => {
1773 log::warn!(
1774 "Failed to parse tokenized instrument {pair_name}: {e}"
1775 );
1776 None
1777 }
1778 })
1779 .collect();
1780 instruments.extend(tokenized_instruments);
1781 }
1782 Err(e) => {
1783 log::warn!("Failed to fetch tokenized asset pairs: {e}");
1784 }
1785 }
1786 }
1787
1788 Ok(instruments)
1789 }
1790
1791 pub async fn request_instrument_statuses(
1797 &self,
1798 pairs: Option<Vec<String>>,
1799 ) -> anyhow::Result<AHashMap<InstrumentId, MarketStatusAction>, KrakenHttpError> {
1800 let asset_pairs = self.inner.get_asset_pairs(pairs.clone(), None).await?;
1801 let mut statuses = collect_spot_statuses(&asset_pairs);
1802
1803 let tokenized_pairs = self
1804 .inner
1805 .get_asset_pairs(pairs, Some("tokenized_asset"))
1806 .await?;
1807 statuses.extend(collect_spot_statuses(&tokenized_pairs));
1808
1809 Ok(statuses)
1810 }
1811
1812 pub async fn request_trades(
1814 &self,
1815 instrument_id: InstrumentId,
1816 start: Option<Timestamp>,
1817 end: Option<Timestamp>,
1818 limit: Option<u64>,
1819 ) -> anyhow::Result<Vec<TradeTick>, KrakenHttpError> {
1820 let instrument = self
1821 .get_cached_instrument(&instrument_id.symbol.inner())
1822 .ok_or_else(|| {
1823 KrakenHttpError::ParseError(
1824 InstrumentLookupError::not_found(instrument_id).to_string(),
1825 )
1826 })?;
1827
1828 let raw_symbol = instrument.raw_symbol().to_string();
1829 let asset_class = Self::asset_class_for(&instrument);
1830 let ts_init = self.generate_ts_init();
1831
1832 let since = start.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0).to_string());
1834 let response = self
1835 .inner
1836 .get_trades(&raw_symbol, since, asset_class)
1837 .await?;
1838
1839 let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1840 let mut trades = Vec::new();
1841
1842 for (_pair_name, trade_arrays) in &response.data {
1843 for trade_array in trade_arrays {
1844 match parse_trade_tick_from_array(trade_array, &instrument, ts_init) {
1845 Ok(trade_tick) => {
1846 if let Some(end_nanos) = end_ns
1847 && trade_tick.ts_event.as_u64() > end_nanos
1848 {
1849 continue;
1850 }
1851 trades.push(trade_tick);
1852 }
1853 Err(e) => {
1854 log::warn!("Failed to parse trade tick: {e}");
1855 }
1856 }
1857 }
1858 }
1859
1860 apply_count_limit(&mut trades, start, limit);
1862
1863 Ok(trades)
1864 }
1865
1866 pub async fn request_bars(
1868 &self,
1869 bar_type: BarType,
1870 start: Option<Timestamp>,
1871 end: Option<Timestamp>,
1872 limit: Option<u64>,
1873 ) -> anyhow::Result<Vec<Bar>, KrakenHttpError> {
1874 let instrument_id = bar_type.instrument_id();
1875 let instrument = self
1876 .get_cached_instrument(&instrument_id.symbol.inner())
1877 .ok_or_else(|| {
1878 KrakenHttpError::ParseError(
1879 InstrumentLookupError::not_found(instrument_id).to_string(),
1880 )
1881 })?;
1882
1883 let raw_symbol = instrument.raw_symbol().to_string();
1884 let asset_class = Self::asset_class_for(&instrument);
1885 let ts_init = self.generate_ts_init();
1886
1887 let interval = Some(
1888 bar_type_to_spot_interval(bar_type)
1889 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?,
1890 );
1891
1892 let since = start.map(|dt| dt.as_second());
1894 let end_ns = end.map(|dt| u64::try_from(dt.as_nanosecond()).unwrap_or(0));
1895 let response = self
1896 .inner
1897 .get_ohlc(&raw_symbol, interval, since, asset_class)
1898 .await?;
1899
1900 let mut bars = Vec::new();
1901
1902 for (_pair_name, ohlc_arrays) in &response.data {
1903 for ohlc_array in ohlc_arrays {
1904 if ohlc_array.len() < 8 {
1905 let len = ohlc_array.len();
1906 log::warn!("OHLC array too short: {len}");
1907 continue;
1908 }
1909
1910 let ohlc = OhlcData {
1911 time: ohlc_array[0].as_i64().unwrap_or(0),
1912 open: ohlc_array[1].as_str().unwrap_or("0").to_string(),
1913 high: ohlc_array[2].as_str().unwrap_or("0").to_string(),
1914 low: ohlc_array[3].as_str().unwrap_or("0").to_string(),
1915 close: ohlc_array[4].as_str().unwrap_or("0").to_string(),
1916 vwap: ohlc_array[5].as_str().unwrap_or("0").to_string(),
1917 volume: ohlc_array[6].as_str().unwrap_or("0").to_string(),
1918 count: ohlc_array[7].as_i64().unwrap_or(0),
1919 };
1920
1921 match parse_bar(&ohlc, &instrument, bar_type, ts_init) {
1922 Ok(bar) => {
1923 if let Some(end_nanos) = end_ns
1924 && bar.ts_event.as_u64() > end_nanos
1925 {
1926 continue;
1927 }
1928 bars.push(bar);
1929 }
1930 Err(e) => {
1931 log::warn!("Failed to parse bar: {e}");
1932 }
1933 }
1934 }
1935 }
1936
1937 apply_count_limit(&mut bars, start, limit);
1940
1941 Ok(bars)
1942 }
1943
1944 pub async fn request_book_snapshot(
1946 &self,
1947 instrument_id: InstrumentId,
1948 depth: Option<u32>,
1949 ) -> anyhow::Result<OrderBook, KrakenHttpError> {
1950 let instrument = self
1951 .get_cached_instrument(&instrument_id.symbol.inner())
1952 .ok_or_else(|| {
1953 KrakenHttpError::ParseError(
1954 InstrumentLookupError::not_found(instrument_id).to_string(),
1955 )
1956 })?;
1957
1958 let raw_symbol = instrument.raw_symbol().to_string();
1959 let asset_class = Self::asset_class_for(&instrument);
1960 let price_precision = instrument.price_precision();
1961 let size_precision = instrument.size_precision();
1962 let ts_event = self.generate_ts_init();
1963
1964 let response = self
1965 .inner
1966 .get_book_depth(&raw_symbol, depth, asset_class)
1967 .await?;
1968
1969 let book_data = response.values().next().ok_or_else(|| {
1970 KrakenHttpError::ParseError(format!("No book data returned for {instrument_id}"))
1971 })?;
1972
1973 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
1974
1975 for (i, level) in book_data.bids.iter().enumerate() {
1978 let price_str = level.first().and_then(|v| v.as_str()).unwrap_or("0");
1979 let size_str = level.get(1).and_then(|v| v.as_str()).unwrap_or("0");
1980 let price = Price::from_decimal_dp(
1981 Decimal::from_str_exact(price_str).unwrap_or(Decimal::ZERO),
1982 price_precision,
1983 )
1984 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1985 let size = Quantity::from_decimal_dp(
1986 Decimal::from_str_exact(size_str).unwrap_or(Decimal::ZERO),
1987 size_precision,
1988 )
1989 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
1990 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
1991 book.add(order, 0, 0, ts_event);
1992 }
1993
1994 let bids_len = book_data.bids.len();
1995
1996 for (i, level) in book_data.asks.iter().enumerate() {
1997 let price_str = level.first().and_then(|v| v.as_str()).unwrap_or("0");
1998 let size_str = level.get(1).and_then(|v| v.as_str()).unwrap_or("0");
1999 let price = Price::from_decimal_dp(
2000 Decimal::from_str_exact(price_str).unwrap_or(Decimal::ZERO),
2001 price_precision,
2002 )
2003 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
2004 let size = Quantity::from_decimal_dp(
2005 Decimal::from_str_exact(size_str).unwrap_or(Decimal::ZERO),
2006 size_precision,
2007 )
2008 .map_err(|e| KrakenHttpError::ParseError(e.to_string()))?;
2009 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
2010 book.add(order, 0, 0, ts_event);
2011 }
2012
2013 Ok(book)
2014 }
2015
2016 pub async fn request_account_state(
2027 &self,
2028 account_id: AccountId,
2029 account_type: AccountType,
2030 margin_balance_asset: Option<&str>,
2031 ) -> anyhow::Result<AccountState> {
2032 self.request_account_state_with_metrics(account_id, account_type, margin_balance_asset)
2033 .await
2034 .map(|(state, _)| state)
2035 }
2036
2037 pub async fn request_account_state_with_metrics(
2059 &self,
2060 account_id: AccountId,
2061 account_type: AccountType,
2062 margin_balance_asset: Option<&str>,
2063 ) -> anyhow::Result<(AccountState, IndexMap<String, String>)> {
2064 let balances_raw = self.inner.get_balance_ex().await?;
2065 let ts_init = self.generate_ts_init();
2066
2067 let (margins, metrics, margin_entry, target_code) = if account_type == AccountType::Margin {
2068 let snapshot = self
2069 .fetch_trade_balance_snapshot(margin_balance_asset)
2070 .await?;
2071 let target_code = normalize_currency_code(margin_balance_asset.unwrap_or("ZUSD"));
2072 let currency = Currency::new(target_code, 8, 0, "0", CurrencyType::Crypto);
2073 let margin_entry = AccountBalance::from_total_and_free(
2074 snapshot.equity,
2075 snapshot.free_margin,
2076 currency,
2077 )
2078 .context("Failed to build synthetic margin AccountBalance from TradeBalance")?;
2079
2080 (
2081 snapshot.margins,
2082 snapshot.metrics,
2083 Some(margin_entry),
2084 target_code,
2085 )
2086 } else {
2087 (Vec::new(), IndexMap::new(), None, "")
2088 };
2089
2090 let skip_margin_wallet = margin_entry.is_some();
2091
2092 let balances: Vec<AccountBalance> = balances_raw
2093 .iter()
2094 .filter_map(|(currency_code, entry)| {
2095 let balance = Decimal::from_str_exact(&entry.balance).ok()?;
2096 let credit = optional_credit_amount(entry.credit.as_deref())?;
2097 let credit_used = optional_credit_amount(entry.credit_used.as_deref())?;
2098
2099 let total = balance + credit - credit_used;
2102
2103 let normalized_code = normalize_currency_code(currency_code);
2104
2105 if skip_margin_wallet && normalized_code == target_code {
2106 return None;
2107 }
2108
2109 let locked = Decimal::from_str_exact(&entry.hold_trade).ok()?;
2110 let currency =
2111 Currency::new(normalized_code, 8, 0, normalized_code, CurrencyType::Crypto);
2112 AccountBalance::from_total_and_locked(total, locked, currency).ok()
2113 })
2114 .chain(margin_entry)
2115 .collect();
2116
2117 let state = AccountState::new(
2118 account_id,
2119 account_type,
2120 balances,
2121 margins,
2122 true,
2123 UUID4::new(),
2124 ts_init,
2125 ts_init,
2126 None,
2127 );
2128
2129 Ok((state, metrics))
2130 }
2131
2132 async fn fetch_trade_balance_snapshot(
2147 &self,
2148 asset: Option<&str>,
2149 ) -> anyhow::Result<TradeBalanceSnapshot> {
2150 let tb = self.inner.get_trade_balance(asset).await?;
2151
2152 let used_margin = Decimal::from_str_exact(&tb.m)
2153 .with_context(|| format!("Failed to parse TradeBalance 'm' field {:?}", tb.m))?;
2154 let free_margin = Decimal::from_str_exact(&tb.mf)
2155 .with_context(|| format!("Failed to parse TradeBalance 'mf' field {:?}", tb.mf))?;
2156 let equity = Decimal::from_str_exact(&tb.e)
2157 .with_context(|| format!("Failed to parse TradeBalance 'e' field {:?}", tb.e))?;
2158
2159 let margins = if used_margin.is_zero() {
2160 Vec::new()
2161 } else {
2162 let currency = trade_balance_currency(asset);
2163 let initial = Money::from_decimal(used_margin, currency)
2164 .context("Failed to build initial margin from TradeBalance 'm'")?;
2165 let maintenance = Money::zero(currency);
2166 vec![MarginBalance::new(initial, maintenance, None)]
2167 };
2168
2169 let mut metrics = IndexMap::new();
2170 metrics.insert("equivalent_balance".to_string(), tb.eb);
2171 metrics.insert("trade_balance".to_string(), tb.tb);
2172 metrics.insert("used_margin".to_string(), tb.m);
2173 metrics.insert("unexecuted_value".to_string(), tb.uv);
2174 metrics.insert("unrealized_pnl".to_string(), tb.n);
2175 metrics.insert("cost_basis".to_string(), tb.c);
2176 metrics.insert("valuation".to_string(), tb.v);
2177 metrics.insert("equity".to_string(), tb.e);
2178 metrics.insert("free_margin".to_string(), tb.mf);
2179 if let Some(ml) = tb.ml {
2180 metrics.insert("margin_level".to_string(), ml);
2181 }
2182 metrics.insert(
2183 "asset".to_string(),
2184 normalize_currency_code(asset.unwrap_or("ZUSD")).to_string(),
2185 );
2186
2187 Ok(TradeBalanceSnapshot {
2188 margins,
2189 metrics,
2190 free_margin,
2191 equity,
2192 })
2193 }
2194
2195 pub async fn request_margin_metrics(
2208 &self,
2209 asset: Option<&str>,
2210 ) -> anyhow::Result<IndexMap<String, String>> {
2211 self.fetch_trade_balance_snapshot(asset)
2212 .await
2213 .map(|snapshot| snapshot.metrics)
2214 }
2215
2216 pub async fn request_order_status_reports(
2218 &self,
2219 account_id: AccountId,
2220 instrument_id: Option<InstrumentId>,
2221 start: Option<Timestamp>,
2222 end: Option<Timestamp>,
2223 open_only: bool,
2224 ) -> anyhow::Result<Vec<OrderStatusReport>> {
2225 self.request_order_status_reports_checked(account_id, instrument_id, start, end, open_only)
2226 .await
2227 .map(|(reports, _)| reports)
2228 }
2229
2230 pub(crate) async fn request_order_status_reports_checked(
2241 &self,
2242 account_id: AccountId,
2243 instrument_id: Option<InstrumentId>,
2244 start: Option<Timestamp>,
2245 end: Option<Timestamp>,
2246 open_only: bool,
2247 ) -> anyhow::Result<(Vec<OrderStatusReport>, ReportCoverage)> {
2248 const PAGE_SIZE: i32 = 50;
2249
2250 let ts_init = self.generate_ts_init();
2251 let mut all_reports = Vec::new();
2252 let mut coverage = ReportCoverage::default();
2253
2254 if let Some(ref target_id) = instrument_id
2259 && self
2260 .get_cached_instrument(&target_id.symbol.inner())
2261 .is_none()
2262 {
2263 if product_type_from_symbol(target_id.symbol.as_str()) == KrakenProductType::Spot {
2264 coverage.truncate(None);
2265 }
2266 return Ok((all_reports, coverage));
2267 }
2268
2269 let open_orders = self.inner.get_open_orders(Some(true), None).await?;
2270
2271 for (order_id, order) in &open_orders {
2272 let resolved = self.get_instrument_by_raw_symbol(order.descr.pair.as_str());
2275 if let Some(ref target_id) = instrument_id {
2276 match &resolved {
2277 Some(inst) if inst.id() != *target_id => continue,
2278 None => {
2280 coverage.skip(order_id);
2281 continue;
2282 }
2283 Some(_) => {}
2284 }
2285 }
2286
2287 let instrument = resolved.ok_or_else(|| {
2288 anyhow::anyhow!(
2289 "OpenOrders: instrument not in cache for pair {}",
2290 order.descr.pair
2291 )
2292 })?;
2293
2294 let report =
2297 parse_order_status_report(order_id, order, &instrument, account_id, ts_init)
2298 .map_err(|e| {
2299 anyhow::anyhow!("OpenOrders: failed to parse order {order_id}: {e}")
2300 })?;
2301 all_reports.push(report);
2302 }
2303
2304 if open_only {
2305 return Ok((all_reports, coverage));
2306 }
2307
2308 let start_ts = start.map(|dt| dt.as_second());
2310 let end_ts = end.map(|dt| dt.as_second());
2311
2312 let mut offset = 0;
2313 let mut pages = 0;
2314
2315 loop {
2316 if pages >= MAX_REPORT_PAGES {
2317 log::warn!(
2318 "ClosedOrders pagination hit the cap of {MAX_REPORT_PAGES} pages; returning a truncated set and marking it incomplete"
2319 );
2320 coverage.truncate(None);
2321 break;
2322 }
2323
2324 let closed_orders = self
2325 .inner
2326 .get_closed_orders(Some(true), None, start_ts, end_ts, Some(offset), None)
2327 .await?;
2328
2329 if closed_orders.is_empty() {
2330 break;
2331 }
2332
2333 pages += 1;
2334
2335 for (order_id, order) in &closed_orders {
2336 let resolved = self.get_instrument_by_raw_symbol(order.descr.pair.as_str());
2337 if let Some(ref target_id) = instrument_id {
2338 match &resolved {
2339 Some(inst) if inst.id() != *target_id => continue,
2340 None => {
2342 coverage.skip(order_id);
2343 continue;
2344 }
2345 Some(_) => {}
2346 }
2347 }
2348
2349 let Some(instrument) = resolved else {
2352 log::warn!(
2353 "ClosedOrders: instrument not in cache for pair {}, skipping order {order_id}",
2354 order.descr.pair
2355 );
2356 coverage.skip(order_id);
2357 continue;
2358 };
2359
2360 match parse_order_status_report(order_id, order, &instrument, account_id, ts_init) {
2361 Ok(report) => all_reports.push(report),
2362 Err(e) => {
2363 log::warn!("Failed to parse order {order_id}: {e}");
2364 coverage.skip(order_id);
2365 }
2366 }
2367 }
2368
2369 offset += PAGE_SIZE;
2370 }
2371
2372 Ok((all_reports, coverage))
2373 }
2374
2375 pub async fn request_fill_reports(
2377 &self,
2378 account_id: AccountId,
2379 instrument_id: Option<InstrumentId>,
2380 start: Option<Timestamp>,
2381 end: Option<Timestamp>,
2382 ) -> anyhow::Result<Vec<FillReport>> {
2383 self.request_fill_reports_checked(account_id, instrument_id, start, end)
2384 .await
2385 .map(|(reports, _)| reports)
2386 }
2387
2388 pub(crate) async fn request_fill_reports_checked(
2393 &self,
2394 account_id: AccountId,
2395 instrument_id: Option<InstrumentId>,
2396 start: Option<Timestamp>,
2397 end: Option<Timestamp>,
2398 ) -> anyhow::Result<(Vec<FillReport>, bool)> {
2399 const PAGE_SIZE: i32 = 50;
2400
2401 let ts_init = self.generate_ts_init();
2402 let mut all_reports = Vec::new();
2403 let mut complete = true;
2404
2405 if let Some(ref target_id) = instrument_id
2407 && self
2408 .get_cached_instrument(&target_id.symbol.inner())
2409 .is_none()
2410 {
2411 return Ok((all_reports, complete));
2412 }
2413
2414 let start_ts = start.map(|dt| dt.as_second());
2416 let end_ts = end.map(|dt| dt.as_second());
2417
2418 let mut offset = 0;
2419 let mut pages = 0;
2420
2421 loop {
2422 if pages >= MAX_REPORT_PAGES {
2423 log::warn!(
2424 "TradesHistory pagination hit the cap of {MAX_REPORT_PAGES} pages; returning a truncated set and marking it incomplete, which only mass status surfaces"
2425 );
2426 complete = false;
2427 break;
2428 }
2429
2430 let trades = self
2431 .inner
2432 .get_trades_history(None, Some(true), start_ts, end_ts, Some(offset))
2433 .await?;
2434
2435 if trades.is_empty() {
2436 break;
2437 }
2438
2439 pages += 1;
2440
2441 for (trade_id, trade) in &trades {
2442 let resolved = self.get_instrument_by_raw_symbol(trade.pair.as_str());
2443 if let Some(ref target_id) = instrument_id
2444 && resolved.as_ref().is_none_or(|inst| inst.id() != *target_id)
2445 {
2446 continue;
2447 }
2448
2449 let Some(instrument) = resolved else {
2451 log::warn!(
2452 "TradesHistory: instrument not in cache for pair {}, skipping trade {trade_id}",
2453 trade.pair
2454 );
2455 complete = false;
2456 continue;
2457 };
2458
2459 match parse_fill_report(trade_id, trade, &instrument, account_id, ts_init) {
2460 Ok(report) => all_reports.push(report),
2461 Err(e) => {
2462 log::warn!("Failed to parse trade {trade_id}: {e}");
2463 complete = false;
2464 }
2465 }
2466 }
2467
2468 offset += PAGE_SIZE;
2469 }
2470
2471 Ok((all_reports, complete))
2472 }
2473
2474 pub async fn request_position_status_reports(
2480 &self,
2481 account_id: AccountId,
2482 instrument_id: Option<InstrumentId>,
2483 account_type: AccountType,
2484 use_spot_position_reports: bool,
2485 quote_currency: Ustr,
2486 ) -> anyhow::Result<Vec<PositionStatusReport>> {
2487 if account_type == AccountType::Margin {
2488 self.generate_margin_position_reports(account_id, instrument_id)
2489 .await
2490 } else if use_spot_position_reports {
2491 self.generate_spot_position_reports_from_wallet(
2492 account_id,
2493 instrument_id,
2494 quote_currency,
2495 )
2496 .await
2497 } else {
2498 Ok(Vec::new())
2499 }
2500 }
2501
2502 pub fn covers_bulk_position_reports(
2511 &self,
2512 instrument_id: InstrumentId,
2513 account_type: AccountType,
2514 use_spot_position_reports: bool,
2515 quote_currency: Ustr,
2516 ) -> bool {
2517 if account_type == AccountType::Margin || !use_spot_position_reports {
2518 return false;
2519 }
2520
2521 self.get_cached_instrument(&instrument_id.symbol.inner())
2522 .is_some_and(|instrument| {
2523 wallet_report_base_currency(&instrument, quote_currency).is_some()
2524 })
2525 }
2526
2527 async fn generate_margin_position_reports(
2529 &self,
2530 account_id: AccountId,
2531 instrument_id: Option<InstrumentId>,
2532 ) -> anyhow::Result<Vec<PositionStatusReport>> {
2533 let open_positions = self
2534 .inner
2535 .get_open_positions(&SpotOpenPositionsParams::default())
2536 .await?;
2537
2538 let ts_init = self.generate_ts_init();
2539
2540 let mut agg: IndexMap<String, OpenPositionAggregate> = IndexMap::new();
2548
2549 let target_pair: Option<Ustr> = match &instrument_id {
2550 Some(target_id) => match self.get_cached_instrument(&target_id.symbol.inner()) {
2551 Some(inst) => Some(Ustr::from(inst.raw_symbol().as_str())),
2552 None => return Ok(Vec::new()),
2553 },
2554 None => None,
2555 };
2556
2557 for (_pos_id, pos) in open_positions.iter() {
2558 if let Some(pair) = target_pair
2559 && pair.as_str() != pos.pair
2560 {
2561 continue;
2562 }
2563
2564 if let Some(status) = pos.posstatus.as_deref()
2565 && status != "open"
2566 {
2567 log::debug!(
2568 "Skipping non-open OpenPositions entry for {}: posstatus={status}",
2569 pos.pair,
2570 );
2571 continue;
2572 }
2573
2574 let instrument = self
2575 .get_instrument_by_raw_symbol(pos.pair.as_str())
2576 .ok_or_else(|| {
2577 anyhow::anyhow!(
2578 "OpenPositions: instrument not in cache for pair {}",
2579 pos.pair
2580 )
2581 })?;
2582
2583 let vol = Decimal::from_str_exact(&pos.vol)
2584 .with_context(|| format!("OpenPositions: failed to parse vol for {}", pos.pair))?;
2585 let vol_closed = Decimal::from_str_exact(&pos.vol_closed).with_context(|| {
2586 format!("OpenPositions: failed to parse vol_closed for {}", pos.pair)
2587 })?;
2588
2589 let lot_net = (vol - vol_closed).max(Decimal::ZERO);
2590 let signed_lot = match pos.side {
2591 KrakenOrderSide::Buy => lot_net,
2592 KrakenOrderSide::Sell => -lot_net,
2593 };
2594
2595 let cost = Decimal::from_str_exact(&pos.cost)
2599 .with_context(|| format!("OpenPositions: failed to parse cost for {}", pos.pair))?;
2600
2601 let entry = agg
2602 .entry(pos.pair.clone())
2603 .or_insert_with(|| OpenPositionAggregate::new(instrument.id()));
2604 entry.signed_qty += signed_lot;
2605
2606 if !vol.is_zero() {
2607 let entry_px = cost / vol;
2608
2609 match pos.side {
2610 KrakenOrderSide::Buy => {
2611 entry.long_qty += lot_net;
2612 entry.long_notional += lot_net * entry_px;
2613 }
2614 KrakenOrderSide::Sell => {
2615 entry.short_qty += lot_net;
2616 entry.short_notional += lot_net * entry_px;
2617 }
2618 }
2619 }
2620 }
2621
2622 let mut reports = Vec::new();
2623
2624 for (_, aggregate) in agg {
2625 let signed_qty = aggregate.signed_qty;
2626 let inst_id = aggregate.instrument_id;
2627 let instrument = self
2628 .get_cached_instrument(&inst_id.symbol.inner())
2629 .ok_or_else(|| InstrumentLookupError::not_found(inst_id))?;
2630
2631 let side = if signed_qty.is_sign_positive() && !signed_qty.is_zero() {
2632 PositionSide::Long
2633 } else if signed_qty.is_sign_negative() && !signed_qty.is_zero() {
2634 PositionSide::Short
2635 } else {
2636 PositionSide::Flat
2637 };
2638 let quantity = Quantity::from_decimal_dp(signed_qty.abs(), instrument.size_precision())
2639 .map_err(|e| {
2640 anyhow::anyhow!("OpenPositions: failed to build Quantity for {inst_id}: {e:?}")
2641 })?;
2642 let avg_px_open = aggregate.avg_px_open(side);
2643 let report = PositionStatusReport::new(
2644 account_id,
2645 inst_id,
2646 side,
2647 quantity,
2648 ts_init,
2649 ts_init,
2650 None,
2651 None,
2652 avg_px_open,
2653 )
2654 .with_avg_px_open_reconciliation(AvgPxReconciliation::OpeningOnly);
2658 reports.push(report);
2659 }
2660
2661 if let Some(target_id) = instrument_id {
2668 let already_reported = reports.iter().any(|r| r.instrument_id == target_id);
2669
2670 if !already_reported
2671 && let Some(instrument) = self.get_cached_instrument(&target_id.symbol.inner())
2672 {
2673 let precision = instrument.size_precision();
2674 reports.push(PositionStatusReport::new(
2675 account_id,
2676 target_id,
2677 PositionSide::Flat,
2678 Quantity::zero(precision),
2679 ts_init,
2680 ts_init,
2681 None,
2682 None,
2683 None,
2684 ));
2685 }
2686 }
2687
2688 Ok(reports)
2689 }
2690
2691 async fn generate_spot_position_reports_from_wallet(
2697 &self,
2698 account_id: AccountId,
2699 instrument_id: Option<InstrumentId>,
2700 quote_currency: Ustr,
2701 ) -> anyhow::Result<Vec<PositionStatusReport>> {
2702 let balances_raw = self.inner.get_balance().await?;
2703 let ts_init = self.generate_ts_init();
2704 let mut wallet_by_coin: HashMap<Ustr, Decimal> = HashMap::new();
2705
2706 for (currency_code, amount_str) in &balances_raw {
2707 let balance = match Decimal::from_str_exact(amount_str) {
2708 Ok(b) => b,
2709 Err(_) => continue,
2710 };
2711
2712 if balance.is_zero() {
2713 continue;
2714 }
2715
2716 wallet_by_coin.insert(Ustr::from(normalize_currency_code(currency_code)), balance);
2717 }
2718
2719 let mut reports = Vec::new();
2720
2721 if let Some(instrument_id) = instrument_id {
2722 if let Some(instrument) = self.get_cached_instrument(&instrument_id.symbol.inner()) {
2723 let base_currency = match instrument.base_currency() {
2724 Some(currency) => currency,
2725 None => return Ok(reports),
2726 };
2727
2728 let coin = Ustr::from(base_currency.code.as_str());
2729 let wallet_balance = wallet_by_coin.get(&coin).copied().unwrap_or(Decimal::ZERO);
2730
2731 let side = if wallet_balance > Decimal::ZERO {
2732 PositionSide::Long
2733 } else {
2734 PositionSide::Flat
2735 };
2736
2737 let abs_balance = wallet_balance.abs();
2738 let quantity = Quantity::from_decimal_dp(abs_balance, instrument.size_precision())?;
2739
2740 let report = PositionStatusReport::new(
2741 account_id,
2742 instrument_id,
2743 side,
2744 quantity,
2745 ts_init,
2746 ts_init,
2747 None,
2748 None,
2749 None,
2750 );
2751
2752 reports.push(report);
2753 }
2754 } else {
2755 let instruments_guard = self.instruments_cache.load();
2756 for instrument in instruments_guard.values() {
2757 let Some(base_currency) = wallet_report_base_currency(instrument, quote_currency)
2758 else {
2759 continue;
2760 };
2761
2762 let coin = Ustr::from(base_currency.code.as_str());
2763 let wallet_balance = wallet_by_coin.get(&coin).copied().unwrap_or(Decimal::ZERO);
2764
2765 if wallet_balance.is_zero() {
2766 continue;
2767 }
2768
2769 let side = PositionSide::Long;
2770 let quantity =
2771 Quantity::from_decimal_dp(wallet_balance, instrument.size_precision())?;
2772
2773 if quantity.is_zero() {
2774 continue;
2775 }
2776
2777 log::debug!(
2778 "Spot position: {} {} (quote: {})",
2779 quantity,
2780 base_currency.code,
2781 instrument.quote_currency().code
2782 );
2783
2784 let report = PositionStatusReport::new(
2785 account_id,
2786 instrument.id(),
2787 side,
2788 quantity,
2789 ts_init,
2790 ts_init,
2791 None,
2792 None,
2793 None,
2794 );
2795
2796 reports.push(report);
2797 }
2798 }
2799
2800 Ok(reports)
2801 }
2802
2803 #[expect(clippy::too_many_arguments)]
2816 pub async fn submit_order(
2817 &self,
2818 _account_id: AccountId,
2819 instrument_id: InstrumentId,
2820 client_order_id: ClientOrderId,
2821 order_side: OrderSide,
2822 order_type: OrderType,
2823 quantity: Quantity,
2824 time_in_force: TimeInForce,
2825 expire_time: Option<UnixNanos>,
2826 price: Option<Price>,
2827 trigger_price: Option<Price>,
2828 trigger_type: Option<TriggerType>,
2829 trailing_offset: Option<Decimal>,
2830 limit_offset: Option<Decimal>,
2831 reduce_only: bool,
2832 post_only: bool,
2833 quote_quantity: bool,
2834 display_qty: Option<Quantity>,
2835 leverage: Option<u16>,
2836 account_type: AccountType,
2837 ) -> anyhow::Result<VenueOrderId> {
2838 let params = self.build_add_order_params(
2839 instrument_id,
2840 client_order_id,
2841 order_side,
2842 order_type,
2843 quantity,
2844 time_in_force,
2845 expire_time,
2846 price,
2847 trigger_price,
2848 trigger_type,
2849 trailing_offset,
2850 limit_offset,
2851 reduce_only,
2852 post_only,
2853 quote_quantity,
2854 display_qty,
2855 leverage,
2856 account_type,
2857 )?;
2858 let response = self.inner.add_order(¶ms).await?;
2859
2860 let venue_order_id =
2861 response
2862 .txid
2863 .first()
2864 .ok_or_else(|| KrakenSubmitOrderError::MissingOrderId {
2865 detail: "transaction ID was absent".to_string(),
2866 })?;
2867
2868 Ok(VenueOrderId::new(venue_order_id))
2869 }
2870
2871 #[expect(clippy::type_complexity)]
2876 pub async fn submit_orders_batch(
2877 &self,
2878 orders: Vec<(
2879 InstrumentId,
2880 ClientOrderId,
2881 OrderSide,
2882 OrderType,
2883 Quantity,
2884 TimeInForce,
2885 Option<UnixNanos>,
2886 Option<Price>,
2887 Option<Price>,
2888 Option<TriggerType>,
2889 Option<Decimal>,
2890 Option<Decimal>,
2891 bool,
2892 bool,
2893 bool,
2894 Option<Quantity>,
2895 Option<u16>,
2896 )>,
2897 account_type: AccountType,
2898 ) -> anyhow::Result<Vec<String>> {
2899 Ok(self
2900 .send_order_batches(orders, account_type)
2901 .await
2902 .into_iter()
2903 .map(|result| match result {
2904 Ok(order) if order.txid.is_some() => "placed".to_string(),
2905 Ok(order) => order.error.unwrap_or_else(|| "Unknown error".to_string()),
2906 Err(e)
2907 if matches!(
2908 e.downcast_ref::<KrakenBatchOrderError>(),
2909 Some(KrakenBatchOrderError::Validation { .. })
2910 ) =>
2911 {
2912 format!("validation_error: {e}")
2913 }
2914 Err(e) => format!("batch_error: {e}"),
2915 })
2916 .collect())
2917 }
2918
2919 pub(crate) async fn send_order_batches(
2920 &self,
2921 orders: Vec<SpotBatchOrder>,
2922 account_type: AccountType,
2923 ) -> Vec<anyhow::Result<SpotBatchOrderResponse>> {
2924 let count = orders.len();
2925 if count == 0 {
2926 return Vec::new();
2927 }
2928
2929 let mut results: Vec<Option<anyhow::Result<SpotBatchOrderResponse>>> =
2930 (0..count).map(|_| None).collect();
2931 let mut grouped: AHashMap<Ustr, Vec<(usize, KrakenSpotAddOrderParams)>> = AHashMap::new();
2932
2933 for (
2934 idx,
2935 (
2936 instrument_id,
2937 client_order_id,
2938 order_side,
2939 order_type,
2940 quantity,
2941 time_in_force,
2942 expire_time,
2943 price,
2944 trigger_price,
2945 trigger_type,
2946 trailing_offset,
2947 limit_offset,
2948 reduce_only,
2949 post_only,
2950 quote_quantity,
2951 display_qty,
2952 leverage,
2953 ),
2954 ) in orders.into_iter().enumerate()
2955 {
2956 match self.build_add_order_params(
2957 instrument_id,
2958 client_order_id,
2959 order_side,
2960 order_type,
2961 quantity,
2962 time_in_force,
2963 expire_time,
2964 price,
2965 trigger_price,
2966 trigger_type,
2967 trailing_offset,
2968 limit_offset,
2969 reduce_only,
2970 post_only,
2971 quote_quantity,
2972 display_qty,
2973 leverage,
2974 account_type,
2975 ) {
2976 Ok(params) => {
2977 grouped.entry(params.pair).or_default().push((idx, params));
2978 }
2979 Err(e) => {
2980 results[idx] = Some(Err(KrakenBatchOrderError::Validation {
2981 reason: e.to_string(),
2982 }
2983 .into()));
2984 }
2985 }
2986 }
2987
2988 let mut batches = Vec::new();
2989 let mut grouped_orders: Vec<_> = grouped.into_values().collect();
2990 grouped_orders.sort_by_key(|group| group.first().map_or(usize::MAX, |(idx, _)| *idx));
2991 for group in grouped_orders {
2992 batches.extend(group.chunks(BATCH_SUBMIT_LIMIT).map(|chunk| chunk.to_vec()));
2993 }
2994
2995 for (batch_index, batch) in batches.iter().enumerate() {
2996 let response = if batch.len() == 1 {
2997 self.inner
2998 .add_order(&batch[0].1)
2999 .await
3000 .map(|response| SpotAddOrderBatchResponse {
3001 orders: vec![SpotBatchOrderResponse {
3002 descr: response.descr,
3003 error: None,
3004 txid: response.txid.into_iter().next(),
3005 }],
3006 })
3007 } else {
3008 let params = KrakenSpotAddOrderBatchParams {
3009 pair: batch[0].1.pair,
3010 orders: batch
3011 .iter()
3012 .map(|(_, params)| params.clone().into())
3013 .collect(),
3014 asset_class: batch[0].1.asset_class,
3015 };
3016 self.inner.add_order_batch(¶ms).await
3017 };
3018
3019 match response {
3020 Ok(response) if response.orders.len() == batch.len() => {
3023 for ((idx, _), order) in batch.iter().zip(response.orders) {
3024 results[*idx] = Some(Ok(order));
3025 }
3026 }
3027 Ok(response) => {
3028 for (idx, _) in batch {
3029 results[*idx] = Some(Err(KrakenBatchOrderError::ResponseCount {
3030 expected: batch.len(),
3031 actual: response.orders.len(),
3032 }
3033 .into()));
3034 }
3035 }
3036 Err(e) => {
3037 for (idx, _) in batch {
3038 results[*idx] = Some(Err(anyhow::Error::new(e.clone())));
3039 }
3040
3041 for later_batch in &batches[batch_index + 1..] {
3042 for (idx, _) in later_batch {
3043 results[*idx] = Some(Err(KrakenBatchOrderError::NotAttempted.into()));
3044 }
3045 }
3046 break;
3047 }
3048 }
3049 }
3050
3051 results
3052 .into_iter()
3053 .map(|result| result.unwrap_or_else(|| Err(KrakenBatchOrderError::NotAttempted.into())))
3054 .collect()
3055 }
3056
3057 pub async fn modify_order(
3069 &self,
3070 instrument_id: InstrumentId,
3071 client_order_id: Option<ClientOrderId>,
3072 venue_order_id: Option<VenueOrderId>,
3073 quantity: Option<Quantity>,
3074 price: Option<Price>,
3075 trigger_price: Option<Price>,
3076 ) -> anyhow::Result<VenueOrderId> {
3077 let _ = self
3078 .get_cached_instrument(&instrument_id.symbol.inner())
3079 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
3080
3081 let txid = venue_order_id.as_ref().map(|id| id.to_string());
3082 let cl_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
3083
3084 if txid.is_none() && cl_ord_id.is_none() {
3085 anyhow::bail!("Either client_order_id or venue_order_id must be provided");
3086 }
3087
3088 let mut builder = KrakenSpotAmendOrderParamsBuilder::default();
3089 if let Some(ref id) = txid {
3090 builder.txid(id.clone());
3091 } else if let Some(ref id) = cl_ord_id {
3092 builder.cl_ord_id(id.clone());
3093 }
3094
3095 if let Some(qty) = quantity {
3096 builder.order_qty(qty.to_string());
3097 }
3098
3099 if let Some(p) = price {
3100 builder.limit_price(p.to_string());
3101 }
3102
3103 if let Some(tp) = trigger_price {
3104 builder.trigger_price(tp.to_string());
3105 }
3106
3107 let params = builder
3108 .build()
3109 .map_err(|e| anyhow::anyhow!("Failed to build amend order params: {e}"))?;
3110
3111 let _response = self.inner.amend_order(¶ms).await?;
3112
3113 let order_id = venue_order_id.ok_or(KrakenModifyOrderError::MissingOrderId)?;
3115
3116 Ok(order_id)
3117 }
3118
3119 pub async fn cancel_order(
3129 &self,
3130 _account_id: AccountId,
3131 instrument_id: InstrumentId,
3132 client_order_id: Option<ClientOrderId>,
3133 venue_order_id: Option<VenueOrderId>,
3134 ) -> anyhow::Result<()> {
3135 let _ = self
3136 .get_cached_instrument(&instrument_id.symbol.inner())
3137 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
3138
3139 let txid = venue_order_id.as_ref().map(|id| id.to_string());
3140 let cl_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
3141
3142 if txid.is_none() && cl_ord_id.is_none() {
3143 anyhow::bail!("Either client_order_id or venue_order_id must be provided");
3144 }
3145
3146 let mut builder = KrakenSpotCancelOrderParamsBuilder::default();
3149
3150 if let Some(ref id) = txid {
3151 builder.txid(id.clone());
3152 } else if let Some(ref id) = cl_ord_id {
3153 builder.cl_ord_id(id.clone());
3154 }
3155 let params = builder
3156 .build()
3157 .map_err(|e| anyhow::anyhow!("Failed to build cancel params: {e}"))?;
3158
3159 self.inner.cancel_order(¶ms).await?;
3160
3161 Ok(())
3162 }
3163
3164 pub async fn cancel_orders_batch(
3166 &self,
3167 venue_order_ids: Vec<VenueOrderId>,
3168 ) -> anyhow::Result<i32> {
3169 if venue_order_ids.is_empty() {
3170 return Ok(0);
3171 }
3172
3173 let mut total_cancelled = 0;
3174
3175 for chunk in venue_order_ids.chunks(BATCH_CANCEL_LIMIT) {
3176 let orders: Vec<String> = chunk.iter().map(|id| id.to_string()).collect();
3177 let params = KrakenSpotCancelOrderBatchParams {
3178 orders,
3179 cl_ord_ids: Vec::new(),
3180 };
3181
3182 let response = self.inner.cancel_order_batch(¶ms).await?;
3183 total_cancelled += response.count;
3184 }
3185
3186 Ok(total_cancelled)
3187 }
3188
3189 #[expect(clippy::too_many_arguments)]
3190 fn build_add_order_params(
3191 &self,
3192 instrument_id: InstrumentId,
3193 client_order_id: ClientOrderId,
3194 order_side: OrderSide,
3195 order_type: OrderType,
3196 quantity: Quantity,
3197 time_in_force: TimeInForce,
3198 expire_time: Option<UnixNanos>,
3199 price: Option<Price>,
3200 trigger_price: Option<Price>,
3201 trigger_type: Option<TriggerType>,
3202 trailing_offset: Option<Decimal>,
3203 limit_offset: Option<Decimal>,
3204 reduce_only: bool,
3205 post_only: bool,
3206 quote_quantity: bool,
3207 display_qty: Option<Quantity>,
3208 leverage: Option<u16>,
3209 account_type: AccountType,
3210 ) -> anyhow::Result<KrakenSpotAddOrderParams> {
3211 let instrument = self
3212 .get_cached_instrument(&instrument_id.symbol.inner())
3213 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
3214
3215 let raw_symbol = instrument.raw_symbol().inner();
3216 let asset_class = Self::asset_class_for(&instrument);
3217
3218 let kraken_side = match order_side {
3219 OrderSide::Buy => KrakenOrderSide::Buy,
3220 OrderSide::Sell => KrakenOrderSide::Sell,
3221 };
3222
3223 let kraken_order_type = match order_type {
3224 OrderType::Market => KrakenOrderType::Market,
3225 OrderType::Limit => KrakenOrderType::Limit,
3226 OrderType::StopMarket => KrakenOrderType::StopLoss,
3227 OrderType::StopLimit => KrakenOrderType::StopLossLimit,
3228 OrderType::MarketIfTouched => KrakenOrderType::TakeProfit,
3229 OrderType::LimitIfTouched => KrakenOrderType::TakeProfitLimit,
3230 OrderType::TrailingStopMarket => KrakenOrderType::TrailingStop,
3231 OrderType::TrailingStopLimit => KrakenOrderType::TrailingStopLimit,
3232 _ => anyhow::bail!("Unsupported order type: {order_type:?}"),
3233 };
3234
3235 let mut oflags = Vec::new();
3236 let is_limit_order = matches!(
3237 order_type,
3238 OrderType::Limit
3239 | OrderType::StopLimit
3240 | OrderType::LimitIfTouched
3241 | OrderType::TrailingStopLimit
3242 );
3243
3244 if time_in_force == TimeInForce::Fok && order_type != OrderType::Limit {
3245 anyhow::bail!("FOK time in force only supported for LIMIT orders on Kraken Spot");
3246 }
3247
3248 let (timeinforce, expiretm) =
3249 compute_time_in_force(is_limit_order, time_in_force, expire_time)?;
3250
3251 if post_only {
3252 oflags.push(KRAKEN_OFLAG_POST_ONLY);
3253 }
3254
3255 if quote_quantity {
3256 oflags.push(KRAKEN_OFLAG_QUOTE_QUANTITY);
3257 }
3258
3259 let mut builder = KrakenSpotAddOrderParamsBuilder::default();
3260 builder
3261 .cl_ord_id(truncate_cl_ord_id(&client_order_id))
3262 .broker(NAUTILUS_KRAKEN_BROKER_ID)
3263 .pair(raw_symbol)
3264 .side(kraken_side)
3265 .volume(quantity.to_string())
3266 .order_type(kraken_order_type);
3267
3268 let is_conditional = matches!(
3269 order_type,
3270 OrderType::StopMarket
3271 | OrderType::StopLimit
3272 | OrderType::MarketIfTouched
3273 | OrderType::LimitIfTouched
3274 | OrderType::TrailingStopMarket
3275 | OrderType::TrailingStopLimit
3276 );
3277
3278 let is_trailing = matches!(
3279 order_type,
3280 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
3281 );
3282
3283 if is_trailing {
3284 if trigger_price.is_some() {
3285 anyhow::bail!(
3286 "Kraken Spot trailing stops do not support activation trigger prices"
3287 );
3288 }
3289
3290 if let Some(offset) = trailing_offset {
3291 builder.price(offset.to_string());
3292 }
3293
3294 if let Some(offset) = limit_offset {
3295 builder.price2(offset.to_string());
3296 }
3297 } else if is_conditional {
3298 if let Some(trigger) = trigger_price {
3299 builder.price(trigger.to_string());
3300 }
3301
3302 if let Some(limit) = price {
3303 builder.price2(limit.to_string());
3304 }
3305 } else if let Some(limit) = price {
3306 builder.price(limit.to_string());
3307 }
3308
3309 if is_conditional {
3310 match trigger_type {
3311 Some(TriggerType::IndexPrice) => {
3312 builder.trigger("index".to_string());
3313 }
3314 Some(TriggerType::LastPrice | TriggerType::Default) | None => {}
3315 Some(other) => {
3316 anyhow::bail!(
3317 "Unsupported trigger type for Kraken Spot: {other:?} (only LastPrice and IndexPrice supported)"
3318 );
3319 }
3320 }
3321 }
3322
3323 if !oflags.is_empty() {
3324 builder.oflags(oflags.join(","));
3325 }
3326
3327 if let Some(tif) = timeinforce {
3328 builder.timeinforce(tif);
3329 }
3330
3331 if let Some(expire) = expiretm {
3332 builder.expiretm(expire);
3333 }
3334
3335 if let Some(dq) = display_qty {
3336 builder.displayvol(dq.to_string());
3337 }
3338
3339 if let Some(ac) = asset_class {
3340 builder.asset_class(ac);
3341 }
3342
3343 if leverage.is_some() && account_type != AccountType::Margin {
3344 anyhow::bail!("leverage requires spot_account_type=Margin (current: Cash)");
3345 }
3346
3347 if let Some(n) = leverage {
3348 let tiers = self.leverage_tiers_cache.get_cloned(&raw_symbol);
3349 let (buy_tiers, sell_tiers) = tiers.ok_or_else(|| {
3350 anyhow::anyhow!(
3351 "Leverage tiers not loaded for {raw_symbol}; cannot validate leverage {n}:1 (instruments must be initialized before submitting margin orders)"
3352 )
3353 })?;
3354 let valid_tiers = match order_side {
3355 OrderSide::Buy => buy_tiers,
3356 _ => sell_tiers,
3357 };
3358 let side_label = match order_side {
3359 OrderSide::Buy => "buy",
3360 _ => "sell",
3361 };
3362
3363 if valid_tiers.is_empty() {
3364 anyhow::bail!("Leverage not supported for {raw_symbol} on {side_label} side");
3365 }
3366
3367 if !valid_tiers.contains(&(n as i32)) {
3368 anyhow::bail!(
3369 "Leverage {n}:1 not supported for {raw_symbol} on {side_label} side (valid: {valid_tiers:?})"
3370 );
3371 }
3372 builder.leverage(format!("{n}:1"));
3373 }
3374
3375 if reduce_only {
3376 if account_type != AccountType::Margin {
3377 anyhow::bail!("reduce_only requires spot_account_type=Margin (current: Cash)");
3378 }
3379 builder.reduce_only(true);
3380 }
3381
3382 builder
3383 .build()
3384 .map_err(|e| anyhow::anyhow!("Failed to build order params: {e}"))
3385 }
3386}
3387
3388fn collect_spot_statuses(
3389 asset_pairs: &AssetPairsResponse,
3390) -> AHashMap<InstrumentId, MarketStatusAction> {
3391 asset_pairs
3392 .iter()
3393 .map(|(_, definition)| {
3394 let symbol_str = definition.wsname.as_ref().unwrap_or(&definition.altname);
3395 let normalized_symbol = normalize_spot_symbol(symbol_str.as_str());
3396 let instrument_id = InstrumentId::new(Symbol::new(&normalized_symbol), *KRAKEN_VENUE);
3397 let action = definition
3398 .status
3399 .map_or(MarketStatusAction::Trading, MarketStatusAction::from);
3400
3401 (instrument_id, action)
3402 })
3403 .collect()
3404}
3405
3406type LeverageTiersCache = Arc<AtomicMap<Ustr, (Vec<i32>, Vec<i32>)>>;
3408
3409struct OpenPositionAggregate {
3414 instrument_id: InstrumentId,
3415 signed_qty: Decimal,
3416 long_qty: Decimal,
3417 long_notional: Decimal,
3418 short_qty: Decimal,
3419 short_notional: Decimal,
3420}
3421
3422impl OpenPositionAggregate {
3423 fn new(instrument_id: InstrumentId) -> Self {
3424 Self {
3425 instrument_id,
3426 signed_qty: Decimal::ZERO,
3427 long_qty: Decimal::ZERO,
3428 long_notional: Decimal::ZERO,
3429 short_qty: Decimal::ZERO,
3430 short_notional: Decimal::ZERO,
3431 }
3432 }
3433
3434 fn avg_px_open(&self, side: PositionSide) -> Option<Decimal> {
3436 match side {
3437 PositionSide::Long if !self.long_qty.is_zero() => {
3438 Some(self.long_notional / self.long_qty)
3439 }
3440 PositionSide::Short if !self.short_qty.is_zero() => {
3441 Some(self.short_notional / self.short_qty)
3442 }
3443 _ => None,
3444 }
3445 }
3446}
3447
3448struct TradeBalanceSnapshot {
3449 margins: Vec<MarginBalance>,
3450 metrics: IndexMap<String, String>,
3451 free_margin: Decimal,
3452 equity: Decimal,
3453}
3454
3455fn wallet_report_base_currency(
3465 instrument: &InstrumentAny,
3466 quote_currency: Ustr,
3467) -> Option<Currency> {
3468 let quote_filter = Ustr::from(normalize_currency_code(quote_currency.as_str()));
3469 if instrument.quote_currency().code != quote_filter {
3470 return None;
3471 }
3472
3473 instrument.base_currency()
3474}
3475
3476fn optional_credit_amount(value: Option<&str>) -> Option<Decimal> {
3482 match value {
3483 Some(amount) => Decimal::from_str_exact(amount).ok(),
3484 None => Some(Decimal::ZERO),
3485 }
3486}
3487
3488fn trade_balance_currency(asset: Option<&str>) -> Currency {
3494 let raw = asset.unwrap_or("ZUSD");
3495 let normalized = normalize_currency_code(raw);
3496 Currency::try_from_str(normalized)
3497 .unwrap_or_else(|| Currency::new(normalized, 2, 0, normalized, CurrencyType::Fiat))
3498}
3499
3500fn compute_time_in_force(
3501 is_limit_order: bool,
3502 time_in_force: TimeInForce,
3503 expire_time: Option<UnixNanos>,
3504) -> anyhow::Result<(Option<String>, Option<String>)> {
3505 if !is_limit_order {
3506 return Ok((None, None));
3507 }
3508
3509 match time_in_force {
3510 TimeInForce::Gtc => Ok((None, None)),
3511 TimeInForce::Ioc => Ok((Some("IOC".to_string()), None)),
3512 TimeInForce::Fok => Ok((Some("FOK".to_string()), None)),
3513 TimeInForce::Gtd => {
3514 let expire = expire_time.ok_or_else(|| {
3515 anyhow::anyhow!("GTD time in force requires expire_time parameter")
3516 })?;
3517 let expire_secs = expire.as_u64() / NANOSECONDS_IN_SECOND;
3518 Ok((Some("GTD".to_string()), Some(expire_secs.to_string())))
3519 }
3520 _ => anyhow::bail!("Unsupported time in force: {time_in_force:?}"),
3521 }
3522}
3523
3524#[cfg(test)]
3525mod tests {
3526 use std::{sync::Arc, time::Duration};
3527
3528 use nautilus_model::instruments::CurrencyPair;
3529 use nautilus_testkit::http::assert_http_redirect_rejected;
3530 use rstest::rstest;
3531
3532 use super::*;
3533
3534 #[tokio::test]
3535 async fn test_authenticated_client_rejects_redirects() {
3536 let client = KrakenSpotRawHttpClient::with_credentials(
3537 "key".into(),
3538 "secret".into(),
3539 KrakenEnvironment::Live,
3540 None,
3541 3,
3542 Some(0),
3543 None,
3544 None,
3545 None,
3546 10,
3547 )
3548 .unwrap()
3549 .client;
3550 assert_http_redirect_rejected(|url| async move {
3551 client
3552 .get(url, None, None, Some(3), None)
3553 .await
3554 .unwrap()
3555 .status
3556 .as_u16()
3557 })
3558 .await;
3559 }
3560
3561 #[rstest]
3562 fn test_raw_client_creation() {
3563 let client = KrakenSpotRawHttpClient::default();
3564 assert!(client.credential.is_none());
3565 }
3566
3567 #[rstest]
3568 fn test_raw_client_with_credentials() {
3569 let client = KrakenSpotRawHttpClient::with_credentials(
3570 "test_key".to_string(),
3571 "test_secret".to_string(),
3572 KrakenEnvironment::Live,
3573 None,
3574 60,
3575 None,
3576 None,
3577 None,
3578 None,
3579 KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3580 )
3581 .unwrap();
3582 assert!(client.credential.is_some());
3583 }
3584
3585 #[rstest]
3586 #[tokio::test]
3587 async fn test_order_request_cancellation_before_transport() {
3588 let client = Arc::new(KrakenSpotRawHttpClient::default());
3589 let guard = client.auth_mutex.lock().await;
3590 let cancellation_token = client.cancellation_token();
3591 let waiting_token = cancellation_token.clone();
3592 let waiting_client = Arc::clone(&client);
3593 let waiting = tokio::spawn(async move {
3594 waiting_client
3595 .lock_order_request(&waiting_token)
3596 .await
3597 .map(drop)
3598 });
3599
3600 tokio::task::yield_now().await;
3601 client.cancel_all_requests();
3602 let waiting_result = tokio::time::timeout(Duration::from_secs(1), waiting)
3603 .await
3604 .expect("auth lock wait should stop on cancellation")
3605 .expect("auth lock task should complete");
3606 let request_result = client
3607 .send_order_request(
3608 Method::POST,
3609 "/0/private/AddOrder",
3610 KrakenSpotRawHttpClient::default_headers(),
3611 None,
3612 &cancellation_token,
3613 )
3614 .await;
3615 drop(guard);
3616 client.reset_cancellation_token();
3617 let reset_token = client.cancellation_token();
3618
3619 assert!(matches!(
3620 waiting_result,
3621 Err(KrakenHttpError::RequestNotStarted(ref message))
3622 if message == "Request canceled before transport invocation"
3623 ));
3624 assert!(matches!(
3625 request_result,
3626 Err(KrakenHttpError::RequestNotStarted(ref message))
3627 if message == "Request canceled before transport invocation"
3628 ));
3629 assert!(!reset_token.is_cancelled());
3630 }
3631
3632 #[rstest]
3633 #[tokio::test]
3634 async fn test_trade_volume_request_cancellation_before_transport() {
3635 let client = KrakenSpotRawHttpClient::with_credentials(
3636 "test_key".to_string(),
3637 "dGVzdF9hcGlfc2VjcmV0X2Jhc2U2NA==".to_string(),
3638 KrakenEnvironment::Live,
3639 Some("http://127.0.0.1:1".to_string()),
3640 1,
3641 None,
3642 None,
3643 None,
3644 None,
3645 KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3646 )
3647 .unwrap();
3648 let params = SpotTradeVolumeParams {
3649 pair: SpotTradeVolumePairs::Names("XBTUSDT".to_string()),
3650 };
3651 client.cancel_all_requests();
3652
3653 let result = client.get_trade_volume(¶ms).await;
3654
3655 assert!(matches!(
3656 result,
3657 Err(KrakenHttpError::NetworkError(ref message))
3658 if message.contains("canceled") || message.contains("cancelled")
3659 ));
3660 }
3661
3662 #[rstest]
3663 fn test_client_creation() {
3664 let client = KrakenSpotHttpClient::default();
3665 assert!(client.instruments_cache.is_empty());
3666 }
3667
3668 #[rstest]
3669 fn test_client_with_credentials() {
3670 let client = KrakenSpotHttpClient::with_credentials(
3671 "test_key".to_string(),
3672 "test_secret".to_string(),
3673 KrakenEnvironment::Live,
3674 None,
3675 60,
3676 None,
3677 None,
3678 None,
3679 None,
3680 KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
3681 )
3682 .unwrap();
3683 assert!(client.instruments_cache.is_empty());
3684 }
3685
3686 #[rstest]
3687 fn test_nonce_generation_strictly_increasing() {
3688 let client = KrakenSpotRawHttpClient::default();
3689
3690 let nonce1 = client.generate_nonce();
3691 let nonce2 = client.generate_nonce();
3692 let nonce3 = client.generate_nonce();
3693
3694 assert!(
3695 nonce2 > nonce1,
3696 "nonce2 ({nonce2}) should be > nonce1 ({nonce1})"
3697 );
3698 assert!(
3699 nonce3 > nonce2,
3700 "nonce3 ({nonce3}) should be > nonce2 ({nonce2})"
3701 );
3702 }
3703
3704 #[rstest]
3705 fn test_nonce_is_nanosecond_timestamp() {
3706 let client = KrakenSpotRawHttpClient::default();
3707
3708 let nonce = client.generate_nonce();
3709
3710 assert!(
3713 nonce > 1_500_000_000_000_000_000,
3714 "Nonce should be nanosecond timestamp"
3715 );
3716 }
3717
3718 #[rstest]
3719 #[case::gtc_limit(true, TimeInForce::Gtc, None, None, None)]
3720 #[case::ioc_limit(true, TimeInForce::Ioc, None, Some("IOC"), None)]
3721 #[case::fok_limit(true, TimeInForce::Fok, None, Some("FOK"), None)]
3722 #[case::gtd_limit_with_expire(
3723 true,
3724 TimeInForce::Gtd,
3725 Some(1_704_067_200_000_000_000u64),
3726 Some("GTD"),
3727 Some("1704067200")
3728 )]
3729 #[case::gtc_market(false, TimeInForce::Gtc, None, None, None)]
3730 #[case::ioc_market(false, TimeInForce::Ioc, None, None, None)]
3731 fn test_compute_time_in_force_success(
3732 #[case] is_limit: bool,
3733 #[case] tif: TimeInForce,
3734 #[case] expire_nanos: Option<u64>,
3735 #[case] expected_tif: Option<&str>,
3736 #[case] expected_expire: Option<&str>,
3737 ) {
3738 let expire_time = expire_nanos.map(UnixNanos::from);
3739 let result = compute_time_in_force(is_limit, tif, expire_time).unwrap();
3740 assert_eq!(result.0, expected_tif.map(String::from));
3741 assert_eq!(result.1, expected_expire.map(String::from));
3742 }
3743
3744 #[rstest]
3745 #[case::gtd_missing_expire(TimeInForce::Gtd, None, "expire_time")]
3746 fn test_compute_time_in_force_errors(
3747 #[case] tif: TimeInForce,
3748 #[case] expire_nanos: Option<u64>,
3749 #[case] expected_error: &str,
3750 ) {
3751 let expire_time = expire_nanos.map(UnixNanos::from);
3752 let result = compute_time_in_force(true, tif, expire_time);
3753 assert!(result.is_err());
3754 assert!(result.unwrap_err().to_string().contains(expected_error));
3755 }
3756
3757 #[rstest]
3758 fn test_build_add_order_params_sets_index_trigger_for_conditional_orders() {
3759 let client = KrakenSpotHttpClient::default();
3760 let instrument_id = cache_test_spot_instrument(&client);
3761
3762 let params = client
3763 .build_add_order_params(
3764 instrument_id,
3765 ClientOrderId::new("spot-trigger-index"),
3766 OrderSide::Buy,
3767 OrderType::StopMarket,
3768 Quantity::from("0.01"),
3769 TimeInForce::Gtc,
3770 None,
3771 None,
3772 Some(Price::from("50000")),
3773 Some(TriggerType::IndexPrice),
3774 None,
3775 None,
3776 false,
3777 false,
3778 false,
3779 None,
3780 None,
3781 AccountType::Cash,
3782 )
3783 .unwrap();
3784
3785 assert_eq!(params.trigger, Some("index".to_string()));
3786 assert_eq!(params.price, Some("50000".to_string()));
3787 }
3788
3789 #[rstest]
3790 fn test_build_add_order_params_sets_trailing_offsets() {
3791 let client = KrakenSpotHttpClient::default();
3792 let instrument_id = cache_test_spot_instrument(&client);
3793
3794 let params = client
3795 .build_add_order_params(
3796 instrument_id,
3797 ClientOrderId::new("spot-trailing"),
3798 OrderSide::Sell,
3799 OrderType::TrailingStopLimit,
3800 Quantity::from("0.01"),
3801 TimeInForce::Gtc,
3802 None,
3803 Some(Price::from("49900")),
3804 None,
3805 Some(TriggerType::LastPrice),
3806 Some(Decimal::from(50)),
3807 Some(Decimal::from(25)),
3808 false,
3809 false,
3810 false,
3811 Some(Quantity::from("0.005")),
3812 None,
3813 AccountType::Cash,
3814 )
3815 .unwrap();
3816
3817 assert_eq!(params.price, Some("50".to_string()));
3818 assert_eq!(params.price2, Some("25".to_string()));
3819 assert_eq!(params.trigger, None);
3820 assert_eq!(params.displayvol, Some("0.005".to_string()));
3821 }
3822
3823 #[rstest]
3824 fn test_build_add_order_params_rejects_unsupported_trigger_type() {
3825 let client = KrakenSpotHttpClient::default();
3826 let instrument_id = cache_test_spot_instrument(&client);
3827
3828 let error = client
3829 .build_add_order_params(
3830 instrument_id,
3831 ClientOrderId::new("spot-trigger-invalid"),
3832 OrderSide::Buy,
3833 OrderType::StopMarket,
3834 Quantity::from("0.01"),
3835 TimeInForce::Gtc,
3836 None,
3837 None,
3838 Some(Price::from("50000")),
3839 Some(TriggerType::MarkPrice),
3840 None,
3841 None,
3842 false,
3843 false,
3844 false,
3845 None,
3846 None,
3847 AccountType::Cash,
3848 )
3849 .unwrap_err();
3850
3851 assert!(
3852 error
3853 .to_string()
3854 .contains("Unsupported trigger type for Kraken Spot")
3855 );
3856 }
3857
3858 fn cache_spot_pair(
3859 client: &KrakenSpotHttpClient,
3860 symbol: &str,
3861 quote: Currency,
3862 ) -> InstrumentId {
3863 let instrument_id = InstrumentId::from(symbol);
3864
3865 client.cache_instrument(InstrumentAny::CurrencyPair(
3866 CurrencyPair::builder()
3867 .instrument_id(instrument_id)
3868 .raw_symbol(Symbol::new(instrument_id.symbol.as_str()))
3869 .base_currency(Currency::BTC())
3870 .quote_currency(quote)
3871 .price_precision(1)
3872 .size_precision(8)
3873 .price_increment(Price::from("0.1"))
3874 .size_increment(Quantity::from("0.00000001"))
3875 .ts_event(0.into())
3876 .ts_init(0.into())
3877 .build()
3878 .unwrap(),
3879 ));
3880
3881 instrument_id
3882 }
3883
3884 #[rstest]
3890 #[case(AccountType::Margin, true, "XBT/USDT.KRAKEN", false)]
3891 #[case(AccountType::Margin, false, "XBT/USDT.KRAKEN", false)]
3892 #[case(AccountType::Cash, false, "XBT/USDT.KRAKEN", false)]
3893 #[case(AccountType::Cash, true, "XBT/USDT.KRAKEN", true)]
3894 #[case(AccountType::Cash, true, "XBT/USD.KRAKEN", false)]
3895 #[case(AccountType::Cash, true, "XBT/EUR.KRAKEN", false)]
3896 fn test_covers_bulk_position_reports(
3897 #[case] account_type: AccountType,
3898 #[case] use_spot_position_reports: bool,
3899 #[case] instrument: &str,
3900 #[case] expected: bool,
3901 ) {
3902 let client = KrakenSpotHttpClient::default();
3903 cache_spot_pair(&client, "XBT/USDT.KRAKEN", Currency::USDT());
3904 cache_spot_pair(&client, "XBT/USD.KRAKEN", Currency::USD());
3905
3906 let covered = client.covers_bulk_position_reports(
3908 InstrumentId::from(instrument),
3909 account_type,
3910 use_spot_position_reports,
3911 Ustr::from("USDT"),
3912 );
3913
3914 assert_eq!(covered, expected);
3915 }
3916
3917 #[rstest]
3922 fn test_covers_bulk_position_reports_accepts_legacy_quote_code() {
3923 let client = KrakenSpotHttpClient::default();
3924 let eur = cache_spot_pair(&client, "XBT/EUR.KRAKEN", Currency::EUR());
3925 cache_spot_pair(&client, "XBT/USDT.KRAKEN", Currency::USDT());
3926
3927 let legacy =
3928 client.covers_bulk_position_reports(eur, AccountType::Cash, true, Ustr::from("ZEUR"));
3929 let standard =
3930 client.covers_bulk_position_reports(eur, AccountType::Cash, true, Ustr::from("EUR"));
3931 let other = client.covers_bulk_position_reports(
3932 InstrumentId::from("XBT/USDT.KRAKEN"),
3933 AccountType::Cash,
3934 true,
3935 Ustr::from("ZEUR"),
3936 );
3937
3938 assert!(legacy);
3939 assert!(standard);
3940 assert!(!other);
3941 }
3942
3943 fn cache_test_spot_instrument(client: &KrakenSpotHttpClient) -> InstrumentId {
3944 let instrument_id = InstrumentId::from("XBT/USD.KRAKEN");
3945
3946 client.cache_instrument(InstrumentAny::CurrencyPair(
3947 CurrencyPair::builder()
3948 .instrument_id(instrument_id)
3949 .raw_symbol(Symbol::new("XBTUSD"))
3950 .base_currency(Currency::BTC())
3951 .quote_currency(Currency::USD())
3952 .price_precision(1)
3953 .size_precision(8)
3954 .price_increment(Price::from("0.1"))
3955 .size_increment(Quantity::from("0.00000001"))
3956 .ts_event(0.into())
3957 .ts_init(0.into())
3958 .build()
3959 .unwrap(),
3960 ));
3961
3962 instrument_id
3963 }
3964
3965 fn cache_test_spot_instrument_with_leverage(
3966 client: &KrakenSpotHttpClient,
3967 leverage_buy: &[i32],
3968 leverage_sell: &[i32],
3969 ) -> InstrumentId {
3970 let instrument_id = cache_test_spot_instrument(client);
3971 let raw_symbol = Ustr::from("XBTUSD");
3972 client.leverage_tiers_cache.rcu(|m| {
3973 m.insert(raw_symbol, (leverage_buy.to_vec(), leverage_sell.to_vec()));
3974 });
3975 instrument_id
3976 }
3977
3978 #[rstest]
3979 fn test_build_add_order_params_leverage_serialized_as_ratio() {
3980 let client = KrakenSpotHttpClient::default();
3981 let instrument_id =
3982 cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
3983
3984 let params = client
3985 .build_add_order_params(
3986 instrument_id,
3987 ClientOrderId::new("spot-margin-buy"),
3988 OrderSide::Buy,
3989 OrderType::Limit,
3990 Quantity::from("0.01"),
3991 TimeInForce::Gtc,
3992 None,
3993 Some(Price::from("50000")),
3994 None,
3995 None,
3996 None,
3997 None,
3998 false,
3999 false,
4000 false,
4001 None,
4002 Some(3),
4003 AccountType::Margin,
4004 )
4005 .unwrap();
4006
4007 assert_eq!(params.leverage, Some("3:1".to_string()));
4008 }
4009
4010 #[rstest]
4011 fn test_build_add_order_params_invalid_leverage_rejected() {
4012 let client = KrakenSpotHttpClient::default();
4013 let instrument_id =
4014 cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
4015
4016 let err = client
4017 .build_add_order_params(
4018 instrument_id,
4019 ClientOrderId::new("spot-margin-bad"),
4020 OrderSide::Buy,
4021 OrderType::Limit,
4022 Quantity::from("0.01"),
4023 TimeInForce::Gtc,
4024 None,
4025 Some(Price::from("50000")),
4026 None,
4027 None,
4028 None,
4029 None,
4030 false,
4031 false,
4032 false,
4033 None,
4034 Some(7),
4035 AccountType::Margin,
4036 )
4037 .unwrap_err();
4038
4039 assert!(
4040 err.to_string().contains("not supported"),
4041 "Expected tier-validation error: {err}"
4042 );
4043 }
4044
4045 #[rstest]
4046 fn test_build_add_order_params_no_leverage_is_cash() {
4047 let client = KrakenSpotHttpClient::default();
4048 let instrument_id =
4049 cache_test_spot_instrument_with_leverage(&client, &[2, 3, 5], &[2, 3, 5]);
4050
4051 let params = client
4052 .build_add_order_params(
4053 instrument_id,
4054 ClientOrderId::new("spot-cash"),
4055 OrderSide::Buy,
4056 OrderType::Limit,
4057 Quantity::from("0.01"),
4058 TimeInForce::Gtc,
4059 None,
4060 Some(Price::from("50000")),
4061 None,
4062 None,
4063 None,
4064 None,
4065 false,
4066 false,
4067 false,
4068 None,
4069 None,
4070 AccountType::Cash,
4071 )
4072 .unwrap();
4073
4074 assert_eq!(
4075 params.leverage, None,
4076 "Cash order should not have leverage field"
4077 );
4078 }
4079
4080 #[rstest]
4081 fn test_build_add_order_params_rejects_per_order_leverage_in_cash_mode() {
4082 let client = KrakenSpotHttpClient::default();
4083 let instrument_id = cache_test_spot_instrument(&client);
4084
4085 let err = client
4086 .build_add_order_params(
4087 instrument_id,
4088 ClientOrderId::new("cash-with-leverage"),
4089 OrderSide::Buy,
4090 OrderType::Limit,
4091 Quantity::from("0.01"),
4092 TimeInForce::Gtc,
4093 None,
4094 Some(Price::from("50000")),
4095 None,
4096 None,
4097 None,
4098 None,
4099 false,
4100 false,
4101 false,
4102 None,
4103 Some(3),
4104 AccountType::Cash,
4105 )
4106 .unwrap_err();
4107
4108 assert!(
4109 err.to_string().contains("Margin"),
4110 "Expected Margin mode rejection: {err}"
4111 );
4112 }
4113
4114 #[rstest]
4115 fn test_build_add_order_params_reduce_only_forwarded_in_margin_mode() {
4116 let client = KrakenSpotHttpClient::default();
4117 let instrument_id = cache_test_spot_instrument(&client);
4118
4119 let params = client
4120 .build_add_order_params(
4121 instrument_id,
4122 ClientOrderId::new("margin-reduce-only"),
4123 OrderSide::Sell,
4124 OrderType::Limit,
4125 Quantity::from("0.01"),
4126 TimeInForce::Gtc,
4127 None,
4128 Some(Price::from("50000")),
4129 None,
4130 None,
4131 None,
4132 None,
4133 true,
4134 false,
4135 false,
4136 None,
4137 None,
4138 AccountType::Margin,
4139 )
4140 .unwrap();
4141
4142 assert_eq!(params.reduce_only, Some(true));
4143 }
4144
4145 #[rstest]
4146 fn test_build_add_order_params_rejects_reduce_only_in_cash_mode() {
4147 let client = KrakenSpotHttpClient::default();
4148 let instrument_id = cache_test_spot_instrument(&client);
4149
4150 let err = client
4151 .build_add_order_params(
4152 instrument_id,
4153 ClientOrderId::new("cash-reduce-only"),
4154 OrderSide::Sell,
4155 OrderType::Limit,
4156 Quantity::from("0.01"),
4157 TimeInForce::Gtc,
4158 None,
4159 Some(Price::from("50000")),
4160 None,
4161 None,
4162 None,
4163 None,
4164 true,
4165 false,
4166 false,
4167 None,
4168 None,
4169 AccountType::Cash,
4170 )
4171 .unwrap_err();
4172
4173 assert!(
4174 err.to_string().contains("reduce_only requires"),
4175 "expected reduce_only Margin rejection: {err}"
4176 );
4177 }
4178
4179 #[rstest]
4180 fn test_build_add_order_params_rejects_leverage_when_tiers_not_loaded() {
4181 let client = KrakenSpotHttpClient::default();
4182 let instrument_id = cache_test_spot_instrument(&client);
4183
4184 let err = client
4185 .build_add_order_params(
4186 instrument_id,
4187 ClientOrderId::new("missing-tiers"),
4188 OrderSide::Buy,
4189 OrderType::Limit,
4190 Quantity::from("0.01"),
4191 TimeInForce::Gtc,
4192 None,
4193 Some(Price::from("50000")),
4194 None,
4195 None,
4196 None,
4197 None,
4198 false,
4199 false,
4200 false,
4201 None,
4202 Some(3),
4203 AccountType::Margin,
4204 )
4205 .unwrap_err();
4206
4207 assert!(
4208 err.to_string().contains("Leverage tiers not loaded"),
4209 "expected cache-miss rejection: {err}"
4210 );
4211 }
4212}