1use std::{
68 collections::VecDeque,
69 hash::Hash,
70 sync::{
71 Mutex,
72 atomic::{AtomicBool, Ordering},
73 },
74};
75
76use ahash::AHashSet;
77use dashmap::{DashMap, DashSet};
78use nautilus_core::{MUTEX_POISONED, UUID4, UnixNanos};
79use nautilus_live::ExecutionEventEmitter;
80use nautilus_model::{
81 enums::{OrderSide, OrderStatus, OrderType},
82 events::{
83 OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
84 OrderTriggered, OrderUpdated,
85 },
86 identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TradeId, VenueOrderId},
87 reports::{FillReport, OrderStatusReport},
88 types::{Price, Quantity},
89};
90use ustr::Ustr;
91
92use crate::{
93 common::consts::HYPERLIQUID_POST_ONLY_WOULD_MATCH,
94 http::models::HyperliquidExecPlaceOrderRequest,
95};
96
97pub const DEDUP_CAPACITY: usize = 10_000;
98
99#[derive(Debug, Clone)]
106pub struct OrderIdentity {
107 pub strategy_id: StrategyId,
109 pub instrument_id: InstrumentId,
111 pub order_side: OrderSide,
113 pub order_type: OrderType,
115 pub quantity: Quantity,
117 pub price: Option<Price>,
121}
122
123#[derive(Debug)]
131pub struct BoundedDedup<T>
132where
133 T: Eq + Hash + Clone,
134{
135 order: VecDeque<T>,
136 set: AHashSet<T>,
137 capacity: usize,
138}
139
140impl<T> BoundedDedup<T>
141where
142 T: Eq + Hash + Clone,
143{
144 #[must_use]
146 pub fn new(capacity: usize) -> Self {
147 Self {
148 order: VecDeque::with_capacity(capacity),
149 set: AHashSet::with_capacity(capacity),
150 capacity,
151 }
152 }
153
154 pub fn insert(&mut self, value: T) -> bool {
156 if self.set.contains(&value) {
157 return true;
158 }
159
160 if self.order.len() >= self.capacity
161 && let Some(evicted) = self.order.pop_front()
162 {
163 self.set.remove(&evicted);
164 }
165
166 self.order.push_back(value.clone());
167 self.set.insert(value);
168 false
169 }
170
171 #[must_use]
173 pub fn len(&self) -> usize {
174 self.set.len()
175 }
176
177 #[must_use]
179 pub fn is_empty(&self) -> bool {
180 self.set.is_empty()
181 }
182
183 #[must_use]
185 pub fn contains(&self, value: &T) -> bool {
186 self.set.contains(value)
187 }
188}
189
190pub const MAX_PENDING_MODIFY_INTENTS: usize = 32;
194
195#[derive(Debug, Clone)]
204pub struct ModifyIntent {
205 pub generation: u64,
207 pub old_venue_order_id: Option<VenueOrderId>,
209 pub target_qty: Quantity,
211 pub sent_request: Option<HyperliquidExecPlaceOrderRequest>,
213}
214
215#[derive(Debug, Default)]
221struct ModifyChain {
222 intents: VecDeque<ModifyIntent>,
223 next_generation: u64,
224}
225
226#[derive(Debug)]
236pub struct WsDispatchState {
237 pub order_identities: DashMap<ClientOrderId, OrderIdentity>,
239 pub emitted_accepted: DashSet<ClientOrderId>,
241 pending_submissions: DashSet<ClientOrderId>,
243 pending_submission_rejections: DashMap<ClientOrderId, OrderStatusReport>,
246 pub filled_orders: DashSet<ClientOrderId>,
251 pub emitted_trades: Mutex<BoundedDedup<TradeId>>,
256 pub terminal_cloids: Mutex<BoundedDedup<Ustr>>,
259 pub cached_venue_order_ids: DashMap<ClientOrderId, VenueOrderId>,
266 pending_modify_chains: DashMap<ClientOrderId, ModifyChain>,
278 pub buffered_fills: DashMap<ClientOrderId, Vec<FillReport>>,
283 pub order_filled_qty: DashMap<ClientOrderId, Quantity>,
286 pub pending_corrective: DashMap<ClientOrderId, (u64, HyperliquidExecPlaceOrderRequest)>,
289 clearing: AtomicBool,
290}
291
292impl Default for WsDispatchState {
293 fn default() -> Self {
294 Self {
295 order_identities: DashMap::new(),
296 emitted_accepted: DashSet::default(),
297 pending_submissions: DashSet::default(),
298 pending_submission_rejections: DashMap::new(),
299 filled_orders: DashSet::default(),
300 emitted_trades: Mutex::new(BoundedDedup::new(DEDUP_CAPACITY)),
301 terminal_cloids: Mutex::new(BoundedDedup::new(DEDUP_CAPACITY)),
302 cached_venue_order_ids: DashMap::new(),
303 pending_modify_chains: DashMap::new(),
304 buffered_fills: DashMap::new(),
305 order_filled_qty: DashMap::new(),
306 pending_corrective: DashMap::new(),
307 clearing: AtomicBool::new(false),
308 }
309 }
310}
311
312impl WsDispatchState {
313 #[must_use]
315 pub fn new() -> Self {
316 Self::default()
317 }
318
319 pub fn register_identity(&self, client_order_id: ClientOrderId, identity: OrderIdentity) {
322 self.order_identities.insert(client_order_id, identity);
323 }
324
325 #[must_use]
327 pub fn lookup_identity(&self, client_order_id: &ClientOrderId) -> Option<OrderIdentity> {
328 self.order_identities
329 .get(client_order_id)
330 .map(|r| r.clone())
331 }
332
333 pub fn mark_submission_pending(&self, client_order_id: ClientOrderId) {
335 self.pending_submissions.insert(client_order_id);
336 }
337
338 #[must_use]
340 pub fn submission_pending(&self, client_order_id: &ClientOrderId) -> bool {
341 self.pending_submissions.contains(client_order_id)
342 }
343
344 pub fn buffer_submission_rejection(
346 &self,
347 client_order_id: ClientOrderId,
348 report: OrderStatusReport,
349 ) {
350 self.pending_submission_rejections
351 .insert(client_order_id, report);
352 }
353
354 #[must_use]
356 pub fn resolve_submission(&self, client_order_id: &ClientOrderId) -> Option<OrderStatusReport> {
357 self.pending_submissions.remove(client_order_id);
358 self.pending_submission_rejections
359 .remove(client_order_id)
360 .map(|(_, report)| report)
361 }
362
363 pub fn update_identity_price(&self, client_order_id: &ClientOrderId, price: Option<Price>) {
366 if let Some(price) = price
367 && let Some(mut entry) = self.order_identities.get_mut(client_order_id)
368 {
369 entry.price = Some(price);
370 }
371 }
372
373 pub fn update_identity_quantity(&self, client_order_id: &ClientOrderId, quantity: Quantity) {
375 if let Some(mut entry) = self.order_identities.get_mut(client_order_id) {
376 entry.quantity = quantity;
377 }
378 }
379
380 pub fn insert_accepted(&self, cid: ClientOrderId) {
382 self.evict_if_full(&self.emitted_accepted);
383 self.emitted_accepted.insert(cid);
384 }
385
386 pub fn insert_filled(&self, cid: ClientOrderId) -> bool {
391 self.evict_if_full(&self.filled_orders);
392 self.filled_orders.insert(cid)
393 }
394
395 #[allow(
400 clippy::missing_panics_doc,
401 reason = "dedup mutex poisoning is not expected"
402 )]
403 pub fn check_and_insert_trade(&self, trade_id: TradeId) -> bool {
404 let mut set = self.emitted_trades.lock().expect(MUTEX_POISONED);
405 set.insert(trade_id)
406 }
407
408 #[allow(
415 clippy::missing_panics_doc,
416 reason = "terminal cloid mutex poisoning is not expected"
417 )]
418 pub fn insert_terminal_cloid(&self, cloid: Ustr) {
419 let mut set = self.terminal_cloids.lock().expect(MUTEX_POISONED);
420 set.insert(cloid);
421 }
422
423 #[allow(
426 clippy::missing_panics_doc,
427 reason = "terminal cloid mutex poisoning is not expected"
428 )]
429 #[must_use]
430 pub fn terminal_cloid_seen(&self, cloid: &Ustr) -> bool {
431 let set = self.terminal_cloids.lock().expect(MUTEX_POISONED);
432 set.contains(cloid)
433 }
434
435 pub fn record_venue_order_id(
437 &self,
438 client_order_id: ClientOrderId,
439 venue_order_id: VenueOrderId,
440 ) {
441 self.cached_venue_order_ids
442 .insert(client_order_id, venue_order_id);
443 }
444
445 #[must_use]
447 pub fn cached_venue_order_id(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
448 self.cached_venue_order_ids.get(client_order_id).map(|r| *r)
449 }
450
451 pub fn mark_pending_modify(
460 &self,
461 client_order_id: ClientOrderId,
462 old_venue_order_id: VenueOrderId,
463 target_qty: Quantity,
464 ) -> u64 {
465 let mut chain = self
466 .pending_modify_chains
467 .entry(client_order_id)
468 .or_default();
469 let generation = chain.next_generation;
470 chain.next_generation += 1;
471 chain.intents.push_back(ModifyIntent {
472 generation,
473 old_venue_order_id: Some(old_venue_order_id),
474 target_qty,
475 sent_request: None,
476 });
477
478 if chain.intents.len() > MAX_PENDING_MODIFY_INTENTS {
479 chain.intents.pop_front();
480 log::warn!(
481 "Modify chain for {client_order_id} exceeded {MAX_PENDING_MODIFY_INTENTS}; \
482 evicting oldest intent",
483 );
484 }
485 generation
486 }
487
488 pub fn clear_pending_modify(&self, client_order_id: &ClientOrderId) {
490 self.pending_modify_chains.remove(client_order_id);
491 }
492
493 pub fn clear_modify_generation(&self, client_order_id: &ClientOrderId, generation: u64) {
502 let Some(mut chain) = self.pending_modify_chains.get_mut(client_order_id) else {
503 return;
504 };
505 let removed_front_old = chain
506 .intents
507 .front()
508 .filter(|front| front.generation == generation)
509 .and_then(|front| front.old_venue_order_id);
510 chain
511 .intents
512 .retain(|intent| intent.generation != generation);
513
514 if let Some(old) = removed_front_old
515 && let Some(new_front) = chain.intents.front_mut()
516 {
517 new_front.old_venue_order_id = Some(old);
518 }
519 drop(chain);
520 self.pending_modify_chains
523 .remove_if(client_order_id, |_, chain| chain.intents.is_empty());
524 }
525
526 pub fn stash_modify_request(
529 &self,
530 client_order_id: ClientOrderId,
531 request: HyperliquidExecPlaceOrderRequest,
532 ) {
533 if let Some(mut chain) = self.pending_modify_chains.get_mut(&client_order_id)
534 && let Some(back) = chain.intents.back_mut()
535 {
536 back.sent_request = Some(request);
537 } else {
538 log::debug!(
539 "Stash modify request for {client_order_id} with no pending intent; ignoring"
540 );
541 }
542 }
543
544 #[must_use]
546 pub fn modify_request(
547 &self,
548 client_order_id: &ClientOrderId,
549 ) -> Option<HyperliquidExecPlaceOrderRequest> {
550 self.pending_modify_chains
551 .get(client_order_id)
552 .and_then(|chain| chain.intents.front().and_then(|i| i.sent_request.clone()))
553 }
554
555 pub fn claim_front_modify(
563 &self,
564 client_order_id: &ClientOrderId,
565 new_venue_order_id: VenueOrderId,
566 ) -> Option<ModifyIntent> {
567 let mut chain = self.pending_modify_chains.get_mut(client_order_id)?;
568 let claimed = chain.intents.pop_front();
569 if let Some(next) = chain.intents.front_mut() {
570 next.old_venue_order_id = Some(new_venue_order_id);
571 }
572 drop(chain);
573 self.pending_modify_chains
576 .remove_if(client_order_id, |_, chain| chain.intents.is_empty());
577 claimed
578 }
579
580 pub fn queue_corrective(
582 &self,
583 client_order_id: ClientOrderId,
584 oid: u64,
585 request: HyperliquidExecPlaceOrderRequest,
586 ) {
587 self.pending_corrective
588 .insert(client_order_id, (oid, request));
589 }
590
591 #[must_use]
593 pub fn take_corrective(
594 &self,
595 client_order_id: &ClientOrderId,
596 ) -> Option<(u64, HyperliquidExecPlaceOrderRequest)> {
597 self.pending_corrective
598 .remove(client_order_id)
599 .map(|(_, v)| v)
600 }
601
602 #[must_use]
604 pub fn has_pending_modify(&self, client_order_id: &ClientOrderId) -> bool {
605 self.pending_modify_chains
606 .get(client_order_id)
607 .is_some_and(|chain| !chain.intents.is_empty())
608 }
609
610 #[must_use]
612 pub fn pending_modify(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
613 self.pending_modify_chains
614 .get(client_order_id)
615 .and_then(|chain| chain.intents.front().and_then(|i| i.old_venue_order_id))
616 }
617
618 #[must_use]
623 pub fn pending_modify_contains_old(
624 &self,
625 client_order_id: &ClientOrderId,
626 venue_order_id: VenueOrderId,
627 ) -> bool {
628 self.pending_modify_chains
629 .get(client_order_id)
630 .is_some_and(|chain| {
631 chain
632 .intents
633 .iter()
634 .any(|i| i.old_venue_order_id == Some(venue_order_id))
635 })
636 }
637
638 #[must_use]
640 pub fn pending_modify_target_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
641 self.pending_modify_chains
642 .get(client_order_id)
643 .and_then(|chain| chain.intents.front().map(|i| i.target_qty))
644 }
645
646 pub fn buffer_fill(&self, client_order_id: ClientOrderId, fill: FillReport) {
648 self.buffered_fills
649 .entry(client_order_id)
650 .or_default()
651 .push(fill);
652 }
653
654 #[must_use]
656 pub fn drain_buffered_fills(&self, client_order_id: &ClientOrderId) -> Vec<FillReport> {
657 self.buffered_fills
658 .remove(client_order_id)
659 .map(|(_, v)| v)
660 .unwrap_or_default()
661 }
662
663 #[must_use]
665 pub fn buffered_fill_count(&self, client_order_id: &ClientOrderId) -> usize {
666 self.buffered_fills
667 .get(client_order_id)
668 .map_or(0, |r| r.len())
669 }
670
671 pub fn record_filled_qty(&self, client_order_id: ClientOrderId, qty: Quantity) {
673 self.order_filled_qty.insert(client_order_id, qty);
674 }
675
676 #[must_use]
678 pub fn previous_filled_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
679 self.order_filled_qty.get(client_order_id).map(|r| *r)
680 }
681
682 pub fn cleanup_terminal(&self, client_order_id: &ClientOrderId) {
687 self.order_identities.remove(client_order_id);
688 self.emitted_accepted.remove(client_order_id);
689 self.pending_submissions.remove(client_order_id);
690 self.pending_submission_rejections.remove(client_order_id);
691 self.cached_venue_order_ids.remove(client_order_id);
692 self.pending_modify_chains.remove(client_order_id);
693 self.pending_corrective.remove(client_order_id);
694 self.buffered_fills.remove(client_order_id);
695 self.order_filled_qty.remove(client_order_id);
696 }
697
698 fn evict_if_full(&self, set: &DashSet<ClientOrderId>) {
699 if set.len() >= DEDUP_CAPACITY
700 && self
701 .clearing
702 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
703 .is_ok()
704 {
705 set.clear();
706 self.clearing.store(false, Ordering::Release);
707 }
708 }
709}
710
711#[derive(Debug, Clone, Copy, PartialEq, Eq)]
713pub enum DispatchOutcome {
714 Tracked,
718 External,
723 Skip,
727}
728
729pub fn dispatch_order_event(
739 report: &OrderStatusReport,
740 state: &WsDispatchState,
741 emitter: &ExecutionEventEmitter,
742 ts_init: UnixNanos,
743) -> DispatchOutcome {
744 let Some(client_order_id) = report.client_order_id else {
745 return DispatchOutcome::External;
746 };
747
748 if state.filled_orders.contains(&client_order_id) {
749 log::debug!(
750 "Skipping stale report for filled order: cid={client_order_id}, status={:?}",
751 report.order_status,
752 );
753 return DispatchOutcome::Skip;
754 }
755
756 let client_order_id_str = client_order_id.as_str();
757 if client_order_id_str.starts_with("0x")
758 && state.terminal_cloid_seen(&Ustr::from(client_order_id_str))
759 {
760 log::debug!(
761 "Skipping stale terminal report for raw cloid: cid={client_order_id}, status={:?}",
762 report.order_status,
763 );
764 return DispatchOutcome::Skip;
765 }
766
767 let Some(identity) = state.lookup_identity(&client_order_id) else {
768 return DispatchOutcome::External;
769 };
770
771 match report.order_status {
772 OrderStatus::Accepted => {
773 handle_accepted(report, client_order_id, &identity, state, emitter, ts_init)
774 }
775 OrderStatus::Triggered => {
776 handle_triggered(report, client_order_id, &identity, state, emitter, ts_init)
777 }
778 OrderStatus::Canceled => {
779 handle_canceled(report, client_order_id, &identity, state, emitter, ts_init)
780 }
781 OrderStatus::Expired => {
782 handle_expired(report, client_order_id, &identity, state, emitter, ts_init)
783 }
784 OrderStatus::Rejected => {
785 handle_rejected(report, client_order_id, &identity, state, emitter, ts_init)
786 }
787 OrderStatus::Filled => handle_filled_marker(client_order_id, state),
788 OrderStatus::PartiallyFilled => {
789 DispatchOutcome::Tracked
791 }
792 OrderStatus::PendingUpdate
793 | OrderStatus::PendingCancel
794 | OrderStatus::Submitted
795 | OrderStatus::Initialized
796 | OrderStatus::Denied
797 | OrderStatus::Released
798 | OrderStatus::Emulated
799 | OrderStatus::Voided => DispatchOutcome::Tracked,
800 }
801}
802
803pub fn dispatch_order_fill(
814 report: &FillReport,
815 state: &WsDispatchState,
816 emitter: &ExecutionEventEmitter,
817 ts_init: UnixNanos,
818) -> DispatchOutcome {
819 let Some(client_order_id) = report.client_order_id else {
820 return DispatchOutcome::External;
821 };
822
823 if state.filled_orders.contains(&client_order_id) {
824 log::debug!(
825 "Skipping stale fill for filled order: cid={client_order_id}, trade_id={}",
826 report.trade_id,
827 );
828 return DispatchOutcome::Skip;
829 }
830
831 let Some(mut identity) = state.lookup_identity(&client_order_id) else {
832 return DispatchOutcome::External;
833 };
834
835 let mut promoted_corrective: Option<(Quantity, HyperliquidExecPlaceOrderRequest)> = None;
837
838 if state.has_pending_modify(&client_order_id)
841 && let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
842 && report.venue_order_id != cached_voi
843 {
844 let target = state.pending_modify_target_qty(&client_order_id);
845 let sent_request = state.modify_request(&client_order_id);
846 let price = sent_request
848 .as_ref()
849 .zip(identity.price)
850 .and_then(|(r, cached)| Price::from_decimal_dp(r.price, cached.precision).ok())
851 .or(identity.price);
852 let Some(price) = price else {
853 log::warn!(
854 "Cannot promote cancel-replace for {client_order_id} from fill: no target \
855 or cached price; buffering until the replacement ACCEPTED arrives",
856 );
857 state.buffer_fill(client_order_id, report.clone());
858 return DispatchOutcome::Tracked;
859 };
860 let updated_quantity = target.unwrap_or(identity.quantity);
861 promote_cancel_replace(
862 client_order_id,
863 &identity,
864 state,
865 emitter,
866 report.venue_order_id,
867 report.account_id,
868 price,
869 updated_quantity,
870 None,
871 report.ts_event,
872 ts_init,
873 );
874 if let Some(updated) = state.lookup_identity(&client_order_id) {
876 identity = updated;
877 }
878
879 if let (Some(target), Some(sent_request)) = (target, sent_request) {
880 promoted_corrective = Some((target, sent_request));
881 }
882 }
883
884 if state.check_and_insert_trade(report.trade_id) {
885 log::debug!(
886 "Skipping duplicate fill for {client_order_id}: trade_id={}",
887 report.trade_id
888 );
889 return DispatchOutcome::Tracked;
890 }
891
892 let previous = state
893 .previous_filled_qty(&client_order_id)
894 .unwrap_or_else(|| Quantity::zero(report.last_qty.precision));
895 let cumulative = previous + report.last_qty;
896
897 let is_terminal_fill = cumulative >= identity.quantity;
898 if is_terminal_fill && !claim_terminal_order(client_order_id, state, OrderStatus::Filled) {
899 return DispatchOutcome::Skip;
900 }
901
902 ensure_accepted_emitted(
903 client_order_id,
904 report.venue_order_id,
905 report.account_id,
906 &identity,
907 state,
908 emitter,
909 report.ts_event,
910 ts_init,
911 );
912
913 let filled = OrderFilled::new(
914 emitter.trader_id(),
915 identity.strategy_id,
916 identity.instrument_id,
917 client_order_id,
918 report.venue_order_id,
919 report.account_id,
920 report.trade_id,
921 identity.order_side,
922 identity.order_type,
923 report.last_qty,
924 report.last_px,
925 report.commission.currency,
926 report.liquidity_side,
927 UUID4::new(),
928 report.ts_event,
929 ts_init,
930 false,
931 report.venue_position_id,
932 Some(report.commission),
933 None,
934 );
935 emitter.send_order_event(OrderEventAny::Filled(filled));
936
937 state.record_filled_qty(client_order_id, cumulative);
938
939 if let Some((target, sent_request)) = promoted_corrective {
941 maybe_queue_corrective_reduce(
942 state,
943 client_order_id,
944 report.venue_order_id,
945 target,
946 sent_request,
947 );
948 }
949
950 if is_terminal_fill {
951 state.cleanup_terminal(&client_order_id);
952 }
953
954 DispatchOutcome::Tracked
955}
956
957fn handle_accepted(
958 report: &OrderStatusReport,
959 client_order_id: ClientOrderId,
960 identity: &OrderIdentity,
961 state: &WsDispatchState,
962 emitter: &ExecutionEventEmitter,
963 ts_init: UnixNanos,
964) -> DispatchOutcome {
965 let venue_order_id = report.venue_order_id;
966 let ts_event = report.ts_last;
967 let account_id = report.account_id;
968
969 if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
974 && cached_voi != venue_order_id
975 {
976 let price = report.price.or(identity.price);
977 let Some(price) = price else {
978 log::warn!(
979 "Cannot emit OrderUpdated for cancel-replace {client_order_id}: \
980 no price on report and no cached price on identity",
981 );
982 return DispatchOutcome::Skip;
983 };
984
985 let target_total_qty = state.pending_modify_target_qty(&client_order_id);
988 let updated_quantity = target_total_qty.unwrap_or(report.quantity);
989 let sent_request = state.modify_request(&client_order_id);
990
991 promote_cancel_replace(
992 client_order_id,
993 identity,
994 state,
995 emitter,
996 venue_order_id,
997 account_id,
998 price,
999 updated_quantity,
1000 report.trigger_price,
1001 ts_event,
1002 ts_init,
1003 );
1004
1005 if let (Some(target), Some(sent_request)) = (target_total_qty, sent_request) {
1006 maybe_queue_corrective_reduce(
1007 state,
1008 client_order_id,
1009 venue_order_id,
1010 target,
1011 sent_request,
1012 );
1013 }
1014
1015 return DispatchOutcome::Tracked;
1016 }
1017
1018 if state.emitted_accepted.contains(&client_order_id) {
1019 state.update_identity_price(&client_order_id, report.price);
1023 return DispatchOutcome::Tracked;
1024 }
1025
1026 state.insert_accepted(client_order_id);
1027 state.record_venue_order_id(client_order_id, venue_order_id);
1028 state.update_identity_price(&client_order_id, report.price);
1029
1030 let accepted = OrderAccepted::new(
1031 emitter.trader_id(),
1032 identity.strategy_id,
1033 identity.instrument_id,
1034 client_order_id,
1035 venue_order_id,
1036 account_id,
1037 UUID4::new(),
1038 ts_event,
1039 ts_init,
1040 false,
1041 );
1042 emitter.send_order_event(OrderEventAny::Accepted(accepted));
1043 DispatchOutcome::Tracked
1044}
1045
1046#[allow(
1049 clippy::too_many_arguments,
1050 reason = "promotion needs the full OrderUpdated field set, sourced from two report shapes"
1051)]
1052fn promote_cancel_replace(
1053 client_order_id: ClientOrderId,
1054 identity: &OrderIdentity,
1055 state: &WsDispatchState,
1056 emitter: &ExecutionEventEmitter,
1057 venue_order_id: VenueOrderId,
1058 account_id: AccountId,
1059 price: Price,
1060 quantity: Quantity,
1061 trigger_price: Option<Price>,
1062 ts_event: UnixNanos,
1063 ts_init: UnixNanos,
1064) {
1065 state.record_venue_order_id(client_order_id, venue_order_id);
1066 state.update_identity_quantity(&client_order_id, quantity);
1067 state.update_identity_price(&client_order_id, Some(price));
1068 state.claim_front_modify(&client_order_id, venue_order_id);
1070
1071 let updated = OrderUpdated::new(
1072 emitter.trader_id(),
1073 identity.strategy_id,
1074 identity.instrument_id,
1075 client_order_id,
1076 quantity,
1077 UUID4::new(),
1078 ts_event,
1079 ts_init,
1080 false,
1081 Some(venue_order_id),
1082 Some(account_id),
1083 Some(price),
1084 trigger_price,
1085 None,
1086 false,
1087 );
1088 emitter.send_order_event(OrderEventAny::Updated(updated));
1089
1090 let buffered = state.drain_buffered_fills(&client_order_id);
1093 for fill in buffered {
1094 dispatch_order_fill(&fill, state, emitter, ts_init);
1095 }
1096}
1097
1098pub fn promote_replacement_from_query(
1105 report: &OrderStatusReport,
1106 state: &WsDispatchState,
1107 emitter: &ExecutionEventEmitter,
1108 ts_init: UnixNanos,
1109) -> bool {
1110 if report.order_status != OrderStatus::Accepted {
1111 return false;
1112 }
1113
1114 let Some(client_order_id) = report.client_order_id else {
1115 return false;
1116 };
1117
1118 if !state.has_pending_modify(&client_order_id) {
1119 return false;
1120 }
1121
1122 let Some(cached_voi) = state.cached_venue_order_id(&client_order_id) else {
1123 return false;
1124 };
1125
1126 if report.venue_order_id == cached_voi {
1127 return false;
1128 }
1129
1130 let Some(identity) = state.lookup_identity(&client_order_id) else {
1131 return false;
1132 };
1133
1134 let Some(price) = report.price.or(identity.price) else {
1135 log::warn!(
1136 "Cannot promote cancel-replace from query for {client_order_id}: \
1137 no price on report and no cached price on identity",
1138 );
1139 return false;
1140 };
1141
1142 let updated_quantity = state
1144 .pending_modify_target_qty(&client_order_id)
1145 .unwrap_or(report.quantity);
1146
1147 promote_cancel_replace(
1148 client_order_id,
1149 &identity,
1150 state,
1151 emitter,
1152 report.venue_order_id,
1153 report.account_id,
1154 price,
1155 updated_quantity,
1156 report.trigger_price,
1157 report.ts_last,
1158 ts_init,
1159 );
1160
1161 log::debug!("Promoted cancel-replace replacement for {client_order_id} from query");
1162
1163 true
1164}
1165
1166fn maybe_queue_corrective_reduce(
1169 state: &WsDispatchState,
1170 client_order_id: ClientOrderId,
1171 venue_order_id: VenueOrderId,
1172 target: Quantity,
1173 sent_request: HyperliquidExecPlaceOrderRequest,
1174) {
1175 let Ok(new_oid) = venue_order_id.as_str().parse::<u64>() else {
1176 return;
1177 };
1178
1179 let filled = state
1180 .previous_filled_qty(&client_order_id)
1181 .unwrap_or_else(|| Quantity::zero(target.precision));
1182 if filled >= target {
1183 return;
1184 }
1185
1186 let remaining = (target - filled).as_decimal().normalize();
1187
1188 let sent_size = sent_request.size;
1189 if sent_size > remaining {
1190 let mut corrective = sent_request;
1191 corrective.size = remaining;
1192
1193 state.mark_pending_modify(client_order_id, venue_order_id, target);
1194 state.stash_modify_request(client_order_id, corrective.clone());
1195 state.queue_corrective(client_order_id, new_oid, corrective);
1196
1197 log::warn!(
1198 "Cancel-replace left {client_order_id} oversized on {venue_order_id} \
1199 (sent {sent_size}, remaining {remaining}); queuing corrective reduce",
1200 );
1201 }
1202}
1203
1204fn handle_triggered(
1205 report: &OrderStatusReport,
1206 client_order_id: ClientOrderId,
1207 identity: &OrderIdentity,
1208 state: &WsDispatchState,
1209 emitter: &ExecutionEventEmitter,
1210 ts_init: UnixNanos,
1211) -> DispatchOutcome {
1212 if !matches!(
1213 identity.order_type,
1214 OrderType::StopLimit | OrderType::TrailingStopLimit | OrderType::LimitIfTouched
1215 ) {
1216 log::debug!(
1217 "Ignoring TRIGGERED status for non-triggerable order type {:?}: {client_order_id}",
1218 identity.order_type,
1219 );
1220 return DispatchOutcome::Tracked;
1221 }
1222
1223 ensure_accepted_emitted(
1224 client_order_id,
1225 report.venue_order_id,
1226 report.account_id,
1227 identity,
1228 state,
1229 emitter,
1230 report.ts_last,
1231 ts_init,
1232 );
1233
1234 let triggered = OrderTriggered::new(
1235 emitter.trader_id(),
1236 identity.strategy_id,
1237 identity.instrument_id,
1238 client_order_id,
1239 UUID4::new(),
1240 report.ts_last,
1241 ts_init,
1242 false,
1243 Some(report.venue_order_id),
1244 Some(report.account_id),
1245 );
1246 emitter.send_order_event(OrderEventAny::Triggered(triggered));
1247 DispatchOutcome::Tracked
1248}
1249
1250fn handle_canceled(
1251 report: &OrderStatusReport,
1252 client_order_id: ClientOrderId,
1253 identity: &OrderIdentity,
1254 state: &WsDispatchState,
1255 emitter: &ExecutionEventEmitter,
1256 ts_init: UnixNanos,
1257) -> DispatchOutcome {
1258 let venue_order_id = report.venue_order_id;
1259
1260 if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
1264 && cached_voi != venue_order_id
1265 {
1266 log::debug!(
1267 "Skipping stale CANCELED for {venue_order_id} (cached {cached_voi}) on {client_order_id}",
1268 );
1269 return DispatchOutcome::Skip;
1270 }
1271
1272 if state.pending_modify_contains_old(&client_order_id, venue_order_id) {
1278 log::debug!(
1279 "Skipping cancel-before-accept leg for {client_order_id}: venue_order_id={venue_order_id}",
1280 );
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 ensure_accepted_emitted(
1289 client_order_id,
1290 venue_order_id,
1291 report.account_id,
1292 identity,
1293 state,
1294 emitter,
1295 report.ts_last,
1296 ts_init,
1297 );
1298
1299 let canceled = OrderCanceled::new(
1300 emitter.trader_id(),
1301 identity.strategy_id,
1302 identity.instrument_id,
1303 client_order_id,
1304 UUID4::new(),
1305 report.ts_last,
1306 ts_init,
1307 false,
1308 Some(venue_order_id),
1309 Some(report.account_id),
1310 );
1311 emitter.send_order_event(OrderEventAny::Canceled(canceled));
1312
1313 state.cleanup_terminal(&client_order_id);
1314 DispatchOutcome::Tracked
1315}
1316
1317fn handle_expired(
1318 report: &OrderStatusReport,
1319 client_order_id: ClientOrderId,
1320 identity: &OrderIdentity,
1321 state: &WsDispatchState,
1322 emitter: &ExecutionEventEmitter,
1323 ts_init: UnixNanos,
1324) -> DispatchOutcome {
1325 if !claim_terminal_order(client_order_id, state, report.order_status) {
1326 return DispatchOutcome::Skip;
1327 }
1328
1329 ensure_accepted_emitted(
1330 client_order_id,
1331 report.venue_order_id,
1332 report.account_id,
1333 identity,
1334 state,
1335 emitter,
1336 report.ts_last,
1337 ts_init,
1338 );
1339
1340 let expired = OrderExpired::new(
1341 emitter.trader_id(),
1342 identity.strategy_id,
1343 identity.instrument_id,
1344 client_order_id,
1345 UUID4::new(),
1346 report.ts_last,
1347 ts_init,
1348 false,
1349 Some(report.venue_order_id),
1350 Some(report.account_id),
1351 );
1352 emitter.send_order_event(OrderEventAny::Expired(expired));
1353 state.cleanup_terminal(&client_order_id);
1354 DispatchOutcome::Tracked
1355}
1356
1357fn handle_rejected(
1358 report: &OrderStatusReport,
1359 client_order_id: ClientOrderId,
1360 identity: &OrderIdentity,
1361 state: &WsDispatchState,
1362 emitter: &ExecutionEventEmitter,
1363 ts_init: UnixNanos,
1364) -> DispatchOutcome {
1365 if state.submission_pending(&client_order_id) {
1366 state.buffer_submission_rejection(client_order_id, report.clone());
1367 return DispatchOutcome::Skip;
1368 }
1369
1370 if !claim_terminal_order(client_order_id, state, report.order_status) {
1371 return DispatchOutcome::Skip;
1372 }
1373
1374 let reason = report
1375 .cancel_reason
1376 .clone()
1377 .unwrap_or_else(|| "Order rejected by exchange".to_string());
1378 let rejected = OrderRejected::new(
1379 emitter.trader_id(),
1380 identity.strategy_id,
1381 identity.instrument_id,
1382 client_order_id,
1383 report.account_id,
1384 Ustr::from(&reason),
1385 UUID4::new(),
1386 report.ts_last,
1387 ts_init,
1388 false,
1389 report.post_only && reason.contains(HYPERLIQUID_POST_ONLY_WOULD_MATCH),
1390 );
1391 emitter.send_order_event(OrderEventAny::Rejected(rejected));
1392 state.cleanup_terminal(&client_order_id);
1393 DispatchOutcome::Tracked
1394}
1395
1396fn claim_terminal_order(
1397 client_order_id: ClientOrderId,
1398 state: &WsDispatchState,
1399 status: OrderStatus,
1400) -> bool {
1401 let claimed = state.insert_filled(client_order_id);
1402 if !claimed {
1403 log::debug!("Skipping duplicate terminal event for {client_order_id}: status={status:?}",);
1404 }
1405
1406 claimed
1407}
1408
1409fn handle_filled_marker(
1410 _client_order_id: ClientOrderId,
1411 _state: &WsDispatchState,
1412) -> DispatchOutcome {
1413 DispatchOutcome::Tracked
1421}
1422
1423#[allow(clippy::too_many_arguments)]
1431fn ensure_accepted_emitted(
1432 client_order_id: ClientOrderId,
1433 venue_order_id: VenueOrderId,
1434 account_id: AccountId,
1435 identity: &OrderIdentity,
1436 state: &WsDispatchState,
1437 emitter: &ExecutionEventEmitter,
1438 ts_event: UnixNanos,
1439 ts_init: UnixNanos,
1440) {
1441 if state.emitted_accepted.contains(&client_order_id) {
1442 return;
1443 }
1444 state.insert_accepted(client_order_id);
1445 state.record_venue_order_id(client_order_id, venue_order_id);
1446
1447 let accepted = OrderAccepted::new(
1448 emitter.trader_id(),
1449 identity.strategy_id,
1450 identity.instrument_id,
1451 client_order_id,
1452 venue_order_id,
1453 account_id,
1454 UUID4::new(),
1455 ts_event,
1456 ts_init,
1457 false,
1458 );
1459 emitter.send_order_event(OrderEventAny::Accepted(accepted));
1460}
1461
1462#[cfg(test)]
1463mod tests {
1464 use nautilus_model::identifiers::{ClientOrderId, InstrumentId, StrategyId, TradeId};
1465 use rstest::rstest;
1466 use rust_decimal::Decimal;
1467
1468 use super::*;
1469 use crate::http::models::{
1470 HyperliquidExecLimitParams, HyperliquidExecOrderKind, HyperliquidExecTif,
1471 };
1472
1473 fn make_identity() -> OrderIdentity {
1474 OrderIdentity {
1475 strategy_id: StrategyId::from("S-001"),
1476 instrument_id: InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
1477 order_side: OrderSide::Buy,
1478 order_type: OrderType::Limit,
1479 quantity: Quantity::from("0.0001"),
1480 price: None,
1481 }
1482 }
1483
1484 #[rstest]
1485 fn test_register_and_lookup_identity() {
1486 let state = WsDispatchState::new();
1487 let cid = ClientOrderId::new("O-001");
1488 state.register_identity(cid, make_identity());
1489
1490 let found = state.lookup_identity(&cid);
1491 assert!(found.is_some());
1492 let identity = found.unwrap();
1493 assert_eq!(identity.strategy_id.as_str(), "S-001");
1494 assert_eq!(identity.order_side, OrderSide::Buy);
1495 }
1496
1497 #[rstest]
1498 fn test_lookup_identity_missing_returns_none() {
1499 let state = WsDispatchState::new();
1500 let cid = ClientOrderId::new("not-tracked");
1501 assert!(state.lookup_identity(&cid).is_none());
1502 }
1503
1504 #[rstest]
1505 fn test_insert_accepted_dedup() {
1506 let state = WsDispatchState::new();
1507 let cid = ClientOrderId::new("O-002");
1508 assert!(!state.emitted_accepted.contains(&cid));
1509 state.insert_accepted(cid);
1510 assert!(state.emitted_accepted.contains(&cid));
1511 state.insert_accepted(cid);
1512 assert!(state.emitted_accepted.contains(&cid));
1513 }
1514
1515 #[rstest]
1516 fn test_check_and_insert_trade_detects_duplicates() {
1517 let state = WsDispatchState::new();
1518 let trade = TradeId::new("trade-1");
1519 assert!(!state.check_and_insert_trade(trade));
1520 assert!(state.check_and_insert_trade(trade));
1521 }
1522
1523 #[rstest]
1524 fn test_bounded_dedup_fifo_eviction_preserves_recent_ids() {
1525 let mut dedup: BoundedDedup<TradeId> = BoundedDedup::new(3);
1526 assert!(!dedup.insert(TradeId::new("t-0")));
1527 assert!(!dedup.insert(TradeId::new("t-1")));
1528 assert!(!dedup.insert(TradeId::new("t-2")));
1529 assert_eq!(dedup.len(), 3);
1530
1531 assert!(!dedup.insert(TradeId::new("t-3")));
1533 assert_eq!(dedup.len(), 3);
1534 assert!(!dedup.contains(&TradeId::new("t-0")));
1535 assert!(dedup.contains(&TradeId::new("t-1")));
1536 assert!(dedup.contains(&TradeId::new("t-3")));
1537 }
1538
1539 #[rstest]
1540 fn test_pending_modify_roundtrip() {
1541 let state = WsDispatchState::new();
1542 let cid = ClientOrderId::new("O-010");
1543 let voi = VenueOrderId::new("v-1");
1544 let target_qty = Quantity::from("0.0001");
1545
1546 assert!(state.pending_modify(&cid).is_none());
1547 assert!(state.pending_modify_target_qty(&cid).is_none());
1548 state.mark_pending_modify(cid, voi, target_qty);
1549 assert_eq!(state.pending_modify(&cid), Some(voi));
1550 assert_eq!(state.pending_modify_target_qty(&cid), Some(target_qty));
1551 state.clear_pending_modify(&cid);
1552 assert!(state.pending_modify(&cid).is_none());
1553 assert!(state.pending_modify_target_qty(&cid).is_none());
1554 }
1555
1556 #[rstest]
1557 fn test_cleanup_terminal_preserves_filled_marker() {
1558 let state = WsDispatchState::new();
1559 let cid = ClientOrderId::new("O-020");
1560 state.register_identity(cid, make_identity());
1561 state.insert_accepted(cid);
1562 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.0001"));
1563 state.insert_filled(cid);
1564 state.cleanup_terminal(&cid);
1565
1566 assert!(state.lookup_identity(&cid).is_none());
1567 assert!(!state.emitted_accepted.contains(&cid));
1568 assert!(state.pending_modify(&cid).is_none());
1569 assert!(state.pending_modify_target_qty(&cid).is_none());
1570 assert!(state.filled_orders.contains(&cid));
1572 }
1573
1574 #[rstest]
1575 fn test_cleanup_terminal_clears_corrective_state() {
1576 let state = WsDispatchState::new();
1577 let cid = ClientOrderId::new("O-021");
1578 let request = sample_request(Decimal::from(1));
1579 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("1"));
1580 state.stash_modify_request(cid, request.clone());
1581 state.queue_corrective(cid, 1, request);
1582 assert!(state.modify_request(&cid).is_some());
1583
1584 state.cleanup_terminal(&cid);
1585
1586 assert!(state.modify_request(&cid).is_none());
1587 assert!(state.take_corrective(&cid).is_none());
1588 assert!(state.pending_modify(&cid).is_none());
1589 }
1590
1591 fn sample_request(size: Decimal) -> HyperliquidExecPlaceOrderRequest {
1592 HyperliquidExecPlaceOrderRequest {
1593 asset: 0,
1594 is_buy: true,
1595 price: "100".parse::<Decimal>().unwrap(),
1596 size,
1597 reduce_only: false,
1598 kind: HyperliquidExecOrderKind::Limit {
1599 limit: HyperliquidExecLimitParams {
1600 tif: HyperliquidExecTif::Gtc,
1601 },
1602 },
1603 cloid: None,
1604 }
1605 }
1606
1607 #[rstest]
1608 fn test_modify_chain_keeps_both_intents_on_rapid_modifies() {
1609 let state = WsDispatchState::new();
1610 let cid = ClientOrderId::new("O-100");
1611 let g0 =
1612 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1613 let g1 =
1614 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00030"));
1615
1616 assert_ne!(g0, g1);
1617 assert!(state.has_pending_modify(&cid));
1618 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
1620 assert_eq!(
1621 state.pending_modify_target_qty(&cid),
1622 Some(Quantity::from("0.00020")),
1623 );
1624 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1626 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
1627 }
1628
1629 #[rstest]
1630 fn test_clear_modify_generation_preserves_newer_intent() {
1631 let state = WsDispatchState::new();
1632 let cid = ClientOrderId::new("O-101");
1633 let g0 =
1636 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1637 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
1638
1639 state.clear_modify_generation(&cid, g0);
1642
1643 assert!(state.has_pending_modify(&cid));
1644 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
1645 assert_eq!(
1646 state.pending_modify_target_qty(&cid),
1647 Some(Quantity::from("0.00030")),
1648 );
1649 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1650 }
1651
1652 #[rstest]
1653 fn test_claim_front_modify_advances_next_old_id() {
1654 let state = WsDispatchState::new();
1655 let cid = ClientOrderId::new("O-102");
1656 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1658 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
1659
1660 let claimed = state.claim_front_modify(&cid, VenueOrderId::new("v-1"));
1663 assert_eq!(
1664 claimed.map(|i| i.target_qty),
1665 Some(Quantity::from("0.00020"))
1666 );
1667
1668 assert!(state.has_pending_modify(&cid));
1669 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
1670 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
1671 assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1673
1674 let claimed2 = state.claim_front_modify(&cid, VenueOrderId::new("v-2"));
1676 assert_eq!(
1677 claimed2.map(|i| i.target_qty),
1678 Some(Quantity::from("0.00030"))
1679 );
1680 assert!(!state.has_pending_modify(&cid));
1681 assert!(state.pending_modify(&cid).is_none());
1682 }
1683
1684 #[rstest]
1685 fn test_modify_chain_caps_and_evicts_oldest() {
1686 let state = WsDispatchState::new();
1687 let cid = ClientOrderId::new("O-106");
1688 for i in 0..=MAX_PENDING_MODIFY_INTENTS {
1690 let voi = format!("v-{i}");
1691 state.mark_pending_modify(cid, VenueOrderId::new(&voi), Quantity::from("0.00020"));
1692 }
1693
1694 assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1697 let newest = format!("v-{MAX_PENDING_MODIFY_INTENTS}");
1698 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new(&newest)));
1699 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
1700 }
1701
1702 #[rstest]
1703 fn test_clear_front_modify_reparents_next_old() {
1704 let state = WsDispatchState::new();
1705 let cid = ClientOrderId::new("O-104");
1706 let g_front =
1709 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00020"));
1710 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
1711
1712 state.clear_modify_generation(&cid, g_front);
1716
1717 assert!(state.has_pending_modify(&cid));
1718 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
1719 assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
1720 assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1721 }
1722
1723 #[rstest]
1724 fn test_clear_non_front_modify_leaves_front_old() {
1725 let state = WsDispatchState::new();
1726 let cid = ClientOrderId::new("O-105");
1727 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1728 let g_back =
1729 state.mark_pending_modify(cid, VenueOrderId::new("v-9"), Quantity::from("0.00030"));
1730
1731 state.clear_modify_generation(&cid, g_back);
1733
1734 assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
1735 assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-9")));
1736 }
1737
1738 #[rstest]
1739 fn test_stash_modify_request_targets_latest_intent() {
1740 let state = WsDispatchState::new();
1741 let cid = ClientOrderId::new("O-103");
1742 state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1743 state.stash_modify_request(cid, sample_request(Decimal::from(1)));
1744 state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00030"));
1745 state.stash_modify_request(cid, sample_request(Decimal::from(2)));
1746
1747 assert_eq!(
1749 state.modify_request(&cid).map(|r| r.size),
1750 Some(Decimal::from(1)),
1751 );
1752 state.claim_front_modify(&cid, VenueOrderId::new("v-1"));
1754 assert_eq!(
1755 state.modify_request(&cid).map(|r| r.size),
1756 Some(Decimal::from(2)),
1757 );
1758 }
1759}