1use std::{
74 collections::VecDeque,
75 sync::atomic::{AtomicBool, Ordering},
76};
77
78use ahash::AHashMap;
79use dashmap::{DashMap, DashSet};
80use nautilus_common::cache::fifo::FifoCache;
81use nautilus_core::{UUID4, UnixNanos};
82use nautilus_live::{ExecutionEventEmitter, execution::context::OrderContext};
83use nautilus_model::{
84 enums::{OrderStatus, OrderType},
85 events::{
86 OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
87 OrderTriggered, OrderUpdated,
88 },
89 identifiers::{AccountId, ClientOrderId, TradeId, VenueOrderId},
90 orders::LIMIT_ORDER_TYPES,
91 reports::{FillReport, OrderStatusReport},
92 types::{Price, Quantity},
93};
94use parking_lot::Mutex;
95use ustr::Ustr;
96
97use crate::{
98 common::consts::HYPERLIQUID_POST_ONLY_WOULD_MATCH,
99 http::models::HyperliquidExchangePlaceOrderRequest,
100};
101
102pub const DEDUP_CAPACITY: usize = 10_000;
103
104pub const MAX_PENDING_MODIFY_INTENTS: usize = 32;
108
109#[derive(Debug, Clone)]
118pub struct ModifyIntent {
119 pub generation: u64,
121 pub old_venue_order_id: Option<VenueOrderId>,
123 pub target_qty: Quantity,
125 pub sent_request: Option<HyperliquidExchangePlaceOrderRequest>,
127}
128
129#[derive(Debug, Clone)]
134pub struct CorrectiveReduce {
135 pub oid: u64,
137 pub generation: u64,
139 pub request: HyperliquidExchangePlaceOrderRequest,
141}
142
143#[derive(Debug, Default)]
150struct ModifyChain {
151 intents: VecDeque<ModifyIntent>,
152 next_generation: u64,
153 held_cancel: Option<OrderStatusReport>,
154}
155
156impl ModifyChain {
157 fn targets(&self, venue_order_id: VenueOrderId) -> bool {
158 self.intents
159 .iter()
160 .any(|intent| intent.old_venue_order_id == Some(venue_order_id))
161 }
162
163 fn release_held_cancel(&mut self) -> Option<OrderStatusReport> {
165 let venue_order_id = self.held_cancel.as_ref()?.venue_order_id;
166
167 if self.targets(venue_order_id) {
168 return None;
169 }
170
171 self.held_cancel.take()
172 }
173}
174
175#[derive(Debug)]
189pub struct WsDispatchState {
190 pub order_contexts: DashMap<ClientOrderId, OrderContext>,
199 pub emitted_accepted: DashSet<ClientOrderId>,
201 emitted_triggered: DashSet<ClientOrderId>,
202 pending_submissions: DashSet<ClientOrderId>,
204 pending_submission_rejections: DashMap<ClientOrderId, OrderStatusReport>,
207 pub filled_orders: DashSet<ClientOrderId>,
212 emitted_trades: Mutex<FifoCache<TradeId, DEDUP_CAPACITY>>,
217 terminal_cloids: Mutex<FifoCache<Ustr, DEDUP_CAPACITY>>,
219 closed_venue_order_ids: Mutex<FifoCache<VenueOrderId, DEDUP_CAPACITY>>,
220 pub cached_venue_order_ids: DashMap<ClientOrderId, VenueOrderId>,
227 pending_modify_chains: DashMap<ClientOrderId, ModifyChain>,
239 pub buffered_fills: DashMap<ClientOrderId, Vec<FillReport>>,
244 pub order_filled_qty: DashMap<ClientOrderId, Quantity>,
247 order_filled_qty_by_venue: Mutex<AHashMap<ClientOrderId, AHashMap<VenueOrderId, Quantity>>>,
248 pending_rejections: Mutex<AHashMap<ClientOrderId, OrderStatusReport>>,
249 pub pending_corrective: DashMap<ClientOrderId, CorrectiveReduce>,
252 released_cancels: Mutex<Vec<OrderStatusReport>>,
253 released_cancel_notify: tokio::sync::Notify,
254 clearing: AtomicBool,
255}
256
257impl Default for WsDispatchState {
258 fn default() -> Self {
259 Self {
260 order_contexts: DashMap::new(),
261 emitted_accepted: DashSet::default(),
262 emitted_triggered: DashSet::default(),
263 pending_submissions: DashSet::default(),
264 pending_submission_rejections: DashMap::new(),
265 filled_orders: DashSet::default(),
266 emitted_trades: Mutex::new(FifoCache::new()),
267 terminal_cloids: Mutex::new(FifoCache::new()),
268 closed_venue_order_ids: Mutex::new(FifoCache::new()),
269 cached_venue_order_ids: DashMap::new(),
270 pending_modify_chains: DashMap::new(),
271 buffered_fills: DashMap::new(),
272 order_filled_qty: DashMap::new(),
273 order_filled_qty_by_venue: Mutex::new(AHashMap::new()),
274 pending_rejections: Mutex::new(AHashMap::new()),
275 pending_corrective: DashMap::new(),
276 released_cancels: Mutex::new(Vec::new()),
277 released_cancel_notify: tokio::sync::Notify::new(),
278 clearing: AtomicBool::new(false),
279 }
280 }
281}
282
283impl WsDispatchState {
284 #[must_use]
286 pub fn new() -> Self {
287 Self::default()
288 }
289
290 pub fn register_context(&self, context: OrderContext) {
293 self.order_contexts
294 .insert(context.identity.client_order_id, context);
295 }
296
297 #[must_use]
299 pub fn lookup_context(&self, client_order_id: &ClientOrderId) -> Option<OrderContext> {
300 self.order_contexts.get(client_order_id).map(|r| *r)
301 }
302
303 pub fn mark_submission_pending(&self, client_order_id: ClientOrderId) {
305 self.pending_submissions.insert(client_order_id);
306 }
307
308 #[must_use]
310 pub fn submission_pending(&self, client_order_id: &ClientOrderId) -> bool {
311 self.pending_submissions.contains(client_order_id)
312 }
313
314 pub fn buffer_submission_rejection(
317 &self,
318 client_order_id: ClientOrderId,
319 report: OrderStatusReport,
320 ) {
321 self.pending_submission_rejections
322 .entry(client_order_id)
323 .and_modify(|pending| retain_rejection_report(pending, &report))
324 .or_insert(report);
325 }
326
327 #[must_use]
329 pub fn resolve_submission(&self, client_order_id: &ClientOrderId) -> Option<OrderStatusReport> {
330 self.pending_submissions.remove(client_order_id);
331 self.pending_submission_rejections
332 .remove(client_order_id)
333 .map(|(_, report)| report)
334 }
335
336 pub fn update_context_price(&self, client_order_id: &ClientOrderId, price: Option<Price>) {
339 if let Some(price) = price
340 && let Some(mut entry) = self.order_contexts.get_mut(client_order_id)
341 {
342 entry.price = Some(price);
343 }
344 }
345
346 pub fn update_context_quantity(&self, client_order_id: &ClientOrderId, quantity: Quantity) {
348 if let Some(mut entry) = self.order_contexts.get_mut(client_order_id) {
349 entry.quantity = quantity;
350 }
351 }
352
353 pub fn insert_accepted(&self, cid: ClientOrderId) {
355 self.evict_if_full(&self.emitted_accepted);
356 self.emitted_accepted.insert(cid);
357 }
358
359 fn insert_triggered(&self, cid: ClientOrderId) -> bool {
361 self.evict_if_full(&self.emitted_triggered);
362 self.emitted_triggered.insert(cid)
363 }
364
365 pub fn insert_filled(&self, cid: ClientOrderId) -> bool {
370 self.evict_if_full(&self.filled_orders);
371 self.filled_orders.insert(cid)
372 }
373
374 pub fn check_and_insert_trade(&self, trade_id: TradeId) -> bool {
379 let mut set = self.emitted_trades.lock();
380 !set.insert(trade_id)
381 }
382
383 #[must_use]
384 pub(crate) fn trade_emitted(&self, trade_id: &TradeId) -> bool {
385 self.emitted_trades.lock().contains(trade_id)
386 }
387
388 pub fn insert_terminal_cloid(&self, cloid: Ustr) {
393 let mut set = self.terminal_cloids.lock();
394 let _ = set.insert(cloid);
395 }
396
397 #[must_use]
399 pub fn terminal_cloid_seen(&self, cloid: &Ustr) -> bool {
400 let set = self.terminal_cloids.lock();
401 set.contains(cloid)
402 }
403
404 pub fn record_venue_order_id(
406 &self,
407 client_order_id: ClientOrderId,
408 venue_order_id: VenueOrderId,
409 ) {
410 self.cached_venue_order_ids
411 .insert(client_order_id, venue_order_id);
412 }
413
414 #[must_use]
416 pub fn cached_venue_order_id(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
417 self.cached_venue_order_ids.get(client_order_id).map(|r| *r)
418 }
419
420 pub fn mark_pending_modify(
429 &self,
430 client_order_id: ClientOrderId,
431 old_venue_order_id: VenueOrderId,
432 target_qty: Quantity,
433 ) -> u64 {
434 let mut chain = self
435 .pending_modify_chains
436 .entry(client_order_id)
437 .or_default();
438 let generation = chain.next_generation;
439 chain.next_generation += 1;
440 chain.intents.push_back(ModifyIntent {
441 generation,
442 old_venue_order_id: Some(old_venue_order_id),
443 target_qty,
444 sent_request: None,
445 });
446
447 if chain.intents.len() > MAX_PENDING_MODIFY_INTENTS {
448 chain.intents.pop_front();
449 log::warn!(
450 "Modify chain for {client_order_id} exceeded {MAX_PENDING_MODIFY_INTENTS}; \
451 evicting oldest intent",
452 );
453 }
454 generation
455 }
456
457 pub fn clear_pending_modify(&self, client_order_id: &ClientOrderId) {
459 self.pending_modify_chains.remove(client_order_id);
460 }
461
462 pub fn clear_modify_generation(&self, client_order_id: &ClientOrderId, generation: u64) {
472 let Some(mut chain) = self.pending_modify_chains.get_mut(client_order_id) else {
473 return;
474 };
475 let removed_front_old = chain
476 .intents
477 .front()
478 .filter(|front| front.generation == generation)
479 .and_then(|front| front.old_venue_order_id);
480 chain
481 .intents
482 .retain(|intent| intent.generation != generation);
483
484 if let Some(old) = removed_front_old
485 && let Some(new_front) = chain.intents.front_mut()
486 {
487 new_front.old_venue_order_id = Some(old);
488 }
489
490 let released = chain.release_held_cancel();
491 drop(chain);
492 self.pending_modify_chains
495 .remove_if(client_order_id, |_, chain| chain.intents.is_empty());
496
497 if let Some(report) = released {
498 self.release_cancel(client_order_id, report);
499 }
500 }
501
502 pub fn stash_modify_request(
505 &self,
506 client_order_id: ClientOrderId,
507 request: HyperliquidExchangePlaceOrderRequest,
508 ) {
509 if let Some(mut chain) = self.pending_modify_chains.get_mut(&client_order_id)
510 && let Some(back) = chain.intents.back_mut()
511 {
512 back.sent_request = Some(request);
513 } else {
514 log::debug!(
515 "Stash modify request for {client_order_id} with no pending intent; ignoring"
516 );
517 }
518 }
519
520 #[must_use]
522 pub fn modify_request(
523 &self,
524 client_order_id: &ClientOrderId,
525 ) -> Option<HyperliquidExchangePlaceOrderRequest> {
526 self.pending_modify_chains
527 .get(client_order_id)
528 .and_then(|chain| chain.intents.front().and_then(|i| i.sent_request.clone()))
529 }
530
531 pub fn claim_front_modify(
540 &self,
541 client_order_id: &ClientOrderId,
542 new_venue_order_id: VenueOrderId,
543 ) -> Option<ModifyIntent> {
544 let mut chain = self.pending_modify_chains.get_mut(client_order_id)?;
545 let claimed = chain.intents.pop_front();
546 chain.held_cancel = None;
547 if let Some(next) = chain.intents.front_mut() {
548 next.old_venue_order_id = Some(new_venue_order_id);
549 }
550 drop(chain);
551 self.pending_modify_chains
554 .remove_if(client_order_id, |_, chain| chain.intents.is_empty());
555 claimed
556 }
557
558 pub(crate) fn record_closed_venue_order_id(&self, venue_order_id: VenueOrderId) {
559 self.closed_venue_order_ids.lock().add(venue_order_id);
560 }
561
562 pub fn queue_corrective(&self, client_order_id: ClientOrderId, corrective: CorrectiveReduce) {
564 self.pending_corrective.insert(client_order_id, corrective);
565 }
566
567 #[must_use]
569 pub fn take_corrective(&self, client_order_id: &ClientOrderId) -> Option<CorrectiveReduce> {
570 self.pending_corrective
571 .remove(client_order_id)
572 .map(|(_, v)| v)
573 }
574
575 #[must_use]
577 pub fn has_pending_modify(&self, client_order_id: &ClientOrderId) -> bool {
578 self.pending_modify_chains
579 .get(client_order_id)
580 .is_some_and(|chain| !chain.intents.is_empty())
581 }
582
583 #[must_use]
585 pub fn pending_modify(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
586 self.pending_modify_chains
587 .get(client_order_id)
588 .and_then(|chain| chain.intents.front().and_then(|i| i.old_venue_order_id))
589 }
590
591 #[must_use]
596 pub fn pending_modify_contains_old(
597 &self,
598 client_order_id: &ClientOrderId,
599 venue_order_id: VenueOrderId,
600 ) -> bool {
601 self.pending_modify_chains
602 .get(client_order_id)
603 .is_some_and(|chain| chain.targets(venue_order_id))
604 }
605
606 pub(crate) fn hold_cancel(
610 &self,
611 client_order_id: &ClientOrderId,
612 report: &OrderStatusReport,
613 ) -> bool {
614 let Some(mut chain) = self.pending_modify_chains.get_mut(client_order_id) else {
615 return false;
616 };
617
618 if !chain.targets(report.venue_order_id) {
619 return false;
620 }
621
622 chain.held_cancel = Some(report.clone());
623 true
624 }
625
626 fn release_cancel(&self, client_order_id: &ClientOrderId, report: OrderStatusReport) {
627 log::debug!(
628 "Releasing held terminal report for {} on {client_order_id}: status={:?}",
629 report.venue_order_id,
630 report.order_status,
631 );
632 self.released_cancels.lock().push(report);
633 self.released_cancel_notify.notify_one();
634 }
635
636 pub(crate) async fn recv_released_cancels(&self) -> Vec<OrderStatusReport> {
638 loop {
639 let released = std::mem::take(&mut *self.released_cancels.lock());
640
641 if !released.is_empty() {
642 return released;
643 }
644
645 self.released_cancel_notify.notified().await;
646 }
647 }
648
649 #[must_use]
651 pub fn pending_modify_target_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
652 self.pending_modify_chains
653 .get(client_order_id)
654 .and_then(|chain| chain.intents.front().map(|i| i.target_qty))
655 }
656
657 pub fn buffer_fill(&self, client_order_id: ClientOrderId, fill: FillReport) {
659 self.buffered_fills
660 .entry(client_order_id)
661 .or_default()
662 .push(fill);
663 }
664
665 #[must_use]
667 pub fn drain_buffered_fills(&self, client_order_id: &ClientOrderId) -> Vec<FillReport> {
668 self.buffered_fills
669 .remove(client_order_id)
670 .map(|(_, v)| v)
671 .unwrap_or_default()
672 }
673
674 #[must_use]
676 pub fn buffered_fill_count(&self, client_order_id: &ClientOrderId) -> usize {
677 self.buffered_fills
678 .get(client_order_id)
679 .map_or(0, |r| r.len())
680 }
681
682 pub fn record_filled_qty(&self, client_order_id: ClientOrderId, qty: Quantity) {
684 self.order_filled_qty.insert(client_order_id, qty);
685 }
686
687 #[must_use]
689 pub fn previous_filled_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
690 self.order_filled_qty.get(client_order_id).map(|r| *r)
691 }
692
693 pub fn cleanup_terminal(&self, client_order_id: &ClientOrderId) {
698 self.order_contexts.remove(client_order_id);
699 self.emitted_accepted.remove(client_order_id);
700 self.emitted_triggered.remove(client_order_id);
701 self.pending_submissions.remove(client_order_id);
702 self.pending_submission_rejections.remove(client_order_id);
703 self.cached_venue_order_ids.remove(client_order_id);
704 self.pending_modify_chains.remove(client_order_id);
705 self.pending_corrective.remove(client_order_id);
706 self.buffered_fills.remove(client_order_id);
707 self.order_filled_qty.remove(client_order_id);
708 self.order_filled_qty_by_venue
709 .lock()
710 .remove(client_order_id);
711 self.pending_rejections.lock().remove(client_order_id);
712 }
713
714 fn evict_if_full(&self, set: &DashSet<ClientOrderId>) {
715 if set.len() >= DEDUP_CAPACITY
716 && self
717 .clearing
718 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
719 .is_ok()
720 {
721 set.clear();
722 self.clearing.store(false, Ordering::Release);
723 }
724 }
725}
726
727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729pub enum DispatchOutcome {
730 Tracked,
734 External,
739 Skip,
743}
744
745pub fn dispatch_order_event(
755 report: &OrderStatusReport,
756 state: &WsDispatchState,
757 emitter: &ExecutionEventEmitter,
758 ts_init: UnixNanos,
759) -> DispatchOutcome {
760 let Some(client_order_id) = report.client_order_id else {
761 return DispatchOutcome::External;
762 };
763
764 if state.filled_orders.contains(&client_order_id) {
765 log::debug!(
766 "Skipping stale report for filled order: cid={client_order_id}, status={:?}",
767 report.order_status,
768 );
769 return DispatchOutcome::Skip;
770 }
771
772 let client_order_id_str = client_order_id.as_str();
773 if client_order_id_str.starts_with("0x")
774 && state.terminal_cloid_seen(&Ustr::from(client_order_id_str))
775 {
776 log::debug!(
777 "Skipping stale terminal report for raw cloid: cid={client_order_id}, status={:?}",
778 report.order_status,
779 );
780 return DispatchOutcome::Skip;
781 }
782
783 let Some(context) = state.lookup_context(&client_order_id) else {
784 return DispatchOutcome::External;
785 };
786
787 match report.order_status {
788 OrderStatus::Accepted => {
789 handle_accepted(report, client_order_id, &context, state, emitter, ts_init)
790 }
791 OrderStatus::Triggered => {
792 handle_triggered(report, client_order_id, &context, state, emitter, ts_init)
793 }
794 OrderStatus::Canceled => {
795 handle_canceled(report, client_order_id, &context, state, emitter, ts_init)
796 }
797 OrderStatus::Expired => {
798 handle_expired(report, client_order_id, &context, state, emitter, ts_init)
799 }
800 OrderStatus::Rejected => {
801 handle_rejected(report, client_order_id, &context, state, emitter, ts_init)
802 }
803 OrderStatus::Filled => handle_filled_marker(client_order_id, state),
804 OrderStatus::PartiallyFilled => {
805 DispatchOutcome::Tracked
807 }
808 OrderStatus::PendingUpdate
809 | OrderStatus::PendingCancel
810 | OrderStatus::Submitted
811 | OrderStatus::Initialized
812 | OrderStatus::Denied
813 | OrderStatus::Released
814 | OrderStatus::Emulated
815 | OrderStatus::Voided => DispatchOutcome::Tracked,
816 }
817}
818
819pub fn dispatch_order_fill(
830 report: &FillReport,
831 state: &WsDispatchState,
832 emitter: &ExecutionEventEmitter,
833 ts_init: UnixNanos,
834) -> DispatchOutcome {
835 let Some(client_order_id) = report.client_order_id else {
836 return DispatchOutcome::External;
837 };
838
839 if state.filled_orders.contains(&client_order_id) {
840 return if state.check_and_insert_trade(report.trade_id) {
841 DispatchOutcome::Skip
842 } else {
843 DispatchOutcome::External
844 };
845 }
846
847 let Some(mut context) = state.lookup_context(&client_order_id) else {
848 return DispatchOutcome::External;
849 };
850
851 let mut promoted_corrective: Option<(Quantity, HyperliquidExchangePlaceOrderRequest)> = None;
853
854 if state.has_pending_modify(&client_order_id)
858 && let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
859 && report.venue_order_id != cached_voi
860 && venue_oid(report.venue_order_id) >= venue_oid(cached_voi)
861 {
862 let target = state.pending_modify_target_qty(&client_order_id);
863 let sent_request = state.modify_request(&client_order_id);
864 let price = sent_request
866 .as_ref()
867 .zip(context.price)
868 .and_then(|(r, cached)| Price::from_decimal_dp(r.price, cached.precision).ok())
869 .or(context.price);
870 let Some(price) = price else {
871 log::warn!(
872 "Cannot promote cancel-replace for {client_order_id} from fill: no target \
873 or cached price; buffering until the replacement ACCEPTED arrives",
874 );
875 state.buffer_fill(client_order_id, report.clone());
876 return DispatchOutcome::Tracked;
877 };
878 let updated_quantity = target.unwrap_or(context.quantity);
879 promote_cancel_replace(
880 client_order_id,
881 &context,
882 state,
883 emitter,
884 report.venue_order_id,
885 report.account_id,
886 price,
887 updated_quantity,
888 None,
889 report.ts_event,
890 ts_init,
891 );
892 context = state.lookup_context(&client_order_id).unwrap_or(context);
894
895 if let (Some(target), Some(sent_request)) = (target, sent_request) {
896 promoted_corrective = Some((target, sent_request));
897 }
898 }
899
900 if state.check_and_insert_trade(report.trade_id) {
901 log::debug!(
902 "Skipping duplicate fill for {client_order_id}: trade_id={}",
903 report.trade_id
904 );
905 return DispatchOutcome::Tracked;
906 }
907
908 let previous = state
909 .previous_filled_qty(&client_order_id)
910 .unwrap_or_else(|| Quantity::zero(report.last_qty.precision));
911 let cumulative = previous + report.last_qty;
912
913 let is_terminal_fill = cumulative >= context.quantity;
914 if is_terminal_fill && !claim_terminal_order(client_order_id, state, OrderStatus::Filled) {
915 return DispatchOutcome::Skip;
916 }
917
918 ensure_accepted_emitted(
919 client_order_id,
920 report.venue_order_id,
921 report.account_id,
922 &context,
923 state,
924 emitter,
925 report.ts_event,
926 ts_init,
927 );
928
929 let filled = OrderFilled::new(
930 emitter.trader_id(),
931 context.identity.strategy_id,
932 context.identity.instrument_id,
933 client_order_id,
934 report.venue_order_id,
935 report.account_id,
936 report.trade_id,
937 context.identity.order_side,
938 context.identity.order_type,
939 report.last_qty,
940 report.last_px,
941 report.commission.currency,
942 report.liquidity_side,
943 UUID4::new(),
944 report.ts_event,
945 ts_init,
946 false,
947 report.venue_position_id,
948 Some(report.commission),
949 None,
950 );
951 emitter.send_order_event(OrderEventAny::Filled(filled));
952
953 state.record_filled_qty(client_order_id, cumulative);
954 {
955 let mut orders = state.order_filled_qty_by_venue.lock();
956 let fills = orders.entry(client_order_id).or_default();
957 let quantity = fills
958 .entry(report.venue_order_id)
959 .or_insert_with(|| Quantity::zero(report.last_qty.precision));
960 *quantity = *quantity + report.last_qty;
961 }
962
963 if let Some((target, sent_request)) = promoted_corrective {
965 maybe_queue_corrective_reduce(
966 state,
967 client_order_id,
968 report.venue_order_id,
969 target,
970 sent_request,
971 );
972 }
973
974 if is_terminal_fill {
975 state.cleanup_terminal(&client_order_id);
976 return DispatchOutcome::Tracked;
977 }
978
979 release_rejection_after_fill(client_order_id, state);
980 DispatchOutcome::Tracked
981}
982
983fn release_rejection_after_fill(client_order_id: ClientOrderId, state: &WsDispatchState) {
984 let mut pending = state.pending_rejections.lock();
985
986 let Some(rejection) = pending.get(&client_order_id) else {
987 return;
988 };
989
990 if filled_qty_for_venue(state, client_order_id, rejection.venue_order_id) < rejection.filled_qty
991 {
992 return;
993 }
994
995 let mut canceled = rejection.clone();
996 pending.remove(&client_order_id);
997 drop(pending);
998 canceled.order_status = OrderStatus::Canceled;
999 state.release_cancel(&client_order_id, canceled);
1000}
1001
1002fn handle_accepted(
1003 report: &OrderStatusReport,
1004 client_order_id: ClientOrderId,
1005 context: &OrderContext,
1006 state: &WsDispatchState,
1007 emitter: &ExecutionEventEmitter,
1008 ts_init: UnixNanos,
1009) -> DispatchOutcome {
1010 let venue_order_id = report.venue_order_id;
1011 let ts_event = report.ts_last;
1012 let account_id = report.account_id;
1013
1014 if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
1019 && cached_voi != venue_order_id
1020 {
1021 if venue_oid(venue_order_id) < venue_oid(cached_voi) {
1023 log::debug!(
1024 "Skipping ACCEPTED for superseded leg {venue_order_id} on {client_order_id}"
1025 );
1026 return DispatchOutcome::Skip;
1027 }
1028
1029 let price = report.price.or(context.price);
1030 let Some(price) = price else {
1031 log::warn!(
1032 "Cannot emit OrderUpdated for cancel-replace {client_order_id}: \
1033 no price on report and no cached price on context",
1034 );
1035 return DispatchOutcome::Skip;
1036 };
1037
1038 let target_total_qty = state.pending_modify_target_qty(&client_order_id);
1041 let updated_quantity = target_total_qty.unwrap_or(report.quantity);
1042 let sent_request = state.modify_request(&client_order_id);
1043
1044 promote_cancel_replace(
1045 client_order_id,
1046 context,
1047 state,
1048 emitter,
1049 venue_order_id,
1050 account_id,
1051 price,
1052 updated_quantity,
1053 report.trigger_price,
1054 ts_event,
1055 ts_init,
1056 );
1057
1058 if let (Some(target), Some(sent_request)) = (target_total_qty, sent_request) {
1059 maybe_queue_corrective_reduce(
1060 state,
1061 client_order_id,
1062 venue_order_id,
1063 target,
1064 sent_request,
1065 );
1066 }
1067
1068 return DispatchOutcome::Tracked;
1069 }
1070
1071 if state.emitted_accepted.contains(&client_order_id) {
1072 state.update_context_price(&client_order_id, report.price);
1076 return DispatchOutcome::Tracked;
1077 }
1078
1079 state.insert_accepted(client_order_id);
1080 state.record_venue_order_id(client_order_id, venue_order_id);
1081 state.update_context_price(&client_order_id, report.price);
1082
1083 let accepted = OrderAccepted::new(
1084 emitter.trader_id(),
1085 context.identity.strategy_id,
1086 context.identity.instrument_id,
1087 client_order_id,
1088 venue_order_id,
1089 account_id,
1090 UUID4::new(),
1091 ts_event,
1092 ts_init,
1093 false,
1094 );
1095 emitter.send_order_event(OrderEventAny::Accepted(accepted));
1096 DispatchOutcome::Tracked
1097}
1098
1099#[allow(
1102 clippy::too_many_arguments,
1103 reason = "promotion needs the full OrderUpdated field set, sourced from two report shapes"
1104)]
1105fn promote_cancel_replace(
1106 client_order_id: ClientOrderId,
1107 context: &OrderContext,
1108 state: &WsDispatchState,
1109 emitter: &ExecutionEventEmitter,
1110 venue_order_id: VenueOrderId,
1111 account_id: AccountId,
1112 price: Price,
1113 quantity: Quantity,
1114 trigger_price: Option<Price>,
1115 ts_event: UnixNanos,
1116 ts_init: UnixNanos,
1117) {
1118 state.record_venue_order_id(client_order_id, venue_order_id);
1119 state.update_context_quantity(&client_order_id, quantity);
1120 state.update_context_price(&client_order_id, Some(price));
1121 state.claim_front_modify(&client_order_id, venue_order_id);
1123
1124 let updated = OrderUpdated::new(
1125 emitter.trader_id(),
1126 context.identity.strategy_id,
1127 context.identity.instrument_id,
1128 client_order_id,
1129 quantity,
1130 UUID4::new(),
1131 ts_event,
1132 ts_init,
1133 false,
1134 Some(venue_order_id),
1135 Some(account_id),
1136 LIMIT_ORDER_TYPES
1137 .contains(&context.identity.order_type)
1138 .then_some(price),
1139 trigger_price,
1140 None,
1141 false,
1142 );
1143 emitter.send_order_event(OrderEventAny::Updated(updated));
1144
1145 let buffered = state.drain_buffered_fills(&client_order_id);
1148 for fill in buffered {
1149 dispatch_order_fill(&fill, state, emitter, ts_init);
1150 }
1151}
1152
1153pub fn promote_replacement_from_query(
1160 report: &OrderStatusReport,
1161 state: &WsDispatchState,
1162 emitter: &ExecutionEventEmitter,
1163 ts_init: UnixNanos,
1164) -> bool {
1165 if report.order_status != OrderStatus::Accepted {
1166 return false;
1167 }
1168
1169 let Some(client_order_id) = report.client_order_id else {
1170 return false;
1171 };
1172
1173 if !state.has_pending_modify(&client_order_id) {
1174 return false;
1175 }
1176
1177 let Some(cached_voi) = state.cached_venue_order_id(&client_order_id) else {
1178 return false;
1179 };
1180
1181 if report.venue_order_id == cached_voi {
1182 return false;
1183 }
1184
1185 let Some(context) = state.lookup_context(&client_order_id) else {
1186 return false;
1187 };
1188
1189 let Some(price) = report.price.or(context.price) else {
1190 log::warn!(
1191 "Cannot promote cancel-replace from query for {client_order_id}: \
1192 no price on report and no cached price on context",
1193 );
1194 return false;
1195 };
1196
1197 let updated_quantity = state
1199 .pending_modify_target_qty(&client_order_id)
1200 .unwrap_or(report.quantity);
1201
1202 promote_cancel_replace(
1203 client_order_id,
1204 &context,
1205 state,
1206 emitter,
1207 report.venue_order_id,
1208 report.account_id,
1209 price,
1210 updated_quantity,
1211 report.trigger_price,
1212 report.ts_last,
1213 ts_init,
1214 );
1215
1216 log::debug!("Promoted cancel-replace replacement for {client_order_id} from query");
1217
1218 true
1219}
1220
1221fn maybe_queue_corrective_reduce(
1224 state: &WsDispatchState,
1225 client_order_id: ClientOrderId,
1226 venue_order_id: VenueOrderId,
1227 target: Quantity,
1228 sent_request: HyperliquidExchangePlaceOrderRequest,
1229) {
1230 if state
1232 .closed_venue_order_ids
1233 .lock()
1234 .contains(&venue_order_id)
1235 {
1236 return;
1237 }
1238
1239 let Ok(new_oid) = venue_order_id.as_str().parse::<u64>() else {
1240 return;
1241 };
1242
1243 let filled = state
1244 .previous_filled_qty(&client_order_id)
1245 .unwrap_or_else(|| Quantity::zero(target.precision));
1246 if filled >= target {
1247 return;
1248 }
1249
1250 let remaining = (target - filled).as_decimal().normalize();
1251
1252 let sent_size = sent_request.size;
1253 if sent_size > remaining {
1254 let mut corrective = sent_request;
1255 corrective.size = remaining;
1256
1257 let generation = state.mark_pending_modify(client_order_id, venue_order_id, target);
1258 state.stash_modify_request(client_order_id, corrective.clone());
1259 state.queue_corrective(
1260 client_order_id,
1261 CorrectiveReduce {
1262 oid: new_oid,
1263 generation,
1264 request: corrective,
1265 },
1266 );
1267
1268 log::warn!(
1269 "Cancel-replace left {client_order_id} oversized on {venue_order_id} \
1270 (sent {sent_size}, remaining {remaining}); queuing corrective reduce",
1271 );
1272 }
1273}
1274
1275fn handle_triggered(
1276 report: &OrderStatusReport,
1277 client_order_id: ClientOrderId,
1278 context: &OrderContext,
1279 state: &WsDispatchState,
1280 emitter: &ExecutionEventEmitter,
1281 ts_init: UnixNanos,
1282) -> DispatchOutcome {
1283 if !matches!(
1284 context.identity.order_type,
1285 OrderType::StopLimit | OrderType::TrailingStopLimit | OrderType::LimitIfTouched
1286 ) {
1287 log::debug!(
1288 "Ignoring TRIGGERED status for non-triggerable order type {:?}: {client_order_id}",
1289 context.identity.order_type,
1290 );
1291 return DispatchOutcome::Tracked;
1292 }
1293
1294 if !state.insert_triggered(client_order_id) {
1296 log::debug!("Skipping repeated TRIGGERED for {client_order_id}");
1297 return DispatchOutcome::Skip;
1298 }
1299
1300 ensure_accepted_emitted(
1301 client_order_id,
1302 report.venue_order_id,
1303 report.account_id,
1304 context,
1305 state,
1306 emitter,
1307 report.ts_last,
1308 ts_init,
1309 );
1310
1311 let triggered = OrderTriggered::new(
1312 emitter.trader_id(),
1313 context.identity.strategy_id,
1314 context.identity.instrument_id,
1315 client_order_id,
1316 UUID4::new(),
1317 report.ts_last,
1318 ts_init,
1319 false,
1320 Some(report.venue_order_id),
1321 Some(report.account_id),
1322 );
1323 emitter.send_order_event(OrderEventAny::Triggered(triggered));
1324 DispatchOutcome::Tracked
1325}
1326
1327fn handle_canceled(
1328 report: &OrderStatusReport,
1329 client_order_id: ClientOrderId,
1330 context: &OrderContext,
1331 state: &WsDispatchState,
1332 emitter: &ExecutionEventEmitter,
1333 ts_init: UnixNanos,
1334) -> DispatchOutcome {
1335 let venue_order_id = report.venue_order_id;
1336
1337 if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
1338 && cached_voi != venue_order_id
1339 {
1340 if !state.has_pending_modify(&client_order_id)
1341 || venue_oid(venue_order_id) <= venue_oid(cached_voi)
1342 {
1343 log::debug!(
1344 "Skipping stale CANCELED for {venue_order_id} (cached {cached_voi}) on {client_order_id}",
1345 );
1346 return DispatchOutcome::Skip;
1347 }
1348
1349 let Some(price) = report.price.or(context.price) else {
1350 log::warn!(
1351 "Cannot promote canceled replacement for {client_order_id}: no report or cached price",
1352 );
1353 return DispatchOutcome::Skip;
1354 };
1355
1356 let quantity = state
1357 .pending_modify_target_qty(&client_order_id)
1358 .unwrap_or(report.quantity);
1359
1360 promote_cancel_replace(
1363 client_order_id,
1364 context,
1365 state,
1366 emitter,
1367 venue_order_id,
1368 report.account_id,
1369 price,
1370 quantity,
1371 report.trigger_price,
1372 report.ts_last,
1373 ts_init,
1374 );
1375 }
1376
1377 if state.hold_cancel(&client_order_id, report) {
1383 log::debug!(
1384 "Holding cancel-before-accept leg for {client_order_id}: venue_order_id={venue_order_id}",
1385 );
1386 return DispatchOutcome::Skip;
1387 }
1388
1389 if !claim_terminal_order(client_order_id, state, report.order_status) {
1390 return DispatchOutcome::Skip;
1391 }
1392
1393 ensure_accepted_emitted(
1394 client_order_id,
1395 venue_order_id,
1396 report.account_id,
1397 context,
1398 state,
1399 emitter,
1400 report.ts_last,
1401 ts_init,
1402 );
1403
1404 let canceled = OrderCanceled::new(
1405 emitter.trader_id(),
1406 context.identity.strategy_id,
1407 context.identity.instrument_id,
1408 client_order_id,
1409 UUID4::new(),
1410 report.ts_last,
1411 ts_init,
1412 false,
1413 Some(venue_order_id),
1414 Some(report.account_id),
1415 None,
1416 );
1417 emitter.send_order_event(OrderEventAny::Canceled(canceled));
1418
1419 state.cleanup_terminal(&client_order_id);
1420 DispatchOutcome::Tracked
1421}
1422
1423fn handle_expired(
1424 report: &OrderStatusReport,
1425 client_order_id: ClientOrderId,
1426 context: &OrderContext,
1427 state: &WsDispatchState,
1428 emitter: &ExecutionEventEmitter,
1429 ts_init: UnixNanos,
1430) -> DispatchOutcome {
1431 if !claim_terminal_order(client_order_id, state, report.order_status) {
1432 return DispatchOutcome::Skip;
1433 }
1434
1435 ensure_accepted_emitted(
1436 client_order_id,
1437 report.venue_order_id,
1438 report.account_id,
1439 context,
1440 state,
1441 emitter,
1442 report.ts_last,
1443 ts_init,
1444 );
1445
1446 let expired = OrderExpired::new(
1447 emitter.trader_id(),
1448 context.identity.strategy_id,
1449 context.identity.instrument_id,
1450 client_order_id,
1451 UUID4::new(),
1452 report.ts_last,
1453 ts_init,
1454 false,
1455 Some(report.venue_order_id),
1456 Some(report.account_id),
1457 );
1458 emitter.send_order_event(OrderEventAny::Expired(expired));
1459 state.cleanup_terminal(&client_order_id);
1460 DispatchOutcome::Tracked
1461}
1462
1463fn handle_rejected(
1464 report: &OrderStatusReport,
1465 client_order_id: ClientOrderId,
1466 context: &OrderContext,
1467 state: &WsDispatchState,
1468 emitter: &ExecutionEventEmitter,
1469 ts_init: UnixNanos,
1470) -> DispatchOutcome {
1471 if state.submission_pending(&client_order_id) {
1472 state.buffer_submission_rejection(client_order_id, report.clone());
1473 return DispatchOutcome::Skip;
1474 }
1475
1476 if let Some(bound) = state.cached_venue_order_id(&client_order_id)
1479 && bound != report.venue_order_id
1480 {
1481 return DispatchOutcome::Skip;
1482 }
1483
1484 {
1485 let mut pending = state.pending_rejections.lock();
1487 if !report.filled_qty.is_zero()
1488 && filled_qty_for_venue(state, client_order_id, report.venue_order_id)
1489 < report.filled_qty
1490 {
1491 pending
1492 .entry(client_order_id)
1493 .and_modify(|pending| retain_rejection_report(pending, report))
1494 .or_insert_with(|| report.clone());
1495 return DispatchOutcome::Skip;
1496 }
1497
1498 if pending.contains_key(&client_order_id) {
1499 return DispatchOutcome::Skip;
1500 }
1501 }
1502
1503 if !report.filled_qty.is_zero()
1504 || state
1505 .previous_filled_qty(&client_order_id)
1506 .is_some_and(|qty| !qty.is_zero())
1507 {
1508 return handle_canceled(report, client_order_id, context, state, emitter, ts_init);
1509 }
1510
1511 if !claim_terminal_order(client_order_id, state, report.order_status) {
1512 return DispatchOutcome::Skip;
1513 }
1514
1515 let reason = report
1516 .cancel_reason
1517 .clone()
1518 .unwrap_or_else(|| "Order rejected by exchange".to_string());
1519 let rejected = OrderRejected::new(
1520 emitter.trader_id(),
1521 context.identity.strategy_id,
1522 context.identity.instrument_id,
1523 client_order_id,
1524 report.account_id,
1525 Ustr::from(&reason),
1526 UUID4::new(),
1527 report.ts_last,
1528 ts_init,
1529 false,
1530 report.post_only && reason.contains(HYPERLIQUID_POST_ONLY_WOULD_MATCH),
1531 );
1532 emitter.send_order_event(OrderEventAny::Rejected(rejected));
1533 state.cleanup_terminal(&client_order_id);
1534 DispatchOutcome::Tracked
1535}
1536
1537fn retain_rejection_report(pending: &mut OrderStatusReport, report: &OrderStatusReport) {
1538 if report.venue_order_id != pending.venue_order_id || report.filled_qty >= pending.filled_qty {
1539 *pending = report.clone();
1540 }
1541}
1542
1543fn filled_qty_for_venue(
1544 state: &WsDispatchState,
1545 client_order_id: ClientOrderId,
1546 venue_order_id: VenueOrderId,
1547) -> Quantity {
1548 state
1549 .order_filled_qty_by_venue
1550 .lock()
1551 .get(&client_order_id)
1552 .and_then(|fills| fills.get(&venue_order_id).copied())
1553 .unwrap_or_else(|| Quantity::zero(0))
1554}
1555
1556fn claim_terminal_order(
1557 client_order_id: ClientOrderId,
1558 state: &WsDispatchState,
1559 status: OrderStatus,
1560) -> bool {
1561 let claimed = state.insert_filled(client_order_id);
1562 if !claimed {
1563 log::debug!("Skipping duplicate terminal event for {client_order_id}: status={status:?}",);
1564 }
1565
1566 claimed
1567}
1568
1569fn handle_filled_marker(
1570 _client_order_id: ClientOrderId,
1571 _state: &WsDispatchState,
1572) -> DispatchOutcome {
1573 DispatchOutcome::Tracked
1581}
1582
1583#[allow(clippy::too_many_arguments)]
1591fn ensure_accepted_emitted(
1592 client_order_id: ClientOrderId,
1593 venue_order_id: VenueOrderId,
1594 account_id: AccountId,
1595 context: &OrderContext,
1596 state: &WsDispatchState,
1597 emitter: &ExecutionEventEmitter,
1598 ts_event: UnixNanos,
1599 ts_init: UnixNanos,
1600) {
1601 if state.emitted_accepted.contains(&client_order_id) {
1602 return;
1603 }
1604 state.insert_accepted(client_order_id);
1605 state.record_venue_order_id(client_order_id, venue_order_id);
1606
1607 let accepted = OrderAccepted::new(
1608 emitter.trader_id(),
1609 context.identity.strategy_id,
1610 context.identity.instrument_id,
1611 client_order_id,
1612 venue_order_id,
1613 account_id,
1614 UUID4::new(),
1615 ts_event,
1616 ts_init,
1617 false,
1618 );
1619 emitter.send_order_event(OrderEventAny::Accepted(accepted));
1620}
1621
1622pub(crate) fn venue_oid(venue_order_id: VenueOrderId) -> u64 {
1624 venue_order_id.as_str().parse().unwrap_or_default()
1625}
1626
1627#[cfg(test)]
1628mod tests {
1629 use std::{sync::Arc, time::Duration};
1630
1631 use futures_util::FutureExt;
1632 use nautilus_common::{messages::ExecutionEvent, testing::wait_until};
1633 use nautilus_core::time::get_atomic_clock_realtime;
1634 use nautilus_live::execution::context::OrderIdentity;
1635 use nautilus_model::{
1636 enums::{AccountType, LiquiditySide, OrderSide, TimeInForce},
1637 identifiers::{ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId},
1638 types::Money,
1639 };
1640 use rstest::rstest;
1641 use rust_decimal::Decimal;
1642
1643 use super::*;
1644 use crate::http::models::{
1645 HyperliquidExchangeLimitParams, HyperliquidExchangeOrderKind, HyperliquidExchangeTif,
1646 };
1647
1648 fn make_context(client_order_id: ClientOrderId) -> OrderContext {
1649 OrderContext {
1650 identity: OrderIdentity {
1651 client_order_id,
1652 strategy_id: StrategyId::from("S-001"),
1653 instrument_id: InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
1654 order_side: OrderSide::Buy,
1655 order_type: OrderType::Limit,
1656 },
1657 quantity: Quantity::from("0.0001"),
1658 price: None,
1659 trigger_price: None,
1660 trigger_type: None,
1661 time_in_force: TimeInForce::Gtc,
1662 is_post_only: false,
1663 is_reduce_only: false,
1664 is_quote_quantity: false,
1665 }
1666 }
1667
1668 #[rstest]
1669 fn test_rejection_registers_atomically_with_fill_completion() {
1670 let state = Arc::new(WsDispatchState::new());
1671 let (emitter, mut rx) = test_emitter();
1672 let cid = ClientOrderId::new("O-REJECTION-RACE");
1673 let context = make_context(cid);
1674 state.register_context(context);
1675
1676 let first = FillReport::new(
1677 AccountId::new("HYPERLIQUID-001"),
1678 context.identity.instrument_id,
1679 VenueOrderId::new("100"),
1680 TradeId::new("T-RACE-1"),
1681 OrderSide::Buy,
1682 Quantity::from("0.00003"),
1683 Price::from("56730.0"),
1684 Money::from("0.17 USD"),
1685 LiquiditySide::Taker,
1686 Some(cid),
1687 None,
1688 UnixNanos::default(),
1689 UnixNanos::default(),
1690 None,
1691 );
1692 dispatch_order_fill(&first, &state, &emitter, UnixNanos::default());
1693
1694 let rejection = OrderStatusReport::new(
1695 first.account_id,
1696 first.instrument_id,
1697 Some(cid),
1698 first.venue_order_id,
1699 OrderSide::Buy.into(),
1700 OrderType::Limit,
1701 TimeInForce::Gtc,
1702 OrderStatus::Rejected,
1703 context.quantity,
1704 Quantity::from("0.00005"),
1705 UnixNanos::default(),
1706 UnixNanos::default(),
1707 UnixNanos::default(),
1708 None,
1709 );
1710 let fills = state.order_filled_qty_by_venue.lock();
1711 let reject_state = state.clone();
1712 let reject_emitter = emitter.clone();
1713
1714 let rejected = std::thread::spawn(move || {
1715 dispatch_order_event(
1716 &rejection,
1717 &reject_state,
1718 &reject_emitter,
1719 UnixNanos::default(),
1720 )
1721 });
1722
1723 let timeout = Duration::from_secs(5);
1724 wait_until(|| state.pending_rejections.is_locked(), timeout);
1725 let mut last = first;
1726 last.trade_id = TradeId::new("T-RACE-2");
1727 last.last_qty = Quantity::from("0.00002");
1728 last.commission = Money::from("0.29 USD");
1729 let fill_state = state.clone();
1730 let fill_emitter = emitter.clone();
1731 let filled = std::thread::spawn(move || {
1732 dispatch_order_fill(&last, &fill_state, &fill_emitter, UnixNanos::default())
1733 });
1734
1735 wait_until(
1736 || state.previous_filled_qty(&cid) == Some(Quantity::from("0.00005")),
1737 timeout,
1738 );
1739 drop(fills);
1740 rejected.join().unwrap();
1741 assert_eq!(filled.join().unwrap(), DispatchOutcome::Tracked);
1742
1743 if let Some(released) = state.recv_released_cancels().now_or_never() {
1744 assert_eq!(released.len(), 1);
1745 dispatch_order_event(&released[0], &state, &emitter, UnixNanos::default());
1746 }
1747
1748 let mut events = Vec::new();
1749 while let Ok(event) = rx.try_recv() {
1750 events.push(event);
1751 }
1752
1753 let [
1754 ExecutionEvent::Order(OrderEventAny::Accepted(_)),
1755 ExecutionEvent::Order(OrderEventAny::Filled(first)),
1756 ExecutionEvent::Order(OrderEventAny::Filled(last)),
1757 ExecutionEvent::Order(OrderEventAny::Canceled(_)),
1758 ] = events.as_slice()
1759 else {
1760 panic!("expected two fills followed by cancellation: {events:?}");
1761 };
1762
1763 assert_eq!(first.trade_id, TradeId::new("T-RACE-1"));
1764 assert_eq!(first.last_qty, Quantity::from("0.00003"));
1765 assert_eq!(first.commission, Some(Money::from("0.17 USD")));
1766 assert_eq!(last.trade_id, TradeId::new("T-RACE-2"));
1767 assert_eq!(last.last_qty, Quantity::from("0.00002"));
1768 assert_eq!(last.commission, Some(Money::from("0.29 USD")));
1769 assert_eq!(state.lookup_context(&cid), None);
1770 assert!(state.filled_orders.contains(&cid));
1771 }
1772
1773 #[rstest]
1774 fn test_register_and_lookup_context() {
1775 let state = WsDispatchState::new();
1776 let cid = ClientOrderId::new("O-001");
1777 state.register_context(make_context(cid));
1778
1779 assert_eq!(state.lookup_context(&cid), Some(make_context(cid)));
1780 }
1781
1782 #[rstest]
1783 fn test_lookup_context_missing_returns_none() {
1784 let state = WsDispatchState::new();
1785 let cid = ClientOrderId::new("not-tracked");
1786 assert!(state.lookup_context(&cid).is_none());
1787 }
1788
1789 #[rstest]
1790 fn test_update_context_refreshes_price_and_quantity() {
1791 let state = WsDispatchState::new();
1792 let cid = ClientOrderId::new("O-003");
1793 state.register_context(make_context(cid));
1794
1795 state.update_context_price(&cid, Some(Price::from("56731.5")));
1796 state.update_context_quantity(&cid, Quantity::from("0.0002"));
1797
1798 assert_eq!(
1799 state.lookup_context(&cid),
1800 Some(OrderContext {
1801 price: Some(Price::from("56731.5")),
1802 quantity: Quantity::from("0.0002"),
1803 ..make_context(cid)
1804 }),
1805 );
1806 }
1807
1808 #[rstest]
1809 fn test_update_context_price_without_price_keeps_cached_price() {
1810 let state = WsDispatchState::new();
1811 let cid = ClientOrderId::new("O-004");
1812 let cached = OrderContext {
1813 price: Some(Price::from("56730.0")),
1814 ..make_context(cid)
1815 };
1816 state.register_context(cached);
1817
1818 state.update_context_price(&cid, None);
1819
1820 assert_eq!(state.lookup_context(&cid), Some(cached));
1821 }
1822
1823 #[rstest]
1824 fn test_insert_accepted_dedup() {
1825 let state = WsDispatchState::new();
1826 let cid = ClientOrderId::new("O-002");
1827 assert!(!state.emitted_accepted.contains(&cid));
1828 state.insert_accepted(cid);
1829 assert!(state.emitted_accepted.contains(&cid));
1830 state.insert_accepted(cid);
1831 assert!(state.emitted_accepted.contains(&cid));
1832 }
1833
1834 #[rstest]
1835 fn test_check_and_insert_trade_detects_duplicates() {
1836 let state = WsDispatchState::new();
1837 let trade = TradeId::new("trade-1");
1838 assert!(!state.check_and_insert_trade(trade));
1839 assert!(state.check_and_insert_trade(trade));
1840 }
1841
1842 #[rstest]
1843 fn test_pending_modify_roundtrip() {
1844 let state = WsDispatchState::new();
1845 let cid = ClientOrderId::new("O-010");
1846 let voi = VenueOrderId::new("v-1");
1847 let target_qty = Quantity::from("0.0001");
1848
1849 assert!(state.pending_modify(&cid).is_none());
1850 assert!(state.pending_modify_target_qty(&cid).is_none());
1851 state.mark_pending_modify(cid, voi, target_qty);
1852 assert_eq!(state.pending_modify(&cid), Some(voi));
1853 assert_eq!(state.pending_modify_target_qty(&cid), Some(target_qty));
1854 state.clear_pending_modify(&cid);
1855 assert!(state.pending_modify(&cid).is_none());
1856 assert!(state.pending_modify_target_qty(&cid).is_none());
1857 }
1858
1859 #[rstest]
1860 fn test_cleanup_terminal_preserves_filled_marker() {
1861 let state = WsDispatchState::new();
1862 let cid = ClientOrderId::new("O-020");
1863 state.register_context(make_context(cid));
1864 state.insert_accepted(cid);
1865 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.0001"));
1866 state.insert_filled(cid);
1867 state.cleanup_terminal(&cid);
1868
1869 assert!(state.lookup_context(&cid).is_none());
1870 assert!(!state.emitted_accepted.contains(&cid));
1871 assert!(state.pending_modify(&cid).is_none());
1872 assert!(state.pending_modify_target_qty(&cid).is_none());
1873 assert!(state.filled_orders.contains(&cid));
1875 }
1876
1877 #[rstest]
1878 fn test_cleanup_terminal_clears_corrective_state() {
1879 let state = WsDispatchState::new();
1880 let cid = ClientOrderId::new("O-021");
1881 let request = sample_request(Decimal::from(1));
1882 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("1"));
1883 state.stash_modify_request(cid, request.clone());
1884 state.queue_corrective(
1885 cid,
1886 CorrectiveReduce {
1887 oid: 1,
1888 generation: 0,
1889 request,
1890 },
1891 );
1892
1893 assert!(state.modify_request(&cid).is_some());
1894
1895 state.cleanup_terminal(&cid);
1896
1897 assert!(state.modify_request(&cid).is_none());
1898 assert!(state.take_corrective(&cid).is_none());
1899 assert!(state.pending_modify(&cid).is_none());
1900 }
1901
1902 fn sample_request(size: Decimal) -> HyperliquidExchangePlaceOrderRequest {
1903 HyperliquidExchangePlaceOrderRequest {
1904 asset: 0,
1905 is_buy: true,
1906 price: "100".parse::<Decimal>().unwrap(),
1907 size,
1908 reduce_only: false,
1909 kind: HyperliquidExchangeOrderKind::Limit {
1910 limit: HyperliquidExchangeLimitParams {
1911 tif: HyperliquidExchangeTif::Gtc,
1912 },
1913 },
1914 cloid: None,
1915 }
1916 }
1917
1918 #[rstest]
1919 fn test_modify_chain_keeps_both_intents_on_rapid_modifies() {
1920 let state = WsDispatchState::new();
1921 let cid = ClientOrderId::new("O-100");
1922 let g0 =
1923 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1924 let g1 =
1925 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00030"));
1926
1927 assert_ne!(g0, g1);
1928 assert!(state.has_pending_modify(&cid));
1929 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
1931 assert_eq!(
1932 state.pending_modify_target_qty(&cid),
1933 Some(Quantity::from("0.00020")),
1934 );
1935 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1937 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
1938 }
1939
1940 #[rstest]
1941 fn test_clear_modify_generation_preserves_newer_intent() {
1942 let state = WsDispatchState::new();
1943 let cid = ClientOrderId::new("O-101");
1944 let g0 =
1947 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1948 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
1949
1950 state.clear_modify_generation(&cid, g0);
1953
1954 assert!(state.has_pending_modify(&cid));
1955 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
1956 assert_eq!(
1957 state.pending_modify_target_qty(&cid),
1958 Some(Quantity::from("0.00030")),
1959 );
1960 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1961 }
1962
1963 #[rstest]
1964 fn test_claim_front_modify_advances_next_old_id() {
1965 let state = WsDispatchState::new();
1966 let cid = ClientOrderId::new("O-102");
1967 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1969 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
1970
1971 let claimed = state.claim_front_modify(&cid, VenueOrderId::new("v-1"));
1974 assert_eq!(
1975 claimed.map(|i| i.target_qty),
1976 Some(Quantity::from("0.00020"))
1977 );
1978
1979 assert!(state.has_pending_modify(&cid));
1980 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
1981 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
1982 assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1984
1985 let claimed2 = state.claim_front_modify(&cid, VenueOrderId::new("v-2"));
1987 assert_eq!(
1988 claimed2.map(|i| i.target_qty),
1989 Some(Quantity::from("0.00030"))
1990 );
1991 assert!(!state.has_pending_modify(&cid));
1992 assert!(state.pending_modify(&cid).is_none());
1993 }
1994
1995 #[rstest]
1996 fn test_modify_chain_caps_and_evicts_oldest() {
1997 let state = WsDispatchState::new();
1998 let cid = ClientOrderId::new("O-106");
1999 for i in 0..=MAX_PENDING_MODIFY_INTENTS {
2001 let voi = format!("v-{i}");
2002 state.mark_pending_modify(cid, VenueOrderId::new(&voi), Quantity::from("0.00020"));
2003 }
2004
2005 assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
2008 let newest = format!("v-{MAX_PENDING_MODIFY_INTENTS}");
2009 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new(&newest)));
2010 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
2011 }
2012
2013 #[rstest]
2014 fn test_clear_front_modify_reparents_next_old() {
2015 let state = WsDispatchState::new();
2016 let cid = ClientOrderId::new("O-104");
2017 let g_front =
2020 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00020"));
2021 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
2022
2023 state.clear_modify_generation(&cid, g_front);
2027
2028 assert!(state.has_pending_modify(&cid));
2029 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
2030 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
2031 assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
2032 }
2033
2034 #[rstest]
2035 fn test_clear_non_front_modify_leaves_front_old() {
2036 let state = WsDispatchState::new();
2037 let cid = ClientOrderId::new("O-105");
2038 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
2039 let g_back =
2040 state.mark_pending_modify(cid, VenueOrderId::new("v-9"), Quantity::from("0.00030"));
2041
2042 state.clear_modify_generation(&cid, g_back);
2044
2045 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
2046 assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-9")));
2047 }
2048
2049 #[rstest]
2050 fn test_stash_modify_request_targets_latest_intent() {
2051 let state = WsDispatchState::new();
2052 let cid = ClientOrderId::new("O-103");
2053 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
2054 state.stash_modify_request(cid, sample_request(Decimal::from(1)));
2055 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00030"));
2056 state.stash_modify_request(cid, sample_request(Decimal::from(2)));
2057
2058 assert_eq!(
2060 state.modify_request(&cid).map(|r| r.size),
2061 Some(Decimal::from(1)),
2062 );
2063 state.claim_front_modify(&cid, VenueOrderId::new("v-1"));
2065 assert_eq!(
2066 state.modify_request(&cid).map(|r| r.size),
2067 Some(Decimal::from(2)),
2068 );
2069 }
2070
2071 fn canceled_report(client_order_id: ClientOrderId, venue_order_id: &str) -> OrderStatusReport {
2072 OrderStatusReport::new(
2073 AccountId::from("HYPERLIQUID-001"),
2074 InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
2075 Some(client_order_id),
2076 VenueOrderId::new(venue_order_id),
2077 Some(OrderSide::Buy),
2078 OrderType::Limit,
2079 TimeInForce::Gtc,
2080 OrderStatus::Canceled,
2081 Quantity::from("0.0001"),
2082 Quantity::from("0"),
2083 UnixNanos::default(),
2084 UnixNanos::default(),
2085 UnixNanos::default(),
2086 Some(UUID4::new()),
2087 )
2088 }
2089
2090 fn released_cancels(state: &WsDispatchState) -> Vec<OrderStatusReport> {
2091 state
2092 .recv_released_cancels()
2093 .now_or_never()
2094 .unwrap_or_default()
2095 }
2096
2097 fn test_emitter() -> (
2098 ExecutionEventEmitter,
2099 tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2100 ) {
2101 let mut emitter = ExecutionEventEmitter::new(
2102 get_atomic_clock_realtime(),
2103 TraderId::from("TESTER-001"),
2104 AccountId::from("HYPERLIQUID-001"),
2105 AccountType::Margin,
2106 None,
2107 );
2108 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2109 emitter.set_sender(tx);
2110 (emitter, rx)
2111 }
2112
2113 #[rstest]
2114 #[case::bound_leg(Some("v-0"), true)]
2115 #[case::unbound_leg(None, true)]
2116 #[case::superseded_leg(Some("v-1"), false)]
2117 fn test_canceled_for_modified_leg_is_held_unless_superseded(
2118 #[case] bound_venue_order_id: Option<&str>,
2119 #[case] expected_held: bool,
2120 ) {
2121 let (emitter, mut rx) = test_emitter();
2122 let state = WsDispatchState::new();
2123 let cid = ClientOrderId::new("O-107");
2124 state.register_context(make_context(cid));
2125
2126 if let Some(voi) = bound_venue_order_id {
2127 state.record_venue_order_id(cid, VenueOrderId::new(voi));
2128 }
2129
2130 let generation =
2131 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.0001"));
2132 let canceled = canceled_report(cid, "v-0");
2133
2134 let outcome = dispatch_order_event(&canceled, &state, &emitter, UnixNanos::default());
2135 state.clear_modify_generation(&cid, generation);
2136
2137 assert_eq!(outcome, DispatchOutcome::Skip);
2138 assert!(rx.try_recv().is_err());
2139 assert_eq!(
2140 released_cancels(&state),
2141 expected_held
2142 .then_some(canceled)
2143 .into_iter()
2144 .collect::<Vec<_>>(),
2145 );
2146 }
2147
2148 #[rstest]
2149 fn test_held_cancel_releases_when_last_modify_of_its_leg_fails() {
2150 let state = WsDispatchState::new();
2151 let cid = ClientOrderId::new("O-108");
2152 let leg = VenueOrderId::new("v-0");
2153 state.record_venue_order_id(cid, leg);
2154 let g0 = state.mark_pending_modify(cid, leg, Quantity::from("0.00020"));
2155 let g1 = state.mark_pending_modify(cid, leg, Quantity::from("0.00030"));
2156 let canceled = canceled_report(cid, "v-0");
2157
2158 let held = state.hold_cancel(&cid, &canceled);
2159 state.clear_modify_generation(&cid, g0);
2160 let released_while_targeted = released_cancels(&state);
2161 state.clear_modify_generation(&cid, g1);
2162
2163 assert!(held);
2164 assert_eq!(released_while_targeted, vec![]);
2165 assert_eq!(released_cancels(&state), vec![canceled]);
2166 assert!(!state.has_pending_modify(&cid));
2167 }
2168
2169 #[rstest]
2170 #[case::response_cancel_reject(["response", "cancel", "reject"])]
2171 #[case::response_reject_cancel(["response", "reject", "cancel"])]
2172 #[case::cancel_response_reject(["cancel", "response", "reject"])]
2173 #[case::cancel_reject_response(["cancel", "reject", "response"])]
2174 #[case::reject_response_cancel(["reject", "response", "cancel"])]
2175 #[case::reject_cancel_response(["reject", "cancel", "response"])]
2176 fn test_failed_modify_closes_original_once(
2177 #[case] sequence: [&str; 3],
2178 #[values(false, true)] filled: bool,
2179 ) {
2180 let (emitter, mut rx) = test_emitter();
2181 let state = WsDispatchState::new();
2182 let cid = ClientOrderId::new("O-MODIFY-REJECT");
2183 let original = VenueOrderId::new("100");
2184 state.register_context(make_context(cid));
2185 state.record_venue_order_id(cid, original);
2186 state.insert_accepted(cid);
2187 if filled {
2188 state.record_filled_qty(cid, Quantity::from("0.00004"));
2189 }
2190
2191 let generation = state.mark_pending_modify(cid, original, Quantity::from("0.0001"));
2192 let mut canceled = canceled_report(cid, "100");
2193 canceled.ts_last = UnixNanos::from(100);
2194 let mut rejected = canceled_report(cid, "101");
2195 rejected.order_status = OrderStatus::Rejected;
2196 rejected.ts_last = UnixNanos::from(200);
2197
2198 for action in sequence {
2199 match action {
2200 "response" => state.clear_modify_generation(&cid, generation),
2201 "cancel" => {
2202 dispatch_order_event(&canceled, &state, &emitter, UnixNanos::default());
2203 }
2204 "reject" => {
2205 dispatch_order_event(&rejected, &state, &emitter, UnixNanos::default());
2206 }
2207 _ => unreachable!(),
2208 }
2209
2210 for report in released_cancels(&state) {
2211 dispatch_order_event(&report, &state, &emitter, UnixNanos::default());
2212 }
2213 }
2214
2215 let canceled_replay =
2216 dispatch_order_event(&canceled, &state, &emitter, UnixNanos::default());
2217 let rejected_replay =
2218 dispatch_order_event(&rejected, &state, &emitter, UnixNanos::default());
2219 let event = rx.try_recv().unwrap();
2220
2221 let ExecutionEvent::Order(OrderEventAny::Canceled(event)) = event else {
2222 panic!("expected OrderCanceled, received {event:?}");
2223 };
2224
2225 assert_eq!(event.trader_id, emitter.trader_id());
2226 assert_eq!(event.strategy_id, StrategyId::from("S-001"));
2227 assert_eq!(
2228 event.instrument_id,
2229 InstrumentId::from("BTC-USD-PERP.HYPERLIQUID")
2230 );
2231 assert_eq!(event.client_order_id, cid);
2232 assert_eq!(event.venue_order_id, Some(original));
2233 assert_eq!(event.account_id, Some(canceled.account_id));
2234 assert_eq!(event.ts_event, canceled.ts_last);
2235 assert_eq!(event.ts_init, UnixNanos::default());
2236 assert!(!event.reconciliation);
2237 assert_eq!(event.reason, None);
2238 assert_eq!(event.causation_id, None);
2239 assert!(rx.try_recv().is_err());
2240 assert_eq!(canceled_replay, DispatchOutcome::Skip);
2241 assert_eq!(rejected_replay, DispatchOutcome::Skip);
2242 assert!(state.lookup_context(&cid).is_none());
2243 assert!(!state.has_pending_modify(&cid));
2244 }
2245
2246 #[rstest]
2247 fn test_promotion_drops_held_cancel() {
2248 let state = WsDispatchState::new();
2249 let cid = ClientOrderId::new("O-109");
2250 state.record_venue_order_id(cid, VenueOrderId::new("v-0"));
2251 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
2252 let g1 =
2253 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
2254
2255 let held = state.hold_cancel(&cid, &canceled_report(cid, "v-0"));
2258 state.claim_front_modify(&cid, VenueOrderId::new("v-1"));
2259 state.clear_modify_generation(&cid, g1);
2260
2261 assert!(held);
2262 assert_eq!(released_cancels(&state), vec![]);
2263 assert!(!state.has_pending_modify(&cid));
2264 }
2265}