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