Skip to main content

nautilus_kraken/websocket/dispatch/
spot_orders.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//! WebSocket order-request dispatch state for Kraken Spot v2.
17
18use std::{
19    sync::{
20        Arc,
21        atomic::{AtomicU64, Ordering},
22    },
23    time::Duration,
24};
25
26use ahash::AHashMap;
27use dashmap::DashMap;
28use nautilus_common::live::get_runtime;
29use nautilus_core::{UUID4, time::AtomicTime};
30use nautilus_model::{
31    events::{
32        OrderAccepted, OrderCancelRejected, OrderEventAny, OrderModifyRejected, OrderRejected,
33        OrderUpdated,
34    },
35    identifiers::{AccountId, ClientOrderId, TraderId, VenueOrderId},
36    types::{Price, Quantity},
37};
38use tokio_util::sync::CancellationToken;
39use ustr::Ustr;
40
41use super::WsDispatchState;
42use crate::{
43    common::parse::truncate_cl_ord_id,
44    websocket::spot_v2::{
45        enums::KrakenWsMethod,
46        handler::SpotHandlerCommand,
47        messages::{
48            KrakenWsAddOrderParams, KrakenWsAmendOrderParams, KrakenWsBatchAddParams,
49            KrakenWsCancelOrderParams, KrakenWsOrderResponse, KrakenWsOrderResult, KrakenWsParams,
50            KrakenWsRequest,
51        },
52    },
53};
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum PendingOperation {
57    /// Pending add_order request.
58    Submit,
59    /// Pending amend_order request.
60    Amend,
61    /// Pending cancel_order request.
62    Cancel,
63    /// Pending batch_add request.
64    BatchAdd,
65}
66
67#[derive(Debug, Clone)]
68pub struct PendingRequest {
69    /// The pending operation type.
70    pub operation: PendingOperation,
71    /// Client order IDs associated with this request.
72    pub client_order_ids: Vec<ClientOrderId>,
73    /// Venue order IDs associated with this request, if known.
74    pub venue_order_ids: Vec<Option<VenueOrderId>>,
75    /// UNIX nanosecond timestamp when the request was sent.
76    pub ts_sent_ns: u64,
77    /// New quantity carried for amend operations so the success-path
78    /// `OrderUpdated` event reflects the requested change rather than the
79    /// originally registered quantity. `None` for non-amend operations or
80    /// amends that do not modify quantity.
81    pub new_quantity: Option<Quantity>,
82    /// New limit price carried for amend operations so the success-path
83    /// `OrderUpdated` event surfaces the amended price to strategies.
84    /// `None` for non-amend operations or amends that do not modify price.
85    pub new_price: Option<Price>,
86    /// New trigger price carried for amend operations on conditional orders.
87    /// `None` for non-amend operations or amends that do not modify the
88    /// trigger price.
89    pub new_trigger_price: Option<Price>,
90}
91
92#[derive(Debug)]
93pub struct OrderRequestState {
94    req_id_counter: Arc<AtomicU64>,
95    pending: DashMap<u64, PendingRequest>,
96    timeout: Duration,
97    /// Shared handle to the WS handler cmd_tx. Read at send time, not at
98    /// construction: `connect()` swaps the inner sender, and the
99    /// post-`new()` placeholder's receiver is already dropped.
100    cmd_tx_handle: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<SpotHandlerCommand>>>,
101    event_tx: tokio::sync::mpsc::UnboundedSender<OrderEventAny>,
102    dispatch_state: Arc<WsDispatchState>,
103    trader_id: TraderId,
104    account_id: AccountId,
105    /// WS auth token shared with the client; used to build compensating
106    /// cancels when a submit request times out.
107    auth_token: Arc<tokio::sync::RwLock<Option<String>>>,
108    /// Cancellation signal that aborts pending timeout tasks on shutdown so
109    /// the runtime can drop without waiting for in-flight timers.
110    cancellation_token: CancellationToken,
111    /// Clock used to stamp `ts_event` on synthesized timeout events.
112    /// Sharing the caller's clock keeps test ts_event values consistent with
113    /// the ts_sent_ns the same caller stamped at submit time.
114    clock: &'static AtomicTime,
115}
116
117impl OrderRequestState {
118    /// Creates a new [`OrderRequestState`].
119    #[expect(clippy::too_many_arguments, reason = "all fields are independent")]
120    pub fn new(
121        cmd_tx_handle: Arc<
122            tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<SpotHandlerCommand>>,
123        >,
124        event_tx: tokio::sync::mpsc::UnboundedSender<OrderEventAny>,
125        dispatch_state: Arc<WsDispatchState>,
126        req_id_counter: Arc<AtomicU64>,
127        timeout: Duration,
128        trader_id: TraderId,
129        account_id: AccountId,
130        auth_token: Arc<tokio::sync::RwLock<Option<String>>>,
131        cancellation_token: CancellationToken,
132        clock: &'static AtomicTime,
133    ) -> Self {
134        Self {
135            req_id_counter,
136            pending: DashMap::new(),
137            timeout,
138            cmd_tx_handle,
139            event_tx,
140            dispatch_state,
141            trader_id,
142            account_id,
143            auth_token,
144            cancellation_token,
145            clock,
146        }
147    }
148
149    /// Reads a clone of the current cmd_tx. Returns `None` only when the
150    /// handle's `RwLock` is being written to (connect/disconnect window).
151    fn cmd_tx(&self) -> Option<tokio::sync::mpsc::UnboundedSender<SpotHandlerCommand>> {
152        self.cmd_tx_handle.try_read().ok().map(|g| g.clone())
153    }
154
155    /// Returns the next request ID and advances the counter.
156    pub fn next_req_id(&self) -> u64 {
157        self.req_id_counter.fetch_add(1, Ordering::Relaxed) + 1
158    }
159
160    /// Sends an add_order request over the WebSocket transport.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if the JSON envelope fails to serialise or if the
165    /// handler command channel is closed.
166    pub fn submit(
167        self: &Arc<Self>,
168        params: KrakenWsAddOrderParams,
169        identity: PendingRequest,
170        ts_now_ns: u64,
171    ) -> anyhow::Result<u64> {
172        self.send(
173            KrakenWsRequest {
174                method: KrakenWsMethod::AddOrder,
175                params: Some(KrakenWsParams::AddOrder(params)),
176                req_id: None,
177            },
178            identity,
179            ts_now_ns,
180        )
181    }
182
183    /// Sends an amend_order request over the WebSocket transport.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if serialisation fails or the handler command channel is closed.
188    pub fn amend(
189        self: &Arc<Self>,
190        params: KrakenWsAmendOrderParams,
191        identity: PendingRequest,
192        ts_now_ns: u64,
193    ) -> anyhow::Result<u64> {
194        self.send(
195            KrakenWsRequest {
196                method: KrakenWsMethod::AmendOrder,
197                params: Some(KrakenWsParams::AmendOrder(params)),
198                req_id: None,
199            },
200            identity,
201            ts_now_ns,
202        )
203    }
204
205    /// Sends a cancel_order request over the WebSocket transport.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if serialisation fails or the handler command channel is closed.
210    pub fn cancel(
211        self: &Arc<Self>,
212        params: KrakenWsCancelOrderParams,
213        identity: PendingRequest,
214        ts_now_ns: u64,
215    ) -> anyhow::Result<u64> {
216        self.send(
217            KrakenWsRequest {
218                method: KrakenWsMethod::CancelOrder,
219                params: Some(KrakenWsParams::CancelOrder(params)),
220                req_id: None,
221            },
222            identity,
223            ts_now_ns,
224        )
225    }
226
227    /// Sends a batch_add request over the WebSocket transport.
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if serialisation fails or the handler command channel is closed.
232    pub fn batch_add(
233        self: &Arc<Self>,
234        params: KrakenWsBatchAddParams,
235        identity: PendingRequest,
236        ts_now_ns: u64,
237    ) -> anyhow::Result<u64> {
238        self.send(
239            KrakenWsRequest {
240                method: KrakenWsMethod::BatchAdd,
241                params: Some(KrakenWsParams::BatchAdd(params)),
242                req_id: None,
243            },
244            identity,
245            ts_now_ns,
246        )
247    }
248
249    fn send(
250        self: &Arc<Self>,
251        mut envelope: KrakenWsRequest,
252        mut identity: PendingRequest,
253        ts_now_ns: u64,
254    ) -> anyhow::Result<u64> {
255        let req_id = self.next_req_id();
256        envelope.req_id = Some(req_id);
257        identity.ts_sent_ns = ts_now_ns;
258
259        let payload = serde_json::to_string(&envelope)
260            .map_err(|e| anyhow::anyhow!("serialize WS order request: {e}"))?;
261
262        let cmd_tx = self
263            .cmd_tx()
264            .ok_or_else(|| anyhow::anyhow!("WS handler command sender unavailable"))?;
265
266        self.pending.insert(req_id, identity);
267
268        if let Err(e) = cmd_tx.send(SpotHandlerCommand::SendOrderRequest { req_id, payload }) {
269            self.pending.remove(&req_id);
270            anyhow::bail!("handler command channel closed: {e}");
271        }
272
273        let state_for_timeout = Arc::clone(self);
274        let cancel = state_for_timeout.cancellation_token.clone();
275
276        get_runtime().spawn(async move {
277            tokio::select! {
278                () = cancel.cancelled() => {
279                    state_for_timeout.pending.remove(&req_id);
280                }
281                () = tokio::time::sleep(state_for_timeout.timeout) => {
282                    if let Some((_, pending)) = state_for_timeout.pending.remove(&req_id) {
283                        log::warn!(
284                            "Kraken WS response timeout req_id={req_id} op={:?} cl_ord_ids={:?}",
285                            pending.operation,
286                            pending.client_order_ids,
287                        );
288                        let ts_event_ns = state_for_timeout.clock.get_time_ns().as_u64();
289                        state_for_timeout.emit_timeout_rejection(req_id, &pending, ts_event_ns);
290                    }
291                }
292            }
293        });
294
295        Ok(req_id)
296    }
297
298    /// Routes an order-method WebSocket response to the correct event emitter.
299    ///
300    /// `ts_event_ns` is the local receipt time. Kraken's `time_in`/`time_out`
301    /// are ignored to keep event ordering monotonic against the local clock.
302    /// Late responses (after timeout eviction) are logged and dropped.
303    pub fn handle_response(&self, response: &KrakenWsOrderResponse, ts_event_ns: u64) {
304        let req_id = match response.req_id {
305            Some(id) => id,
306            None => {
307                log::warn!(
308                    "Kraken WS order response without req_id method={:?} success={}",
309                    response.method,
310                    response.success,
311                );
312                return;
313            }
314        };
315
316        let Some((_, pending)) = self.pending.remove(&req_id) else {
317            log::debug!("Kraken WS response after eviction (timeout) req_id={req_id}");
318            return;
319        };
320
321        match (pending.operation, response.success, response.method) {
322            (PendingOperation::Submit, true, KrakenWsMethod::AddOrder) => {
323                self.emit_order_accepted(&pending, response, ts_event_ns);
324            }
325            (PendingOperation::Submit, false, KrakenWsMethod::AddOrder) => {
326                self.emit_order_rejected(&pending, response, ts_event_ns);
327            }
328            (PendingOperation::Amend, true, KrakenWsMethod::AmendOrder) => {
329                self.emit_order_updated(&pending, response, ts_event_ns);
330            }
331            (PendingOperation::Amend, false, KrakenWsMethod::AmendOrder) => {
332                self.emit_order_modify_rejected(&pending, response, ts_event_ns);
333            }
334            (PendingOperation::Cancel, true, KrakenWsMethod::CancelOrder) => {
335                log::debug!(
336                    "Kraken WS cancel ack req_id={req_id} cl_ord_ids={:?}",
337                    pending.client_order_ids,
338                );
339            }
340            (PendingOperation::Cancel, false, KrakenWsMethod::CancelOrder) => {
341                self.emit_order_cancel_rejected(&pending, response, ts_event_ns);
342            }
343            (PendingOperation::BatchAdd, _, KrakenWsMethod::BatchAdd) => {
344                self.handle_batch_add_response(&pending, response, ts_event_ns);
345            }
346            (op, ok, method) => {
347                log::error!(
348                    "Kraken WS response method {method:?} mismatched pending op {op:?} success={ok} req_id={req_id}",
349                );
350            }
351        }
352    }
353
354    fn emit_timeout_rejection(&self, req_id: u64, pending: &PendingRequest, ts_event_ns: u64) {
355        let response = KrakenWsOrderResponse {
356            method: pending_op_to_method(pending.operation),
357            req_id: Some(req_id),
358            success: false,
359            time_in: None,
360            time_out: None,
361            error: Some(format!("Kraken WS request timed out req_id={req_id}")),
362            result: None,
363        };
364
365        match pending.operation {
366            PendingOperation::Submit => {
367                self.emit_order_rejected(pending, &response, ts_event_ns);
368                self.send_compensating_cancel(&pending.client_order_ids);
369            }
370            PendingOperation::Amend => {
371                self.emit_order_modify_rejected(pending, &response, ts_event_ns);
372            }
373            PendingOperation::Cancel => {
374                log::warn!(
375                    "Kraken WS cancel request timed out req_id={req_id}; awaiting reconciliation"
376                );
377            }
378            PendingOperation::BatchAdd => {
379                for cl_ord_id in &pending.client_order_ids {
380                    let leg = PendingRequest {
381                        operation: PendingOperation::Submit,
382                        client_order_ids: vec![*cl_ord_id],
383                        venue_order_ids: vec![None],
384                        ts_sent_ns: pending.ts_sent_ns,
385                        new_quantity: None,
386                        new_price: None,
387                        new_trigger_price: None,
388                    };
389                    self.emit_order_rejected(&leg, &response, ts_event_ns);
390                }
391                self.send_compensating_cancel(&pending.client_order_ids);
392            }
393        }
394    }
395
396    /// Sends a best-effort `cancel_order` over the WebSocket after a Submit or
397    /// BatchAdd request has timed out, defending against the case where the
398    /// venue accepted the order but the response was delayed past the
399    /// configured timeout window.
400    ///
401    /// Fire-and-forget: the response is silently dropped because no `pending`
402    /// entry is registered for the cancel `req_id`. If the auth token is not
403    /// available or the command channel is closed the cancel is skipped and
404    /// the engine relies on reconciliation to detect any orphan order.
405    ///
406    /// # Known race
407    ///
408    /// When the timeout fires the dispatch has already emitted an
409    /// `OrderRejected` event, which moves the local cache to `Rejected`. If
410    /// the venue actually accepted the order AND the executions stream
411    /// delivers a fill before the compensating cancel lands at Kraken, the
412    /// fill cannot be applied to a `Rejected` order in the strategy state
413    /// machine. The live execution reconciliation engine
414    /// (`open_check_interval_secs`) is the recovery path: the next reconcile
415    /// poll observes the divergent venue state and emits the missing events.
416    /// Operators who cannot tolerate that recovery latency should set a
417    /// `ws_request_timeout_secs` comfortably above their observed Kraken
418    /// round-trip latency (default `5` is roughly 25× typical) so the timeout
419    /// only fires under genuine network failure.
420    fn send_compensating_cancel(&self, cl_ord_ids: &[ClientOrderId]) {
421        let Some(token) = self.auth_token.try_read().ok().and_then(|g| g.clone()) else {
422            log::error!(
423                "Submit timeout: no auth token for compensating cancel cl_ord_ids={cl_ord_ids:?}; \
424                 relying on reconciliation to recover any orphan order",
425            );
426            return;
427        };
428
429        let req_id = self.next_req_id();
430        let params = KrakenWsCancelOrderParams {
431            token,
432            order_id: None,
433            cl_ord_id: Some(cl_ord_ids.iter().map(truncate_cl_ord_id).collect()),
434        };
435        let envelope = KrakenWsRequest {
436            method: KrakenWsMethod::CancelOrder,
437            params: Some(KrakenWsParams::CancelOrder(params)),
438            req_id: Some(req_id),
439        };
440
441        let payload = match serde_json::to_string(&envelope) {
442            Ok(p) => p,
443            Err(e) => {
444                log::warn!("Submit timeout: compensating cancel serialise failed: {e}");
445                return;
446            }
447        };
448
449        let Some(cmd_tx) = self.cmd_tx() else {
450            log::error!(
451                "Submit timeout: compensating cancel sender unavailable cl_ord_ids={cl_ord_ids:?}; \
452                 relying on reconciliation to recover any orphan order",
453            );
454            return;
455        };
456
457        if let Err(e) = cmd_tx.send(SpotHandlerCommand::SendOrderRequest { req_id, payload }) {
458            log::error!(
459                "Submit timeout: compensating cancel channel closed: {e}; \
460                 relying on reconciliation to recover any orphan order",
461            );
462        } else {
463            log::debug!(
464                "Submit timeout: compensating cancel sent req_id={req_id} cl_ord_ids={cl_ord_ids:?}",
465            );
466        }
467    }
468
469    fn emit_order_accepted(
470        &self,
471        pending: &PendingRequest,
472        response: &KrakenWsOrderResponse,
473        ts_event_ns: u64,
474    ) {
475        let Some(client_order_id) = pending.client_order_ids.first().copied() else {
476            log::error!("Kraken WS add_order response without client_order_id");
477            return;
478        };
479        let Some(identity) = self.dispatch_state.lookup_identity(&client_order_id) else {
480            log::warn!(
481                "Kraken WS add_order response for untracked order client_order_id={client_order_id}",
482            );
483            return;
484        };
485        let venue_order_id = response
486            .result
487            .as_ref()
488            .and_then(|r| r.order_id.as_deref())
489            .map(VenueOrderId::new);
490        let Some(venue_order_id) = venue_order_id else {
491            log::error!(
492                "Kraken WS add_order success without order_id client_order_id={client_order_id}",
493            );
494            return;
495        };
496
497        if !self.dispatch_state.insert_accepted(client_order_id) {
498            return;
499        }
500
501        let event = OrderAccepted::new(
502            self.trader_id,
503            identity.strategy_id,
504            identity.instrument_id,
505            client_order_id,
506            venue_order_id,
507            self.account_id,
508            UUID4::new(),
509            ts_event_ns.into(),
510            ts_event_ns.into(),
511            false,
512        );
513        self.send_event(OrderEventAny::Accepted(event));
514    }
515
516    fn emit_order_rejected(
517        &self,
518        pending: &PendingRequest,
519        response: &KrakenWsOrderResponse,
520        ts_event_ns: u64,
521    ) {
522        let Some(client_order_id) = pending.client_order_ids.first().copied() else {
523            log::error!("Kraken WS add_order rejection without client_order_id");
524            return;
525        };
526        let Some(identity) = self.dispatch_state.lookup_identity(&client_order_id) else {
527            log::warn!(
528                "Kraken WS add_order rejection for untracked order client_order_id={client_order_id}",
529            );
530            return;
531        };
532        let reason = response
533            .error
534            .as_deref()
535            .filter(|s| !s.is_empty())
536            .unwrap_or("UNKNOWN");
537
538        let event = OrderRejected::new(
539            self.trader_id,
540            identity.strategy_id,
541            identity.instrument_id,
542            client_order_id,
543            self.account_id,
544            Ustr::from(reason),
545            UUID4::new(),
546            ts_event_ns.into(),
547            ts_event_ns.into(),
548            false,
549            false,
550        );
551        self.send_event(OrderEventAny::Rejected(event));
552    }
553
554    fn emit_order_updated(
555        &self,
556        pending: &PendingRequest,
557        response: &KrakenWsOrderResponse,
558        ts_event_ns: u64,
559    ) {
560        let Some(client_order_id) = pending.client_order_ids.first().copied() else {
561            log::error!("Kraken WS amend_order response without client_order_id");
562            return;
563        };
564        let Some(identity) = self.dispatch_state.lookup_identity(&client_order_id) else {
565            log::warn!(
566                "Kraken WS amend_order response for untracked order client_order_id={client_order_id}",
567            );
568            return;
569        };
570        let venue_order_id = response
571            .result
572            .as_ref()
573            .and_then(|r| r.order_id.as_deref())
574            .map(VenueOrderId::new)
575            .or_else(|| pending.venue_order_ids.first().copied().flatten());
576
577        let quantity = pending.new_quantity.unwrap_or(identity.quantity);
578        if pending.new_quantity.is_some() {
579            self.dispatch_state
580                .update_identity_quantity(&client_order_id, quantity);
581        }
582
583        let event = OrderUpdated::new(
584            self.trader_id,
585            identity.strategy_id,
586            identity.instrument_id,
587            client_order_id,
588            quantity,
589            UUID4::new(),
590            ts_event_ns.into(),
591            ts_event_ns.into(),
592            false,
593            venue_order_id,
594            Some(self.account_id),
595            pending.new_price,
596            pending.new_trigger_price,
597            None,
598            false,
599        );
600        self.send_event(OrderEventAny::Updated(event));
601    }
602
603    fn emit_order_modify_rejected(
604        &self,
605        pending: &PendingRequest,
606        response: &KrakenWsOrderResponse,
607        ts_event_ns: u64,
608    ) {
609        let Some(client_order_id) = pending.client_order_ids.first().copied() else {
610            log::error!("Kraken WS amend_order rejection without client_order_id");
611            return;
612        };
613        let Some(identity) = self.dispatch_state.lookup_identity(&client_order_id) else {
614            log::warn!(
615                "Kraken WS amend_order rejection for untracked order client_order_id={client_order_id}",
616            );
617            return;
618        };
619        let venue_order_id = pending.venue_order_ids.first().copied().flatten();
620        let reason = response
621            .error
622            .as_deref()
623            .filter(|s| !s.is_empty())
624            .unwrap_or("UNKNOWN");
625
626        let event = OrderModifyRejected::new(
627            self.trader_id,
628            identity.strategy_id,
629            identity.instrument_id,
630            client_order_id,
631            Ustr::from(reason),
632            UUID4::new(),
633            ts_event_ns.into(),
634            ts_event_ns.into(),
635            false,
636            venue_order_id,
637            Some(self.account_id),
638        );
639        self.send_event(OrderEventAny::ModifyRejected(event));
640    }
641
642    fn emit_order_cancel_rejected(
643        &self,
644        pending: &PendingRequest,
645        response: &KrakenWsOrderResponse,
646        ts_event_ns: u64,
647    ) {
648        let Some(client_order_id) = pending.client_order_ids.first().copied() else {
649            log::error!("Kraken WS cancel_order rejection without client_order_id");
650            return;
651        };
652        let Some(identity) = self.dispatch_state.lookup_identity(&client_order_id) else {
653            log::warn!(
654                "Kraken WS cancel_order rejection for untracked order client_order_id={client_order_id}",
655            );
656            return;
657        };
658        let venue_order_id = pending.venue_order_ids.first().copied().flatten();
659        let reason = response
660            .error
661            .as_deref()
662            .filter(|s| !s.is_empty())
663            .unwrap_or("UNKNOWN");
664
665        let event = OrderCancelRejected::new(
666            self.trader_id,
667            identity.strategy_id,
668            identity.instrument_id,
669            client_order_id,
670            Ustr::from(reason),
671            UUID4::new(),
672            ts_event_ns.into(),
673            ts_event_ns.into(),
674            false,
675            venue_order_id,
676            Some(self.account_id),
677        );
678        self.send_event(OrderEventAny::CancelRejected(event));
679    }
680
681    fn handle_batch_add_response(
682        &self,
683        pending: &PendingRequest,
684        response: &KrakenWsOrderResponse,
685        ts_event_ns: u64,
686    ) {
687        let per_order = response
688            .result
689            .as_ref()
690            .and_then(|r| r.orders.as_ref())
691            .map_or(&[][..], Vec::as_slice);
692
693        // Match per-leg results by the cl_ord_id Kraken echoes back rather than
694        // relying purely on positional alignment with the legs we sent. Echoed
695        // matches are robust against the venue reordering or omitting entries
696        // in the response array; positional fallback covers the (currently
697        // dominant) case where the echoed cl_ord_id is missing.
698        let echo_index: AHashMap<&str, usize> = per_order
699            .iter()
700            .enumerate()
701            .filter_map(|(i, r)| r.cl_ord_id.as_deref().map(|cid| (cid, i)))
702            .collect();
703
704        for (idx, client_order_id) in pending.client_order_ids.iter().copied().enumerate() {
705            let leg_venue = pending.venue_order_ids.get(idx).copied().flatten();
706            let truncated = truncate_cl_ord_id(&client_order_id);
707            let leg_result = echo_index
708                .get(truncated.as_str())
709                .and_then(|&i| per_order.get(i))
710                .or_else(|| per_order.get(idx));
711
712            if leg_result.is_none() {
713                log::error!(
714                    "Kraken WS batch_add response missing per-leg result for client_order_id={client_order_id} idx={idx} \
715                     legs_sent={legs_sent} legs_received={legs_received}; treating as rejection",
716                    legs_sent = pending.client_order_ids.len(),
717                    legs_received = per_order.len(),
718                );
719            }
720
721            let leg_success = leg_result.is_some_and(|r| r.success);
722            let leg_venue_order_id = leg_result
723                .and_then(|r| r.order_id.as_deref())
724                .map(VenueOrderId::new)
725                .or(leg_venue);
726            let leg_error = leg_result.and_then(|r| r.error.clone()).or_else(|| {
727                if leg_result.is_none() {
728                    Some(format!(
729                        "batch_add response missing per-leg result (legs_sent={}, legs_received={})",
730                        pending.client_order_ids.len(),
731                        per_order.len(),
732                    ))
733                } else {
734                    response.error.clone()
735                }
736            });
737
738            let leg_response = KrakenWsOrderResponse {
739                method: KrakenWsMethod::AddOrder,
740                req_id: response.req_id,
741                success: leg_success,
742                time_in: response.time_in.clone(),
743                time_out: response.time_out.clone(),
744                error: leg_error,
745                result: leg_venue_order_id.map(|v| KrakenWsOrderResult {
746                    order_id: Some(v.to_string()),
747                    cl_ord_id: leg_result.and_then(|r| r.cl_ord_id.clone()),
748                    order_userref: None,
749                    warning: None,
750                    orders: None,
751                }),
752            };
753            let leg_pending = PendingRequest {
754                operation: PendingOperation::Submit,
755                client_order_ids: vec![client_order_id],
756                venue_order_ids: vec![leg_venue_order_id],
757                ts_sent_ns: pending.ts_sent_ns,
758                new_quantity: None,
759                new_price: None,
760                new_trigger_price: None,
761            };
762
763            if leg_success {
764                self.emit_order_accepted(&leg_pending, &leg_response, ts_event_ns);
765            } else {
766                self.emit_order_rejected(&leg_pending, &leg_response, ts_event_ns);
767            }
768        }
769    }
770
771    /// Forwards an order event to the execution-engine channel.
772    ///
773    /// A send failure means the receiver has been dropped, which only happens
774    /// during shutdown after the forwarder task has exited. Events lost in
775    /// that window are recovered on the next start by the reconciliation
776    /// engine (`open_check_interval_secs`), which queries the venue for any
777    /// orders or fills the local cache is missing. The error log preserves
778    /// operator visibility for the (rare) shutdown-race case.
779    fn send_event(&self, event: OrderEventAny) {
780        if let Err(e) = self.event_tx.send(event) {
781            log::error!("Kraken WS order-event channel send failed: {e}");
782        }
783    }
784}
785
786fn pending_op_to_method(op: PendingOperation) -> KrakenWsMethod {
787    match op {
788        PendingOperation::Submit => KrakenWsMethod::AddOrder,
789        PendingOperation::Amend => KrakenWsMethod::AmendOrder,
790        PendingOperation::Cancel => KrakenWsMethod::CancelOrder,
791        PendingOperation::BatchAdd => KrakenWsMethod::BatchAdd,
792    }
793}
794
795#[cfg(test)]
796impl OrderRequestState {
797    pub(crate) fn pending_len(&self) -> usize {
798        self.pending.len()
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use std::sync::atomic::AtomicU64;
805
806    use nautilus_model::{
807        enums::{OrderSide, OrderType},
808        identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId},
809        types::{Price, Quantity},
810    };
811    use rstest::rstest;
812
813    use super::*;
814    use crate::{
815        common::enums::{KrakenOrderSide, KrakenOrderType},
816        websocket::{
817            dispatch::OrderIdentity,
818            spot_v2::messages::{
819                KrakenWsAddOrderParams, KrakenWsAmendOrderParams, KrakenWsBatchAddParams,
820                KrakenWsBatchOrderResult, KrakenWsCancelOrderParams, KrakenWsOrderResponse,
821                KrakenWsOrderResult,
822            },
823        },
824    };
825
826    const CLIENT_ORDER_ID: &str = "O-1";
827    const VENUE_ORDER_ID: &str = "O-VENUE";
828    const INSTRUMENT_ID: &str = "BTCUSD.KRAKEN";
829
830    pub(super) struct Harness {
831        pub(super) state: Arc<OrderRequestState>,
832        pub(super) cmd_rx: tokio::sync::mpsc::UnboundedReceiver<SpotHandlerCommand>,
833        pub(super) event_rx: tokio::sync::mpsc::UnboundedReceiver<OrderEventAny>,
834        pub(super) dispatch_state: Arc<WsDispatchState>,
835        pub(super) auth_token: Arc<tokio::sync::RwLock<Option<String>>>,
836        pub(super) cancellation_token: CancellationToken,
837        pub(super) cmd_tx_handle:
838            Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<SpotHandlerCommand>>>,
839    }
840
841    pub(super) fn make_harness(timeout_ms: u64) -> Harness {
842        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
843        let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel();
844        let counter = Arc::new(AtomicU64::new(0));
845        let dispatch_state = Arc::new(WsDispatchState::new());
846        let auth_token = Arc::new(tokio::sync::RwLock::new(None));
847        let cancellation_token = CancellationToken::new();
848        let cmd_tx_handle = Arc::new(tokio::sync::RwLock::new(cmd_tx));
849        let state = Arc::new(OrderRequestState::new(
850            Arc::clone(&cmd_tx_handle),
851            event_tx,
852            Arc::clone(&dispatch_state),
853            counter,
854            Duration::from_millis(timeout_ms),
855            TraderId::new("TESTER-001"),
856            AccountId::new("KRAKEN-001"),
857            Arc::clone(&auth_token),
858            cancellation_token.clone(),
859            nautilus_core::time::get_atomic_clock_realtime(),
860        ));
861        Harness {
862            state,
863            cmd_rx,
864            event_rx,
865            dispatch_state,
866            auth_token,
867            cancellation_token,
868            cmd_tx_handle,
869        }
870    }
871
872    fn register_default_identity(dispatch_state: &WsDispatchState, cl_ord_id: ClientOrderId) {
873        dispatch_state.register_identity(
874            cl_ord_id,
875            OrderIdentity {
876                strategy_id: StrategyId::new("S-1"),
877                instrument_id: InstrumentId::from(INSTRUMENT_ID),
878                order_side: OrderSide::Buy,
879                order_type: OrderType::Limit,
880                quantity: Quantity::from("0.001"),
881            },
882        );
883    }
884
885    fn make_state(
886        timeout_ms: u64,
887    ) -> (
888        Arc<OrderRequestState>,
889        tokio::sync::mpsc::UnboundedReceiver<SpotHandlerCommand>,
890    ) {
891        let harness = make_harness(timeout_ms);
892        (harness.state, harness.cmd_rx)
893    }
894
895    fn make_identity(op: PendingOperation) -> PendingRequest {
896        PendingRequest {
897            operation: op,
898            client_order_ids: vec![ClientOrderId::from(CLIENT_ORDER_ID)],
899            venue_order_ids: vec![None],
900            ts_sent_ns: 0,
901            new_quantity: None,
902            new_price: None,
903            new_trigger_price: None,
904        }
905    }
906
907    #[rstest]
908    fn test_next_req_id_is_monotonic() {
909        let (state, _rx) = make_state(1_000);
910        let a = state.next_req_id();
911        let b = state.next_req_id();
912        let c = state.next_req_id();
913        assert!(b > a && c > b);
914    }
915
916    #[rstest]
917    fn test_submit_registers_pending_and_sends_command() {
918        let (state, mut rx) = make_state(60_000);
919        let identity = make_identity(PendingOperation::Submit);
920
921        let params = KrakenWsAddOrderParams {
922            order_type: KrakenOrderType::Limit,
923            side: KrakenOrderSide::Buy,
924            order_qty: 0.001,
925            symbol: "BTC/USD".to_string(),
926            token: "test-token".to_string(),
927            limit_price: Some(50_000.0),
928            time_in_force: None,
929            expire_time: None,
930            cl_ord_id: Some("O-1".to_string()),
931            post_only: None,
932            reduce_only: None,
933            leverage: None,
934            trigger: None,
935            conditional: None,
936        };
937
938        let req_id = state.submit(params, identity, 1).expect("submit ok");
939        assert_eq!(state.pending_len(), 1);
940
941        let cmd = rx.try_recv().expect("cmd queued");
942        match cmd {
943            SpotHandlerCommand::SendOrderRequest {
944                req_id: rid,
945                payload,
946            } => {
947                assert_eq!(rid, req_id);
948                assert!(payload.contains("\"add_order\""));
949                assert!(payload.contains(&format!("\"req_id\":{req_id}")));
950            }
951            _ => panic!("wrong cmd variant"),
952        }
953    }
954
955    #[tokio::test]
956    async fn test_submit_uses_swapped_command_sender() {
957        // Regression: dispatcher built before connect() must observe the
958        // live cmd_tx after the swap, not the dropped placeholder.
959        let harness = make_harness(60_000);
960
961        drop(harness.cmd_rx);
962        let (new_cmd_tx, mut new_cmd_rx) = tokio::sync::mpsc::unbounded_channel();
963        *harness.cmd_tx_handle.write().await = new_cmd_tx;
964
965        let params = KrakenWsAddOrderParams {
966            order_type: KrakenOrderType::Limit,
967            side: KrakenOrderSide::Buy,
968            order_qty: 0.001,
969            symbol: "BTC/USD".to_string(),
970            token: "TKN".to_string(),
971            limit_price: Some(50_000.0),
972            time_in_force: None,
973            expire_time: None,
974            cl_ord_id: Some(CLIENT_ORDER_ID.to_string()),
975            post_only: None,
976            reduce_only: None,
977            leverage: None,
978            trigger: None,
979            conditional: None,
980        };
981        let identity = make_identity(PendingOperation::Submit);
982
983        harness
984            .state
985            .submit(params, identity, 1)
986            .expect("submit must succeed via the swapped sender");
987
988        let cmd = new_cmd_rx
989            .try_recv()
990            .expect("swapped receiver must observe the order");
991
992        match cmd {
993            SpotHandlerCommand::SendOrderRequest { payload, .. } => {
994                assert!(payload.contains("\"add_order\""));
995            }
996            other => panic!("expected SendOrderRequest, was {other:?}"),
997        }
998    }
999
1000    #[rstest]
1001    fn test_amend_sends_amend_order_envelope() {
1002        let (state, mut rx) = make_state(60_000);
1003        let params = KrakenWsAmendOrderParams {
1004            order_id: Some("O-VENUE".to_string()),
1005            cl_ord_id: None,
1006            order_qty: Some(0.005),
1007            limit_price: None,
1008            trigger_price: None,
1009            token: "TKN".to_string(),
1010        };
1011        let identity = PendingRequest {
1012            operation: PendingOperation::Amend,
1013            client_order_ids: vec![ClientOrderId::from("O-1")],
1014            venue_order_ids: vec![Some(VenueOrderId::from("O-VENUE"))],
1015            ts_sent_ns: 0,
1016            new_quantity: None,
1017            new_price: None,
1018            new_trigger_price: None,
1019        };
1020        let _ = state.amend(params, identity, 1).expect("amend ok");
1021        let cmd = rx.try_recv().unwrap();
1022        if let SpotHandlerCommand::SendOrderRequest { payload, .. } = cmd {
1023            assert!(payload.contains("\"amend_order\""));
1024        } else {
1025            panic!("wrong variant");
1026        }
1027    }
1028
1029    #[rstest]
1030    fn test_cancel_sends_cancel_order_envelope() {
1031        let (state, mut rx) = make_state(60_000);
1032        let params = KrakenWsCancelOrderParams {
1033            order_id: Some(vec!["O-VENUE".to_string()]),
1034            cl_ord_id: None,
1035            token: "TKN".to_string(),
1036        };
1037        let identity = PendingRequest {
1038            operation: PendingOperation::Cancel,
1039            client_order_ids: vec![ClientOrderId::from("O-1")],
1040            venue_order_ids: vec![Some(VenueOrderId::from("O-VENUE"))],
1041            ts_sent_ns: 0,
1042            new_quantity: None,
1043            new_price: None,
1044            new_trigger_price: None,
1045        };
1046        let _ = state.cancel(params, identity, 1).expect("cancel ok");
1047        let cmd = rx.try_recv().unwrap();
1048        if let SpotHandlerCommand::SendOrderRequest { payload, .. } = cmd {
1049            assert!(payload.contains("\"cancel_order\""));
1050        } else {
1051            panic!("wrong variant");
1052        }
1053    }
1054
1055    #[rstest]
1056    fn test_batch_add_sends_batch_add_envelope() {
1057        let (state, mut rx) = make_state(60_000);
1058        let params = KrakenWsBatchAddParams {
1059            symbol: "BTC/USD".to_string(),
1060            orders: vec![],
1061            token: "TKN".to_string(),
1062        };
1063        let identity = PendingRequest {
1064            operation: PendingOperation::BatchAdd,
1065            client_order_ids: vec![ClientOrderId::from("O-A"), ClientOrderId::from("O-B")],
1066            venue_order_ids: vec![None, None],
1067            ts_sent_ns: 0,
1068            new_quantity: None,
1069            new_price: None,
1070            new_trigger_price: None,
1071        };
1072        let _ = state.batch_add(params, identity, 1).expect("batch ok");
1073        let cmd = rx.try_recv().unwrap();
1074        if let SpotHandlerCommand::SendOrderRequest { payload, .. } = cmd {
1075            assert!(payload.contains("\"batch_add\""));
1076        } else {
1077            panic!("wrong variant");
1078        }
1079    }
1080
1081    fn make_response(
1082        method: KrakenWsMethod,
1083        success: bool,
1084        req_id: u64,
1085        order_id: Option<&str>,
1086        error: Option<&str>,
1087    ) -> KrakenWsOrderResponse {
1088        KrakenWsOrderResponse {
1089            method,
1090            req_id: Some(req_id),
1091            success,
1092            time_in: None,
1093            time_out: None,
1094            error: error.map(str::to_string),
1095            result: order_id.map(|id| KrakenWsOrderResult {
1096                order_id: Some(id.to_string()),
1097                cl_ord_id: None,
1098                order_userref: None,
1099                warning: None,
1100                orders: None,
1101            }),
1102        }
1103    }
1104
1105    #[rstest]
1106    fn test_handle_response_submit_success_emits_order_accepted() {
1107        let mut harness = make_harness(60_000);
1108        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1109        register_default_identity(&harness.dispatch_state, cl_ord_id);
1110
1111        let req_id = 42;
1112        harness
1113            .state
1114            .pending
1115            .insert(req_id, make_identity(PendingOperation::Submit));
1116
1117        let response = make_response(
1118            KrakenWsMethod::AddOrder,
1119            true,
1120            req_id,
1121            Some(VENUE_ORDER_ID),
1122            None,
1123        );
1124        harness.state.handle_response(&response, 1_000);
1125
1126        assert_eq!(harness.state.pending_len(), 0);
1127        let event = harness.event_rx.try_recv().expect("event emitted");
1128        match event {
1129            OrderEventAny::Accepted(e) => {
1130                assert_eq!(e.client_order_id, cl_ord_id);
1131                assert_eq!(e.venue_order_id.as_str(), VENUE_ORDER_ID);
1132                assert_eq!(e.account_id.as_str(), "KRAKEN-001");
1133            }
1134            other => panic!("expected Accepted, was {other:?}"),
1135        }
1136    }
1137
1138    #[rstest]
1139    fn test_handle_response_submit_failure_emits_order_rejected() {
1140        let mut harness = make_harness(60_000);
1141        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1142        register_default_identity(&harness.dispatch_state, cl_ord_id);
1143
1144        let req_id = 7;
1145        harness
1146            .state
1147            .pending
1148            .insert(req_id, make_identity(PendingOperation::Submit));
1149
1150        let response = make_response(
1151            KrakenWsMethod::AddOrder,
1152            false,
1153            req_id,
1154            None,
1155            Some("Insufficient funds"),
1156        );
1157        harness.state.handle_response(&response, 2_000);
1158
1159        assert_eq!(harness.state.pending_len(), 0);
1160        let event = harness.event_rx.try_recv().expect("event emitted");
1161        match event {
1162            OrderEventAny::Rejected(e) => {
1163                assert_eq!(e.client_order_id, cl_ord_id);
1164                assert_eq!(e.reason.as_str(), "Insufficient funds");
1165            }
1166            other => panic!("expected Rejected, was {other:?}"),
1167        }
1168    }
1169
1170    #[rstest]
1171    fn test_handle_response_amend_success_emits_order_updated() {
1172        let mut harness = make_harness(60_000);
1173        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1174        register_default_identity(&harness.dispatch_state, cl_ord_id);
1175
1176        let req_id = 11;
1177        let pending = PendingRequest {
1178            operation: PendingOperation::Amend,
1179            client_order_ids: vec![cl_ord_id],
1180            venue_order_ids: vec![Some(VenueOrderId::from(VENUE_ORDER_ID))],
1181            ts_sent_ns: 0,
1182            new_quantity: None,
1183            new_price: None,
1184            new_trigger_price: None,
1185        };
1186        harness.state.pending.insert(req_id, pending);
1187
1188        let response = make_response(
1189            KrakenWsMethod::AmendOrder,
1190            true,
1191            req_id,
1192            Some(VENUE_ORDER_ID),
1193            None,
1194        );
1195        harness.state.handle_response(&response, 3_000);
1196
1197        let event = harness.event_rx.try_recv().expect("event emitted");
1198        match event {
1199            OrderEventAny::Updated(e) => {
1200                assert_eq!(e.client_order_id, cl_ord_id);
1201                assert_eq!(
1202                    e.venue_order_id.expect("venue id present").as_str(),
1203                    VENUE_ORDER_ID
1204                );
1205                assert_eq!(e.quantity, Quantity::from("0.001"));
1206            }
1207            other => panic!("expected Updated, was {other:?}"),
1208        }
1209    }
1210
1211    #[rstest]
1212    fn test_handle_response_amend_with_new_quantity_emits_new_quantity() {
1213        let mut harness = make_harness(60_000);
1214        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1215        register_default_identity(&harness.dispatch_state, cl_ord_id);
1216
1217        let req_id = 11;
1218        let new_qty = Quantity::from("0.005");
1219        let pending = PendingRequest {
1220            operation: PendingOperation::Amend,
1221            client_order_ids: vec![cl_ord_id],
1222            venue_order_ids: vec![Some(VenueOrderId::from(VENUE_ORDER_ID))],
1223            ts_sent_ns: 0,
1224            new_quantity: Some(new_qty),
1225            new_price: None,
1226            new_trigger_price: None,
1227        };
1228        harness.state.pending.insert(req_id, pending);
1229
1230        let response = make_response(
1231            KrakenWsMethod::AmendOrder,
1232            true,
1233            req_id,
1234            Some(VENUE_ORDER_ID),
1235            None,
1236        );
1237        harness.state.handle_response(&response, 3_500);
1238
1239        let event = harness.event_rx.try_recv().expect("event emitted");
1240        match event {
1241            OrderEventAny::Updated(e) => {
1242                assert_eq!(e.quantity, new_qty, "OrderUpdated must carry new quantity");
1243            }
1244            other => panic!("expected Updated, was {other:?}"),
1245        }
1246
1247        let identity = harness
1248            .dispatch_state
1249            .lookup_identity(&cl_ord_id)
1250            .expect("identity present");
1251        assert_eq!(
1252            identity.quantity, new_qty,
1253            "dispatch identity must be updated for follow-up ops",
1254        );
1255    }
1256
1257    #[rstest]
1258    fn test_handle_response_amend_carries_new_price_and_trigger() {
1259        let mut harness = make_harness(60_000);
1260        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1261        register_default_identity(&harness.dispatch_state, cl_ord_id);
1262
1263        let req_id = 13;
1264        let new_price = Price::from("31000.00");
1265        let new_trigger = Price::from("30500.00");
1266        let pending = PendingRequest {
1267            operation: PendingOperation::Amend,
1268            client_order_ids: vec![cl_ord_id],
1269            venue_order_ids: vec![Some(VenueOrderId::from(VENUE_ORDER_ID))],
1270            ts_sent_ns: 0,
1271            new_quantity: None,
1272            new_price: Some(new_price),
1273            new_trigger_price: Some(new_trigger),
1274        };
1275        harness.state.pending.insert(req_id, pending);
1276
1277        let response = make_response(
1278            KrakenWsMethod::AmendOrder,
1279            true,
1280            req_id,
1281            Some(VENUE_ORDER_ID),
1282            None,
1283        );
1284        harness.state.handle_response(&response, 4_500);
1285
1286        let event = harness.event_rx.try_recv().expect("event emitted");
1287        match event {
1288            OrderEventAny::Updated(e) => {
1289                assert_eq!(
1290                    e.price,
1291                    Some(new_price),
1292                    "OrderUpdated must carry amended price",
1293                );
1294                assert_eq!(
1295                    e.trigger_price,
1296                    Some(new_trigger),
1297                    "OrderUpdated must carry amended trigger price",
1298                );
1299            }
1300            other => panic!("expected Updated, was {other:?}"),
1301        }
1302    }
1303
1304    #[rstest]
1305    fn test_handle_response_amend_failure_emits_order_modify_rejected() {
1306        let mut harness = make_harness(60_000);
1307        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1308        register_default_identity(&harness.dispatch_state, cl_ord_id);
1309
1310        let req_id = 12;
1311        let pending = PendingRequest {
1312            operation: PendingOperation::Amend,
1313            client_order_ids: vec![cl_ord_id],
1314            venue_order_ids: vec![Some(VenueOrderId::from(VENUE_ORDER_ID))],
1315            ts_sent_ns: 0,
1316            new_quantity: None,
1317            new_price: None,
1318            new_trigger_price: None,
1319        };
1320        harness.state.pending.insert(req_id, pending);
1321
1322        let response = make_response(
1323            KrakenWsMethod::AmendOrder,
1324            false,
1325            req_id,
1326            None,
1327            Some("Order not found"),
1328        );
1329        harness.state.handle_response(&response, 4_000);
1330
1331        let event = harness.event_rx.try_recv().expect("event emitted");
1332        match event {
1333            OrderEventAny::ModifyRejected(e) => {
1334                assert_eq!(e.client_order_id, cl_ord_id);
1335                assert_eq!(e.reason.as_str(), "Order not found");
1336            }
1337            other => panic!("expected ModifyRejected, was {other:?}"),
1338        }
1339    }
1340
1341    #[rstest]
1342    fn test_handle_response_cancel_failure_emits_order_cancel_rejected() {
1343        let mut harness = make_harness(60_000);
1344        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1345        register_default_identity(&harness.dispatch_state, cl_ord_id);
1346
1347        let req_id = 21;
1348        let pending = PendingRequest {
1349            operation: PendingOperation::Cancel,
1350            client_order_ids: vec![cl_ord_id],
1351            venue_order_ids: vec![Some(VenueOrderId::from(VENUE_ORDER_ID))],
1352            ts_sent_ns: 0,
1353            new_quantity: None,
1354            new_price: None,
1355            new_trigger_price: None,
1356        };
1357        harness.state.pending.insert(req_id, pending);
1358
1359        let response = make_response(
1360            KrakenWsMethod::CancelOrder,
1361            false,
1362            req_id,
1363            None,
1364            Some("Unknown order"),
1365        );
1366        harness.state.handle_response(&response, 5_000);
1367
1368        let event = harness.event_rx.try_recv().expect("event emitted");
1369        match event {
1370            OrderEventAny::CancelRejected(e) => {
1371                assert_eq!(e.client_order_id, cl_ord_id);
1372                assert_eq!(e.reason.as_str(), "Unknown order");
1373            }
1374            other => panic!("expected CancelRejected, was {other:?}"),
1375        }
1376    }
1377
1378    #[rstest]
1379    fn test_handle_response_cancel_success_is_silent() {
1380        let mut harness = make_harness(60_000);
1381        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1382        register_default_identity(&harness.dispatch_state, cl_ord_id);
1383
1384        let req_id = 22;
1385        let pending = PendingRequest {
1386            operation: PendingOperation::Cancel,
1387            client_order_ids: vec![cl_ord_id],
1388            venue_order_ids: vec![Some(VenueOrderId::from(VENUE_ORDER_ID))],
1389            ts_sent_ns: 0,
1390            new_quantity: None,
1391            new_price: None,
1392            new_trigger_price: None,
1393        };
1394        harness.state.pending.insert(req_id, pending);
1395
1396        let response = make_response(KrakenWsMethod::CancelOrder, true, req_id, None, None);
1397        harness.state.handle_response(&response, 6_000);
1398
1399        assert_eq!(harness.state.pending_len(), 0);
1400        assert!(harness.event_rx.try_recv().is_err());
1401    }
1402
1403    #[rstest]
1404    fn test_cancel_timeout_emits_no_order_cancel_rejected() {
1405        let mut harness = make_harness(60_000);
1406        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1407        register_default_identity(&harness.dispatch_state, cl_ord_id);
1408
1409        let pending = PendingRequest {
1410            operation: PendingOperation::Cancel,
1411            client_order_ids: vec![cl_ord_id],
1412            venue_order_ids: vec![Some(VenueOrderId::from(VENUE_ORDER_ID))],
1413            ts_sent_ns: 0,
1414            new_quantity: None,
1415            new_price: None,
1416            new_trigger_price: None,
1417        };
1418
1419        harness.state.emit_timeout_rejection(23, &pending, 7_000);
1420
1421        assert!(harness.event_rx.try_recv().is_err());
1422    }
1423
1424    #[rstest]
1425    fn test_handle_response_late_after_timeout_is_noop() {
1426        let mut harness = make_harness(60_000);
1427        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1428        register_default_identity(&harness.dispatch_state, cl_ord_id);
1429
1430        let req_id = 99;
1431        harness
1432            .state
1433            .pending
1434            .insert(req_id, make_identity(PendingOperation::Submit));
1435        harness.state.pending.remove(&req_id);
1436
1437        let response = make_response(
1438            KrakenWsMethod::AddOrder,
1439            true,
1440            req_id,
1441            Some(VENUE_ORDER_ID),
1442            None,
1443        );
1444        harness.state.handle_response(&response, 7_000);
1445
1446        assert!(harness.event_rx.try_recv().is_err());
1447    }
1448
1449    #[rstest]
1450    fn test_handle_response_method_op_mismatch_logs_and_drops() {
1451        let mut harness = make_harness(60_000);
1452        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1453        register_default_identity(&harness.dispatch_state, cl_ord_id);
1454
1455        let req_id = 33;
1456        harness
1457            .state
1458            .pending
1459            .insert(req_id, make_identity(PendingOperation::Submit));
1460
1461        let response = make_response(KrakenWsMethod::CancelOrder, true, req_id, None, None);
1462        harness.state.handle_response(&response, 8_000);
1463
1464        assert_eq!(harness.state.pending_len(), 0);
1465        assert!(harness.event_rx.try_recv().is_err());
1466    }
1467
1468    #[rstest]
1469    fn test_handle_response_batch_add_emits_per_leg_events() {
1470        let mut harness = make_harness(60_000);
1471        let cl_a = ClientOrderId::from("O-A");
1472        let cl_b = ClientOrderId::from("O-B");
1473        register_default_identity(&harness.dispatch_state, cl_a);
1474        register_default_identity(&harness.dispatch_state, cl_b);
1475
1476        let req_id = 50;
1477        let pending = PendingRequest {
1478            operation: PendingOperation::BatchAdd,
1479            client_order_ids: vec![cl_a, cl_b],
1480            venue_order_ids: vec![None, None],
1481            ts_sent_ns: 0,
1482            new_quantity: None,
1483            new_price: None,
1484            new_trigger_price: None,
1485        };
1486        harness.state.pending.insert(req_id, pending);
1487
1488        let response = KrakenWsOrderResponse {
1489            method: KrakenWsMethod::BatchAdd,
1490            req_id: Some(req_id),
1491            success: true,
1492            time_in: None,
1493            time_out: None,
1494            error: None,
1495            result: Some(KrakenWsOrderResult {
1496                order_id: None,
1497                cl_ord_id: None,
1498                order_userref: None,
1499                warning: None,
1500                orders: Some(vec![
1501                    KrakenWsBatchOrderResult {
1502                        success: true,
1503                        order_id: Some("V-A".to_string()),
1504                        cl_ord_id: Some("O-A".to_string()),
1505                        error: None,
1506                    },
1507                    KrakenWsBatchOrderResult {
1508                        success: false,
1509                        order_id: None,
1510                        cl_ord_id: Some("O-B".to_string()),
1511                        error: Some("Bad price".to_string()),
1512                    },
1513                ]),
1514            }),
1515        };
1516        harness.state.handle_response(&response, 9_000);
1517
1518        let first = harness.event_rx.try_recv().expect("first event");
1519        let second = harness.event_rx.try_recv().expect("second event");
1520
1521        match first {
1522            OrderEventAny::Accepted(e) => {
1523                assert_eq!(e.client_order_id, cl_a);
1524                assert_eq!(e.venue_order_id.as_str(), "V-A");
1525            }
1526            other => panic!("expected Accepted, was {other:?}"),
1527        }
1528
1529        match second {
1530            OrderEventAny::Rejected(e) => {
1531                assert_eq!(e.client_order_id, cl_b);
1532                assert_eq!(e.reason.as_str(), "Bad price");
1533            }
1534            other => panic!("expected Rejected, was {other:?}"),
1535        }
1536    }
1537
1538    #[rstest]
1539    fn test_handle_response_batch_add_matches_legs_by_echoed_cl_ord_id() {
1540        let mut harness = make_harness(60_000);
1541        let cl_a = ClientOrderId::from("O-A");
1542        let cl_b = ClientOrderId::from("O-B");
1543        register_default_identity(&harness.dispatch_state, cl_a);
1544        register_default_identity(&harness.dispatch_state, cl_b);
1545
1546        let req_id = 60;
1547        let pending = PendingRequest {
1548            operation: PendingOperation::BatchAdd,
1549            client_order_ids: vec![cl_a, cl_b],
1550            venue_order_ids: vec![None, None],
1551            ts_sent_ns: 0,
1552            new_quantity: None,
1553            new_price: None,
1554            new_trigger_price: None,
1555        };
1556        harness.state.pending.insert(req_id, pending);
1557
1558        // Vendor returns per-leg results in REVERSE order vs sent. Echo-based
1559        // matching must attribute V-B to cl_b and V-A to cl_a regardless of
1560        // index alignment.
1561        let response = KrakenWsOrderResponse {
1562            method: KrakenWsMethod::BatchAdd,
1563            req_id: Some(req_id),
1564            success: true,
1565            time_in: None,
1566            time_out: None,
1567            error: None,
1568            result: Some(KrakenWsOrderResult {
1569                order_id: None,
1570                cl_ord_id: None,
1571                order_userref: None,
1572                warning: None,
1573                orders: Some(vec![
1574                    KrakenWsBatchOrderResult {
1575                        success: true,
1576                        order_id: Some("V-B".to_string()),
1577                        cl_ord_id: Some("O-B".to_string()),
1578                        error: None,
1579                    },
1580                    KrakenWsBatchOrderResult {
1581                        success: true,
1582                        order_id: Some("V-A".to_string()),
1583                        cl_ord_id: Some("O-A".to_string()),
1584                        error: None,
1585                    },
1586                ]),
1587            }),
1588        };
1589        harness.state.handle_response(&response, 14_000);
1590
1591        let first = harness.event_rx.try_recv().expect("first event");
1592        let second = harness.event_rx.try_recv().expect("second event");
1593        let mut by_cl_ord = std::collections::HashMap::new();
1594
1595        for event in [first, second] {
1596            match event {
1597                OrderEventAny::Accepted(e) => {
1598                    by_cl_ord.insert(e.client_order_id, e.venue_order_id);
1599                }
1600                other => panic!("expected Accepted, was {other:?}"),
1601            }
1602        }
1603        assert_eq!(
1604            by_cl_ord.get(&cl_a).map(|v| v.as_str()),
1605            Some("V-A"),
1606            "cl_a must be paired with V-A despite reversed response order",
1607        );
1608        assert_eq!(
1609            by_cl_ord.get(&cl_b).map(|v| v.as_str()),
1610            Some("V-B"),
1611            "cl_b must be paired with V-B despite reversed response order",
1612        );
1613    }
1614
1615    #[rstest]
1616    fn test_handle_response_batch_add_truncated_per_leg_results_rejects_trailing_legs() {
1617        let mut harness = make_harness(60_000);
1618        let cl_a = ClientOrderId::from("O-A");
1619        let cl_b = ClientOrderId::from("O-B");
1620        let cl_c = ClientOrderId::from("O-C");
1621        register_default_identity(&harness.dispatch_state, cl_a);
1622        register_default_identity(&harness.dispatch_state, cl_b);
1623        register_default_identity(&harness.dispatch_state, cl_c);
1624
1625        let req_id = 51;
1626        let pending = PendingRequest {
1627            operation: PendingOperation::BatchAdd,
1628            client_order_ids: vec![cl_a, cl_b, cl_c],
1629            venue_order_ids: vec![None, None, None],
1630            ts_sent_ns: 0,
1631            new_quantity: None,
1632            new_price: None,
1633            new_trigger_price: None,
1634        };
1635        harness.state.pending.insert(req_id, pending);
1636
1637        // Vendor returns envelope.success=true but only one per-leg entry for three sent.
1638        let response = KrakenWsOrderResponse {
1639            method: KrakenWsMethod::BatchAdd,
1640            req_id: Some(req_id),
1641            success: true,
1642            time_in: None,
1643            time_out: None,
1644            error: None,
1645            result: Some(KrakenWsOrderResult {
1646                order_id: None,
1647                cl_ord_id: None,
1648                order_userref: None,
1649                warning: None,
1650                orders: Some(vec![KrakenWsBatchOrderResult {
1651                    success: true,
1652                    order_id: Some("V-A".to_string()),
1653                    cl_ord_id: Some("O-A".to_string()),
1654                    error: None,
1655                }]),
1656            }),
1657        };
1658        harness.state.handle_response(&response, 12_000);
1659
1660        let first = harness.event_rx.try_recv().expect("first event");
1661        let second = harness.event_rx.try_recv().expect("second event");
1662        let third = harness.event_rx.try_recv().expect("third event");
1663
1664        match first {
1665            OrderEventAny::Accepted(e) => assert_eq!(e.client_order_id, cl_a),
1666            other => panic!("expected Accepted for present leg, was {other:?}"),
1667        }
1668
1669        for (event, cl_id) in [(second, cl_b), (third, cl_c)] {
1670            match event {
1671                OrderEventAny::Rejected(e) => {
1672                    assert_eq!(
1673                        e.client_order_id, cl_id,
1674                        "missing-leg rejection cl_ord_id mismatch",
1675                    );
1676                    assert!(
1677                        e.reason.as_str().contains("missing per-leg result"),
1678                        "expected truncation reason, was {}",
1679                        e.reason,
1680                    );
1681                }
1682                other => panic!(
1683                    "missing per-leg result must reject (not inherit envelope.success), was {other:?}",
1684                ),
1685            }
1686        }
1687    }
1688
1689    fn drain_send_payloads(
1690        rx: &mut tokio::sync::mpsc::UnboundedReceiver<SpotHandlerCommand>,
1691    ) -> Vec<String> {
1692        let mut out = Vec::new();
1693
1694        while let Ok(cmd) = rx.try_recv() {
1695            if let SpotHandlerCommand::SendOrderRequest { payload, .. } = cmd {
1696                out.push(payload);
1697            }
1698        }
1699        out
1700    }
1701
1702    // The compensating cancel is the last command the timeout task emits, so
1703    // awaiting it means the original request and the rejection event are
1704    // already enqueued; avoids racing a fixed sleep against the global runtime.
1705    async fn recv_send_payloads_until_cancel(
1706        rx: &mut tokio::sync::mpsc::UnboundedReceiver<SpotHandlerCommand>,
1707    ) -> Vec<String> {
1708        let mut out = Vec::new();
1709
1710        loop {
1711            let cmd = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1712                .await
1713                .expect("timed out awaiting compensating cancel")
1714                .expect("command channel closed");
1715
1716            let SpotHandlerCommand::SendOrderRequest { payload, .. } = cmd else {
1717                continue;
1718            };
1719            let is_cancel = payload.contains("\"cancel_order\"");
1720            out.push(payload);
1721
1722            if is_cancel {
1723                return out;
1724            }
1725        }
1726    }
1727
1728    #[tokio::test]
1729    async fn test_submit_timeout_sends_compensating_cancel() {
1730        let mut harness = make_harness(50);
1731        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1732        register_default_identity(&harness.dispatch_state, cl_ord_id);
1733        *harness.auth_token.write().await = Some("TEST-TOKEN".to_string());
1734
1735        let params = KrakenWsAddOrderParams {
1736            order_type: KrakenOrderType::Limit,
1737            side: KrakenOrderSide::Buy,
1738            order_qty: 0.001,
1739            symbol: "BTC/USD".to_string(),
1740            token: "TEST-TOKEN".to_string(),
1741            limit_price: Some(50_000.0),
1742            time_in_force: None,
1743            expire_time: None,
1744            cl_ord_id: Some(CLIENT_ORDER_ID.to_string()),
1745            post_only: None,
1746            reduce_only: None,
1747            leverage: None,
1748            trigger: None,
1749            conditional: None,
1750        };
1751        let identity = make_identity(PendingOperation::Submit);
1752        harness
1753            .state
1754            .submit(params, identity, 1)
1755            .expect("submit ok");
1756
1757        let payloads = recv_send_payloads_until_cancel(&mut harness.cmd_rx).await;
1758        assert!(
1759            payloads.iter().any(|p| p.contains("\"add_order\"")),
1760            "original add_order missing: {payloads:?}",
1761        );
1762        let cancel = payloads
1763            .iter()
1764            .find(|p| p.contains("\"cancel_order\""))
1765            .expect("compensating cancel_order missing");
1766        assert!(
1767            cancel.contains(CLIENT_ORDER_ID),
1768            "compensating cancel must reference cl_ord_id, was {cancel}",
1769        );
1770
1771        let event = harness.event_rx.try_recv().expect("rejection event");
1772        match event {
1773            OrderEventAny::Rejected(_) => {}
1774            other => panic!("expected Rejected, was {other:?}"),
1775        }
1776    }
1777
1778    #[tokio::test]
1779    async fn test_compensating_cancel_response_is_silently_dropped() {
1780        // The compensating cancel after a submit timeout is fire-and-forget;
1781        // a late response (success or failure) must not surface an event,
1782        // since the order is already in `Rejected`.
1783        let mut harness = make_harness(50);
1784        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1785        register_default_identity(&harness.dispatch_state, cl_ord_id);
1786        *harness.auth_token.write().await = Some("TEST-TOKEN".to_string());
1787
1788        let params = KrakenWsAddOrderParams {
1789            order_type: KrakenOrderType::Limit,
1790            side: KrakenOrderSide::Buy,
1791            order_qty: 0.001,
1792            symbol: "BTC/USD".to_string(),
1793            token: "TEST-TOKEN".to_string(),
1794            limit_price: Some(50_000.0),
1795            time_in_force: None,
1796            expire_time: None,
1797            cl_ord_id: Some(CLIENT_ORDER_ID.to_string()),
1798            post_only: None,
1799            reduce_only: None,
1800            leverage: None,
1801            trigger: None,
1802            conditional: None,
1803        };
1804        let identity = make_identity(PendingOperation::Submit);
1805        harness
1806            .state
1807            .submit(params, identity, 1)
1808            .expect("submit ok");
1809
1810        let payloads = recv_send_payloads_until_cancel(&mut harness.cmd_rx).await;
1811
1812        let _ = harness.event_rx.try_recv().expect("rejection event");
1813
1814        let cancel = payloads
1815            .iter()
1816            .find(|p| p.contains("\"cancel_order\""))
1817            .expect("compensating cancel missing");
1818        let cancel_value: serde_json::Value = serde_json::from_str(cancel).expect("valid json");
1819        let cancel_req_id = cancel_value["req_id"].as_u64().expect("req_id present");
1820
1821        for success in [true, false] {
1822            let response = KrakenWsOrderResponse {
1823                method: KrakenWsMethod::CancelOrder,
1824                req_id: Some(cancel_req_id),
1825                success,
1826                time_in: None,
1827                time_out: None,
1828                error: (!success).then(|| "Unknown order".to_string()),
1829                result: None,
1830            };
1831            harness.state.handle_response(&response, 9_000);
1832        }
1833
1834        assert!(
1835            harness.event_rx.try_recv().is_err(),
1836            "compensating-cancel responses must not surface events to strategies",
1837        );
1838    }
1839
1840    #[tokio::test]
1841    async fn test_submit_timeout_rejection_uses_fire_time_not_send_time() {
1842        let mut harness = make_harness(50);
1843        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1844        register_default_identity(&harness.dispatch_state, cl_ord_id);
1845
1846        let params = KrakenWsAddOrderParams {
1847            order_type: KrakenOrderType::Limit,
1848            side: KrakenOrderSide::Buy,
1849            order_qty: 0.001,
1850            symbol: "BTC/USD".to_string(),
1851            token: String::new(),
1852            limit_price: Some(50_000.0),
1853            time_in_force: None,
1854            expire_time: None,
1855            cl_ord_id: Some(CLIENT_ORDER_ID.to_string()),
1856            post_only: None,
1857            reduce_only: None,
1858            leverage: None,
1859            trigger: None,
1860            conditional: None,
1861        };
1862        // Use a deliberately ancient ts_sent so we can prove the synthesized
1863        // rejection's ts_event is NOT the send time. ts_sent_ns = 1 (1 ns past
1864        // epoch) means a successful fix gives ts_event >> 1.
1865        let identity = PendingRequest {
1866            operation: PendingOperation::Submit,
1867            client_order_ids: vec![cl_ord_id],
1868            venue_order_ids: vec![None],
1869            ts_sent_ns: 1,
1870            new_quantity: None,
1871            new_price: None,
1872            new_trigger_price: None,
1873        };
1874        harness
1875            .state
1876            .submit(params, identity, 1)
1877            .expect("submit ok");
1878
1879        let event = tokio::time::timeout(Duration::from_secs(5), harness.event_rx.recv())
1880            .await
1881            .expect("timed out awaiting rejection event")
1882            .expect("event channel closed");
1883        match event {
1884            OrderEventAny::Rejected(e) => {
1885                let ts_event_ns = e.ts_event.as_u64();
1886                assert!(
1887                    ts_event_ns > 1,
1888                    "ts_event must be the timeout-fire time (clock now), \
1889                     not the send time (1 ns); was {ts_event_ns}",
1890                );
1891            }
1892            other => panic!("expected Rejected, was {other:?}"),
1893        }
1894    }
1895
1896    #[tokio::test]
1897    async fn test_submit_timeout_without_token_skips_compensating_cancel() {
1898        let mut harness = make_harness(50);
1899        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1900        register_default_identity(&harness.dispatch_state, cl_ord_id);
1901
1902        let params = KrakenWsAddOrderParams {
1903            order_type: KrakenOrderType::Limit,
1904            side: KrakenOrderSide::Buy,
1905            order_qty: 0.001,
1906            symbol: "BTC/USD".to_string(),
1907            token: String::new(),
1908            limit_price: Some(50_000.0),
1909            time_in_force: None,
1910            expire_time: None,
1911            cl_ord_id: Some(CLIENT_ORDER_ID.to_string()),
1912            post_only: None,
1913            reduce_only: None,
1914            leverage: None,
1915            trigger: None,
1916            conditional: None,
1917        };
1918        let identity = make_identity(PendingOperation::Submit);
1919        harness
1920            .state
1921            .submit(params, identity, 1)
1922            .expect("submit ok");
1923
1924        let event = tokio::time::timeout(Duration::from_secs(5), harness.event_rx.recv())
1925            .await
1926            .expect("timed out awaiting rejection event")
1927            .expect("event channel closed");
1928        assert!(matches!(event, OrderEventAny::Rejected(_)));
1929
1930        let payloads = drain_send_payloads(&mut harness.cmd_rx);
1931        assert!(
1932            payloads.iter().all(|p| !p.contains("\"cancel_order\"")),
1933            "no compensating cancel expected without token, was {payloads:?}",
1934        );
1935    }
1936
1937    #[tokio::test]
1938    async fn test_batch_add_timeout_sends_compensating_cancel_for_all_legs() {
1939        let mut harness = make_harness(50);
1940        let cl_a = ClientOrderId::from("O-A");
1941        let cl_b = ClientOrderId::from("O-B");
1942        register_default_identity(&harness.dispatch_state, cl_a);
1943        register_default_identity(&harness.dispatch_state, cl_b);
1944        *harness.auth_token.write().await = Some("TEST-TOKEN".to_string());
1945
1946        let params = KrakenWsBatchAddParams {
1947            symbol: "BTC/USD".to_string(),
1948            orders: vec![],
1949            token: "TEST-TOKEN".to_string(),
1950        };
1951        let identity = PendingRequest {
1952            operation: PendingOperation::BatchAdd,
1953            client_order_ids: vec![cl_a, cl_b],
1954            venue_order_ids: vec![None, None],
1955            ts_sent_ns: 0,
1956            new_quantity: None,
1957            new_price: None,
1958            new_trigger_price: None,
1959        };
1960        harness
1961            .state
1962            .batch_add(params, identity, 1)
1963            .expect("batch ok");
1964
1965        let payloads = recv_send_payloads_until_cancel(&mut harness.cmd_rx).await;
1966        let cancel = payloads
1967            .iter()
1968            .find(|p| p.contains("\"cancel_order\""))
1969            .expect("compensating cancel missing");
1970        assert!(cancel.contains("O-A") && cancel.contains("O-B"));
1971    }
1972
1973    #[tokio::test]
1974    async fn test_cancellation_token_aborts_pending_timeout() {
1975        let harness = make_harness(60_000);
1976        let cl_ord_id = ClientOrderId::from(CLIENT_ORDER_ID);
1977        register_default_identity(&harness.dispatch_state, cl_ord_id);
1978
1979        let params = KrakenWsAddOrderParams {
1980            order_type: KrakenOrderType::Limit,
1981            side: KrakenOrderSide::Buy,
1982            order_qty: 0.001,
1983            symbol: "BTC/USD".to_string(),
1984            token: String::new(),
1985            limit_price: Some(50_000.0),
1986            time_in_force: None,
1987            expire_time: None,
1988            cl_ord_id: Some(CLIENT_ORDER_ID.to_string()),
1989            post_only: None,
1990            reduce_only: None,
1991            leverage: None,
1992            trigger: None,
1993            conditional: None,
1994        };
1995        let identity = make_identity(PendingOperation::Submit);
1996        harness
1997            .state
1998            .submit(params, identity, 1)
1999            .expect("submit ok");
2000        assert_eq!(harness.state.pending_len(), 1);
2001
2002        harness.cancellation_token.cancel();
2003
2004        // Cancellation clears the pending entry from a task on the global runtime,
2005        // so poll the condition rather than racing a fixed sleep.
2006        nautilus_common::testing::wait_until_async(
2007            || {
2008                let state = Arc::clone(&harness.state);
2009                async move { state.pending_len() == 0 }
2010            },
2011            Duration::from_secs(5),
2012        )
2013        .await;
2014
2015        assert_eq!(
2016            harness.state.pending_len(),
2017            0,
2018            "pending entry must be cleared on cancellation",
2019        );
2020    }
2021}
2022
2023#[cfg(test)]
2024mod property_tests {
2025    use nautilus_model::identifiers::ClientOrderId;
2026    use proptest::prelude::*;
2027    use rstest::rstest;
2028
2029    use super::{tests::make_harness, *};
2030    use crate::websocket::spot_v2::messages::{KrakenWsBatchOrderResult, KrakenWsOrderResult};
2031
2032    proptest! {
2033        #[rstest]
2034        fn no_pending_leak_on_random_response_interleaving(
2035            ops in proptest::collection::vec(
2036                prop_oneof![
2037                    Just((PendingOperation::Submit, true, 1usize)),
2038                    Just((PendingOperation::Submit, false, 1usize)),
2039                    Just((PendingOperation::Amend, true, 1usize)),
2040                    Just((PendingOperation::Amend, false, 1usize)),
2041                    Just((PendingOperation::Cancel, true, 1usize)),
2042                    Just((PendingOperation::Cancel, false, 1usize)),
2043                    (2usize..=4usize).prop_map(|n| (PendingOperation::BatchAdd, true, n)),
2044                    (2usize..=4usize).prop_map(|n| (PendingOperation::BatchAdd, false, n)),
2045                ],
2046                1..50usize,
2047            )
2048        ) {
2049            let h = make_harness(60_000);
2050            let state = &h.state;
2051            let mut req_ids = Vec::new();
2052
2053            for (op, success, leg_count) in &ops {
2054                let req_id = state.next_req_id();
2055                let client_order_ids: Vec<ClientOrderId> = (0..*leg_count)
2056                    .map(|i| ClientOrderId::from(format!("O-{req_id}-{i}").as_str()))
2057                    .collect();
2058                let venue_order_ids = vec![None; *leg_count];
2059                state.pending.insert(req_id, PendingRequest {
2060                    operation: *op,
2061                    client_order_ids: client_order_ids.clone(),
2062                    venue_order_ids,
2063                    ts_sent_ns: 0,
2064                    new_quantity: None,
2065                    new_price: None,
2066                    new_trigger_price: None,
2067                });
2068                req_ids.push((req_id, *op, *success, client_order_ids));
2069            }
2070            let mut shuffled = req_ids.clone();
2071            shuffled.reverse();
2072            for (req_id, op, success, client_order_ids) in shuffled {
2073                let method = match op {
2074                    PendingOperation::Submit => KrakenWsMethod::AddOrder,
2075                    PendingOperation::Amend => KrakenWsMethod::AmendOrder,
2076                    PendingOperation::Cancel => KrakenWsMethod::CancelOrder,
2077                    PendingOperation::BatchAdd => KrakenWsMethod::BatchAdd,
2078                };
2079                let result = if op == PendingOperation::BatchAdd {
2080                    Some(KrakenWsOrderResult {
2081                        order_id: None,
2082                        cl_ord_id: None,
2083                        order_userref: None,
2084                        warning: None,
2085                        orders: Some(client_order_ids
2086                            .iter()
2087                            .enumerate()
2088                            .map(|(i, cid)| KrakenWsBatchOrderResult {
2089                                success,
2090                                order_id: success.then(|| format!("V-{req_id}-{i}")),
2091                                cl_ord_id: Some(cid.as_str().to_string()),
2092                                error: (!success).then(|| "test-error".to_string()),
2093                            })
2094                            .collect()),
2095                    })
2096                } else {
2097                    None
2098                };
2099                let response = KrakenWsOrderResponse {
2100                    method,
2101                    req_id: Some(req_id),
2102                    success,
2103                    time_in: None,
2104                    time_out: None,
2105                    error: if success {
2106                        None
2107                    } else {
2108                        Some("test-error".to_string())
2109                    },
2110                    result,
2111                };
2112                state.handle_response(&response, 1);
2113            }
2114            prop_assert_eq!(state.pending_len(), 0);
2115        }
2116    }
2117}