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