1use std::{
68 collections::VecDeque,
69 sync::{
70 Mutex,
71 atomic::{AtomicBool, Ordering},
72 },
73};
74
75use dashmap::{DashMap, DashSet};
76use nautilus_common::cache::fifo::FifoCache;
77use nautilus_core::{MUTEX_POISONED, UUID4, UnixNanos};
78use nautilus_live::{ExecutionEventEmitter, execution::context::OrderContext};
79use nautilus_model::{
80 enums::{OrderStatus, OrderType},
81 events::{
82 OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
83 OrderTriggered, OrderUpdated,
84 },
85 identifiers::{AccountId, ClientOrderId, TradeId, VenueOrderId},
86 reports::{FillReport, OrderStatusReport},
87 types::{Price, Quantity},
88};
89use ustr::Ustr;
90
91use crate::{
92 common::consts::HYPERLIQUID_POST_ONLY_WOULD_MATCH,
93 http::models::HyperliquidExecPlaceOrderRequest,
94};
95
96pub const DEDUP_CAPACITY: usize = 10_000;
97
98pub const MAX_PENDING_MODIFY_INTENTS: usize = 32;
102
103#[derive(Debug, Clone)]
112pub struct ModifyIntent {
113 pub generation: u64,
115 pub old_venue_order_id: Option<VenueOrderId>,
117 pub target_qty: Quantity,
119 pub sent_request: Option<HyperliquidExecPlaceOrderRequest>,
121}
122
123#[derive(Debug, Default)]
129struct ModifyChain {
130 intents: VecDeque<ModifyIntent>,
131 next_generation: u64,
132}
133
134#[derive(Debug)]
144pub struct WsDispatchState {
145 pub order_contexts: DashMap<ClientOrderId, OrderContext>,
154 pub emitted_accepted: DashSet<ClientOrderId>,
156 pending_submissions: DashSet<ClientOrderId>,
158 pending_submission_rejections: DashMap<ClientOrderId, OrderStatusReport>,
161 pub filled_orders: DashSet<ClientOrderId>,
166 emitted_trades: Mutex<FifoCache<TradeId, DEDUP_CAPACITY>>,
171 terminal_cloids: Mutex<FifoCache<Ustr, DEDUP_CAPACITY>>,
174 pub cached_venue_order_ids: DashMap<ClientOrderId, VenueOrderId>,
181 pending_modify_chains: DashMap<ClientOrderId, ModifyChain>,
193 pub buffered_fills: DashMap<ClientOrderId, Vec<FillReport>>,
198 pub order_filled_qty: DashMap<ClientOrderId, Quantity>,
201 pub pending_corrective: DashMap<ClientOrderId, (u64, HyperliquidExecPlaceOrderRequest)>,
204 clearing: AtomicBool,
205}
206
207impl Default for WsDispatchState {
208 fn default() -> Self {
209 Self {
210 order_contexts: DashMap::new(),
211 emitted_accepted: DashSet::default(),
212 pending_submissions: DashSet::default(),
213 pending_submission_rejections: DashMap::new(),
214 filled_orders: DashSet::default(),
215 emitted_trades: Mutex::new(FifoCache::new()),
216 terminal_cloids: Mutex::new(FifoCache::new()),
217 cached_venue_order_ids: DashMap::new(),
218 pending_modify_chains: DashMap::new(),
219 buffered_fills: DashMap::new(),
220 order_filled_qty: DashMap::new(),
221 pending_corrective: DashMap::new(),
222 clearing: AtomicBool::new(false),
223 }
224 }
225}
226
227impl WsDispatchState {
228 #[must_use]
230 pub fn new() -> Self {
231 Self::default()
232 }
233
234 pub fn register_context(&self, context: OrderContext) {
237 self.order_contexts
238 .insert(context.identity.client_order_id, context);
239 }
240
241 #[must_use]
243 pub fn lookup_context(&self, client_order_id: &ClientOrderId) -> Option<OrderContext> {
244 self.order_contexts.get(client_order_id).map(|r| *r)
245 }
246
247 pub fn mark_submission_pending(&self, client_order_id: ClientOrderId) {
249 self.pending_submissions.insert(client_order_id);
250 }
251
252 #[must_use]
254 pub fn submission_pending(&self, client_order_id: &ClientOrderId) -> bool {
255 self.pending_submissions.contains(client_order_id)
256 }
257
258 pub fn buffer_submission_rejection(
260 &self,
261 client_order_id: ClientOrderId,
262 report: OrderStatusReport,
263 ) {
264 self.pending_submission_rejections
265 .insert(client_order_id, report);
266 }
267
268 #[must_use]
270 pub fn resolve_submission(&self, client_order_id: &ClientOrderId) -> Option<OrderStatusReport> {
271 self.pending_submissions.remove(client_order_id);
272 self.pending_submission_rejections
273 .remove(client_order_id)
274 .map(|(_, report)| report)
275 }
276
277 pub fn update_context_price(&self, client_order_id: &ClientOrderId, price: Option<Price>) {
280 if let Some(price) = price
281 && let Some(mut entry) = self.order_contexts.get_mut(client_order_id)
282 {
283 entry.price = Some(price);
284 }
285 }
286
287 pub fn update_context_quantity(&self, client_order_id: &ClientOrderId, quantity: Quantity) {
289 if let Some(mut entry) = self.order_contexts.get_mut(client_order_id) {
290 entry.quantity = quantity;
291 }
292 }
293
294 pub fn insert_accepted(&self, cid: ClientOrderId) {
296 self.evict_if_full(&self.emitted_accepted);
297 self.emitted_accepted.insert(cid);
298 }
299
300 pub fn insert_filled(&self, cid: ClientOrderId) -> bool {
305 self.evict_if_full(&self.filled_orders);
306 self.filled_orders.insert(cid)
307 }
308
309 #[allow(
314 clippy::missing_panics_doc,
315 reason = "dedup mutex poisoning is not expected"
316 )]
317 pub fn check_and_insert_trade(&self, trade_id: TradeId) -> bool {
318 let mut set = self.emitted_trades.lock().expect(MUTEX_POISONED);
319 !set.insert(trade_id)
320 }
321
322 #[allow(
329 clippy::missing_panics_doc,
330 reason = "terminal cloid mutex poisoning is not expected"
331 )]
332 pub fn insert_terminal_cloid(&self, cloid: Ustr) {
333 let mut set = self.terminal_cloids.lock().expect(MUTEX_POISONED);
334 let _ = set.insert(cloid);
335 }
336
337 #[allow(
340 clippy::missing_panics_doc,
341 reason = "terminal cloid mutex poisoning is not expected"
342 )]
343 #[must_use]
344 pub fn terminal_cloid_seen(&self, cloid: &Ustr) -> bool {
345 let set = self.terminal_cloids.lock().expect(MUTEX_POISONED);
346 set.contains(cloid)
347 }
348
349 pub fn record_venue_order_id(
351 &self,
352 client_order_id: ClientOrderId,
353 venue_order_id: VenueOrderId,
354 ) {
355 self.cached_venue_order_ids
356 .insert(client_order_id, venue_order_id);
357 }
358
359 #[must_use]
361 pub fn cached_venue_order_id(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
362 self.cached_venue_order_ids.get(client_order_id).map(|r| *r)
363 }
364
365 pub fn mark_pending_modify(
374 &self,
375 client_order_id: ClientOrderId,
376 old_venue_order_id: VenueOrderId,
377 target_qty: Quantity,
378 ) -> u64 {
379 let mut chain = self
380 .pending_modify_chains
381 .entry(client_order_id)
382 .or_default();
383 let generation = chain.next_generation;
384 chain.next_generation += 1;
385 chain.intents.push_back(ModifyIntent {
386 generation,
387 old_venue_order_id: Some(old_venue_order_id),
388 target_qty,
389 sent_request: None,
390 });
391
392 if chain.intents.len() > MAX_PENDING_MODIFY_INTENTS {
393 chain.intents.pop_front();
394 log::warn!(
395 "Modify chain for {client_order_id} exceeded {MAX_PENDING_MODIFY_INTENTS}; \
396 evicting oldest intent",
397 );
398 }
399 generation
400 }
401
402 pub fn clear_pending_modify(&self, client_order_id: &ClientOrderId) {
404 self.pending_modify_chains.remove(client_order_id);
405 }
406
407 pub fn clear_modify_generation(&self, client_order_id: &ClientOrderId, generation: u64) {
416 let Some(mut chain) = self.pending_modify_chains.get_mut(client_order_id) else {
417 return;
418 };
419 let removed_front_old = chain
420 .intents
421 .front()
422 .filter(|front| front.generation == generation)
423 .and_then(|front| front.old_venue_order_id);
424 chain
425 .intents
426 .retain(|intent| intent.generation != generation);
427
428 if let Some(old) = removed_front_old
429 && let Some(new_front) = chain.intents.front_mut()
430 {
431 new_front.old_venue_order_id = Some(old);
432 }
433 drop(chain);
434 self.pending_modify_chains
437 .remove_if(client_order_id, |_, chain| chain.intents.is_empty());
438 }
439
440 pub fn stash_modify_request(
443 &self,
444 client_order_id: ClientOrderId,
445 request: HyperliquidExecPlaceOrderRequest,
446 ) {
447 if let Some(mut chain) = self.pending_modify_chains.get_mut(&client_order_id)
448 && let Some(back) = chain.intents.back_mut()
449 {
450 back.sent_request = Some(request);
451 } else {
452 log::debug!(
453 "Stash modify request for {client_order_id} with no pending intent; ignoring"
454 );
455 }
456 }
457
458 #[must_use]
460 pub fn modify_request(
461 &self,
462 client_order_id: &ClientOrderId,
463 ) -> Option<HyperliquidExecPlaceOrderRequest> {
464 self.pending_modify_chains
465 .get(client_order_id)
466 .and_then(|chain| chain.intents.front().and_then(|i| i.sent_request.clone()))
467 }
468
469 pub fn claim_front_modify(
477 &self,
478 client_order_id: &ClientOrderId,
479 new_venue_order_id: VenueOrderId,
480 ) -> Option<ModifyIntent> {
481 let mut chain = self.pending_modify_chains.get_mut(client_order_id)?;
482 let claimed = chain.intents.pop_front();
483 if let Some(next) = chain.intents.front_mut() {
484 next.old_venue_order_id = Some(new_venue_order_id);
485 }
486 drop(chain);
487 self.pending_modify_chains
490 .remove_if(client_order_id, |_, chain| chain.intents.is_empty());
491 claimed
492 }
493
494 pub fn queue_corrective(
496 &self,
497 client_order_id: ClientOrderId,
498 oid: u64,
499 request: HyperliquidExecPlaceOrderRequest,
500 ) {
501 self.pending_corrective
502 .insert(client_order_id, (oid, request));
503 }
504
505 #[must_use]
507 pub fn take_corrective(
508 &self,
509 client_order_id: &ClientOrderId,
510 ) -> Option<(u64, HyperliquidExecPlaceOrderRequest)> {
511 self.pending_corrective
512 .remove(client_order_id)
513 .map(|(_, v)| v)
514 }
515
516 #[must_use]
518 pub fn has_pending_modify(&self, client_order_id: &ClientOrderId) -> bool {
519 self.pending_modify_chains
520 .get(client_order_id)
521 .is_some_and(|chain| !chain.intents.is_empty())
522 }
523
524 #[must_use]
526 pub fn pending_modify(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
527 self.pending_modify_chains
528 .get(client_order_id)
529 .and_then(|chain| chain.intents.front().and_then(|i| i.old_venue_order_id))
530 }
531
532 #[must_use]
537 pub fn pending_modify_contains_old(
538 &self,
539 client_order_id: &ClientOrderId,
540 venue_order_id: VenueOrderId,
541 ) -> bool {
542 self.pending_modify_chains
543 .get(client_order_id)
544 .is_some_and(|chain| {
545 chain
546 .intents
547 .iter()
548 .any(|i| i.old_venue_order_id == Some(venue_order_id))
549 })
550 }
551
552 #[must_use]
554 pub fn pending_modify_target_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
555 self.pending_modify_chains
556 .get(client_order_id)
557 .and_then(|chain| chain.intents.front().map(|i| i.target_qty))
558 }
559
560 pub fn buffer_fill(&self, client_order_id: ClientOrderId, fill: FillReport) {
562 self.buffered_fills
563 .entry(client_order_id)
564 .or_default()
565 .push(fill);
566 }
567
568 #[must_use]
570 pub fn drain_buffered_fills(&self, client_order_id: &ClientOrderId) -> Vec<FillReport> {
571 self.buffered_fills
572 .remove(client_order_id)
573 .map(|(_, v)| v)
574 .unwrap_or_default()
575 }
576
577 #[must_use]
579 pub fn buffered_fill_count(&self, client_order_id: &ClientOrderId) -> usize {
580 self.buffered_fills
581 .get(client_order_id)
582 .map_or(0, |r| r.len())
583 }
584
585 pub fn record_filled_qty(&self, client_order_id: ClientOrderId, qty: Quantity) {
587 self.order_filled_qty.insert(client_order_id, qty);
588 }
589
590 #[must_use]
592 pub fn previous_filled_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
593 self.order_filled_qty.get(client_order_id).map(|r| *r)
594 }
595
596 pub fn cleanup_terminal(&self, client_order_id: &ClientOrderId) {
601 self.order_contexts.remove(client_order_id);
602 self.emitted_accepted.remove(client_order_id);
603 self.pending_submissions.remove(client_order_id);
604 self.pending_submission_rejections.remove(client_order_id);
605 self.cached_venue_order_ids.remove(client_order_id);
606 self.pending_modify_chains.remove(client_order_id);
607 self.pending_corrective.remove(client_order_id);
608 self.buffered_fills.remove(client_order_id);
609 self.order_filled_qty.remove(client_order_id);
610 }
611
612 fn evict_if_full(&self, set: &DashSet<ClientOrderId>) {
613 if set.len() >= DEDUP_CAPACITY
614 && self
615 .clearing
616 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
617 .is_ok()
618 {
619 set.clear();
620 self.clearing.store(false, Ordering::Release);
621 }
622 }
623}
624
625#[derive(Debug, Clone, Copy, PartialEq, Eq)]
627pub enum DispatchOutcome {
628 Tracked,
632 External,
637 Skip,
641}
642
643pub fn dispatch_order_event(
653 report: &OrderStatusReport,
654 state: &WsDispatchState,
655 emitter: &ExecutionEventEmitter,
656 ts_init: UnixNanos,
657) -> DispatchOutcome {
658 let Some(client_order_id) = report.client_order_id else {
659 return DispatchOutcome::External;
660 };
661
662 if state.filled_orders.contains(&client_order_id) {
663 log::debug!(
664 "Skipping stale report for filled order: cid={client_order_id}, status={:?}",
665 report.order_status,
666 );
667 return DispatchOutcome::Skip;
668 }
669
670 let client_order_id_str = client_order_id.as_str();
671 if client_order_id_str.starts_with("0x")
672 && state.terminal_cloid_seen(&Ustr::from(client_order_id_str))
673 {
674 log::debug!(
675 "Skipping stale terminal report for raw cloid: cid={client_order_id}, status={:?}",
676 report.order_status,
677 );
678 return DispatchOutcome::Skip;
679 }
680
681 let Some(context) = state.lookup_context(&client_order_id) else {
682 return DispatchOutcome::External;
683 };
684
685 match report.order_status {
686 OrderStatus::Accepted => {
687 handle_accepted(report, client_order_id, &context, state, emitter, ts_init)
688 }
689 OrderStatus::Triggered => {
690 handle_triggered(report, client_order_id, &context, state, emitter, ts_init)
691 }
692 OrderStatus::Canceled => {
693 handle_canceled(report, client_order_id, &context, state, emitter, ts_init)
694 }
695 OrderStatus::Expired => {
696 handle_expired(report, client_order_id, &context, state, emitter, ts_init)
697 }
698 OrderStatus::Rejected => {
699 handle_rejected(report, client_order_id, &context, state, emitter, ts_init)
700 }
701 OrderStatus::Filled => handle_filled_marker(client_order_id, state),
702 OrderStatus::PartiallyFilled => {
703 DispatchOutcome::Tracked
705 }
706 OrderStatus::PendingUpdate
707 | OrderStatus::PendingCancel
708 | OrderStatus::Submitted
709 | OrderStatus::Initialized
710 | OrderStatus::Denied
711 | OrderStatus::Released
712 | OrderStatus::Emulated
713 | OrderStatus::Voided => DispatchOutcome::Tracked,
714 }
715}
716
717pub fn dispatch_order_fill(
728 report: &FillReport,
729 state: &WsDispatchState,
730 emitter: &ExecutionEventEmitter,
731 ts_init: UnixNanos,
732) -> DispatchOutcome {
733 let Some(client_order_id) = report.client_order_id else {
734 return DispatchOutcome::External;
735 };
736
737 if state.filled_orders.contains(&client_order_id) {
738 log::debug!(
739 "Skipping stale fill for filled order: cid={client_order_id}, trade_id={}",
740 report.trade_id,
741 );
742 return DispatchOutcome::Skip;
743 }
744
745 let Some(mut context) = state.lookup_context(&client_order_id) else {
746 return DispatchOutcome::External;
747 };
748
749 let mut promoted_corrective: Option<(Quantity, HyperliquidExecPlaceOrderRequest)> = None;
751
752 if state.has_pending_modify(&client_order_id)
755 && let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
756 && report.venue_order_id != cached_voi
757 {
758 let target = state.pending_modify_target_qty(&client_order_id);
759 let sent_request = state.modify_request(&client_order_id);
760 let price = sent_request
762 .as_ref()
763 .zip(context.price)
764 .and_then(|(r, cached)| Price::from_decimal_dp(r.price, cached.precision).ok())
765 .or(context.price);
766 let Some(price) = price else {
767 log::warn!(
768 "Cannot promote cancel-replace for {client_order_id} from fill: no target \
769 or cached price; buffering until the replacement ACCEPTED arrives",
770 );
771 state.buffer_fill(client_order_id, report.clone());
772 return DispatchOutcome::Tracked;
773 };
774 let updated_quantity = target.unwrap_or(context.quantity);
775 promote_cancel_replace(
776 client_order_id,
777 &context,
778 state,
779 emitter,
780 report.venue_order_id,
781 report.account_id,
782 price,
783 updated_quantity,
784 None,
785 report.ts_event,
786 ts_init,
787 );
788 if let Some(updated) = state.lookup_context(&client_order_id) {
790 context = updated;
791 }
792
793 if let (Some(target), Some(sent_request)) = (target, sent_request) {
794 promoted_corrective = Some((target, sent_request));
795 }
796 }
797
798 if state.check_and_insert_trade(report.trade_id) {
799 log::debug!(
800 "Skipping duplicate fill for {client_order_id}: trade_id={}",
801 report.trade_id
802 );
803 return DispatchOutcome::Tracked;
804 }
805
806 let previous = state
807 .previous_filled_qty(&client_order_id)
808 .unwrap_or_else(|| Quantity::zero(report.last_qty.precision));
809 let cumulative = previous + report.last_qty;
810
811 let is_terminal_fill = cumulative >= context.quantity;
812 if is_terminal_fill && !claim_terminal_order(client_order_id, state, OrderStatus::Filled) {
813 return DispatchOutcome::Skip;
814 }
815
816 ensure_accepted_emitted(
817 client_order_id,
818 report.venue_order_id,
819 report.account_id,
820 &context,
821 state,
822 emitter,
823 report.ts_event,
824 ts_init,
825 );
826
827 let filled = OrderFilled::new(
828 emitter.trader_id(),
829 context.identity.strategy_id,
830 context.identity.instrument_id,
831 client_order_id,
832 report.venue_order_id,
833 report.account_id,
834 report.trade_id,
835 context.identity.order_side,
836 context.identity.order_type,
837 report.last_qty,
838 report.last_px,
839 report.commission.currency,
840 report.liquidity_side,
841 UUID4::new(),
842 report.ts_event,
843 ts_init,
844 false,
845 report.venue_position_id,
846 Some(report.commission),
847 None,
848 );
849 emitter.send_order_event(OrderEventAny::Filled(filled));
850
851 state.record_filled_qty(client_order_id, cumulative);
852
853 if let Some((target, sent_request)) = promoted_corrective {
855 maybe_queue_corrective_reduce(
856 state,
857 client_order_id,
858 report.venue_order_id,
859 target,
860 sent_request,
861 );
862 }
863
864 if is_terminal_fill {
865 state.cleanup_terminal(&client_order_id);
866 }
867
868 DispatchOutcome::Tracked
869}
870
871fn handle_accepted(
872 report: &OrderStatusReport,
873 client_order_id: ClientOrderId,
874 context: &OrderContext,
875 state: &WsDispatchState,
876 emitter: &ExecutionEventEmitter,
877 ts_init: UnixNanos,
878) -> DispatchOutcome {
879 let venue_order_id = report.venue_order_id;
880 let ts_event = report.ts_last;
881 let account_id = report.account_id;
882
883 if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
888 && cached_voi != venue_order_id
889 {
890 let price = report.price.or(context.price);
891 let Some(price) = price else {
892 log::warn!(
893 "Cannot emit OrderUpdated for cancel-replace {client_order_id}: \
894 no price on report and no cached price on context",
895 );
896 return DispatchOutcome::Skip;
897 };
898
899 let target_total_qty = state.pending_modify_target_qty(&client_order_id);
902 let updated_quantity = target_total_qty.unwrap_or(report.quantity);
903 let sent_request = state.modify_request(&client_order_id);
904
905 promote_cancel_replace(
906 client_order_id,
907 context,
908 state,
909 emitter,
910 venue_order_id,
911 account_id,
912 price,
913 updated_quantity,
914 report.trigger_price,
915 ts_event,
916 ts_init,
917 );
918
919 if let (Some(target), Some(sent_request)) = (target_total_qty, sent_request) {
920 maybe_queue_corrective_reduce(
921 state,
922 client_order_id,
923 venue_order_id,
924 target,
925 sent_request,
926 );
927 }
928
929 return DispatchOutcome::Tracked;
930 }
931
932 if state.emitted_accepted.contains(&client_order_id) {
933 state.update_context_price(&client_order_id, report.price);
937 return DispatchOutcome::Tracked;
938 }
939
940 state.insert_accepted(client_order_id);
941 state.record_venue_order_id(client_order_id, venue_order_id);
942 state.update_context_price(&client_order_id, report.price);
943
944 let accepted = OrderAccepted::new(
945 emitter.trader_id(),
946 context.identity.strategy_id,
947 context.identity.instrument_id,
948 client_order_id,
949 venue_order_id,
950 account_id,
951 UUID4::new(),
952 ts_event,
953 ts_init,
954 false,
955 );
956 emitter.send_order_event(OrderEventAny::Accepted(accepted));
957 DispatchOutcome::Tracked
958}
959
960#[allow(
963 clippy::too_many_arguments,
964 reason = "promotion needs the full OrderUpdated field set, sourced from two report shapes"
965)]
966fn promote_cancel_replace(
967 client_order_id: ClientOrderId,
968 context: &OrderContext,
969 state: &WsDispatchState,
970 emitter: &ExecutionEventEmitter,
971 venue_order_id: VenueOrderId,
972 account_id: AccountId,
973 price: Price,
974 quantity: Quantity,
975 trigger_price: Option<Price>,
976 ts_event: UnixNanos,
977 ts_init: UnixNanos,
978) {
979 state.record_venue_order_id(client_order_id, venue_order_id);
980 state.update_context_quantity(&client_order_id, quantity);
981 state.update_context_price(&client_order_id, Some(price));
982 state.claim_front_modify(&client_order_id, venue_order_id);
984
985 let updated = OrderUpdated::new(
986 emitter.trader_id(),
987 context.identity.strategy_id,
988 context.identity.instrument_id,
989 client_order_id,
990 quantity,
991 UUID4::new(),
992 ts_event,
993 ts_init,
994 false,
995 Some(venue_order_id),
996 Some(account_id),
997 Some(price),
998 trigger_price,
999 None,
1000 false,
1001 );
1002 emitter.send_order_event(OrderEventAny::Updated(updated));
1003
1004 let buffered = state.drain_buffered_fills(&client_order_id);
1007 for fill in buffered {
1008 dispatch_order_fill(&fill, state, emitter, ts_init);
1009 }
1010}
1011
1012pub fn promote_replacement_from_query(
1019 report: &OrderStatusReport,
1020 state: &WsDispatchState,
1021 emitter: &ExecutionEventEmitter,
1022 ts_init: UnixNanos,
1023) -> bool {
1024 if report.order_status != OrderStatus::Accepted {
1025 return false;
1026 }
1027
1028 let Some(client_order_id) = report.client_order_id else {
1029 return false;
1030 };
1031
1032 if !state.has_pending_modify(&client_order_id) {
1033 return false;
1034 }
1035
1036 let Some(cached_voi) = state.cached_venue_order_id(&client_order_id) else {
1037 return false;
1038 };
1039
1040 if report.venue_order_id == cached_voi {
1041 return false;
1042 }
1043
1044 let Some(context) = state.lookup_context(&client_order_id) else {
1045 return false;
1046 };
1047
1048 let Some(price) = report.price.or(context.price) else {
1049 log::warn!(
1050 "Cannot promote cancel-replace from query for {client_order_id}: \
1051 no price on report and no cached price on context",
1052 );
1053 return false;
1054 };
1055
1056 let updated_quantity = state
1058 .pending_modify_target_qty(&client_order_id)
1059 .unwrap_or(report.quantity);
1060
1061 promote_cancel_replace(
1062 client_order_id,
1063 &context,
1064 state,
1065 emitter,
1066 report.venue_order_id,
1067 report.account_id,
1068 price,
1069 updated_quantity,
1070 report.trigger_price,
1071 report.ts_last,
1072 ts_init,
1073 );
1074
1075 log::debug!("Promoted cancel-replace replacement for {client_order_id} from query");
1076
1077 true
1078}
1079
1080fn maybe_queue_corrective_reduce(
1083 state: &WsDispatchState,
1084 client_order_id: ClientOrderId,
1085 venue_order_id: VenueOrderId,
1086 target: Quantity,
1087 sent_request: HyperliquidExecPlaceOrderRequest,
1088) {
1089 let Ok(new_oid) = venue_order_id.as_str().parse::<u64>() else {
1090 return;
1091 };
1092
1093 let filled = state
1094 .previous_filled_qty(&client_order_id)
1095 .unwrap_or_else(|| Quantity::zero(target.precision));
1096 if filled >= target {
1097 return;
1098 }
1099
1100 let remaining = (target - filled).as_decimal().normalize();
1101
1102 let sent_size = sent_request.size;
1103 if sent_size > remaining {
1104 let mut corrective = sent_request;
1105 corrective.size = remaining;
1106
1107 state.mark_pending_modify(client_order_id, venue_order_id, target);
1108 state.stash_modify_request(client_order_id, corrective.clone());
1109 state.queue_corrective(client_order_id, new_oid, corrective);
1110
1111 log::warn!(
1112 "Cancel-replace left {client_order_id} oversized on {venue_order_id} \
1113 (sent {sent_size}, remaining {remaining}); queuing corrective reduce",
1114 );
1115 }
1116}
1117
1118fn handle_triggered(
1119 report: &OrderStatusReport,
1120 client_order_id: ClientOrderId,
1121 context: &OrderContext,
1122 state: &WsDispatchState,
1123 emitter: &ExecutionEventEmitter,
1124 ts_init: UnixNanos,
1125) -> DispatchOutcome {
1126 if !matches!(
1127 context.identity.order_type,
1128 OrderType::StopLimit | OrderType::TrailingStopLimit | OrderType::LimitIfTouched
1129 ) {
1130 log::debug!(
1131 "Ignoring TRIGGERED status for non-triggerable order type {:?}: {client_order_id}",
1132 context.identity.order_type,
1133 );
1134 return DispatchOutcome::Tracked;
1135 }
1136
1137 ensure_accepted_emitted(
1138 client_order_id,
1139 report.venue_order_id,
1140 report.account_id,
1141 context,
1142 state,
1143 emitter,
1144 report.ts_last,
1145 ts_init,
1146 );
1147
1148 let triggered = OrderTriggered::new(
1149 emitter.trader_id(),
1150 context.identity.strategy_id,
1151 context.identity.instrument_id,
1152 client_order_id,
1153 UUID4::new(),
1154 report.ts_last,
1155 ts_init,
1156 false,
1157 Some(report.venue_order_id),
1158 Some(report.account_id),
1159 );
1160 emitter.send_order_event(OrderEventAny::Triggered(triggered));
1161 DispatchOutcome::Tracked
1162}
1163
1164fn handle_canceled(
1165 report: &OrderStatusReport,
1166 client_order_id: ClientOrderId,
1167 context: &OrderContext,
1168 state: &WsDispatchState,
1169 emitter: &ExecutionEventEmitter,
1170 ts_init: UnixNanos,
1171) -> DispatchOutcome {
1172 let venue_order_id = report.venue_order_id;
1173
1174 if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
1178 && cached_voi != venue_order_id
1179 {
1180 log::debug!(
1181 "Skipping stale CANCELED for {venue_order_id} (cached {cached_voi}) on {client_order_id}",
1182 );
1183 return DispatchOutcome::Skip;
1184 }
1185
1186 if state.pending_modify_contains_old(&client_order_id, venue_order_id) {
1192 log::debug!(
1193 "Skipping cancel-before-accept leg for {client_order_id}: venue_order_id={venue_order_id}",
1194 );
1195 return DispatchOutcome::Skip;
1196 }
1197
1198 if !claim_terminal_order(client_order_id, state, report.order_status) {
1199 return DispatchOutcome::Skip;
1200 }
1201
1202 ensure_accepted_emitted(
1203 client_order_id,
1204 venue_order_id,
1205 report.account_id,
1206 context,
1207 state,
1208 emitter,
1209 report.ts_last,
1210 ts_init,
1211 );
1212
1213 let canceled = OrderCanceled::new(
1214 emitter.trader_id(),
1215 context.identity.strategy_id,
1216 context.identity.instrument_id,
1217 client_order_id,
1218 UUID4::new(),
1219 report.ts_last,
1220 ts_init,
1221 false,
1222 Some(venue_order_id),
1223 Some(report.account_id),
1224 );
1225 emitter.send_order_event(OrderEventAny::Canceled(canceled));
1226
1227 state.cleanup_terminal(&client_order_id);
1228 DispatchOutcome::Tracked
1229}
1230
1231fn handle_expired(
1232 report: &OrderStatusReport,
1233 client_order_id: ClientOrderId,
1234 context: &OrderContext,
1235 state: &WsDispatchState,
1236 emitter: &ExecutionEventEmitter,
1237 ts_init: UnixNanos,
1238) -> DispatchOutcome {
1239 if !claim_terminal_order(client_order_id, state, report.order_status) {
1240 return DispatchOutcome::Skip;
1241 }
1242
1243 ensure_accepted_emitted(
1244 client_order_id,
1245 report.venue_order_id,
1246 report.account_id,
1247 context,
1248 state,
1249 emitter,
1250 report.ts_last,
1251 ts_init,
1252 );
1253
1254 let expired = OrderExpired::new(
1255 emitter.trader_id(),
1256 context.identity.strategy_id,
1257 context.identity.instrument_id,
1258 client_order_id,
1259 UUID4::new(),
1260 report.ts_last,
1261 ts_init,
1262 false,
1263 Some(report.venue_order_id),
1264 Some(report.account_id),
1265 );
1266 emitter.send_order_event(OrderEventAny::Expired(expired));
1267 state.cleanup_terminal(&client_order_id);
1268 DispatchOutcome::Tracked
1269}
1270
1271fn handle_rejected(
1272 report: &OrderStatusReport,
1273 client_order_id: ClientOrderId,
1274 context: &OrderContext,
1275 state: &WsDispatchState,
1276 emitter: &ExecutionEventEmitter,
1277 ts_init: UnixNanos,
1278) -> DispatchOutcome {
1279 if state.submission_pending(&client_order_id) {
1280 state.buffer_submission_rejection(client_order_id, report.clone());
1281 return DispatchOutcome::Skip;
1282 }
1283
1284 if !claim_terminal_order(client_order_id, state, report.order_status) {
1285 return DispatchOutcome::Skip;
1286 }
1287
1288 let reason = report
1289 .cancel_reason
1290 .clone()
1291 .unwrap_or_else(|| "Order rejected by exchange".to_string());
1292 let rejected = OrderRejected::new(
1293 emitter.trader_id(),
1294 context.identity.strategy_id,
1295 context.identity.instrument_id,
1296 client_order_id,
1297 report.account_id,
1298 Ustr::from(&reason),
1299 UUID4::new(),
1300 report.ts_last,
1301 ts_init,
1302 false,
1303 report.post_only && reason.contains(HYPERLIQUID_POST_ONLY_WOULD_MATCH),
1304 );
1305 emitter.send_order_event(OrderEventAny::Rejected(rejected));
1306 state.cleanup_terminal(&client_order_id);
1307 DispatchOutcome::Tracked
1308}
1309
1310fn claim_terminal_order(
1311 client_order_id: ClientOrderId,
1312 state: &WsDispatchState,
1313 status: OrderStatus,
1314) -> bool {
1315 let claimed = state.insert_filled(client_order_id);
1316 if !claimed {
1317 log::debug!("Skipping duplicate terminal event for {client_order_id}: status={status:?}",);
1318 }
1319
1320 claimed
1321}
1322
1323fn handle_filled_marker(
1324 _client_order_id: ClientOrderId,
1325 _state: &WsDispatchState,
1326) -> DispatchOutcome {
1327 DispatchOutcome::Tracked
1335}
1336
1337#[allow(clippy::too_many_arguments)]
1345fn ensure_accepted_emitted(
1346 client_order_id: ClientOrderId,
1347 venue_order_id: VenueOrderId,
1348 account_id: AccountId,
1349 context: &OrderContext,
1350 state: &WsDispatchState,
1351 emitter: &ExecutionEventEmitter,
1352 ts_event: UnixNanos,
1353 ts_init: UnixNanos,
1354) {
1355 if state.emitted_accepted.contains(&client_order_id) {
1356 return;
1357 }
1358 state.insert_accepted(client_order_id);
1359 state.record_venue_order_id(client_order_id, venue_order_id);
1360
1361 let accepted = OrderAccepted::new(
1362 emitter.trader_id(),
1363 context.identity.strategy_id,
1364 context.identity.instrument_id,
1365 client_order_id,
1366 venue_order_id,
1367 account_id,
1368 UUID4::new(),
1369 ts_event,
1370 ts_init,
1371 false,
1372 );
1373 emitter.send_order_event(OrderEventAny::Accepted(accepted));
1374}
1375
1376#[cfg(test)]
1377mod tests {
1378 use nautilus_live::execution::context::OrderIdentity;
1379 use nautilus_model::{
1380 enums::{OrderSide, TimeInForce},
1381 identifiers::{ClientOrderId, InstrumentId, StrategyId, TradeId},
1382 };
1383 use rstest::rstest;
1384 use rust_decimal::Decimal;
1385
1386 use super::*;
1387 use crate::http::models::{
1388 HyperliquidExecLimitParams, HyperliquidExecOrderKind, HyperliquidExecTif,
1389 };
1390
1391 fn make_context(client_order_id: ClientOrderId) -> OrderContext {
1392 OrderContext {
1393 identity: OrderIdentity {
1394 client_order_id,
1395 strategy_id: StrategyId::from("S-001"),
1396 instrument_id: InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
1397 order_side: OrderSide::Buy,
1398 order_type: OrderType::Limit,
1399 },
1400 quantity: Quantity::from("0.0001"),
1401 price: None,
1402 trigger_price: None,
1403 trigger_type: None,
1404 time_in_force: TimeInForce::Gtc,
1405 is_post_only: false,
1406 is_reduce_only: false,
1407 is_quote_quantity: false,
1408 }
1409 }
1410
1411 #[rstest]
1412 fn test_register_and_lookup_context() {
1413 let state = WsDispatchState::new();
1414 let cid = ClientOrderId::new("O-001");
1415 state.register_context(make_context(cid));
1416
1417 assert_eq!(state.lookup_context(&cid), Some(make_context(cid)));
1418 }
1419
1420 #[rstest]
1421 fn test_lookup_context_missing_returns_none() {
1422 let state = WsDispatchState::new();
1423 let cid = ClientOrderId::new("not-tracked");
1424 assert!(state.lookup_context(&cid).is_none());
1425 }
1426
1427 #[rstest]
1428 fn test_update_context_refreshes_price_and_quantity() {
1429 let state = WsDispatchState::new();
1430 let cid = ClientOrderId::new("O-003");
1431 state.register_context(make_context(cid));
1432
1433 state.update_context_price(&cid, Some(Price::from("56731.5")));
1434 state.update_context_quantity(&cid, Quantity::from("0.0002"));
1435
1436 assert_eq!(
1437 state.lookup_context(&cid),
1438 Some(OrderContext {
1439 price: Some(Price::from("56731.5")),
1440 quantity: Quantity::from("0.0002"),
1441 ..make_context(cid)
1442 }),
1443 );
1444 }
1445
1446 #[rstest]
1447 fn test_update_context_price_without_price_keeps_cached_price() {
1448 let state = WsDispatchState::new();
1449 let cid = ClientOrderId::new("O-004");
1450 let cached = OrderContext {
1451 price: Some(Price::from("56730.0")),
1452 ..make_context(cid)
1453 };
1454 state.register_context(cached);
1455
1456 state.update_context_price(&cid, None);
1457
1458 assert_eq!(state.lookup_context(&cid), Some(cached));
1459 }
1460
1461 #[rstest]
1462 fn test_insert_accepted_dedup() {
1463 let state = WsDispatchState::new();
1464 let cid = ClientOrderId::new("O-002");
1465 assert!(!state.emitted_accepted.contains(&cid));
1466 state.insert_accepted(cid);
1467 assert!(state.emitted_accepted.contains(&cid));
1468 state.insert_accepted(cid);
1469 assert!(state.emitted_accepted.contains(&cid));
1470 }
1471
1472 #[rstest]
1473 fn test_check_and_insert_trade_detects_duplicates() {
1474 let state = WsDispatchState::new();
1475 let trade = TradeId::new("trade-1");
1476 assert!(!state.check_and_insert_trade(trade));
1477 assert!(state.check_and_insert_trade(trade));
1478 }
1479
1480 #[rstest]
1481 fn test_pending_modify_roundtrip() {
1482 let state = WsDispatchState::new();
1483 let cid = ClientOrderId::new("O-010");
1484 let voi = VenueOrderId::new("v-1");
1485 let target_qty = Quantity::from("0.0001");
1486
1487 assert!(state.pending_modify(&cid).is_none());
1488 assert!(state.pending_modify_target_qty(&cid).is_none());
1489 state.mark_pending_modify(cid, voi, target_qty);
1490 assert_eq!(state.pending_modify(&cid), Some(voi));
1491 assert_eq!(state.pending_modify_target_qty(&cid), Some(target_qty));
1492 state.clear_pending_modify(&cid);
1493 assert!(state.pending_modify(&cid).is_none());
1494 assert!(state.pending_modify_target_qty(&cid).is_none());
1495 }
1496
1497 #[rstest]
1498 fn test_cleanup_terminal_preserves_filled_marker() {
1499 let state = WsDispatchState::new();
1500 let cid = ClientOrderId::new("O-020");
1501 state.register_context(make_context(cid));
1502 state.insert_accepted(cid);
1503 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.0001"));
1504 state.insert_filled(cid);
1505 state.cleanup_terminal(&cid);
1506
1507 assert!(state.lookup_context(&cid).is_none());
1508 assert!(!state.emitted_accepted.contains(&cid));
1509 assert!(state.pending_modify(&cid).is_none());
1510 assert!(state.pending_modify_target_qty(&cid).is_none());
1511 assert!(state.filled_orders.contains(&cid));
1513 }
1514
1515 #[rstest]
1516 fn test_cleanup_terminal_clears_corrective_state() {
1517 let state = WsDispatchState::new();
1518 let cid = ClientOrderId::new("O-021");
1519 let request = sample_request(Decimal::from(1));
1520 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("1"));
1521 state.stash_modify_request(cid, request.clone());
1522 state.queue_corrective(cid, 1, request);
1523 assert!(state.modify_request(&cid).is_some());
1524
1525 state.cleanup_terminal(&cid);
1526
1527 assert!(state.modify_request(&cid).is_none());
1528 assert!(state.take_corrective(&cid).is_none());
1529 assert!(state.pending_modify(&cid).is_none());
1530 }
1531
1532 fn sample_request(size: Decimal) -> HyperliquidExecPlaceOrderRequest {
1533 HyperliquidExecPlaceOrderRequest {
1534 asset: 0,
1535 is_buy: true,
1536 price: "100".parse::<Decimal>().unwrap(),
1537 size,
1538 reduce_only: false,
1539 kind: HyperliquidExecOrderKind::Limit {
1540 limit: HyperliquidExecLimitParams {
1541 tif: HyperliquidExecTif::Gtc,
1542 },
1543 },
1544 cloid: None,
1545 }
1546 }
1547
1548 #[rstest]
1549 fn test_modify_chain_keeps_both_intents_on_rapid_modifies() {
1550 let state = WsDispatchState::new();
1551 let cid = ClientOrderId::new("O-100");
1552 let g0 =
1553 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1554 let g1 =
1555 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00030"));
1556
1557 assert_ne!(g0, g1);
1558 assert!(state.has_pending_modify(&cid));
1559 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
1561 assert_eq!(
1562 state.pending_modify_target_qty(&cid),
1563 Some(Quantity::from("0.00020")),
1564 );
1565 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1567 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
1568 }
1569
1570 #[rstest]
1571 fn test_clear_modify_generation_preserves_newer_intent() {
1572 let state = WsDispatchState::new();
1573 let cid = ClientOrderId::new("O-101");
1574 let g0 =
1577 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1578 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
1579
1580 state.clear_modify_generation(&cid, g0);
1583
1584 assert!(state.has_pending_modify(&cid));
1585 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
1586 assert_eq!(
1587 state.pending_modify_target_qty(&cid),
1588 Some(Quantity::from("0.00030")),
1589 );
1590 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1591 }
1592
1593 #[rstest]
1594 fn test_claim_front_modify_advances_next_old_id() {
1595 let state = WsDispatchState::new();
1596 let cid = ClientOrderId::new("O-102");
1597 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1599 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
1600
1601 let claimed = state.claim_front_modify(&cid, VenueOrderId::new("v-1"));
1604 assert_eq!(
1605 claimed.map(|i| i.target_qty),
1606 Some(Quantity::from("0.00020"))
1607 );
1608
1609 assert!(state.has_pending_modify(&cid));
1610 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
1611 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
1612 assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1614
1615 let claimed2 = state.claim_front_modify(&cid, VenueOrderId::new("v-2"));
1617 assert_eq!(
1618 claimed2.map(|i| i.target_qty),
1619 Some(Quantity::from("0.00030"))
1620 );
1621 assert!(!state.has_pending_modify(&cid));
1622 assert!(state.pending_modify(&cid).is_none());
1623 }
1624
1625 #[rstest]
1626 fn test_modify_chain_caps_and_evicts_oldest() {
1627 let state = WsDispatchState::new();
1628 let cid = ClientOrderId::new("O-106");
1629 for i in 0..=MAX_PENDING_MODIFY_INTENTS {
1631 let voi = format!("v-{i}");
1632 state.mark_pending_modify(cid, VenueOrderId::new(&voi), Quantity::from("0.00020"));
1633 }
1634
1635 assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1638 let newest = format!("v-{MAX_PENDING_MODIFY_INTENTS}");
1639 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new(&newest)));
1640 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
1641 }
1642
1643 #[rstest]
1644 fn test_clear_front_modify_reparents_next_old() {
1645 let state = WsDispatchState::new();
1646 let cid = ClientOrderId::new("O-104");
1647 let g_front =
1650 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00020"));
1651 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
1652
1653 state.clear_modify_generation(&cid, g_front);
1657
1658 assert!(state.has_pending_modify(&cid));
1659 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
1660 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
1661 assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1662 }
1663
1664 #[rstest]
1665 fn test_clear_non_front_modify_leaves_front_old() {
1666 let state = WsDispatchState::new();
1667 let cid = ClientOrderId::new("O-105");
1668 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1669 let g_back =
1670 state.mark_pending_modify(cid, VenueOrderId::new("v-9"), Quantity::from("0.00030"));
1671
1672 state.clear_modify_generation(&cid, g_back);
1674
1675 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
1676 assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-9")));
1677 }
1678
1679 #[rstest]
1680 fn test_stash_modify_request_targets_latest_intent() {
1681 let state = WsDispatchState::new();
1682 let cid = ClientOrderId::new("O-103");
1683 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1684 state.stash_modify_request(cid, sample_request(Decimal::from(1)));
1685 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00030"));
1686 state.stash_modify_request(cid, sample_request(Decimal::from(2)));
1687
1688 assert_eq!(
1690 state.modify_request(&cid).map(|r| r.size),
1691 Some(Decimal::from(1)),
1692 );
1693 state.claim_front_modify(&cid, VenueOrderId::new("v-1"));
1695 assert_eq!(
1696 state.modify_request(&cid).map(|r| r.size),
1697 Some(Decimal::from(2)),
1698 );
1699 }
1700}