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