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