1use 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 Submit,
59 Amend,
61 Cancel,
63 BatchAdd,
65}
66
67#[derive(Debug, Clone)]
68pub struct PendingRequest {
69 pub operation: PendingOperation,
71 pub client_order_ids: Vec<ClientOrderId>,
73 pub venue_order_ids: Vec<Option<VenueOrderId>>,
75 pub ts_sent_ns: u64,
77 pub new_quantity: Option<Quantity>,
82 pub new_price: Option<Price>,
86 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 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 auth_token: Arc<tokio::sync::RwLock<Option<String>>>,
108 cancellation_token: CancellationToken,
111 clock: &'static AtomicTime,
115}
116
117impl OrderRequestState {
118 #[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 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 pub fn next_req_id(&self) -> u64 {
157 self.req_id_counter.fetch_add(1, Ordering::Relaxed) + 1
158 }
159
160 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}