1use std::{
19 collections::HashSet,
20 future::Future,
21 sync::Arc,
22 time::{Duration, Instant},
23};
24
25use anyhow::Context;
26use async_trait::async_trait;
27use futures_util::StreamExt;
28use jiff::Timestamp;
29use nautilus_common::{
30 cache::InstrumentLookupError,
31 clients::ExecutionClient,
32 live::runner::get_exec_event_sender,
33 messages::execution::{
34 BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
35 GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
36 ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
37 },
38};
39use nautilus_core::{
40 AtomicMap, DurationNanos, Params, UUID4, UnixNanos,
41 time::{AtomicTime, get_atomic_clock_realtime},
42};
43use nautilus_live::{
44 ExecutionClientCore, ExecutionEventEmitter, SocketControl, execution::failure::CommandFailure,
45 task::TaskGroup,
46};
47use nautilus_model::{
48 accounts::AccountAny,
49 enums::{
50 AccountType, OmsType, OrderSide, OrderType, TimeInForce, TrailingOffsetType, TriggerType,
51 },
52 events::OrderEventAny,
53 identifiers::{
54 AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, Venue, VenueOrderId,
55 },
56 instruments::{Instrument, InstrumentAny},
57 orders::{Order, OrderAny},
58 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
59 types::{AccountBalance, MarginBalance, Price, Quantity},
60};
61use rust_decimal::Decimal;
62use tokio_util::sync::CancellationToken;
63use ustr::Ustr;
64
65use super::{
66 absent_unless_unread, cached_order_for_status_command, command_failure_from_cancel_error,
67 command_failure_from_modify_error, command_failure_from_spot_batch_error,
68 command_failure_from_spot_batch_item, command_failure_from_spot_cancel_error,
69 command_failure_from_submit_error, earliest_venue_time, status_command_label,
70};
71use crate::{
72 common::{
73 consts::{KRAKEN_SPOT_POST_ONLY_ERROR, KRAKEN_VENUE},
74 enums::{KrakenOrderSide, KrakenOrderType, KrakenSpotTrigger, KrakenTimeInForce},
75 order_params::{
76 build_add_order_params, build_amend_order_params, build_cancel_order_params,
77 compute_ws_time_in_force, format_expire_time,
78 },
79 parse::truncate_cl_ord_id,
80 },
81 config::KrakenExecutionClientConfig,
82 http::{
83 KrakenSpotCancelOrderBatchParams, KrakenSpotCancelOrderParamsBuilder, KrakenSpotHttpClient,
84 spot::client::KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND,
85 },
86 websocket::{
87 dispatch::{
88 self, OrderIdentity, WsDispatchState,
89 spot_orders::{OrderRequestState, PendingOperation, PendingRequest},
90 },
91 spot_v2::{
92 client::KrakenSpotWebSocketClient,
93 messages::{
94 KrakenSpotWsMessage, KrakenWsBatchAddOrder, KrakenWsBatchAddParams,
95 KrakenWsTriggerParams,
96 },
97 },
98 },
99};
100
101#[allow(dead_code)]
105#[derive(Debug)]
106pub struct KrakenSpotExecutionClient {
107 core: ExecutionClientCore,
108 clock: &'static AtomicTime,
109 config: KrakenExecutionClientConfig,
110 emitter: ExecutionEventEmitter,
111 http: KrakenSpotHttpClient,
112 ws: KrakenSpotWebSocketClient,
113 cancellation_token: CancellationToken,
114 session_tasks: TaskGroup,
115 pending_tasks: TaskGroup,
116 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
117 order_qty_cache: Arc<AtomicMap<String, Decimal>>,
118 truncated_id_map: Arc<AtomicMap<String, ClientOrderId>>,
119 ws_dispatch_state: Arc<WsDispatchState>,
120 order_request_state: Arc<OrderRequestState>,
121 order_event_rx: Option<tokio::sync::mpsc::UnboundedReceiver<OrderEventAny>>,
122}
123
124impl KrakenSpotExecutionClient {
125 pub fn new(
131 core: ExecutionClientCore,
132 config: KrakenExecutionClientConfig,
133 ) -> anyhow::Result<Self> {
134 config.validate()?;
135
136 let clock = get_atomic_clock_realtime();
137 let emitter = ExecutionEventEmitter::new(
138 clock,
139 core.trader_id,
140 core.account_id,
141 config.spot_account_type,
142 None,
143 );
144
145 let session_tasks = TaskGroup::new();
146 let cancellation_token = session_tasks.cancellation_token();
147 let pending_tasks = TaskGroup::new();
148 let pending_spawner = pending_tasks
149 .spawner()
150 .context("Kraken Spot execution task admission is closed")?;
151 let api_key = config.api_key.expose_secret().to_owned();
152 let api_secret = config.api_secret.expose_secret().to_owned();
153 let proxy_url = config
154 .proxy_url
155 .as_ref()
156 .map(|value| value.expose_secret().to_owned());
157
158 let http = KrakenSpotHttpClient::with_credentials(
159 api_key,
160 api_secret,
161 config.environment,
162 config.base_url.clone(),
163 config.timeout_secs,
164 Some(config.max_retries),
165 None,
166 None,
167 proxy_url.clone(),
168 config
169 .max_requests_per_second
170 .unwrap_or(KRAKEN_SPOT_DEFAULT_RATE_LIMIT_PER_SECOND),
171 )?;
172
173 let data_config = crate::config::KrakenDataClientConfig {
174 api_key: Some(config.api_key.clone()),
175 api_secret: Some(config.api_secret.clone()),
176 product_type: config.product_type,
177 environment: config.environment,
178 base_url: config.base_url.clone(),
179 ws_public_url: None,
180 ws_private_url: Some(config.ws_url()),
181 ws_l3_url: None,
182 validate_l3_checksum: true,
183 validate_l2_checksum: true,
184 proxy_url: config.proxy_url.clone(),
185 timeout_secs: config.timeout_secs,
186 heartbeat_interval_secs: config.heartbeat_interval_secs,
187 ws_idle_timeout_ms: 0,
190 max_requests_per_second: config.max_requests_per_second,
191 transport_backend: config.transport_backend,
192 };
193
194 let ws =
195 KrakenSpotWebSocketClient::new(data_config, cancellation_token.clone(), proxy_url)?
196 .with_socket_control(SocketControl::new(
197 core.client_id,
198 Some(*KRAKEN_VENUE),
199 "kraken-spot-user-streams",
200 ));
201
202 let ws_dispatch_state = Arc::new(WsDispatchState::new());
203 let cmd_tx_handle = ws.handler_command_handle();
206 let (order_event_tx, order_event_rx) = tokio::sync::mpsc::unbounded_channel();
207 let order_request_state = Arc::new(OrderRequestState::new(
208 cmd_tx_handle,
209 order_event_tx,
210 Arc::clone(&ws_dispatch_state),
211 ws.req_id_counter(),
212 Duration::from_secs(config.ws_request_timeout_secs),
213 core.trader_id,
214 core.account_id,
215 ws.auth_token_handle(),
216 pending_spawner,
217 clock,
218 ));
219
220 Ok(Self {
221 core,
222 clock,
223 config,
224 emitter,
225 http,
226 ws,
227 cancellation_token,
228 session_tasks,
229 pending_tasks,
230 instruments: Arc::new(AtomicMap::new()),
231 order_qty_cache: Arc::new(AtomicMap::new()),
232 truncated_id_map: Arc::new(AtomicMap::new()),
233 ws_dispatch_state,
234 order_request_state,
235 order_event_rx: Some(order_event_rx),
236 })
237 }
238
239 fn register_order_identity(&self, order: &OrderAny) {
240 if order.is_quote_quantity() {
247 return;
248 }
249 self.ws_dispatch_state.register_identity(
250 order.client_order_id(),
251 OrderIdentity {
252 strategy_id: order.strategy_id(),
253 instrument_id: order.instrument_id(),
254 order_side: order.order_side(),
255 order_type: order.order_type(),
256 quantity: order.quantity(),
257 },
258 );
259 }
260
261 #[must_use]
263 pub fn clock(&self) -> &'static AtomicTime {
264 self.clock
265 }
266
267 #[must_use]
269 pub fn emitter(&self) -> &ExecutionEventEmitter {
270 &self.emitter
271 }
272
273 fn spawn_task<F>(&self, description: &'static str, fut: F)
274 where
275 F: Future<Output = anyhow::Result<()>> + Send + 'static,
276 {
277 let future = async move {
278 if let Err(e) = fut.await {
279 log::warn!("{description} failed: {e:?}");
280 }
281 };
282
283 if let Err(e) = self.pending_tasks.spawn(future) {
284 log::warn!("Skipping Kraken Spot {description} after shutdown began: {e}");
285 }
286 }
287
288 async fn finish_tasks(&self) -> anyhow::Result<()> {
289 let (session_result, pending_result) = tokio::join!(
290 self.session_tasks
291 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
292 self.pending_tasks
293 .finish_shutdown(Duration::from_secs(2), Duration::from_secs(2)),
294 );
295 session_result.context("failed to finish Kraken Spot execution session tasks")?;
296 pending_result.context("failed to finish Kraken Spot execution command tasks")?;
297 Ok(())
298 }
299
300 async fn prepare_task_groups(&mut self) -> anyhow::Result<()> {
301 if !self.session_tasks.is_open() || !self.pending_tasks.is_open() {
302 self.session_tasks.begin_shutdown();
303 self.pending_tasks.begin_shutdown();
304 self.finish_tasks().await?;
305 self.session_tasks
306 .start_generation()
307 .context("failed to start Kraken Spot execution session task generation")?;
308 self.pending_tasks
309 .start_generation()
310 .context("failed to start Kraken Spot execution command task generation")?;
311 self.cancellation_token = self.session_tasks.cancellation_token();
312 let pending_spawner = self
313 .pending_tasks
314 .spawner()
315 .context("Kraken Spot execution task admission is closed")?;
316 self.order_request_state.reset_task_spawner(pending_spawner);
317 }
318 Ok(())
319 }
320
321 async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
322 self.http.cancel_all_requests();
323 self.pending_tasks.begin_shutdown();
324 self.order_request_state.clear();
325 let ws_result = self.ws.close().await;
326 self.session_tasks.begin_shutdown();
327 let tasks_result = self.finish_tasks().await;
328 self.core.set_disconnected();
329 tasks_result?;
330 Ok(ws_result?)
331 }
332
333 fn submit_single_order(
334 &self,
335 command: &SubmitOrder,
336 order: &OrderAny,
337 task_name: &'static str,
338 leverage: Option<u16>,
339 ) {
340 if order.is_closed() {
341 log::warn!(
342 "Cannot submit closed order: client_order_id={}",
343 order.client_order_id()
344 );
345 return;
346 }
347
348 let order_type = order.order_type();
349 let time_in_force = order.time_in_force();
350
351 if time_in_force == TimeInForce::Fok && order_type != OrderType::Limit {
352 self.emitter.emit_order_denied(
353 order,
354 "FOK time in force only supported for LIMIT orders on Kraken Spot",
355 );
356 return;
357 }
358
359 if matches!(
360 order_type,
361 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
362 ) && let Some(offset_type) = order.trailing_offset_type()
363 && offset_type != TrailingOffsetType::Price
364 {
365 self.emitter.emit_order_denied(
366 order,
367 &format!(
368 "Kraken Spot only supports Price trailing offset type: received {offset_type:?}"
369 ),
370 );
371 return;
372 }
373
374 if order.is_reduce_only() && self.config.spot_account_type == AccountType::Cash {
375 self.emitter
376 .emit_order_denied(order, "reduce_only requires spot_account_type=Margin");
377 return;
378 }
379
380 let client_order_id = order.client_order_id();
381
382 log::debug!("OrderSubmitted: client_order_id={client_order_id}");
383 self.register_order_identity(order);
384 self.emitter.emit_order_submitted(order);
385
386 let kraken_cl_ord_id = truncate_cl_ord_id(&client_order_id);
387
388 if !order.is_quote_quantity() {
389 self.order_qty_cache
390 .insert(kraken_cl_ord_id.clone(), order.quantity().as_decimal());
391 }
392
393 if kraken_cl_ord_id != client_order_id.as_str() {
394 self.truncated_id_map
395 .insert(kraken_cl_ord_id, client_order_id);
396 }
397
398 let use_ws_trade = resolve_use_ws_trade(command.params.as_ref(), self.config.use_ws_trade);
405 if use_ws_trade && self.ws.is_active() && !order.is_quote_quantity() {
406 match self.submit_via_ws(command, order, leverage) {
407 Ok(()) => return,
408 Err(e) => log::warn!("Kraken WS submit_order fallback to REST: {e}"),
409 }
410 }
411
412 self.submit_via_rest(order, task_name, leverage);
413 }
414
415 fn submit_via_rest(&self, order: &OrderAny, task_name: &'static str, leverage: Option<u16>) {
416 let account_id = self.core.account_id;
417 let client_order_id = order.client_order_id();
418 let strategy_id = order.strategy_id();
419 let instrument_id = order.instrument_id();
420 let order_side = order.order_side();
421 let order_type = order.order_type();
422 let quantity = order.quantity();
423 let time_in_force = order.time_in_force();
424 let expire_time = order.expire_time();
425 let price = order.price();
426 let trigger_price = order.trigger_price();
427 let trigger_type = order.trigger_type();
428 let trailing_offset = order.trailing_offset();
429 let limit_offset = order.limit_offset();
430 let is_reduce_only = order.is_reduce_only();
431 let is_post_only = order.is_post_only();
432 let is_quote_quantity = order.is_quote_quantity();
433 let display_qty = order.display_qty();
434
435 let http = self.http.clone();
436 let emitter = self.emitter.clone();
437 let clock = self.clock;
438 let dispatch_state = self.ws_dispatch_state.clone();
439 let spot_account_type = self.config.spot_account_type;
440
441 self.spawn_task(task_name, async move {
442 let result = http
443 .submit_order(
444 account_id,
445 instrument_id,
446 client_order_id,
447 order_side,
448 order_type,
449 quantity,
450 time_in_force,
451 expire_time,
452 price,
453 trigger_price,
454 trigger_type,
455 trailing_offset,
456 limit_offset,
457 is_reduce_only,
458 is_post_only,
459 is_quote_quantity,
460 display_qty,
461 leverage,
462 spot_account_type,
463 )
464 .await;
465
466 match result {
467 Ok(_) => {}
468 Err(e) => match command_failure_from_submit_error(&e) {
469 CommandFailure::Ambiguous(reason) => {
470 log::warn!(
471 "{task_name} outcome is ambiguous for client_order_id={client_order_id}: {reason}"
472 );
473 }
474 CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
475 let ts_event = clock.get_time_ns();
476 let error_msg = format!("{task_name} error: {reason}");
477 let due_post_only = error_msg.contains("POST_ONLY_REJECTED")
478 || error_msg.contains(KRAKEN_SPOT_POST_ONLY_ERROR);
479 dispatch_state.cleanup_terminal(&client_order_id);
480 emitter.emit_order_rejected_event(
481 strategy_id,
482 instrument_id,
483 client_order_id,
484 &error_msg,
485 ts_event,
486 due_post_only,
487 );
488 }
489 },
490 }
491
492 Ok(())
493 });
494 }
495 fn submit_via_ws(
496 &self,
497 command: &SubmitOrder,
498 order: &OrderAny,
499 leverage: Option<u16>,
500 ) -> anyhow::Result<()> {
501 let token = self
502 .ws
503 .auth_token_blocking()
504 .ok_or_else(|| anyhow::anyhow!("missing WS auth token"))?;
505
506 let params = build_add_order_params(command, order, token, leverage)?;
507 let identity = PendingRequest {
508 operation: PendingOperation::Submit,
509 client_order_ids: vec![command.client_order_id],
510 venue_order_ids: vec![None],
511 ts_sent_ns: 0,
512 new_quantity: None,
513 new_price: None,
514 new_trigger_price: None,
515 };
516 self.order_request_state
517 .submit(params, identity, self.clock.get_time_ns().as_u64())?;
518 Ok(())
519 }
520
521 fn cancel_single_order(&self, cmd: &CancelOrder) {
522 let use_ws_trade = resolve_use_ws_trade(cmd.params.as_ref(), self.config.use_ws_trade);
523 if use_ws_trade && self.ws.is_active() {
524 match self.cancel_via_ws(cmd) {
525 Ok(()) => return,
526 Err(e) => log::warn!("Kraken WS cancel_order fallback to REST: {e}"),
527 }
528 }
529
530 self.cancel_via_rest(cmd);
531 }
532
533 fn cancel_via_rest(&self, cmd: &CancelOrder) {
534 let account_id = self.core.account_id;
535 let client_order_id = cmd.client_order_id;
536 let venue_order_id = cmd.venue_order_id;
537 let strategy_id = cmd.strategy_id;
538 let instrument_id = cmd.instrument_id;
539
540 log::debug!(
541 "Canceling order: venue_order_id={venue_order_id:?}, client_order_id={client_order_id}"
542 );
543
544 let http = self.http.clone();
545 let emitter = self.emitter.clone();
546 let clock = self.clock;
547
548 self.spawn_task("cancel_order", async move {
549 if let Err(failure) = cancel_order_for_spot(
550 &http,
551 account_id,
552 instrument_id,
553 Some(client_order_id),
554 venue_order_id,
555 )
556 .await
557 {
558 handle_cancel_failure(
559 &emitter,
560 clock,
561 strategy_id,
562 instrument_id,
563 client_order_id,
564 venue_order_id,
565 failure,
566 );
567 }
568 Ok(())
569 });
570 }
571
572 fn cancel_via_ws(&self, cmd: &CancelOrder) -> anyhow::Result<()> {
573 let token = self
574 .ws
575 .auth_token_blocking()
576 .ok_or_else(|| anyhow::anyhow!("missing WS auth token"))?;
577
578 let params = build_cancel_order_params(cmd, token);
579 let identity = PendingRequest {
580 operation: PendingOperation::Cancel,
581 client_order_ids: vec![cmd.client_order_id],
582 venue_order_ids: vec![cmd.venue_order_id],
583 ts_sent_ns: 0,
584 new_quantity: None,
585 new_price: None,
586 new_trigger_price: None,
587 };
588 self.order_request_state
589 .cancel(params, identity, self.clock.get_time_ns().as_u64())?;
590 Ok(())
591 }
592
593 fn spawn_message_handler(&mut self) -> anyhow::Result<()> {
594 let stream = self.ws.stream().map_err(|e| anyhow::anyhow!("{e}"))?;
595 let emitter = self.emitter.clone();
596 let instruments = self.instruments.clone();
597 let order_qty_cache = self.order_qty_cache.clone();
598 let truncated_id_map = self.truncated_id_map.clone();
599 let dispatch_state = self.ws_dispatch_state.clone();
600 let order_request_state = self.order_request_state.clone();
601 let account_id = self.core.account_id;
602 let clock = self.clock;
603 let cancellation_token = self.cancellation_token.clone();
604
605 let future = async move {
606 tokio::pin!(stream);
607
608 loop {
609 tokio::select! {
610 () = cancellation_token.cancelled() => {
611 log::debug!("Spot execution message handler cancelled");
612 break;
613 }
614 msg = stream.next() => {
615 match msg {
616 Some(ws_msg) => {
617 Self::handle_ws_message(
618 ws_msg,
619 &emitter,
620 &dispatch_state,
621 &order_request_state,
622 &instruments,
623 &order_qty_cache,
624 &truncated_id_map,
625 account_id,
626 clock,
627 );
628 }
629 None => {
630 log::debug!("Spot execution WebSocket stream ended");
631 break;
632 }
633 }
634 }
635 }
636 }
637 };
638
639 self.session_tasks
640 .spawn(future)
641 .context("failed to register Kraken Spot execution stream task")?;
642
643 let event_rx = self.order_event_rx.take();
644
645 if let Some(mut event_rx) = event_rx {
646 let emitter = self.emitter.clone();
647 let cancellation_token = self.cancellation_token.clone();
648
649 let future = async move {
650 loop {
651 tokio::select! {
652 () = cancellation_token.cancelled() => {
653 log::debug!("Spot execution order-event forwarder cancelled");
654 break;
655 }
656 event = event_rx.recv() => {
657 match event {
658 Some(event) => emitter.send_order_event(event),
659 None => {
660 log::debug!("Spot execution order-event channel closed");
661 break;
662 }
663 }
664 }
665 }
666 }
667 };
668 self.session_tasks
669 .spawn(future)
670 .context("failed to register Kraken Spot order event task")?;
671 }
672
673 Ok(())
674 }
675
676 fn modify_single_order(&self, cmd: &ModifyOrder) {
677 let use_ws_trade = resolve_use_ws_trade(cmd.params.as_ref(), self.config.use_ws_trade);
678 if use_ws_trade && self.ws.is_active() {
679 match self.amend_via_ws(cmd) {
680 Ok(()) => return,
681 Err(e) => log::warn!("Kraken WS amend_order fallback to REST: {e}"),
682 }
683 }
684
685 self.amend_via_rest(cmd);
686 }
687
688 fn amend_via_rest(&self, cmd: &ModifyOrder) {
689 let client_order_id = cmd.client_order_id;
690 let venue_order_id = cmd.venue_order_id;
691 let strategy_id = cmd.strategy_id;
692 let instrument_id = cmd.instrument_id;
693 let quantity = cmd.quantity;
694 let price = cmd.price;
695
696 log::debug!(
697 "Modifying order: venue_order_id={venue_order_id:?}, client_order_id={client_order_id}"
698 );
699
700 let http = self.http.clone();
701 let emitter = self.emitter.clone();
702 let clock = self.clock;
703
704 self.spawn_task("modify_order", async move {
705 match http
706 .modify_order(
707 instrument_id,
708 Some(client_order_id),
709 venue_order_id,
710 quantity,
711 price,
712 None,
713 )
714 .await
715 {
716 Ok(_) => {}
717 Err(e) => match command_failure_from_modify_error(&e) {
718 CommandFailure::Ambiguous(reason) => {
719 log::warn!(
720 "modify_order outcome is ambiguous for client_order_id={client_order_id}: {reason}"
721 );
722 }
723 CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
724 let ts_event = clock.get_time_ns();
725 emitter.emit_order_modify_rejected_event(
726 strategy_id,
727 instrument_id,
728 client_order_id,
729 venue_order_id,
730 &format!("modify-order error: {reason}"),
731 ts_event,
732 );
733 }
734 },
735 }
736 Ok(())
737 });
738 }
739
740 fn amend_via_ws(&self, cmd: &ModifyOrder) -> anyhow::Result<()> {
741 let token = self
742 .ws
743 .auth_token_blocking()
744 .ok_or_else(|| anyhow::anyhow!("missing WS auth token"))?;
745
746 let params = build_amend_order_params(cmd, token);
747 let identity = PendingRequest {
748 operation: PendingOperation::Amend,
749 client_order_ids: vec![cmd.client_order_id],
750 venue_order_ids: vec![cmd.venue_order_id],
751 ts_sent_ns: 0,
752 new_quantity: cmd.quantity,
753 new_price: cmd.price,
754 new_trigger_price: cmd.trigger_price,
755 };
756 self.order_request_state
757 .amend(params, identity, self.clock.get_time_ns().as_u64())?;
758 Ok(())
759 }
760
761 async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
763 let account_id = self.core.account_id;
764
765 if self.core.cache().account(&account_id).is_some() {
766 log::info!("Account {account_id} registered");
767 return Ok(());
768 }
769
770 let start = Instant::now();
771 let timeout = Duration::from_secs_f64(timeout_secs);
772 let interval = Duration::from_millis(10);
773
774 loop {
775 tokio::time::sleep(interval).await;
776
777 if self.core.cache().account(&account_id).is_some() {
778 log::info!("Account {account_id} registered");
779 return Ok(());
780 }
781
782 if start.elapsed() >= timeout {
783 anyhow::bail!(
784 "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
785 );
786 }
787 }
788 }
789
790 #[expect(clippy::too_many_arguments)]
791 fn handle_ws_message(
792 msg: KrakenSpotWsMessage,
793 emitter: &ExecutionEventEmitter,
794 dispatch_state: &Arc<WsDispatchState>,
795 order_request_state: &Arc<OrderRequestState>,
796 instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
797 order_qty_cache: &Arc<AtomicMap<String, Decimal>>,
798 truncated_id_map: &Arc<AtomicMap<String, ClientOrderId>>,
799 account_id: AccountId,
800 clock: &'static AtomicTime,
801 ) {
802 match msg {
803 KrakenSpotWsMessage::Execution(executions) => {
804 let ts_init = clock.get_time_ns();
805
806 for exec in &executions {
807 dispatch::spot::execution(
808 exec,
809 dispatch_state,
810 emitter,
811 instruments,
812 truncated_id_map,
813 order_qty_cache,
814 account_id,
815 ts_init,
816 );
817 }
818 }
819 KrakenSpotWsMessage::OrderResponse(response) => {
820 let ts_event = clock.get_time_ns().as_u64();
821 order_request_state.handle_response(&response, ts_event);
822 }
823 KrakenSpotWsMessage::Reconnected => {
824 log::info!("Spot execution WebSocket reconnected");
825 }
826 KrakenSpotWsMessage::Ticker(_)
827 | KrakenSpotWsMessage::Trade(_)
828 | KrakenSpotWsMessage::Book { .. }
829 | KrakenSpotWsMessage::Ohlc(_)
830 | KrakenSpotWsMessage::L3Snapshot(_)
831 | KrakenSpotWsMessage::L3Update(_)
832 | KrakenSpotWsMessage::SubscriptionAck { .. } => {}
833 }
834 }
835
836 fn batch_add_via_rest(
837 &self,
838 order_tuples: Vec<BatchOrderTuple>,
839 order_meta: Vec<(StrategyId, InstrumentId, ClientOrderId)>,
840 ) {
841 let http = self.http.clone();
842 let emitter = self.emitter.clone();
843 let clock = self.clock;
844 let dispatch_state = self.ws_dispatch_state.clone();
845 let spot_account_type = self.config.spot_account_type;
846
847 self.spawn_task("submit_order_list", async move {
848 let results = http
849 .send_order_batches(order_tuples, spot_account_type)
850 .await;
851
852 for (result, (strategy_id, instrument_id, client_order_id)) in
853 results.into_iter().zip(&order_meta)
854 {
855 let outcome = match result {
856 Ok(item) => command_failure_from_spot_batch_item(item),
857 Err(e) => Err(command_failure_from_spot_batch_error(&e)),
858 };
859
860 match outcome {
861 Ok(()) => {}
862 Err(CommandFailure::Ambiguous(reason)) => {
863 log::warn!(
864 "submit_order_list outcome is ambiguous for client_order_id={client_order_id}: {reason}"
865 );
866 }
867 Err(
868 CommandFailure::NotSent(reason)
869 | CommandFailure::VenueRejected(reason),
870 ) => {
871 let ts_event = clock.get_time_ns();
872 let due_post_only = reason.contains("POST_ONLY_REJECTED")
873 || reason.contains(KRAKEN_SPOT_POST_ONLY_ERROR);
874 dispatch_state.cleanup_terminal(client_order_id);
875 emitter.emit_order_rejected_event(
876 *strategy_id,
877 *instrument_id,
878 *client_order_id,
879 &format!("submit_order_list batch item rejected: {reason}"),
880 ts_event,
881 due_post_only,
882 );
883 }
884 }
885 }
886 Ok(())
887 });
888 }
889
890 fn batch_add_via_ws(&self, orders: &[OrderAny], leverage: Option<u16>) -> anyhow::Result<()> {
891 let token = self
892 .ws
893 .auth_token_blocking()
894 .ok_or_else(|| anyhow::anyhow!("missing WS auth token"))?;
895
896 let first = orders
897 .first()
898 .ok_or_else(|| anyhow::anyhow!("batch_add requires at least one order"))?;
899 let symbol = first.instrument_id().symbol.inner().to_string();
900
901 let mut batch_orders = Vec::with_capacity(orders.len());
902 let mut client_order_ids = Vec::with_capacity(orders.len());
903 for order in orders {
904 batch_orders.push(build_batch_order(order, leverage)?);
905 client_order_ids.push(order.client_order_id());
906 }
907 let venue_order_ids = vec![None; orders.len()];
908
909 let params = KrakenWsBatchAddParams {
910 symbol,
911 orders: batch_orders,
912 token,
913 };
914 let identity = PendingRequest {
915 operation: PendingOperation::BatchAdd,
916 client_order_ids,
917 venue_order_ids,
918 ts_sent_ns: 0,
919 new_quantity: None,
920 new_price: None,
921 new_trigger_price: None,
922 };
923 self.order_request_state
924 .batch_add(params, identity, self.clock.get_time_ns().as_u64())?;
925 Ok(())
926 }
927}
928
929type BatchOrderTuple = (
930 InstrumentId,
931 ClientOrderId,
932 OrderSide,
933 OrderType,
934 Quantity,
935 TimeInForce,
936 Option<UnixNanos>,
937 Option<Price>,
938 Option<Price>,
939 Option<TriggerType>,
940 Option<Decimal>,
941 Option<Decimal>,
942 bool,
943 bool,
944 bool,
945 Option<Quantity>,
946 Option<u16>,
947);
948
949fn build_batch_order(
950 order: &OrderAny,
951 leverage: Option<u16>,
952) -> anyhow::Result<KrakenWsBatchAddOrder> {
953 let order_type = order.order_type();
954 let side = match order.order_side() {
955 OrderSide::Buy => KrakenOrderSide::Buy,
956 OrderSide::Sell => KrakenOrderSide::Sell,
957 };
958
959 if matches!(
960 order_type,
961 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
962 ) {
963 anyhow::bail!(
964 "Trailing stop orders are not yet supported on the Kraken WS batch path; use REST",
965 );
966 }
967
968 if order.display_qty().is_some() {
969 anyhow::bail!(
970 "Iceberg (display_qty) orders are not supported on the Kraken WS batch path; use REST",
971 );
972 }
973
974 let kraken_order_type = match order_type {
975 OrderType::Market => KrakenOrderType::Market,
976 OrderType::Limit => KrakenOrderType::Limit,
977 OrderType::StopMarket => KrakenOrderType::StopLoss,
978 OrderType::StopLimit => KrakenOrderType::StopLossLimit,
979 OrderType::MarketIfTouched => KrakenOrderType::TakeProfit,
980 OrderType::LimitIfTouched => KrakenOrderType::TakeProfitLimit,
981 ty => anyhow::bail!("Unsupported order type for Kraken WS batch: {ty:?}"),
982 };
983
984 let is_limit_order = matches!(
985 order_type,
986 OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
987 );
988
989 if is_limit_order && order.price().is_none() {
990 anyhow::bail!("limit_price is required for batch order type {order_type:?}");
991 }
992
993 let ws_tif =
994 compute_ws_time_in_force(is_limit_order, order.time_in_force(), order.expire_time())?;
995 let expire_time = match (ws_tif, order.expire_time()) {
996 (Some(KrakenTimeInForce::GoodTilDate), Some(ts)) => Some(format_expire_time(ts)),
997 _ => None,
998 };
999
1000 let is_conditional = matches!(
1001 order_type,
1002 OrderType::StopMarket
1003 | OrderType::StopLimit
1004 | OrderType::MarketIfTouched
1005 | OrderType::LimitIfTouched
1006 );
1007
1008 let trigger = if is_conditional {
1009 let trigger_ref = match order.trigger_type() {
1010 Some(TriggerType::IndexPrice) => KrakenSpotTrigger::Index,
1011 Some(TriggerType::LastPrice | TriggerType::Default) | None => KrakenSpotTrigger::Last,
1012 Some(other) => anyhow::bail!(
1013 "Unsupported trigger type for Kraken Spot WS batch: {other:?} (only LastPrice and IndexPrice supported)",
1014 ),
1015 };
1016 order.trigger_price().map(|tp| KrakenWsTriggerParams {
1017 reference: trigger_ref,
1018 price: tp.as_decimal(),
1019 price_type: None,
1020 })
1021 } else {
1022 None
1023 };
1024
1025 if is_conditional && trigger.is_none() {
1026 anyhow::bail!(
1027 "Conditional order type {order_type:?} requires trigger_price for Kraken WS batch",
1028 );
1029 }
1030
1031 Ok(KrakenWsBatchAddOrder {
1032 order_type: kraken_order_type,
1033 side,
1034 order_qty: order.quantity().as_decimal(),
1035 limit_price: order.price().map(|p| p.as_decimal()),
1036 cl_ord_id: Some(truncate_cl_ord_id(&order.client_order_id())),
1037 time_in_force: ws_tif,
1038 expire_time,
1039 post_only: order.is_post_only().then_some(true),
1040 reduce_only: order.is_reduce_only().then_some(true),
1041 leverage,
1042 trigger,
1043 })
1044}
1045
1046#[async_trait(?Send)]
1047impl ExecutionClient for KrakenSpotExecutionClient {
1048 fn is_connected(&self) -> bool {
1049 self.core.is_connected()
1050 }
1051
1052 fn client_id(&self) -> ClientId {
1053 self.core.client_id
1054 }
1055
1056 fn account_id(&self) -> AccountId {
1057 self.core.account_id
1058 }
1059
1060 fn venue(&self) -> Venue {
1061 *KRAKEN_VENUE
1062 }
1063
1064 fn oms_type(&self) -> OmsType {
1065 self.core.oms_type
1066 }
1067
1068 fn provides_bulk_position_coverage(&self, instrument_id: InstrumentId) -> bool {
1069 self.http.covers_bulk_position_reports(
1072 instrument_id,
1073 self.config.spot_account_type,
1074 self.config.use_spot_position_reports,
1075 Ustr::from(self.config.spot_positions_quote_currency.as_str()),
1076 )
1077 }
1078
1079 fn get_account(&self) -> Option<AccountAny> {
1080 self.core.cache().account_owned(&self.core.account_id)
1081 }
1082
1083 fn generate_account_state(
1084 &self,
1085 balances: Vec<AccountBalance>,
1086 margins: Vec<MarginBalance>,
1087 reported: bool,
1088 ts_event: UnixNanos,
1089 info: Option<Params>,
1090 ) -> anyhow::Result<()> {
1091 self.emitter
1092 .emit_account_state(balances, margins, reported, ts_event, info);
1093 Ok(())
1094 }
1095
1096 fn start(&mut self) -> anyhow::Result<()> {
1097 if self.core.is_started() {
1098 return Ok(());
1099 }
1100
1101 self.emitter.set_sender(get_exec_event_sender());
1102 self.core.set_started();
1103
1104 log::info!(
1105 "Started: client_id={}, account_id={}, product_type=Spot, environment={:?}",
1106 self.core.client_id,
1107 self.core.account_id,
1108 self.config.environment
1109 );
1110 Ok(())
1111 }
1112
1113 fn stop(&mut self) -> anyhow::Result<()> {
1114 if self.core.is_stopped() {
1115 return Ok(());
1116 }
1117
1118 self.http.cancel_all_requests();
1119 self.session_tasks.begin_shutdown();
1120 self.pending_tasks.begin_shutdown();
1121 self.ws.begin_shutdown();
1122 self.core.set_stopped();
1123 self.core.set_disconnected();
1124 log::info!("Stopped: client_id={}", self.core.client_id);
1125 Ok(())
1126 }
1127
1128 async fn connect(&mut self) -> anyhow::Result<()> {
1129 if self.core.is_connected() && self.session_tasks.is_open() && self.pending_tasks.is_open()
1130 {
1131 return Ok(());
1132 }
1133
1134 self.http.reset_cancellation_token();
1135 self.prepare_task_groups().await?;
1136
1137 if !self.core.instruments_initialized() {
1138 let instruments = self
1139 .http
1140 .request_instruments(None)
1141 .await
1142 .context("Failed to load Kraken spot instruments")?;
1143 log::debug!("Loaded {} Spot instruments", instruments.len());
1144 self.http.cache_instruments(&instruments);
1145 self.core.set_instruments_initialized();
1146 }
1147
1148 let session_result = async {
1149 self.ws
1150 .connect()
1151 .await
1152 .context("Failed to connect spot WebSocket")?;
1153 self.ws
1154 .wait_until_active(10.0)
1155 .await
1156 .context("Spot WebSocket failed to become active")?;
1157
1158 self.ws
1159 .authenticate()
1160 .await
1161 .context("Failed to authenticate spot WebSocket")?;
1162
1163 let account_state = self
1168 .http
1169 .request_account_state(
1170 self.core.account_id,
1171 self.config.spot_account_type,
1172 self.config.margin_balance_asset.as_deref(),
1173 )
1174 .await
1175 .context("Failed to request Kraken account state")?;
1176
1177 if !account_state.balances.is_empty() {
1178 log::debug!(
1179 "Received account state with {} balance(s)",
1180 account_state.balances.len()
1181 );
1182 }
1183
1184 self.emitter.send_account_state(account_state);
1185 self.await_account_registered(30.0).await?;
1186
1187 self.spawn_message_handler()?;
1188
1189 self.instruments.rcu(|m| {
1190 for instrument in self.http.instruments_cache.load().values() {
1191 m.insert(instrument.id(), instrument.clone());
1192 }
1193 });
1194
1195 self.ws
1196 .subscribe_executions(false, false)
1197 .await
1198 .context("Failed to subscribe to executions")?;
1199
1200 log::debug!("Spot WebSocket authenticated and subscribed to executions");
1201
1202 Ok::<(), anyhow::Error>(())
1203 }
1204 .await;
1205
1206 if let Err(e) = session_result {
1207 if let Err(teardown_error) = self.teardown_partial_connect().await {
1208 return Err(e.context(format!(
1209 "Kraken Spot execution startup teardown failed: {teardown_error}"
1210 )));
1211 }
1212 return Err(e);
1213 }
1214
1215 self.core.set_connected();
1216 log::info!("Connected: client_id={}", self.core.client_id);
1217 Ok(())
1218 }
1219
1220 async fn disconnect(&mut self) -> anyhow::Result<()> {
1221 self.teardown_partial_connect().await?;
1222 log::info!("Disconnected: client_id={}", self.core.client_id);
1223 Ok(())
1224 }
1225
1226 async fn generate_order_status_report(
1227 &self,
1228 cmd: &GenerateOrderStatusReport,
1229 ) -> anyhow::Result<Option<OrderStatusReport>> {
1230 log::debug!(
1231 "Generating order status report: venue_order_id={:?}, client_order_id={:?}",
1232 cmd.venue_order_id,
1233 cmd.client_order_id
1234 );
1235
1236 let account_id = self.core.account_id;
1237 let (reports, coverage) = self
1240 .http
1241 .request_order_status_reports_checked(account_id, cmd.instrument_id, None, None, false)
1242 .await?;
1243
1244 let matched = reports.into_iter().find(|r| {
1247 cmd.venue_order_id
1248 .is_some_and(|id| r.venue_order_id.as_str() == id.as_str())
1249 || cmd.client_order_id.is_some_and(|id| {
1250 r.client_order_id
1251 .as_ref()
1252 .is_some_and(|r_id| r_id.as_str() == truncate_cl_ord_id(&id))
1253 })
1254 });
1255
1256 if matched.is_some() {
1257 return Ok(matched);
1258 }
1259
1260 let cached = cached_order_for_status_command(&self.core.cache(), cmd);
1263 let venue_order_id = cmd
1264 .venue_order_id
1265 .or_else(|| cached.as_ref().and_then(|order| order.venue_order_id()));
1266
1267 absent_unless_unread(
1268 &coverage,
1269 venue_order_id,
1270 cached.as_ref().map(earliest_venue_time),
1271 &status_command_label(cmd, venue_order_id),
1272 )
1273 }
1274
1275 async fn generate_order_status_reports(
1276 &self,
1277 cmd: &GenerateOrderStatusReports,
1278 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1279 log::debug!(
1280 "Generating order status reports: instrument_id={:?}, open_only={}",
1281 cmd.instrument_id,
1282 cmd.open_only
1283 );
1284
1285 let account_id = self.core.account_id;
1286 let start = cmd.start.map(Timestamp::from);
1287 let end = cmd.end.map(Timestamp::from);
1288 self.http
1289 .request_order_status_reports(account_id, cmd.instrument_id, start, end, cmd.open_only)
1290 .await
1291 }
1292
1293 async fn generate_fill_reports(
1294 &self,
1295 cmd: GenerateFillReports,
1296 ) -> anyhow::Result<Vec<FillReport>> {
1297 log::debug!(
1298 "Generating fill reports: instrument_id={:?}",
1299 cmd.instrument_id
1300 );
1301
1302 let account_id = self.core.account_id;
1303 let start = cmd.start.map(Timestamp::from);
1304 let end = cmd.end.map(Timestamp::from);
1305 self.http
1306 .request_fill_reports(account_id, cmd.instrument_id, start, end)
1307 .await
1308 }
1309
1310 async fn generate_position_status_reports(
1311 &self,
1312 cmd: &GeneratePositionStatusReports,
1313 ) -> anyhow::Result<Vec<PositionStatusReport>> {
1314 log::debug!(
1315 "Generating position status reports: instrument_id={:?}",
1316 cmd.instrument_id
1317 );
1318
1319 let account_id = self.core.account_id;
1320 self.http
1321 .request_position_status_reports(
1322 account_id,
1323 cmd.instrument_id,
1324 self.config.spot_account_type,
1325 self.config.use_spot_position_reports,
1326 Ustr::from(self.config.spot_positions_quote_currency.as_str()),
1327 )
1328 .await
1329 }
1330
1331 async fn generate_mass_status(
1332 &self,
1333 lookback_mins: Option<u64>,
1334 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
1335 log::debug!("Generating mass status: lookback_mins={lookback_mins:?}");
1336
1337 let ts_init = self.clock.get_time_ns();
1338 let lookback_start = lookback_mins.map(|mins| {
1341 let nanos = mins.saturating_mul(60).saturating_mul(1_000_000_000);
1342 ts_init.saturating_sub(DurationNanos::new(nanos))
1343 });
1344 let start = lookback_start.map(Timestamp::from);
1345
1346 let account_id = self.core.account_id;
1347 let (order_reports, orders_coverage) = self
1351 .http
1352 .request_order_status_reports_checked(account_id, None, start, None, false)
1353 .await?;
1354 let orders_complete = orders_coverage.is_complete();
1355 let (fill_reports, fills_complete) = self
1356 .http
1357 .request_fill_reports_checked(account_id, None, start, None)
1358 .await?;
1359 let position_reports = self
1360 .http
1361 .request_position_status_reports(
1362 account_id,
1363 None,
1364 self.config.spot_account_type,
1365 self.config.use_spot_position_reports,
1366 Ustr::from(self.config.spot_positions_quote_currency.as_str()),
1367 )
1368 .await?;
1369
1370 let mut mass_status = ExecutionMassStatus::new(
1371 self.core.client_id,
1372 self.core.account_id,
1373 *KRAKEN_VENUE,
1374 ts_init,
1375 None,
1376 );
1377 mass_status.add_order_reports(order_reports);
1378 mass_status.add_fill_reports(fill_reports);
1379 mass_status.add_position_reports(position_reports);
1380 mass_status.set_report_window(lookback_start, orders_complete && fills_complete);
1383
1384 Ok(Some(mass_status))
1385 }
1386
1387 fn query_account(&self, cmd: QueryAccount) -> anyhow::Result<()> {
1388 log::debug!("Querying account: {cmd}");
1389
1390 let account_id = self.core.account_id;
1391 let http = self.http.clone();
1392 let emitter = self.emitter.clone();
1393
1394 let spot_account_type = self.config.spot_account_type;
1395 let margin_balance_asset = self.config.margin_balance_asset.clone();
1396 self.spawn_task("query_account", async move {
1397 let account_state = http
1398 .request_account_state(
1399 account_id,
1400 spot_account_type,
1401 margin_balance_asset.as_deref(),
1402 )
1403 .await?;
1404 emitter.emit_account_state(
1405 account_state.balances.clone(),
1406 account_state.margins.clone(),
1407 account_state.is_reported,
1408 account_state.ts_event,
1409 account_state.info,
1410 );
1411 Ok(())
1412 });
1413
1414 Ok(())
1415 }
1416
1417 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
1418 log::debug!("Querying order: {cmd}");
1419
1420 let venue_order_id = cmd
1421 .venue_order_id
1422 .context("venue_order_id required for query_order")?;
1423 let account_id = self.core.account_id;
1424 let instrument_id = cmd.instrument_id;
1425 let http = self.http.clone();
1426 let emitter = self.emitter.clone();
1427
1428 self.spawn_task("query_order", async move {
1429 let reports = http
1432 .request_order_status_reports(account_id, Some(instrument_id), None, None, true)
1433 .await
1434 .context("Failed to query order")?;
1435
1436 match reports
1437 .into_iter()
1438 .find(|r| r.venue_order_id == venue_order_id)
1439 {
1440 Some(report) => emitter.send_order_status_report(report),
1441 None => log::warn!(
1442 "No open order {venue_order_id} found for {instrument_id}; no report emitted"
1443 ),
1444 }
1445 Ok(())
1446 });
1447
1448 Ok(())
1449 }
1450
1451 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
1452 let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
1453 let leverage = match resolve_leverage(cmd.params.as_ref(), self.config.default_leverage) {
1454 Ok(lev) => lev,
1455 Err(reason) => {
1456 self.emitter.emit_order_denied(&order, &reason);
1457 return Ok(());
1458 }
1459 };
1460 self.submit_single_order(&cmd, &order, "submit_order", leverage);
1461 Ok(())
1462 }
1463
1464 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
1465 let orders = self.core.get_orders_for_list(&cmd.order_list)?;
1466
1467 log::debug!(
1468 "Submitting order list: order_list_id={}, count={}",
1469 cmd.order_list.id,
1470 orders.len()
1471 );
1472
1473 let leverage = match resolve_leverage(cmd.params.as_ref(), self.config.default_leverage) {
1474 Ok(lev) => lev,
1475 Err(reason) => {
1476 for order in &orders {
1477 self.emitter.emit_order_denied(order, &reason);
1478 }
1479 return Ok(());
1480 }
1481 };
1482
1483 let mut order_tuples = Vec::with_capacity(orders.len());
1484 let mut order_meta = Vec::with_capacity(orders.len());
1485 let mut prepared_orders = Vec::with_capacity(orders.len());
1486
1487 for order in &orders {
1488 if order.is_closed() {
1489 log::warn!(
1490 "Cannot submit closed order: client_order_id={}",
1491 order.client_order_id()
1492 );
1493 continue;
1494 }
1495
1496 if order.time_in_force() == TimeInForce::Fok && order.order_type() != OrderType::Limit {
1497 self.emitter.emit_order_denied(
1498 order,
1499 "FOK time in force only supported for LIMIT orders on Kraken Spot",
1500 );
1501 continue;
1502 }
1503
1504 if matches!(
1505 order.order_type(),
1506 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
1507 ) && let Some(offset_type) = order.trailing_offset_type()
1508 && offset_type != TrailingOffsetType::Price
1509 {
1510 self.emitter.emit_order_denied(
1511 order,
1512 &format!(
1513 "Kraken Spot only supports Price trailing offset type: received {offset_type:?}"
1514 ),
1515 );
1516 continue;
1517 }
1518
1519 if order.is_reduce_only() && self.config.spot_account_type == AccountType::Cash {
1520 self.emitter
1521 .emit_order_denied(order, "reduce_only requires spot_account_type=Margin");
1522 continue;
1523 }
1524
1525 let client_order_id = order.client_order_id();
1526 let kraken_cl_ord_id = truncate_cl_ord_id(&client_order_id);
1527
1528 self.register_order_identity(order);
1529 self.emitter.emit_order_submitted(order);
1530
1531 if !order.is_quote_quantity() {
1532 self.order_qty_cache
1533 .insert(kraken_cl_ord_id.clone(), order.quantity().as_decimal());
1534 }
1535
1536 if kraken_cl_ord_id != client_order_id.as_str() {
1537 self.truncated_id_map
1538 .insert(kraken_cl_ord_id, client_order_id);
1539 }
1540 order_tuples.push((
1541 order.instrument_id(),
1542 client_order_id,
1543 order.order_side(),
1544 order.order_type(),
1545 order.quantity(),
1546 order.time_in_force(),
1547 order.expire_time(),
1548 order.price(),
1549 order.trigger_price(),
1550 order.trigger_type(),
1551 order.trailing_offset(),
1552 order.limit_offset(),
1553 order.is_reduce_only(),
1554 order.is_post_only(),
1555 order.is_quote_quantity(),
1556 order.display_qty(),
1557 leverage,
1558 ));
1559
1560 order_meta.push((order.strategy_id(), order.instrument_id(), client_order_id));
1561 prepared_orders.push(order.clone());
1562 }
1563
1564 if order_tuples.is_empty() {
1565 return Ok(());
1566 }
1567
1568 let use_ws_trade = resolve_use_ws_trade(cmd.params.as_ref(), self.config.use_ws_trade);
1569 if use_ws_trade && self.ws.is_active() {
1570 let any_quote_qty = prepared_orders.iter().any(|o| o.is_quote_quantity());
1571 let symbols_match = prepared_orders
1572 .windows(2)
1573 .all(|w| w[0].instrument_id() == w[1].instrument_id());
1574
1575 if any_quote_qty {
1576 log::debug!(
1577 "Kraken WS batch_add does not support quote-quantity orders, falling back to REST for order_list_id={}",
1578 cmd.order_list.id,
1579 );
1580 } else if symbols_match {
1581 match self.batch_add_via_ws(&prepared_orders, leverage) {
1582 Ok(()) => return Ok(()),
1583 Err(e) => log::warn!("Kraken WS batch_add fallback to REST: {e}"),
1584 }
1585 } else {
1586 log::debug!(
1587 "Kraken WS batch_add requires single shared symbol, falling back to REST for order_list_id={}",
1588 cmd.order_list.id,
1589 );
1590 }
1591 }
1592
1593 self.batch_add_via_rest(order_tuples, order_meta);
1594
1595 Ok(())
1596 }
1597
1598 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
1599 self.modify_single_order(&cmd);
1600 Ok(())
1601 }
1602
1603 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1604 self.cancel_single_order(&cmd);
1605 Ok(())
1606 }
1607
1608 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
1609 let instrument_id = cmd.instrument_id;
1610
1611 log::debug!(
1612 "Canceling all orders: instrument_id={instrument_id}, side={:?}",
1613 cmd.order_side
1614 );
1615
1616 let cancels: Vec<CancelOrder> = {
1619 let cache = self.core.cache();
1620 let ts_init = self.clock.get_time_ns();
1621 let correlation_id = cmd.correlation_id.or(Some(cmd.command_id));
1622
1623 let mut seen = HashSet::new();
1627
1628 cache
1629 .orders_open(None, Some(&instrument_id), None, None, None)
1630 .into_iter()
1631 .chain(cache.orders_inflight(None, Some(&instrument_id), None, None, None))
1632 .filter(|order| cmd.order_side.is_none_or(|side| order.order_side() == side))
1633 .filter(|order| seen.insert(order.client_order_id()))
1634 .map(|order| {
1635 CancelOrder::new(
1636 cmd.trader_id,
1637 cmd.client_id,
1638 order.strategy_id(),
1640 order.instrument_id(),
1641 order.client_order_id(),
1642 order.venue_order_id(),
1643 UUID4::new(),
1644 ts_init,
1645 cmd.params.clone(),
1646 correlation_id,
1647 )
1648 })
1649 .collect()
1650 };
1651
1652 if cancels.is_empty() {
1653 log::debug!("No open orders to cancel for {instrument_id}");
1654 return Ok(());
1655 }
1656
1657 let http = self.http.clone();
1658
1659 self.spawn_task("cancel_all_orders", async move {
1660 batch_cancel_orders_for_spot(&http, &cancels).await;
1661 Ok(())
1662 });
1663
1664 Ok(())
1665 }
1666
1667 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
1668 log::debug!(
1669 "Batch canceling orders: instrument_id={}, count={}",
1670 cmd.instrument_id,
1671 cmd.cancels.len()
1672 );
1673
1674 let use_ws_trade = resolve_use_ws_trade(cmd.params.as_ref(), self.config.use_ws_trade);
1675 if use_ws_trade && self.ws.is_active() {
1676 for cancel in &cmd.cancels {
1677 self.cancel_single_order(cancel);
1678 }
1679
1680 return Ok(());
1681 }
1682
1683 let http = self.http.clone();
1684 let cancels = cmd.cancels;
1685
1686 self.spawn_task("batch_cancel_orders", async move {
1687 batch_cancel_orders_for_spot(&http, &cancels).await;
1688 Ok(())
1689 });
1690
1691 Ok(())
1692 }
1693}
1694
1695async fn cancel_order_for_spot(
1696 http: &KrakenSpotHttpClient,
1697 _account_id: AccountId,
1698 instrument_id: InstrumentId,
1699 client_order_id: Option<ClientOrderId>,
1700 venue_order_id: Option<VenueOrderId>,
1701) -> Result<(), CommandFailure> {
1702 http.get_cached_instrument(&instrument_id.symbol.inner())
1703 .ok_or_else(|| {
1704 CommandFailure::not_sent(InstrumentLookupError::not_found(instrument_id).to_string())
1705 })?;
1706
1707 let txid = venue_order_id.as_ref().map(ToString::to_string);
1708 let cl_ord_id = client_order_id.as_ref().map(truncate_cl_ord_id);
1709
1710 if txid.is_none() && cl_ord_id.is_none() {
1711 return Err(CommandFailure::not_sent(
1712 "Either client_order_id or venue_order_id must be provided",
1713 ));
1714 }
1715
1716 let mut builder = KrakenSpotCancelOrderParamsBuilder::default();
1717
1718 if let Some(ref id) = txid {
1719 builder.txid(id.clone());
1720 } else if let Some(ref id) = cl_ord_id {
1721 builder.cl_ord_id(id.clone());
1722 }
1723
1724 let params = builder
1725 .build()
1726 .map_err(|e| CommandFailure::not_sent(format!("Failed to build cancel params: {e}")))?;
1727
1728 http.inner
1729 .cancel_order(¶ms)
1730 .await
1731 .map_err(command_failure_from_spot_cancel_error)?;
1732
1733 Ok(())
1734}
1735
1736enum SpotBatchCancelId {
1741 VenueOrderId(String),
1742 ClientOrderId(String),
1743}
1744
1745const SPOT_BATCH_CANCEL_LIMIT: usize = 50;
1746
1747async fn batch_cancel_orders_for_spot(http: &KrakenSpotHttpClient, cancels: &[CancelOrder]) {
1748 let mut ids = Vec::new();
1749
1750 for cancel in cancels {
1751 match batch_cancel_item_for_spot(http, cancel) {
1752 Ok(id) => ids.push(id),
1753 Err(CommandFailure::NotSent(reason)) => {
1754 log::warn!(
1755 "Batch cancel command failed local validation for {}: {reason}",
1756 cancel.client_order_id
1757 );
1758 }
1759 Err(CommandFailure::Ambiguous(reason) | CommandFailure::VenueRejected(reason)) => {
1760 log::warn!(
1761 "Batch cancel command ambiguous failure for {}, awaiting reconciliation: {reason}",
1762 cancel.client_order_id
1763 );
1764 }
1765 }
1766 }
1767
1768 for chunk in ids.chunks(SPOT_BATCH_CANCEL_LIMIT) {
1770 let mut orders = Vec::new();
1771 let mut cl_ord_ids = Vec::new();
1772
1773 for id in chunk {
1774 match id {
1775 SpotBatchCancelId::VenueOrderId(venue_order_id) => {
1776 orders.push(venue_order_id.clone());
1777 }
1778 SpotBatchCancelId::ClientOrderId(client_order_id) => {
1779 cl_ord_ids.push(client_order_id.clone());
1780 }
1781 }
1782 }
1783
1784 let params = KrakenSpotCancelOrderBatchParams { orders, cl_ord_ids };
1785
1786 match http.inner.cancel_order_batch(¶ms).await {
1787 Ok(response) => {
1788 if response.count < chunk.len() as i32 {
1789 log::warn!(
1790 "Batch cancel accepted {} of {} request(s) without per-order results; awaiting reconciliation",
1791 response.count,
1792 chunk.len()
1793 );
1794 }
1795 }
1796 Err(e) => match command_failure_from_cancel_error(e) {
1797 CommandFailure::NotSent(reason) => {
1798 log::warn!("Batch cancel failed local validation: {reason}");
1799 }
1800 CommandFailure::Ambiguous(reason) | CommandFailure::VenueRejected(reason) => {
1801 log::warn!(
1802 "Batch cancel failed without per-order results, awaiting reconciliation: {reason}"
1803 );
1804 }
1805 },
1806 }
1807 }
1808}
1809
1810fn batch_cancel_item_for_spot(
1811 http: &KrakenSpotHttpClient,
1812 cancel: &CancelOrder,
1813) -> Result<SpotBatchCancelId, CommandFailure> {
1814 http.get_cached_instrument(&cancel.instrument_id.symbol.inner())
1815 .ok_or_else(|| {
1816 CommandFailure::not_sent(
1817 InstrumentLookupError::not_found(cancel.instrument_id).to_string(),
1818 )
1819 })?;
1820
1821 if let Some(venue_order_id) = cancel.venue_order_id {
1822 Ok(SpotBatchCancelId::VenueOrderId(venue_order_id.to_string()))
1823 } else {
1824 Ok(SpotBatchCancelId::ClientOrderId(truncate_cl_ord_id(
1825 &cancel.client_order_id,
1826 )))
1827 }
1828}
1829
1830fn handle_cancel_failure(
1831 emitter: &ExecutionEventEmitter,
1832 clock: &'static AtomicTime,
1833 strategy_id: StrategyId,
1834 instrument_id: InstrumentId,
1835 client_order_id: ClientOrderId,
1836 venue_order_id: Option<VenueOrderId>,
1837 failure: CommandFailure,
1838) {
1839 match failure {
1840 CommandFailure::VenueRejected(reason) => {
1841 emitter.emit_order_cancel_rejected_event(
1842 strategy_id,
1843 instrument_id,
1844 client_order_id,
1845 venue_order_id,
1846 &reason,
1847 clock.get_time_ns(),
1848 );
1849 }
1850 CommandFailure::NotSent(reason) => {
1851 log::warn!("Cancel command failed local validation for {client_order_id}: {reason}");
1852 }
1853 CommandFailure::Ambiguous(reason) => {
1854 log::warn!(
1855 "Ambiguous cancel failure for {client_order_id}, awaiting reconciliation: {reason}"
1856 );
1857 }
1858 }
1859}
1860
1861fn resolve_leverage(params: Option<&Params>, default: Option<u16>) -> Result<Option<u16>, String> {
1862 let Some(p) = params else {
1863 return Ok(default);
1864 };
1865 let Some(raw) = p.get("leverage") else {
1866 return Ok(default);
1867 };
1868 let n = raw.as_u64().ok_or_else(|| {
1869 format!("Invalid leverage param: expected unsigned integer, received {raw}")
1870 })?;
1871 let lev =
1872 u16::try_from(n).map_err(|_| format!("leverage {n} exceeds maximum ({})", u16::MAX))?;
1873 Ok(Some(lev))
1874}
1875
1876fn resolve_use_ws_trade(params: Option<&Params>, default: bool) -> bool {
1879 let Some(p) = params else {
1880 return default;
1881 };
1882 let Some(raw) = p.get("use_ws_trade") else {
1883 return default;
1884 };
1885
1886 match raw.as_bool() {
1887 Some(b) => b,
1888 None => {
1889 log::warn!(
1890 "Invalid use_ws_trade param: expected boolean, received {raw}; using default {default}",
1891 );
1892 default
1893 }
1894 }
1895}
1896
1897#[cfg(test)]
1898mod tests {
1899 use std::{
1900 cell::RefCell,
1901 rc::Rc,
1902 sync::{
1903 Arc,
1904 atomic::{AtomicUsize, Ordering},
1905 },
1906 };
1907
1908 use axum::{Router, http::StatusCode, routing::post};
1909 use nautilus_common::{
1910 cache::{Cache, InstrumentLookupError},
1911 clock::VirtualClock,
1912 factories::ExecutionClientFactory,
1913 messages::execution::CancelOrder,
1914 };
1915 use nautilus_core::{Params, UUID4, UnixNanos};
1916 use nautilus_model::identifiers::{
1917 AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId,
1918 };
1919 use rstest::rstest;
1920 use serde_json::json;
1921
1922 use super::{
1923 AccountType, ClientId, CommandFailure, ExecutionClientCore,
1924 KrakenSpotCancelOrderParamsBuilder, KrakenSpotExecutionClient, OmsType,
1925 batch_cancel_item_for_spot, cancel_order_for_spot, resolve_leverage, resolve_use_ws_trade,
1926 };
1927 use crate::{
1928 common::{consts::KRAKEN_VENUE, enums::KrakenProductType},
1929 config::KrakenExecutionClientConfig,
1930 factories::KrakenExecutionClientFactory,
1931 http::KrakenSpotHttpClient,
1932 };
1933
1934 const TEST_INSTRUMENT_ID: &str = "BTC/USDT.KRAKEN";
1935
1936 #[tokio::test]
1937 async fn test_execution_config_max_retries_zero_disables_spot_cancel_retries() {
1938 let request_count = Arc::new(AtomicUsize::new(0));
1939 let handler_count = request_count.clone();
1940 let router = Router::new().route(
1941 "/0/private/CancelOrder",
1942 post(move || {
1943 let handler_count = handler_count.clone();
1944 async move {
1945 handler_count.fetch_add(1, Ordering::Relaxed);
1946 StatusCode::TOO_MANY_REQUESTS
1947 }
1948 }),
1949 );
1950 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1951 let addr = listener.local_addr().unwrap();
1952 tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
1953
1954 let cache = Rc::new(RefCell::new(Cache::default()));
1955 let core = ExecutionClientCore::new(
1956 TraderId::from("TRADER-001"),
1957 ClientId::from("KRAKEN"),
1958 *KRAKEN_VENUE,
1959 OmsType::Netting,
1960 AccountId::from("KRAKEN-001"),
1961 AccountType::Cash,
1962 None,
1963 cache,
1964 );
1965 let config = KrakenExecutionClientConfig {
1966 api_key: "test-key".into(),
1967 api_secret: "c2VjcmV0".into(),
1968 base_url: Some(format!("http://{addr}")),
1969 max_retries: 0,
1970 ..Default::default()
1971 };
1972 let client = KrakenSpotExecutionClient::new(core, config).unwrap();
1973 let params = KrakenSpotCancelOrderParamsBuilder::default()
1974 .txid("V-001".to_string())
1975 .build()
1976 .unwrap();
1977
1978 let result = client.http.inner.cancel_order(¶ms).await;
1979
1980 assert!(result.is_err());
1981 assert_eq!(request_count.load(Ordering::Relaxed), 1);
1982 }
1983
1984 fn params_with(key: &str, val: serde_json::Value) -> Params {
1985 let mut map = indexmap::IndexMap::new();
1986 map.insert(key.to_owned(), val);
1987 Params::new(map)
1988 }
1989
1990 #[tokio::test]
1991 async fn test_cancel_order_for_spot_missing_cached_instrument_returns_canonical_error() {
1992 let http = KrakenSpotHttpClient::default();
1993 let instrument_id = InstrumentId::from(TEST_INSTRUMENT_ID);
1994 let client_order_id = ClientOrderId::from("C-001");
1995 let venue_order_id = VenueOrderId::from("V-001");
1996
1997 let result = cancel_order_for_spot(
1998 &http,
1999 AccountId::from("KRAKEN-001"),
2000 instrument_id,
2001 Some(client_order_id),
2002 Some(venue_order_id),
2003 )
2004 .await;
2005
2006 match result {
2007 Err(CommandFailure::NotSent(reason)) => {
2008 assert_eq!(
2009 reason,
2010 InstrumentLookupError::not_found(instrument_id).to_string()
2011 );
2012 }
2013 _ => panic!("Expected local validation failure"),
2014 }
2015 }
2016
2017 #[rstest]
2018 fn test_batch_cancel_item_for_spot_missing_cached_instrument_returns_canonical_error() {
2019 let http = KrakenSpotHttpClient::default();
2020 let instrument_id = InstrumentId::from(TEST_INSTRUMENT_ID);
2021 let cancel = CancelOrder::new(
2022 TraderId::from("TESTER-001"),
2023 None,
2024 StrategyId::from("S-001"),
2025 instrument_id,
2026 ClientOrderId::from("C-001"),
2027 Some(VenueOrderId::from("V-001")),
2028 UUID4::new(),
2029 UnixNanos::default(),
2030 None,
2031 None,
2032 );
2033
2034 let result = batch_cancel_item_for_spot(&http, &cancel);
2035
2036 match result {
2037 Err(CommandFailure::NotSent(reason)) => {
2038 assert_eq!(
2039 reason,
2040 InstrumentLookupError::not_found(instrument_id).to_string()
2041 );
2042 }
2043 _ => panic!("Expected local validation failure"),
2044 }
2045 }
2046
2047 #[rstest]
2048 fn test_resolve_leverage_absent_uses_default() {
2049 let p = params_with("other", json!(1));
2050 assert_eq!(resolve_leverage(Some(&p), Some(3)).unwrap(), Some(3));
2051 assert_eq!(resolve_leverage(None, Some(5)).unwrap(), Some(5));
2052 assert_eq!(resolve_leverage(None, None).unwrap(), None);
2053 }
2054
2055 #[rstest]
2056 fn test_resolve_leverage_valid_integer() {
2057 let p = params_with("leverage", json!(5u64));
2058 assert_eq!(resolve_leverage(Some(&p), Some(3)).unwrap(), Some(5));
2059 }
2060
2061 #[rstest]
2062 fn test_resolve_leverage_string_value_errors() {
2063 let p = params_with("leverage", json!("5"));
2064 let err = resolve_leverage(Some(&p), Some(3)).unwrap_err();
2065 assert!(err.contains("Invalid leverage param"), "unexpected: {err}");
2066 }
2067
2068 #[rstest]
2069 fn test_resolve_leverage_overflow_errors() {
2070 let p = params_with("leverage", json!(65539u64));
2071 let err = resolve_leverage(Some(&p), None).unwrap_err();
2072 assert!(err.contains("exceeds maximum"), "unexpected: {err}");
2073 }
2074
2075 #[rstest]
2076 fn test_resolve_use_ws_trade_absent_uses_default() {
2077 let p = params_with("other", json!(1));
2078 assert!(resolve_use_ws_trade(Some(&p), true));
2079 assert!(!resolve_use_ws_trade(Some(&p), false));
2080 assert!(resolve_use_ws_trade(None, true));
2081 assert!(!resolve_use_ws_trade(None, false));
2082 }
2083
2084 #[rstest]
2085 fn test_resolve_use_ws_trade_overrides_default() {
2086 let p_false = params_with("use_ws_trade", json!(false));
2087 let p_true = params_with("use_ws_trade", json!(true));
2088 assert!(!resolve_use_ws_trade(Some(&p_false), true));
2089 assert!(resolve_use_ws_trade(Some(&p_true), false));
2090 }
2091
2092 #[rstest]
2093 fn test_resolve_use_ws_trade_non_boolean_falls_back_to_default() {
2094 let p = params_with("use_ws_trade", json!("true"));
2095 assert!(resolve_use_ws_trade(Some(&p), true));
2096 assert!(!resolve_use_ws_trade(Some(&p), false));
2097 }
2098
2099 #[rstest]
2100 fn test_execution_client_constructs_with_ws_trade_enabled() {
2101 let factory = KrakenExecutionClientFactory::new();
2102 let config = KrakenExecutionClientConfig {
2103 product_type: KrakenProductType::Spot,
2104 use_ws_trade: true,
2105 ws_request_timeout_secs: 7,
2106 ..Default::default()
2107 };
2108 let cache = Rc::new(RefCell::new(Cache::default()));
2109 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2110
2111 let result = factory.create(
2112 TraderId::from("TRADER-001"),
2113 "KRAKEN-WS",
2114 &config,
2115 cache.into(),
2116 clock,
2117 );
2118 assert!(result.is_ok(), "construction failed: {:?}", result.err());
2119 }
2120}