1use std::{
64 collections::VecDeque,
65 hash::Hash,
66 sync::{
67 Mutex,
68 atomic::{AtomicBool, Ordering},
69 },
70};
71
72use ahash::AHashSet;
73use dashmap::{DashMap, DashSet};
74use nautilus_core::{MUTEX_POISONED, UUID4, UnixNanos};
75use nautilus_live::ExecutionEventEmitter;
76use nautilus_model::{
77 enums::{OrderSide, OrderStatus, OrderType},
78 events::{
79 OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
80 OrderTriggered, OrderUpdated,
81 },
82 identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TradeId, VenueOrderId},
83 reports::{FillReport, OrderStatusReport},
84 types::{Price, Quantity},
85};
86use ustr::Ustr;
87
88use crate::http::models::HyperliquidExecPlaceOrderRequest;
89
90pub const DEDUP_CAPACITY: usize = 10_000;
91
92#[derive(Debug, Clone)]
99pub struct OrderIdentity {
100 pub strategy_id: StrategyId,
102 pub instrument_id: InstrumentId,
104 pub order_side: OrderSide,
106 pub order_type: OrderType,
108 pub quantity: Quantity,
110 pub price: Option<Price>,
114}
115
116#[derive(Debug)]
124pub struct BoundedDedup<T>
125where
126 T: Eq + Hash + Clone,
127{
128 order: VecDeque<T>,
129 set: AHashSet<T>,
130 capacity: usize,
131}
132
133impl<T> BoundedDedup<T>
134where
135 T: Eq + Hash + Clone,
136{
137 #[must_use]
139 pub fn new(capacity: usize) -> Self {
140 Self {
141 order: VecDeque::with_capacity(capacity),
142 set: AHashSet::with_capacity(capacity),
143 capacity,
144 }
145 }
146
147 pub fn insert(&mut self, value: T) -> bool {
149 if self.set.contains(&value) {
150 return true;
151 }
152
153 if self.order.len() >= self.capacity
154 && let Some(evicted) = self.order.pop_front()
155 {
156 self.set.remove(&evicted);
157 }
158
159 self.order.push_back(value.clone());
160 self.set.insert(value);
161 false
162 }
163
164 #[must_use]
166 pub fn len(&self) -> usize {
167 self.set.len()
168 }
169
170 #[must_use]
172 pub fn is_empty(&self) -> bool {
173 self.set.is_empty()
174 }
175
176 #[must_use]
178 pub fn contains(&self, value: &T) -> bool {
179 self.set.contains(value)
180 }
181}
182
183#[derive(Debug)]
193pub struct WsDispatchState {
194 pub order_identities: DashMap<ClientOrderId, OrderIdentity>,
196 pub emitted_accepted: DashSet<ClientOrderId>,
198 pub filled_orders: DashSet<ClientOrderId>,
203 pub emitted_trades: Mutex<BoundedDedup<TradeId>>,
208 pub terminal_cloids: Mutex<BoundedDedup<Ustr>>,
211 pub cached_venue_order_ids: DashMap<ClientOrderId, VenueOrderId>,
218 pub pending_modify_keys: DashMap<ClientOrderId, VenueOrderId>,
227 pub pending_modify_target_qty: DashMap<ClientOrderId, Quantity>,
231 pub buffered_fills: DashMap<ClientOrderId, Vec<FillReport>>,
236 pub order_filled_qty: DashMap<ClientOrderId, Quantity>,
239 pub pending_modify_request: DashMap<ClientOrderId, HyperliquidExecPlaceOrderRequest>,
242 pub pending_corrective: DashMap<ClientOrderId, (u64, HyperliquidExecPlaceOrderRequest)>,
245 clearing: AtomicBool,
246}
247
248impl Default for WsDispatchState {
249 fn default() -> Self {
250 Self {
251 order_identities: DashMap::new(),
252 emitted_accepted: DashSet::default(),
253 filled_orders: DashSet::default(),
254 emitted_trades: Mutex::new(BoundedDedup::new(DEDUP_CAPACITY)),
255 terminal_cloids: Mutex::new(BoundedDedup::new(DEDUP_CAPACITY)),
256 cached_venue_order_ids: DashMap::new(),
257 pending_modify_keys: DashMap::new(),
258 pending_modify_target_qty: DashMap::new(),
259 buffered_fills: DashMap::new(),
260 order_filled_qty: DashMap::new(),
261 pending_modify_request: DashMap::new(),
262 pending_corrective: DashMap::new(),
263 clearing: AtomicBool::new(false),
264 }
265 }
266}
267
268impl WsDispatchState {
269 #[must_use]
271 pub fn new() -> Self {
272 Self::default()
273 }
274
275 pub fn register_identity(&self, client_order_id: ClientOrderId, identity: OrderIdentity) {
278 self.order_identities.insert(client_order_id, identity);
279 }
280
281 #[must_use]
283 pub fn lookup_identity(&self, client_order_id: &ClientOrderId) -> Option<OrderIdentity> {
284 self.order_identities
285 .get(client_order_id)
286 .map(|r| r.clone())
287 }
288
289 pub fn update_identity_price(&self, client_order_id: &ClientOrderId, price: Option<Price>) {
292 if let Some(price) = price
293 && let Some(mut entry) = self.order_identities.get_mut(client_order_id)
294 {
295 entry.price = Some(price);
296 }
297 }
298
299 pub fn update_identity_quantity(&self, client_order_id: &ClientOrderId, quantity: Quantity) {
301 if let Some(mut entry) = self.order_identities.get_mut(client_order_id) {
302 entry.quantity = quantity;
303 }
304 }
305
306 pub fn insert_accepted(&self, cid: ClientOrderId) {
308 self.evict_if_full(&self.emitted_accepted);
309 self.emitted_accepted.insert(cid);
310 }
311
312 pub fn insert_filled(&self, cid: ClientOrderId) -> bool {
317 self.evict_if_full(&self.filled_orders);
318 self.filled_orders.insert(cid)
319 }
320
321 #[allow(
326 clippy::missing_panics_doc,
327 reason = "dedup mutex poisoning is not expected"
328 )]
329 pub fn check_and_insert_trade(&self, trade_id: TradeId) -> bool {
330 let mut set = self.emitted_trades.lock().expect(MUTEX_POISONED);
331 set.insert(trade_id)
332 }
333
334 #[allow(
341 clippy::missing_panics_doc,
342 reason = "terminal cloid mutex poisoning is not expected"
343 )]
344 pub fn insert_terminal_cloid(&self, cloid: Ustr) {
345 let mut set = self.terminal_cloids.lock().expect(MUTEX_POISONED);
346 set.insert(cloid);
347 }
348
349 #[allow(
352 clippy::missing_panics_doc,
353 reason = "terminal cloid mutex poisoning is not expected"
354 )]
355 #[must_use]
356 pub fn terminal_cloid_seen(&self, cloid: &Ustr) -> bool {
357 let set = self.terminal_cloids.lock().expect(MUTEX_POISONED);
358 set.contains(cloid)
359 }
360
361 pub fn record_venue_order_id(
363 &self,
364 client_order_id: ClientOrderId,
365 venue_order_id: VenueOrderId,
366 ) {
367 self.cached_venue_order_ids
368 .insert(client_order_id, venue_order_id);
369 }
370
371 #[must_use]
373 pub fn cached_venue_order_id(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
374 self.cached_venue_order_ids.get(client_order_id).map(|r| *r)
375 }
376
377 pub fn mark_pending_modify(
380 &self,
381 client_order_id: ClientOrderId,
382 old_venue_order_id: VenueOrderId,
383 target_qty: Quantity,
384 ) {
385 self.pending_modify_keys
386 .insert(client_order_id, old_venue_order_id);
387 self.pending_modify_target_qty
388 .insert(client_order_id, target_qty);
389 }
390
391 pub fn clear_pending_modify(&self, client_order_id: &ClientOrderId) {
393 self.pending_modify_keys.remove(client_order_id);
394 self.pending_modify_target_qty.remove(client_order_id);
395 self.pending_modify_request.remove(client_order_id);
396 }
397
398 pub fn stash_modify_request(
400 &self,
401 client_order_id: ClientOrderId,
402 request: HyperliquidExecPlaceOrderRequest,
403 ) {
404 self.pending_modify_request.insert(client_order_id, request);
405 }
406
407 #[must_use]
409 pub fn modify_request(
410 &self,
411 client_order_id: &ClientOrderId,
412 ) -> Option<HyperliquidExecPlaceOrderRequest> {
413 self.pending_modify_request
414 .get(client_order_id)
415 .map(|r| r.clone())
416 }
417
418 pub fn queue_corrective(
420 &self,
421 client_order_id: ClientOrderId,
422 oid: u64,
423 request: HyperliquidExecPlaceOrderRequest,
424 ) {
425 self.pending_corrective
426 .insert(client_order_id, (oid, request));
427 }
428
429 #[must_use]
431 pub fn take_corrective(
432 &self,
433 client_order_id: &ClientOrderId,
434 ) -> Option<(u64, HyperliquidExecPlaceOrderRequest)> {
435 self.pending_corrective
436 .remove(client_order_id)
437 .map(|(_, v)| v)
438 }
439
440 #[must_use]
442 pub fn pending_modify(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
443 self.pending_modify_keys.get(client_order_id).map(|r| *r)
444 }
445
446 #[must_use]
448 pub fn pending_modify_target_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
449 self.pending_modify_target_qty
450 .get(client_order_id)
451 .map(|r| *r)
452 }
453
454 pub fn buffer_fill(&self, client_order_id: ClientOrderId, fill: FillReport) {
456 self.buffered_fills
457 .entry(client_order_id)
458 .or_default()
459 .push(fill);
460 }
461
462 #[must_use]
464 pub fn drain_buffered_fills(&self, client_order_id: &ClientOrderId) -> Vec<FillReport> {
465 self.buffered_fills
466 .remove(client_order_id)
467 .map(|(_, v)| v)
468 .unwrap_or_default()
469 }
470
471 #[must_use]
473 pub fn buffered_fill_count(&self, client_order_id: &ClientOrderId) -> usize {
474 self.buffered_fills
475 .get(client_order_id)
476 .map_or(0, |r| r.len())
477 }
478
479 pub fn record_filled_qty(&self, client_order_id: ClientOrderId, qty: Quantity) {
481 self.order_filled_qty.insert(client_order_id, qty);
482 }
483
484 #[must_use]
486 pub fn previous_filled_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
487 self.order_filled_qty.get(client_order_id).map(|r| *r)
488 }
489
490 pub fn cleanup_terminal(&self, client_order_id: &ClientOrderId) {
495 self.order_identities.remove(client_order_id);
496 self.emitted_accepted.remove(client_order_id);
497 self.cached_venue_order_ids.remove(client_order_id);
498 self.pending_modify_keys.remove(client_order_id);
499 self.pending_modify_target_qty.remove(client_order_id);
500 self.pending_modify_request.remove(client_order_id);
501 self.pending_corrective.remove(client_order_id);
502 self.buffered_fills.remove(client_order_id);
503 self.order_filled_qty.remove(client_order_id);
504 }
505
506 fn evict_if_full(&self, set: &DashSet<ClientOrderId>) {
507 if set.len() >= DEDUP_CAPACITY
508 && self
509 .clearing
510 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
511 .is_ok()
512 {
513 set.clear();
514 self.clearing.store(false, Ordering::Release);
515 }
516 }
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq)]
521pub enum DispatchOutcome {
522 Tracked,
526 External,
531 Skip,
535}
536
537pub fn dispatch_order_event(
547 report: &OrderStatusReport,
548 state: &WsDispatchState,
549 emitter: &ExecutionEventEmitter,
550 ts_init: UnixNanos,
551) -> DispatchOutcome {
552 let Some(client_order_id) = report.client_order_id else {
553 return DispatchOutcome::External;
554 };
555
556 if state.filled_orders.contains(&client_order_id) {
557 log::debug!(
558 "Skipping stale report for filled order: cid={client_order_id}, status={:?}",
559 report.order_status,
560 );
561 return DispatchOutcome::Skip;
562 }
563
564 let client_order_id_str = client_order_id.as_str();
565 if client_order_id_str.starts_with("0x")
566 && state.terminal_cloid_seen(&Ustr::from(client_order_id_str))
567 {
568 log::debug!(
569 "Skipping stale terminal report for raw cloid: cid={client_order_id}, status={:?}",
570 report.order_status,
571 );
572 return DispatchOutcome::Skip;
573 }
574
575 let Some(identity) = state.lookup_identity(&client_order_id) else {
576 return DispatchOutcome::External;
577 };
578
579 match report.order_status {
580 OrderStatus::Accepted => {
581 handle_accepted(report, client_order_id, &identity, state, emitter, ts_init)
582 }
583 OrderStatus::Triggered => {
584 handle_triggered(report, client_order_id, &identity, state, emitter, ts_init)
585 }
586 OrderStatus::Canceled => {
587 handle_canceled(report, client_order_id, &identity, state, emitter, ts_init)
588 }
589 OrderStatus::Expired => {
590 handle_expired(report, client_order_id, &identity, state, emitter, ts_init)
591 }
592 OrderStatus::Rejected => {
593 handle_rejected(report, client_order_id, &identity, state, emitter, ts_init)
594 }
595 OrderStatus::Filled => handle_filled_marker(client_order_id, state),
596 OrderStatus::PartiallyFilled => {
597 DispatchOutcome::Tracked
599 }
600 OrderStatus::PendingUpdate
601 | OrderStatus::PendingCancel
602 | OrderStatus::Submitted
603 | OrderStatus::Initialized
604 | OrderStatus::Denied
605 | OrderStatus::Released
606 | OrderStatus::Emulated => DispatchOutcome::Tracked,
607 }
608}
609
610pub fn dispatch_order_fill(
621 report: &FillReport,
622 state: &WsDispatchState,
623 emitter: &ExecutionEventEmitter,
624 ts_init: UnixNanos,
625) -> DispatchOutcome {
626 let Some(client_order_id) = report.client_order_id else {
627 return DispatchOutcome::External;
628 };
629
630 if state.filled_orders.contains(&client_order_id) {
631 log::debug!(
632 "Skipping stale fill for filled order: cid={client_order_id}, trade_id={}",
633 report.trade_id,
634 );
635 return DispatchOutcome::Skip;
636 }
637
638 let Some(mut identity) = state.lookup_identity(&client_order_id) else {
639 return DispatchOutcome::External;
640 };
641
642 let mut promoted_corrective: Option<(Quantity, HyperliquidExecPlaceOrderRequest)> = None;
644
645 if state.pending_modify(&client_order_id).is_some()
648 && let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
649 && report.venue_order_id != cached_voi
650 {
651 let target = state.pending_modify_target_qty(&client_order_id);
652 let sent_request = state.modify_request(&client_order_id);
653 let price = sent_request
655 .as_ref()
656 .zip(identity.price)
657 .and_then(|(r, cached)| Price::from_decimal_dp(r.price, cached.precision).ok())
658 .or(identity.price);
659 let Some(price) = price else {
660 log::warn!(
661 "Cannot promote cancel-replace for {client_order_id} from fill: no target \
662 or cached price; buffering until the replacement ACCEPTED arrives",
663 );
664 state.buffer_fill(client_order_id, report.clone());
665 return DispatchOutcome::Tracked;
666 };
667 let updated_quantity = target.unwrap_or(identity.quantity);
668 promote_cancel_replace(
669 client_order_id,
670 &identity,
671 state,
672 emitter,
673 report.venue_order_id,
674 report.account_id,
675 price,
676 updated_quantity,
677 None,
678 report.ts_event,
679 ts_init,
680 );
681 if let Some(updated) = state.lookup_identity(&client_order_id) {
683 identity = updated;
684 }
685
686 if let (Some(target), Some(sent_request)) = (target, sent_request) {
687 promoted_corrective = Some((target, sent_request));
688 }
689 }
690
691 if state.check_and_insert_trade(report.trade_id) {
692 log::debug!(
693 "Skipping duplicate fill for {client_order_id}: trade_id={}",
694 report.trade_id
695 );
696 return DispatchOutcome::Tracked;
697 }
698
699 let previous = state
700 .previous_filled_qty(&client_order_id)
701 .unwrap_or_else(|| Quantity::zero(report.last_qty.precision));
702 let cumulative = previous + report.last_qty;
703
704 let is_terminal_fill = cumulative >= identity.quantity;
705 if is_terminal_fill && !claim_terminal_order(client_order_id, state, OrderStatus::Filled) {
706 return DispatchOutcome::Skip;
707 }
708
709 ensure_accepted_emitted(
710 client_order_id,
711 report.venue_order_id,
712 report.account_id,
713 &identity,
714 state,
715 emitter,
716 report.ts_event,
717 ts_init,
718 );
719
720 let filled = OrderFilled::new(
721 emitter.trader_id(),
722 identity.strategy_id,
723 identity.instrument_id,
724 client_order_id,
725 report.venue_order_id,
726 report.account_id,
727 report.trade_id,
728 identity.order_side,
729 identity.order_type,
730 report.last_qty,
731 report.last_px,
732 report.commission.currency,
733 report.liquidity_side,
734 UUID4::new(),
735 report.ts_event,
736 ts_init,
737 false,
738 report.venue_position_id,
739 Some(report.commission),
740 );
741 emitter.send_order_event(OrderEventAny::Filled(filled));
742
743 state.record_filled_qty(client_order_id, cumulative);
744
745 if let Some((target, sent_request)) = promoted_corrective {
747 maybe_queue_corrective_reduce(
748 state,
749 client_order_id,
750 report.venue_order_id,
751 target,
752 sent_request,
753 );
754 }
755
756 if is_terminal_fill {
757 state.cleanup_terminal(&client_order_id);
758 }
759
760 DispatchOutcome::Tracked
761}
762
763fn handle_accepted(
764 report: &OrderStatusReport,
765 client_order_id: ClientOrderId,
766 identity: &OrderIdentity,
767 state: &WsDispatchState,
768 emitter: &ExecutionEventEmitter,
769 ts_init: UnixNanos,
770) -> DispatchOutcome {
771 let venue_order_id = report.venue_order_id;
772 let ts_event = report.ts_last;
773 let account_id = report.account_id;
774
775 if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
780 && cached_voi != venue_order_id
781 {
782 let price = report.price.or(identity.price);
783 let Some(price) = price else {
784 log::warn!(
785 "Cannot emit OrderUpdated for cancel-replace {client_order_id}: \
786 no price on report and no cached price on identity",
787 );
788 return DispatchOutcome::Skip;
789 };
790
791 let target_total_qty = state.pending_modify_target_qty(&client_order_id);
794 let updated_quantity = target_total_qty.unwrap_or(report.quantity);
795 let sent_request = state.modify_request(&client_order_id);
796
797 promote_cancel_replace(
798 client_order_id,
799 identity,
800 state,
801 emitter,
802 venue_order_id,
803 account_id,
804 price,
805 updated_quantity,
806 report.trigger_price,
807 ts_event,
808 ts_init,
809 );
810
811 if let (Some(target), Some(sent_request)) = (target_total_qty, sent_request) {
812 maybe_queue_corrective_reduce(
813 state,
814 client_order_id,
815 venue_order_id,
816 target,
817 sent_request,
818 );
819 }
820
821 return DispatchOutcome::Tracked;
822 }
823
824 if state.emitted_accepted.contains(&client_order_id) {
825 state.update_identity_price(&client_order_id, report.price);
829 return DispatchOutcome::Tracked;
830 }
831
832 state.insert_accepted(client_order_id);
833 state.record_venue_order_id(client_order_id, venue_order_id);
834 state.update_identity_price(&client_order_id, report.price);
835
836 let accepted = OrderAccepted::new(
837 emitter.trader_id(),
838 identity.strategy_id,
839 identity.instrument_id,
840 client_order_id,
841 venue_order_id,
842 account_id,
843 UUID4::new(),
844 ts_event,
845 ts_init,
846 false,
847 );
848 emitter.send_order_event(OrderEventAny::Accepted(accepted));
849 DispatchOutcome::Tracked
850}
851
852#[allow(
855 clippy::too_many_arguments,
856 reason = "promotion needs the full OrderUpdated field set, sourced from two report shapes"
857)]
858fn promote_cancel_replace(
859 client_order_id: ClientOrderId,
860 identity: &OrderIdentity,
861 state: &WsDispatchState,
862 emitter: &ExecutionEventEmitter,
863 venue_order_id: VenueOrderId,
864 account_id: AccountId,
865 price: Price,
866 quantity: Quantity,
867 trigger_price: Option<Price>,
868 ts_event: UnixNanos,
869 ts_init: UnixNanos,
870) {
871 state.record_venue_order_id(client_order_id, venue_order_id);
872 state.update_identity_quantity(&client_order_id, quantity);
873 state.update_identity_price(&client_order_id, Some(price));
874 state.clear_pending_modify(&client_order_id);
875
876 let updated = OrderUpdated::new(
877 emitter.trader_id(),
878 identity.strategy_id,
879 identity.instrument_id,
880 client_order_id,
881 quantity,
882 UUID4::new(),
883 ts_event,
884 ts_init,
885 false,
886 Some(venue_order_id),
887 Some(account_id),
888 Some(price),
889 trigger_price,
890 None,
891 false,
892 );
893 emitter.send_order_event(OrderEventAny::Updated(updated));
894
895 let buffered = state.drain_buffered_fills(&client_order_id);
898 for fill in buffered {
899 dispatch_order_fill(&fill, state, emitter, ts_init);
900 }
901}
902
903pub fn promote_replacement_from_query(
910 report: &OrderStatusReport,
911 state: &WsDispatchState,
912 emitter: &ExecutionEventEmitter,
913 ts_init: UnixNanos,
914) -> bool {
915 if report.order_status != OrderStatus::Accepted {
916 return false;
917 }
918
919 let Some(client_order_id) = report.client_order_id else {
920 return false;
921 };
922
923 if state.pending_modify(&client_order_id).is_none() {
924 return false;
925 }
926
927 let Some(cached_voi) = state.cached_venue_order_id(&client_order_id) else {
928 return false;
929 };
930
931 if report.venue_order_id == cached_voi {
932 return false;
933 }
934
935 let Some(identity) = state.lookup_identity(&client_order_id) else {
936 return false;
937 };
938
939 let Some(price) = report.price.or(identity.price) else {
940 log::warn!(
941 "Cannot promote cancel-replace from query for {client_order_id}: \
942 no price on report and no cached price on identity",
943 );
944 return false;
945 };
946
947 let updated_quantity = state
949 .pending_modify_target_qty(&client_order_id)
950 .unwrap_or(report.quantity);
951
952 promote_cancel_replace(
953 client_order_id,
954 &identity,
955 state,
956 emitter,
957 report.venue_order_id,
958 report.account_id,
959 price,
960 updated_quantity,
961 report.trigger_price,
962 report.ts_last,
963 ts_init,
964 );
965
966 log::debug!("Promoted cancel-replace replacement for {client_order_id} from query");
967
968 true
969}
970
971fn maybe_queue_corrective_reduce(
974 state: &WsDispatchState,
975 client_order_id: ClientOrderId,
976 venue_order_id: VenueOrderId,
977 target: Quantity,
978 sent_request: HyperliquidExecPlaceOrderRequest,
979) {
980 let Ok(new_oid) = venue_order_id.as_str().parse::<u64>() else {
981 return;
982 };
983
984 let filled = state
985 .previous_filled_qty(&client_order_id)
986 .unwrap_or_else(|| Quantity::zero(target.precision));
987 if filled >= target {
988 return;
989 }
990
991 let remaining = (target - filled).as_decimal().normalize();
992
993 let sent_size = sent_request.size;
994 if sent_size > remaining {
995 let mut corrective = sent_request;
996 corrective.size = remaining;
997
998 state.mark_pending_modify(client_order_id, venue_order_id, target);
999 state.stash_modify_request(client_order_id, corrective.clone());
1000 state.queue_corrective(client_order_id, new_oid, corrective);
1001
1002 log::warn!(
1003 "Cancel-replace left {client_order_id} oversized on {venue_order_id} \
1004 (sent {sent_size}, remaining {remaining}); queuing corrective reduce",
1005 );
1006 }
1007}
1008
1009fn handle_triggered(
1010 report: &OrderStatusReport,
1011 client_order_id: ClientOrderId,
1012 identity: &OrderIdentity,
1013 state: &WsDispatchState,
1014 emitter: &ExecutionEventEmitter,
1015 ts_init: UnixNanos,
1016) -> DispatchOutcome {
1017 if !matches!(
1018 identity.order_type,
1019 OrderType::StopLimit | OrderType::TrailingStopLimit | OrderType::LimitIfTouched
1020 ) {
1021 log::debug!(
1022 "Ignoring TRIGGERED status for non-triggerable order type {:?}: {client_order_id}",
1023 identity.order_type,
1024 );
1025 return DispatchOutcome::Tracked;
1026 }
1027
1028 ensure_accepted_emitted(
1029 client_order_id,
1030 report.venue_order_id,
1031 report.account_id,
1032 identity,
1033 state,
1034 emitter,
1035 report.ts_last,
1036 ts_init,
1037 );
1038
1039 let triggered = OrderTriggered::new(
1040 emitter.trader_id(),
1041 identity.strategy_id,
1042 identity.instrument_id,
1043 client_order_id,
1044 UUID4::new(),
1045 report.ts_last,
1046 ts_init,
1047 false,
1048 Some(report.venue_order_id),
1049 Some(report.account_id),
1050 );
1051 emitter.send_order_event(OrderEventAny::Triggered(triggered));
1052 DispatchOutcome::Tracked
1053}
1054
1055fn handle_canceled(
1056 report: &OrderStatusReport,
1057 client_order_id: ClientOrderId,
1058 identity: &OrderIdentity,
1059 state: &WsDispatchState,
1060 emitter: &ExecutionEventEmitter,
1061 ts_init: UnixNanos,
1062) -> DispatchOutcome {
1063 let venue_order_id = report.venue_order_id;
1064
1065 if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
1069 && cached_voi != venue_order_id
1070 {
1071 log::debug!(
1072 "Skipping stale CANCELED for {venue_order_id} (cached {cached_voi}) on {client_order_id}",
1073 );
1074 return DispatchOutcome::Skip;
1075 }
1076
1077 if let Some(pending_old) = state.pending_modify(&client_order_id)
1083 && pending_old == venue_order_id
1084 {
1085 log::debug!(
1086 "Skipping cancel-before-accept leg for {client_order_id}: venue_order_id={venue_order_id}",
1087 );
1088 return DispatchOutcome::Skip;
1089 }
1090
1091 if !claim_terminal_order(client_order_id, state, report.order_status) {
1092 return DispatchOutcome::Skip;
1093 }
1094
1095 ensure_accepted_emitted(
1096 client_order_id,
1097 venue_order_id,
1098 report.account_id,
1099 identity,
1100 state,
1101 emitter,
1102 report.ts_last,
1103 ts_init,
1104 );
1105
1106 let canceled = OrderCanceled::new(
1107 emitter.trader_id(),
1108 identity.strategy_id,
1109 identity.instrument_id,
1110 client_order_id,
1111 UUID4::new(),
1112 report.ts_last,
1113 ts_init,
1114 false,
1115 Some(venue_order_id),
1116 Some(report.account_id),
1117 );
1118 emitter.send_order_event(OrderEventAny::Canceled(canceled));
1119
1120 state.cleanup_terminal(&client_order_id);
1121 DispatchOutcome::Tracked
1122}
1123
1124fn handle_expired(
1125 report: &OrderStatusReport,
1126 client_order_id: ClientOrderId,
1127 identity: &OrderIdentity,
1128 state: &WsDispatchState,
1129 emitter: &ExecutionEventEmitter,
1130 ts_init: UnixNanos,
1131) -> DispatchOutcome {
1132 if !claim_terminal_order(client_order_id, state, report.order_status) {
1133 return DispatchOutcome::Skip;
1134 }
1135
1136 ensure_accepted_emitted(
1137 client_order_id,
1138 report.venue_order_id,
1139 report.account_id,
1140 identity,
1141 state,
1142 emitter,
1143 report.ts_last,
1144 ts_init,
1145 );
1146
1147 let expired = OrderExpired::new(
1148 emitter.trader_id(),
1149 identity.strategy_id,
1150 identity.instrument_id,
1151 client_order_id,
1152 UUID4::new(),
1153 report.ts_last,
1154 ts_init,
1155 false,
1156 Some(report.venue_order_id),
1157 Some(report.account_id),
1158 );
1159 emitter.send_order_event(OrderEventAny::Expired(expired));
1160 state.cleanup_terminal(&client_order_id);
1161 DispatchOutcome::Tracked
1162}
1163
1164fn handle_rejected(
1165 report: &OrderStatusReport,
1166 client_order_id: ClientOrderId,
1167 identity: &OrderIdentity,
1168 state: &WsDispatchState,
1169 emitter: &ExecutionEventEmitter,
1170 ts_init: UnixNanos,
1171) -> DispatchOutcome {
1172 if !claim_terminal_order(client_order_id, state, report.order_status) {
1173 return DispatchOutcome::Skip;
1174 }
1175
1176 let reason = report
1177 .cancel_reason
1178 .clone()
1179 .unwrap_or_else(|| "Order rejected by exchange".to_string());
1180 let rejected = OrderRejected::new(
1181 emitter.trader_id(),
1182 identity.strategy_id,
1183 identity.instrument_id,
1184 client_order_id,
1185 report.account_id,
1186 Ustr::from(&reason),
1187 UUID4::new(),
1188 report.ts_last,
1189 ts_init,
1190 false,
1191 false,
1192 );
1193 emitter.send_order_event(OrderEventAny::Rejected(rejected));
1194 state.cleanup_terminal(&client_order_id);
1195 DispatchOutcome::Tracked
1196}
1197
1198fn claim_terminal_order(
1199 client_order_id: ClientOrderId,
1200 state: &WsDispatchState,
1201 status: OrderStatus,
1202) -> bool {
1203 let claimed = state.insert_filled(client_order_id);
1204 if !claimed {
1205 log::debug!("Skipping duplicate terminal event for {client_order_id}: status={status:?}",);
1206 }
1207
1208 claimed
1209}
1210
1211fn handle_filled_marker(
1212 _client_order_id: ClientOrderId,
1213 _state: &WsDispatchState,
1214) -> DispatchOutcome {
1215 DispatchOutcome::Tracked
1223}
1224
1225#[allow(clippy::too_many_arguments)]
1233fn ensure_accepted_emitted(
1234 client_order_id: ClientOrderId,
1235 venue_order_id: VenueOrderId,
1236 account_id: AccountId,
1237 identity: &OrderIdentity,
1238 state: &WsDispatchState,
1239 emitter: &ExecutionEventEmitter,
1240 ts_event: UnixNanos,
1241 ts_init: UnixNanos,
1242) {
1243 if state.emitted_accepted.contains(&client_order_id) {
1244 return;
1245 }
1246 state.insert_accepted(client_order_id);
1247 state.record_venue_order_id(client_order_id, venue_order_id);
1248
1249 let accepted = OrderAccepted::new(
1250 emitter.trader_id(),
1251 identity.strategy_id,
1252 identity.instrument_id,
1253 client_order_id,
1254 venue_order_id,
1255 account_id,
1256 UUID4::new(),
1257 ts_event,
1258 ts_init,
1259 false,
1260 );
1261 emitter.send_order_event(OrderEventAny::Accepted(accepted));
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, StrategyId, TradeId};
1267 use rstest::rstest;
1268 use rust_decimal::Decimal;
1269
1270 use super::*;
1271 use crate::http::models::{
1272 HyperliquidExecLimitParams, HyperliquidExecOrderKind, HyperliquidExecTif,
1273 };
1274
1275 fn make_identity() -> OrderIdentity {
1276 OrderIdentity {
1277 strategy_id: StrategyId::from("S-001"),
1278 instrument_id: InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
1279 order_side: OrderSide::Buy,
1280 order_type: OrderType::Limit,
1281 quantity: Quantity::from("0.0001"),
1282 price: None,
1283 }
1284 }
1285
1286 #[rstest]
1287 fn test_register_and_lookup_identity() {
1288 let state = WsDispatchState::new();
1289 let cid = ClientOrderId::new("O-001");
1290 state.register_identity(cid, make_identity());
1291
1292 let found = state.lookup_identity(&cid);
1293 assert!(found.is_some());
1294 let identity = found.unwrap();
1295 assert_eq!(identity.strategy_id.as_str(), "S-001");
1296 assert_eq!(identity.order_side, OrderSide::Buy);
1297 }
1298
1299 #[rstest]
1300 fn test_lookup_identity_missing_returns_none() {
1301 let state = WsDispatchState::new();
1302 let cid = ClientOrderId::new("not-tracked");
1303 assert!(state.lookup_identity(&cid).is_none());
1304 }
1305
1306 #[rstest]
1307 fn test_insert_accepted_dedup() {
1308 let state = WsDispatchState::new();
1309 let cid = ClientOrderId::new("O-002");
1310 assert!(!state.emitted_accepted.contains(&cid));
1311 state.insert_accepted(cid);
1312 assert!(state.emitted_accepted.contains(&cid));
1313 state.insert_accepted(cid);
1314 assert!(state.emitted_accepted.contains(&cid));
1315 }
1316
1317 #[rstest]
1318 fn test_check_and_insert_trade_detects_duplicates() {
1319 let state = WsDispatchState::new();
1320 let trade = TradeId::new("trade-1");
1321 assert!(!state.check_and_insert_trade(trade));
1322 assert!(state.check_and_insert_trade(trade));
1323 }
1324
1325 #[rstest]
1326 fn test_bounded_dedup_fifo_eviction_preserves_recent_ids() {
1327 let mut dedup: BoundedDedup<TradeId> = BoundedDedup::new(3);
1328 assert!(!dedup.insert(TradeId::new("t-0")));
1329 assert!(!dedup.insert(TradeId::new("t-1")));
1330 assert!(!dedup.insert(TradeId::new("t-2")));
1331 assert_eq!(dedup.len(), 3);
1332
1333 assert!(!dedup.insert(TradeId::new("t-3")));
1335 assert_eq!(dedup.len(), 3);
1336 assert!(!dedup.contains(&TradeId::new("t-0")));
1337 assert!(dedup.contains(&TradeId::new("t-1")));
1338 assert!(dedup.contains(&TradeId::new("t-3")));
1339 }
1340
1341 #[rstest]
1342 fn test_pending_modify_roundtrip() {
1343 let state = WsDispatchState::new();
1344 let cid = ClientOrderId::new("O-010");
1345 let voi = VenueOrderId::new("v-1");
1346 let target_qty = Quantity::from("0.0001");
1347
1348 assert!(state.pending_modify(&cid).is_none());
1349 assert!(state.pending_modify_target_qty(&cid).is_none());
1350 state.mark_pending_modify(cid, voi, target_qty);
1351 assert_eq!(state.pending_modify(&cid), Some(voi));
1352 assert_eq!(state.pending_modify_target_qty(&cid), Some(target_qty));
1353 state.clear_pending_modify(&cid);
1354 assert!(state.pending_modify(&cid).is_none());
1355 assert!(state.pending_modify_target_qty(&cid).is_none());
1356 }
1357
1358 #[rstest]
1359 fn test_cleanup_terminal_preserves_filled_marker() {
1360 let state = WsDispatchState::new();
1361 let cid = ClientOrderId::new("O-020");
1362 state.register_identity(cid, make_identity());
1363 state.insert_accepted(cid);
1364 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.0001"));
1365 state.insert_filled(cid);
1366 state.cleanup_terminal(&cid);
1367
1368 assert!(state.lookup_identity(&cid).is_none());
1369 assert!(!state.emitted_accepted.contains(&cid));
1370 assert!(state.pending_modify(&cid).is_none());
1371 assert!(state.pending_modify_target_qty(&cid).is_none());
1372 assert!(state.filled_orders.contains(&cid));
1374 }
1375
1376 #[rstest]
1377 fn test_cleanup_terminal_clears_corrective_state() {
1378 let state = WsDispatchState::new();
1379 let cid = ClientOrderId::new("O-021");
1380 let request = HyperliquidExecPlaceOrderRequest {
1381 asset: 0,
1382 is_buy: true,
1383 price: "100".parse::<Decimal>().unwrap(),
1384 size: Decimal::from(1),
1385 reduce_only: false,
1386 kind: HyperliquidExecOrderKind::Limit {
1387 limit: HyperliquidExecLimitParams {
1388 tif: HyperliquidExecTif::Gtc,
1389 },
1390 },
1391 cloid: None,
1392 };
1393 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("1"));
1394 state.stash_modify_request(cid, request.clone());
1395 state.queue_corrective(cid, 1, request);
1396 assert!(state.modify_request(&cid).is_some());
1397
1398 state.cleanup_terminal(&cid);
1399
1400 assert!(state.modify_request(&cid).is_none());
1401 assert!(state.take_corrective(&cid).is_none());
1402 assert!(state.pending_modify(&cid).is_none());
1403 }
1404}