Skip to main content

nautilus_kraken/execution/
spot.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Kraken Spot execution client implementation.
17
18use 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/// Kraken Spot execution client.
101///
102/// Provides order management and account operations for Kraken Spot markets.
103#[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    /// Creates a new [`KrakenSpotExecutionClient`].
125    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            // The authenticated order-routing WS keeps its prior behavior (no
166            // idle timeout); issue #4255 concerns the public spot data WS.
167            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        // Connect() swaps in a live cmd_tx; capture the shared handle so the
179        // dispatcher reads the current sender, not the dropped placeholder.
180        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        // Quote-quantity orders submit a quote amount (e.g. 100 USD), but the
216        // venue reports fills in base units (e.g. 0.001 BTC). Registering the
217        // raw `order.quantity()` would make the cumulative-fill comparison in
218        // the fill-side dispatch mismatch base against quote, leaving the
219        // order "open" forever. These orders instead flow through the
220        // untracked path and the engine reconciles them from status reports.
221        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    /// Returns a reference to the clock.
237    #[must_use]
238    pub fn clock(&self) -> &'static AtomicTime {
239        self.clock
240    }
241
242    /// Returns a reference to the event emitter.
243    #[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        // Quote-quantity orders submit a quote-currency amount but the venue echoes
330        // fills in base units. The WS dispatch identity is intentionally not
331        // registered for these (see register_order_identity), so a WS round-trip
332        // would never emit OrderAccepted to the strategy. Plus order.quantity()
333        // for quote-qty is a quote amount that would be wrongly sent as `order_qty`
334        // (base units) on the WS path. Force REST.
335        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    /// Polls the cache until the account is registered or timeout is reached.
671    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        // Request initial account state and await registration before spawning
1102        // the message handler. Report events from execution snapshots conflict
1103        // with ExecEngine borrows during startup, so account registration must
1104        // complete first.
1105        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        // Match by venue_order_id or client_order_id (comparing truncated form
1181        // since Kraken stores the truncated cl_ord_id for long IDs)
1182        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(&params)
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(&params).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
1788/// Resolves the per-call `params["use_ws_trade"]` override against the
1789/// configured default. Non-boolean values warn and fall back to the default.
1790fn 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}