Skip to main content

nautilus_hyperliquid/websocket/
dispatch.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! WebSocket execution dispatch for the Hyperliquid execution client.
17//!
18//! Implements the two-tier execution dispatch contract from
19//! `docs/developer_guide/adapters.md` (lines 1232-1296):
20//!
21//! 1. The execution client registers an [`OrderIdentity`] in [`WsDispatchState`]
22//!    when it submits an order, and refreshes the cached venue order id when a
23//!    modify is sent so the WebSocket consumer can detect cancel-replace.
24//! 2. Incoming [`OrderStatusReport`] and [`FillReport`] messages are routed
25//!    through [`dispatch_order_event`] and [`dispatch_order_fill`].
26//!    For tracked orders these build typed [`OrderEventAny`] events and emit
27//!    them via [`ExecutionEventEmitter::send_order_event`]. For untracked /
28//!    external orders the dispatch falls back to forwarding the raw report.
29//!
30//! The dispatch state lives in an `Arc<WsDispatchState>` shared between the
31//! main client task (which registers identities at submission time) and the
32//! spawned WebSocket consumer task.
33//!
34//! # GH-3827 cancel-replace handling
35//!
36//! Hyperliquid implements `modify` as a cancel-and-replace: the venue emits an
37//! `ACCEPTED(new_voi)` together with a `CANCELED(old_voi)` under the same
38//! `client_order_id`. The dispatch detects the replacement leg by comparing
39//! `report.venue_order_id` to the last cached value, promotes it to an
40//! `OrderUpdated` event, and suppresses the stale cancel so strategies never
41//! observe a spurious termination.
42//!
43//! The pending-modify marker (keyed on `client_order_id`) is set by
44//! `modify_order` before the HTTP call and cleared on either the matching
45//! `ACCEPTED(new_voi)` or any modify failure. It lets dispatch skip an
46//! early `CANCELED(old_voi)` that arrives before the replacement
47//! `ACCEPTED(new_voi)` on the WebSocket, regardless of whether the WS
48//! message races ahead of the HTTP response.
49//!
50//! A fill carrying the replacement `venue_order_id` during an in-flight modify
51//! promotes the binding directly (the same `OrderUpdated` path as the
52//! replacement `ACCEPTED`), so a dropped `ACCEPTED` does not strand the fill.
53//! A fill is buffered into [`WsDispatchState::buffered_fills`] only when the
54//! identity has no price to promote with; `handle_accepted` drains the buffer
55//! on the replacement `ACCEPTED`. A delayed earlier-leg fill during a chained
56//! modify is a known limitation. See GH-3972.
57//!
58//! When neither the replacement `ACCEPTED` nor a fill arrives, a query that
59//! resolves the replacement by `cloid` promotes the binding the same way via
60//! [`promote_replacement_from_query`], so a dropped `ACCEPTED` with no fill
61//! cannot leave the order bound to the canceled leg.
62
63use std::{
64    collections::VecDeque,
65    hash::Hash,
66    sync::{
67        Mutex,
68        atomic::{AtomicBool, Ordering},
69    },
70};
71
72use ahash::AHashSet;
73use dashmap::{DashMap, DashSet};
74use nautilus_core::{MUTEX_POISONED, UUID4, UnixNanos};
75use nautilus_live::ExecutionEventEmitter;
76use nautilus_model::{
77    enums::{OrderSide, OrderStatus, OrderType},
78    events::{
79        OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
80        OrderTriggered, OrderUpdated,
81    },
82    identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TradeId, VenueOrderId},
83    reports::{FillReport, OrderStatusReport},
84    types::{Price, Quantity},
85};
86use ustr::Ustr;
87
88use crate::http::models::HyperliquidExecPlaceOrderRequest;
89
90pub const DEDUP_CAPACITY: usize = 10_000;
91
92/// Identity metadata captured when an order is submitted through this client.
93///
94/// Stored in [`WsDispatchState::order_identities`] keyed by the full Nautilus
95/// [`ClientOrderId`]. The dispatch functions use the identity to build typed
96/// order events for tracked orders without needing access to the engine cache
97/// (which is `!Send` and unreachable from the spawned WebSocket task).
98#[derive(Debug, Clone)]
99pub struct OrderIdentity {
100    /// Strategy that owns the order.
101    pub strategy_id: StrategyId,
102    /// Instrument the order targets.
103    pub instrument_id: InstrumentId,
104    /// Order side captured at submission.
105    pub order_side: OrderSide,
106    /// Order type captured at submission.
107    pub order_type: OrderType,
108    /// Order quantity captured at submission.
109    pub quantity: Quantity,
110    /// Last known order price. Populated on submission and refreshed from
111    /// subsequent status reports so a cancel-replace `ACCEPTED` that omits
112    /// `price` can still produce an `OrderUpdated` carrying an accurate value.
113    pub price: Option<Price>,
114}
115
116/// Bounded FIFO deduplication set.
117///
118/// When the capacity is reached, the oldest entry is evicted on the next
119/// insert. A simple `clear()` at the threshold would drop every recent trade
120/// id at once, opening a window where a reconnect or replay right after the
121/// rollover could re-emit duplicate `OrderFilled` events; the FIFO window
122/// slides instead.
123#[derive(Debug)]
124pub struct BoundedDedup<T>
125where
126    T: Eq + Hash + Clone,
127{
128    order: VecDeque<T>,
129    set: AHashSet<T>,
130    capacity: usize,
131}
132
133impl<T> BoundedDedup<T>
134where
135    T: Eq + Hash + Clone,
136{
137    /// Creates a new bounded dedup set with the given `capacity`.
138    #[must_use]
139    pub fn new(capacity: usize) -> Self {
140        Self {
141            order: VecDeque::with_capacity(capacity),
142            set: AHashSet::with_capacity(capacity),
143            capacity,
144        }
145    }
146
147    /// Inserts a value. Returns `true` when the value was already present.
148    pub fn insert(&mut self, value: T) -> bool {
149        if self.set.contains(&value) {
150            return true;
151        }
152
153        if self.order.len() >= self.capacity
154            && let Some(evicted) = self.order.pop_front()
155        {
156            self.set.remove(&evicted);
157        }
158
159        self.order.push_back(value.clone());
160        self.set.insert(value);
161        false
162    }
163
164    /// Returns the number of entries currently tracked.
165    #[must_use]
166    pub fn len(&self) -> usize {
167        self.set.len()
168    }
169
170    /// Returns whether the dedup set is empty.
171    #[must_use]
172    pub fn is_empty(&self) -> bool {
173        self.set.is_empty()
174    }
175
176    /// Returns whether the value is currently tracked.
177    #[must_use]
178    pub fn contains(&self, value: &T) -> bool {
179        self.set.contains(value)
180    }
181}
182
183/// Per-client dispatch state shared between order submission and the
184/// WebSocket consumer task.
185///
186/// Tracks which orders were submitted through this client (so we can route
187/// venue events to typed [`OrderEventAny`] emissions for tracked orders and
188/// fall back to reports for external orders), provides cross-stream dedup
189/// for `OrderAccepted` and `OrderFilled` emissions, and carries the
190/// GH-3827 cancel-replace state (`cached_venue_order_ids` and
191/// `pending_modify_keys`).
192#[derive(Debug)]
193pub struct WsDispatchState {
194    /// Tracked orders keyed by full Nautilus [`ClientOrderId`].
195    pub order_identities: DashMap<ClientOrderId, OrderIdentity>,
196    /// Client order IDs for which an `OrderAccepted` event has been emitted.
197    pub emitted_accepted: DashSet<ClientOrderId>,
198    /// Client order IDs that have reached the filled terminal state.
199    ///
200    /// Retained past `cleanup_terminal` so that late replay of the same
201    /// status or fill does not re-emit events.
202    pub filled_orders: DashSet<ClientOrderId>,
203    /// Trade IDs for which an `OrderFilled` event has been emitted.
204    ///
205    /// Bounded FIFO dedup to bound memory while keeping recent trade ids
206    /// deduped across reconnects.
207    pub emitted_trades: Mutex<BoundedDedup<TradeId>>,
208    /// Raw Hyperliquid CLOIDs that reached a terminal state through the post
209    /// response path before the matching `orderUpdates` event arrived.
210    pub terminal_cloids: Mutex<BoundedDedup<Ustr>>,
211    /// Last venue order id observed for a tracked client order id.
212    ///
213    /// Populated on the first `OrderAccepted` and refreshed on every
214    /// cancel-replace promotion. A later `ACCEPTED` with a different venue
215    /// order id under the same client order id is treated as the
216    /// replacement leg of a Hyperliquid modify and emitted as `OrderUpdated`.
217    pub cached_venue_order_ids: DashMap<ClientOrderId, VenueOrderId>,
218    /// Maps `client_order_id` to the old venue order id of an in-flight
219    /// modify. Populated by `modify_order` before the HTTP call so the WS
220    /// cancel handler sees the marker even when `CANCELED(old_voi)` arrives
221    /// before the HTTP response. Cleared on the matching `ACCEPTED(new_voi)`
222    /// or on any modify failure. A `CANCELED(old_voi)` arriving while the
223    /// marker is set is treated as the cancel leg of a cancel-before-accept
224    /// race and suppressed so the later `ACCEPTED(new_voi)` can flow through
225    /// the `OrderUpdated` path.
226    pub pending_modify_keys: DashMap<ClientOrderId, VenueOrderId>,
227    /// User-intended absolute total qty for an in-flight modify; the
228    /// cancel-replace promotion uses it instead of the venue's
229    /// remaining-only `report.quantity`.
230    pub pending_modify_target_qty: DashMap<ClientOrderId, Quantity>,
231    /// `FillReport`s buffered only when a cancel-replace fill cannot be promoted
232    /// (the identity carries no price); drained by the cancel-replace branch of
233    /// `handle_accepted`. The common path promotes on the fill instead. See
234    /// GH-3972.
235    pub buffered_fills: DashMap<ClientOrderId, Vec<FillReport>>,
236    /// Cumulative filled quantity per tracked order. Compared against
237    /// `OrderIdentity::quantity` to decide when to clean up tracked state.
238    pub order_filled_qty: DashMap<ClientOrderId, Quantity>,
239    /// Exact venue request sent for an in-flight modify, used by the
240    /// cancel-replace promotion to build a corrective reduce.
241    pub pending_modify_request: DashMap<ClientOrderId, HyperliquidExecPlaceOrderRequest>,
242    /// Corrective reduce queued by the cancel-replace promotion: client order
243    /// id to (new venue order id, reduced request). Drained by the WS loop.
244    pub pending_corrective: DashMap<ClientOrderId, (u64, HyperliquidExecPlaceOrderRequest)>,
245    clearing: AtomicBool,
246}
247
248impl Default for WsDispatchState {
249    fn default() -> Self {
250        Self {
251            order_identities: DashMap::new(),
252            emitted_accepted: DashSet::default(),
253            filled_orders: DashSet::default(),
254            emitted_trades: Mutex::new(BoundedDedup::new(DEDUP_CAPACITY)),
255            terminal_cloids: Mutex::new(BoundedDedup::new(DEDUP_CAPACITY)),
256            cached_venue_order_ids: DashMap::new(),
257            pending_modify_keys: DashMap::new(),
258            pending_modify_target_qty: DashMap::new(),
259            buffered_fills: DashMap::new(),
260            order_filled_qty: DashMap::new(),
261            pending_modify_request: DashMap::new(),
262            pending_corrective: DashMap::new(),
263            clearing: AtomicBool::new(false),
264        }
265    }
266}
267
268impl WsDispatchState {
269    /// Creates a new empty dispatch state.
270    #[must_use]
271    pub fn new() -> Self {
272        Self::default()
273    }
274
275    /// Registers an order identity. Called by the execution client at order
276    /// submission time, before any WebSocket events for the order can arrive.
277    pub fn register_identity(&self, client_order_id: ClientOrderId, identity: OrderIdentity) {
278        self.order_identities.insert(client_order_id, identity);
279    }
280
281    /// Returns a clone of the identity for the given client order id, if any.
282    #[must_use]
283    pub fn lookup_identity(&self, client_order_id: &ClientOrderId) -> Option<OrderIdentity> {
284        self.order_identities
285            .get(client_order_id)
286            .map(|r| r.clone())
287    }
288
289    /// Refreshes the tracked price for a modify ack when the new report
290    /// carries an updated price.
291    pub fn update_identity_price(&self, client_order_id: &ClientOrderId, price: Option<Price>) {
292        if let Some(price) = price
293            && let Some(mut entry) = self.order_identities.get_mut(client_order_id)
294        {
295            entry.price = Some(price);
296        }
297    }
298
299    /// Refreshes the tracked quantity for a modify ack.
300    pub fn update_identity_quantity(&self, client_order_id: &ClientOrderId, quantity: Quantity) {
301        if let Some(mut entry) = self.order_identities.get_mut(client_order_id) {
302            entry.quantity = quantity;
303        }
304    }
305
306    /// Marks an `OrderAccepted` event as emitted for this order.
307    pub fn insert_accepted(&self, cid: ClientOrderId) {
308        self.evict_if_full(&self.emitted_accepted);
309        self.emitted_accepted.insert(cid);
310    }
311
312    /// Marks an order as having reached a terminal state.
313    ///
314    /// Returns `true` when this call claimed the terminal state, and `false`
315    /// when another path had already claimed it.
316    pub fn insert_filled(&self, cid: ClientOrderId) -> bool {
317        self.evict_if_full(&self.filled_orders);
318        self.filled_orders.insert(cid)
319    }
320
321    /// Atomically inserts a trade id into the dedup set.
322    ///
323    /// Returns `true` when the trade was already present (i.e. it is a
324    /// duplicate), `false` otherwise.
325    #[allow(
326        clippy::missing_panics_doc,
327        reason = "dedup mutex poisoning is not expected"
328    )]
329    pub fn check_and_insert_trade(&self, trade_id: TradeId) -> bool {
330        let mut set = self.emitted_trades.lock().expect(MUTEX_POISONED);
331        set.insert(trade_id)
332    }
333
334    /// Records a terminal raw Hyperliquid CLOID.
335    ///
336    /// Used when the post response rejects an order before the WebSocket
337    /// `orderUpdates` message. The normal CLOID mapping can be removed while a
338    /// late unresolved order update still gets suppressed instead of forwarded
339    /// as an external report.
340    #[allow(
341        clippy::missing_panics_doc,
342        reason = "terminal cloid mutex poisoning is not expected"
343    )]
344    pub fn insert_terminal_cloid(&self, cloid: Ustr) {
345        let mut set = self.terminal_cloids.lock().expect(MUTEX_POISONED);
346        set.insert(cloid);
347    }
348
349    /// Returns whether a raw Hyperliquid CLOID reached a terminal state through
350    /// the post response path.
351    #[allow(
352        clippy::missing_panics_doc,
353        reason = "terminal cloid mutex poisoning is not expected"
354    )]
355    #[must_use]
356    pub fn terminal_cloid_seen(&self, cloid: &Ustr) -> bool {
357        let set = self.terminal_cloids.lock().expect(MUTEX_POISONED);
358        set.contains(cloid)
359    }
360
361    /// Caches the venue order id observed for a tracked client order id.
362    pub fn record_venue_order_id(
363        &self,
364        client_order_id: ClientOrderId,
365        venue_order_id: VenueOrderId,
366    ) {
367        self.cached_venue_order_ids
368            .insert(client_order_id, venue_order_id);
369    }
370
371    /// Returns the previously cached venue order id, if any.
372    #[must_use]
373    pub fn cached_venue_order_id(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
374        self.cached_venue_order_ids.get(client_order_id).map(|r| *r)
375    }
376
377    /// Marks an in-flight modify for cancel-before-accept suppression and
378    /// records the target absolute total qty for the cancel-replace promotion.
379    pub fn mark_pending_modify(
380        &self,
381        client_order_id: ClientOrderId,
382        old_venue_order_id: VenueOrderId,
383        target_qty: Quantity,
384    ) {
385        self.pending_modify_keys
386            .insert(client_order_id, old_venue_order_id);
387        self.pending_modify_target_qty
388            .insert(client_order_id, target_qty);
389    }
390
391    /// Clears the pending modify marker for a client order id.
392    pub fn clear_pending_modify(&self, client_order_id: &ClientOrderId) {
393        self.pending_modify_keys.remove(client_order_id);
394        self.pending_modify_target_qty.remove(client_order_id);
395        self.pending_modify_request.remove(client_order_id);
396    }
397
398    /// Stashes the exact venue request sent for an in-flight modify.
399    pub fn stash_modify_request(
400        &self,
401        client_order_id: ClientOrderId,
402        request: HyperliquidExecPlaceOrderRequest,
403    ) {
404        self.pending_modify_request.insert(client_order_id, request);
405    }
406
407    /// Returns a clone of the stashed in-flight modify request, if any.
408    #[must_use]
409    pub fn modify_request(
410        &self,
411        client_order_id: &ClientOrderId,
412    ) -> Option<HyperliquidExecPlaceOrderRequest> {
413        self.pending_modify_request
414            .get(client_order_id)
415            .map(|r| r.clone())
416    }
417
418    /// Queues a corrective reduce for the WebSocket consumer loop to post.
419    pub fn queue_corrective(
420        &self,
421        client_order_id: ClientOrderId,
422        oid: u64,
423        request: HyperliquidExecPlaceOrderRequest,
424    ) {
425        self.pending_corrective
426            .insert(client_order_id, (oid, request));
427    }
428
429    /// Removes and returns a queued corrective reduce, if any.
430    #[must_use]
431    pub fn take_corrective(
432        &self,
433        client_order_id: &ClientOrderId,
434    ) -> Option<(u64, HyperliquidExecPlaceOrderRequest)> {
435        self.pending_corrective
436            .remove(client_order_id)
437            .map(|(_, v)| v)
438    }
439
440    /// Returns the pending modify marker for a client order id, if any.
441    #[must_use]
442    pub fn pending_modify(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
443        self.pending_modify_keys.get(client_order_id).map(|r| *r)
444    }
445
446    /// Returns the recorded target absolute total qty, if any.
447    #[must_use]
448    pub fn pending_modify_target_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
449        self.pending_modify_target_qty
450            .get(client_order_id)
451            .map(|r| *r)
452    }
453
454    /// Buffers a `FillReport` arrived during an in-flight cancel-replace.
455    pub fn buffer_fill(&self, client_order_id: ClientOrderId, fill: FillReport) {
456        self.buffered_fills
457            .entry(client_order_id)
458            .or_default()
459            .push(fill);
460    }
461
462    /// Removes and returns buffered fills for the cid, in arrival order.
463    #[must_use]
464    pub fn drain_buffered_fills(&self, client_order_id: &ClientOrderId) -> Vec<FillReport> {
465        self.buffered_fills
466            .remove(client_order_id)
467            .map(|(_, v)| v)
468            .unwrap_or_default()
469    }
470
471    /// Number of buffered fills for the cid.
472    #[must_use]
473    pub fn buffered_fill_count(&self, client_order_id: &ClientOrderId) -> usize {
474        self.buffered_fills
475            .get(client_order_id)
476            .map_or(0, |r| r.len())
477    }
478
479    /// Records cumulative filled quantity for a tracked order.
480    pub fn record_filled_qty(&self, client_order_id: ClientOrderId, qty: Quantity) {
481        self.order_filled_qty.insert(client_order_id, qty);
482    }
483
484    /// Returns the previously recorded cumulative filled quantity, if any.
485    #[must_use]
486    pub fn previous_filled_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
487        self.order_filled_qty.get(client_order_id).map(|r| *r)
488    }
489
490    /// Removes all dispatch state for an order that has reached a terminal state.
491    ///
492    /// `filled_orders` is intentionally *not* cleared here: the marker is
493    /// used to suppress stale replays and must outlive the identity cleanup.
494    pub fn cleanup_terminal(&self, client_order_id: &ClientOrderId) {
495        self.order_identities.remove(client_order_id);
496        self.emitted_accepted.remove(client_order_id);
497        self.cached_venue_order_ids.remove(client_order_id);
498        self.pending_modify_keys.remove(client_order_id);
499        self.pending_modify_target_qty.remove(client_order_id);
500        self.pending_modify_request.remove(client_order_id);
501        self.pending_corrective.remove(client_order_id);
502        self.buffered_fills.remove(client_order_id);
503        self.order_filled_qty.remove(client_order_id);
504    }
505
506    fn evict_if_full(&self, set: &DashSet<ClientOrderId>) {
507        if set.len() >= DEDUP_CAPACITY
508            && self
509                .clearing
510                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
511                .is_ok()
512        {
513            set.clear();
514            self.clearing.store(false, Ordering::Release);
515        }
516    }
517}
518
519/// Outcome of a single dispatch call.
520#[derive(Debug, Clone, Copy, PartialEq, Eq)]
521pub enum DispatchOutcome {
522    /// The report was for a tracked order. Typed events have been emitted
523    /// (or intentionally skipped, e.g. dedup hit). The caller must not
524    /// forward the report as a fallback.
525    Tracked,
526    /// The report is for an external / untracked order. The caller should
527    /// forward the report via [`ExecutionEventEmitter::send_order_status_report`]
528    /// or [`ExecutionEventEmitter::send_fill_report`] so the engine can
529    /// reconcile.
530    External,
531    /// The report was recognised as stale (e.g. cancel leg of a
532    /// cancel-replace modify, or replay after terminal state). The caller
533    /// must drop it without forwarding.
534    Skip,
535}
536
537/// Dispatches an [`OrderStatusReport`] using the two-tier routing contract.
538///
539/// Returns [`DispatchOutcome::Tracked`] when the report maps to a tracked
540/// order (typed events have been emitted or dedup hit), [`External`] when
541/// the caller should forward the report as an untracked fallback, or
542/// [`Skip`] when the report is a stale / race leg that must be dropped.
543///
544/// [`External`]: DispatchOutcome::External
545/// [`Skip`]: DispatchOutcome::Skip
546pub fn dispatch_order_event(
547    report: &OrderStatusReport,
548    state: &WsDispatchState,
549    emitter: &ExecutionEventEmitter,
550    ts_init: UnixNanos,
551) -> DispatchOutcome {
552    let Some(client_order_id) = report.client_order_id else {
553        return DispatchOutcome::External;
554    };
555
556    if state.filled_orders.contains(&client_order_id) {
557        log::debug!(
558            "Skipping stale report for filled order: cid={client_order_id}, status={:?}",
559            report.order_status,
560        );
561        return DispatchOutcome::Skip;
562    }
563
564    let client_order_id_str = client_order_id.as_str();
565    if client_order_id_str.starts_with("0x")
566        && state.terminal_cloid_seen(&Ustr::from(client_order_id_str))
567    {
568        log::debug!(
569            "Skipping stale terminal report for raw cloid: cid={client_order_id}, status={:?}",
570            report.order_status,
571        );
572        return DispatchOutcome::Skip;
573    }
574
575    let Some(identity) = state.lookup_identity(&client_order_id) else {
576        return DispatchOutcome::External;
577    };
578
579    match report.order_status {
580        OrderStatus::Accepted => {
581            handle_accepted(report, client_order_id, &identity, state, emitter, ts_init)
582        }
583        OrderStatus::Triggered => {
584            handle_triggered(report, client_order_id, &identity, state, emitter, ts_init)
585        }
586        OrderStatus::Canceled => {
587            handle_canceled(report, client_order_id, &identity, state, emitter, ts_init)
588        }
589        OrderStatus::Expired => {
590            handle_expired(report, client_order_id, &identity, state, emitter, ts_init)
591        }
592        OrderStatus::Rejected => {
593            handle_rejected(report, client_order_id, &identity, state, emitter, ts_init)
594        }
595        OrderStatus::Filled => handle_filled_marker(client_order_id, state),
596        OrderStatus::PartiallyFilled => {
597            // Fills come via `FillReport`; nothing to emit from the status path.
598            DispatchOutcome::Tracked
599        }
600        OrderStatus::PendingUpdate
601        | OrderStatus::PendingCancel
602        | OrderStatus::Submitted
603        | OrderStatus::Initialized
604        | OrderStatus::Denied
605        | OrderStatus::Released
606        | OrderStatus::Emulated => DispatchOutcome::Tracked,
607    }
608}
609
610/// Dispatches a [`FillReport`] using the two-tier routing contract.
611///
612/// Returns [`DispatchOutcome::Tracked`] when the fill has been emitted as
613/// an `OrderFilled` event (or skipped via trade dedup), [`External`] when
614/// the caller should forward the fill via
615/// [`ExecutionEventEmitter::send_fill_report`], or [`Skip`] when the fill
616/// is a replay for an already-terminal order and must be dropped.
617///
618/// [`External`]: DispatchOutcome::External
619/// [`Skip`]: DispatchOutcome::Skip
620pub fn dispatch_order_fill(
621    report: &FillReport,
622    state: &WsDispatchState,
623    emitter: &ExecutionEventEmitter,
624    ts_init: UnixNanos,
625) -> DispatchOutcome {
626    let Some(client_order_id) = report.client_order_id else {
627        return DispatchOutcome::External;
628    };
629
630    if state.filled_orders.contains(&client_order_id) {
631        log::debug!(
632            "Skipping stale fill for filled order: cid={client_order_id}, trade_id={}",
633            report.trade_id,
634        );
635        return DispatchOutcome::Skip;
636    }
637
638    let Some(mut identity) = state.lookup_identity(&client_order_id) else {
639        return DispatchOutcome::External;
640    };
641
642    // Set when a fill promotes, so the corrective-reduce runs after the fill applies
643    let mut promoted_corrective: Option<(Quantity, HyperliquidExecPlaceOrderRequest)> = None;
644
645    // Promote the binding from the fill so a dropped replacement ACCEPTED cannot
646    // strand it (see module docs).
647    if state.pending_modify(&client_order_id).is_some()
648        && let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
649        && report.venue_order_id != cached_voi
650    {
651        let target = state.pending_modify_target_qty(&client_order_id);
652        let sent_request = state.modify_request(&client_order_id);
653        // Prefer the modify target price over the stale cached identity price
654        let price = sent_request
655            .as_ref()
656            .zip(identity.price)
657            .and_then(|(r, cached)| Price::from_decimal_dp(r.price, cached.precision).ok())
658            .or(identity.price);
659        let Some(price) = price else {
660            log::warn!(
661                "Cannot promote cancel-replace for {client_order_id} from fill: no target \
662                 or cached price; buffering until the replacement ACCEPTED arrives",
663            );
664            state.buffer_fill(client_order_id, report.clone());
665            return DispatchOutcome::Tracked;
666        };
667        let updated_quantity = target.unwrap_or(identity.quantity);
668        promote_cancel_replace(
669            client_order_id,
670            &identity,
671            state,
672            emitter,
673            report.venue_order_id,
674            report.account_id,
675            price,
676            updated_quantity,
677            None,
678            report.ts_event,
679            ts_init,
680        );
681        // Re-read the identity advanced by the promotion (quantity and price)
682        if let Some(updated) = state.lookup_identity(&client_order_id) {
683            identity = updated;
684        }
685
686        if let (Some(target), Some(sent_request)) = (target, sent_request) {
687            promoted_corrective = Some((target, sent_request));
688        }
689    }
690
691    if state.check_and_insert_trade(report.trade_id) {
692        log::debug!(
693            "Skipping duplicate fill for {client_order_id}: trade_id={}",
694            report.trade_id
695        );
696        return DispatchOutcome::Tracked;
697    }
698
699    let previous = state
700        .previous_filled_qty(&client_order_id)
701        .unwrap_or_else(|| Quantity::zero(report.last_qty.precision));
702    let cumulative = previous + report.last_qty;
703
704    let is_terminal_fill = cumulative >= identity.quantity;
705    if is_terminal_fill && !claim_terminal_order(client_order_id, state, OrderStatus::Filled) {
706        return DispatchOutcome::Skip;
707    }
708
709    ensure_accepted_emitted(
710        client_order_id,
711        report.venue_order_id,
712        report.account_id,
713        &identity,
714        state,
715        emitter,
716        report.ts_event,
717        ts_init,
718    );
719
720    let filled = OrderFilled::new(
721        emitter.trader_id(),
722        identity.strategy_id,
723        identity.instrument_id,
724        client_order_id,
725        report.venue_order_id,
726        report.account_id,
727        report.trade_id,
728        identity.order_side,
729        identity.order_type,
730        report.last_qty,
731        report.last_px,
732        report.commission.currency,
733        report.liquidity_side,
734        UUID4::new(),
735        report.ts_event,
736        ts_init,
737        false,
738        report.venue_position_id,
739        Some(report.commission),
740    );
741    emitter.send_order_event(OrderEventAny::Filled(filled));
742
743    state.record_filled_qty(client_order_id, cumulative);
744
745    // Cumulative now includes this fill, so the reduce sizes against the true remaining
746    if let Some((target, sent_request)) = promoted_corrective {
747        maybe_queue_corrective_reduce(
748            state,
749            client_order_id,
750            report.venue_order_id,
751            target,
752            sent_request,
753        );
754    }
755
756    if is_terminal_fill {
757        state.cleanup_terminal(&client_order_id);
758    }
759
760    DispatchOutcome::Tracked
761}
762
763fn handle_accepted(
764    report: &OrderStatusReport,
765    client_order_id: ClientOrderId,
766    identity: &OrderIdentity,
767    state: &WsDispatchState,
768    emitter: &ExecutionEventEmitter,
769    ts_init: UnixNanos,
770) -> DispatchOutcome {
771    let venue_order_id = report.venue_order_id;
772    let ts_event = report.ts_last;
773    let account_id = report.account_id;
774
775    // Cancel-replace detection: if an earlier ACCEPTED cached a different
776    // venue_order_id under the same client_order_id, this ACCEPTED is the
777    // replacement leg of a Hyperliquid modify and must be promoted to
778    // OrderUpdated. See GH-3827.
779    if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
780        && cached_voi != venue_order_id
781    {
782        let price = report.price.or(identity.price);
783        let Some(price) = price else {
784            log::warn!(
785                "Cannot emit OrderUpdated for cancel-replace {client_order_id}: \
786                 no price on report and no cached price on identity",
787            );
788            return DispatchOutcome::Skip;
789        };
790
791        // Prefer user target over venue's remaining-only `report.quantity`;
792        // fall back when no marker (external modify).
793        let target_total_qty = state.pending_modify_target_qty(&client_order_id);
794        let updated_quantity = target_total_qty.unwrap_or(report.quantity);
795        let sent_request = state.modify_request(&client_order_id);
796
797        promote_cancel_replace(
798            client_order_id,
799            identity,
800            state,
801            emitter,
802            venue_order_id,
803            account_id,
804            price,
805            updated_quantity,
806            report.trigger_price,
807            ts_event,
808            ts_init,
809        );
810
811        if let (Some(target), Some(sent_request)) = (target_total_qty, sent_request) {
812            maybe_queue_corrective_reduce(
813                state,
814                client_order_id,
815                venue_order_id,
816                target,
817                sent_request,
818            );
819        }
820
821        return DispatchOutcome::Tracked;
822    }
823
824    if state.emitted_accepted.contains(&client_order_id) {
825        // Repeat ACCEPTED for an already-accepted order. Nothing to emit;
826        // refresh the cached price so a subsequent cancel-replace without a
827        // report price can still recover an accurate value.
828        state.update_identity_price(&client_order_id, report.price);
829        return DispatchOutcome::Tracked;
830    }
831
832    state.insert_accepted(client_order_id);
833    state.record_venue_order_id(client_order_id, venue_order_id);
834    state.update_identity_price(&client_order_id, report.price);
835
836    let accepted = OrderAccepted::new(
837        emitter.trader_id(),
838        identity.strategy_id,
839        identity.instrument_id,
840        client_order_id,
841        venue_order_id,
842        account_id,
843        UUID4::new(),
844        ts_event,
845        ts_init,
846        false,
847    );
848    emitter.send_order_event(OrderEventAny::Accepted(accepted));
849    DispatchOutcome::Tracked
850}
851
852// Shared by the ACCEPTED branch and the fill path (dropped-ACCEPTED recovery) so the
853// cancel-replace binding is recovered from whichever arrives first. See GH-3827, GH-3972.
854#[allow(
855    clippy::too_many_arguments,
856    reason = "promotion needs the full OrderUpdated field set, sourced from two report shapes"
857)]
858fn promote_cancel_replace(
859    client_order_id: ClientOrderId,
860    identity: &OrderIdentity,
861    state: &WsDispatchState,
862    emitter: &ExecutionEventEmitter,
863    venue_order_id: VenueOrderId,
864    account_id: AccountId,
865    price: Price,
866    quantity: Quantity,
867    trigger_price: Option<Price>,
868    ts_event: UnixNanos,
869    ts_init: UnixNanos,
870) {
871    state.record_venue_order_id(client_order_id, venue_order_id);
872    state.update_identity_quantity(&client_order_id, quantity);
873    state.update_identity_price(&client_order_id, Some(price));
874    state.clear_pending_modify(&client_order_id);
875
876    let updated = OrderUpdated::new(
877        emitter.trader_id(),
878        identity.strategy_id,
879        identity.instrument_id,
880        client_order_id,
881        quantity,
882        UUID4::new(),
883        ts_event,
884        ts_init,
885        false,
886        Some(venue_order_id),
887        Some(account_id),
888        Some(price),
889        trigger_price,
890        None,
891        false,
892    );
893    emitter.send_order_event(OrderEventAny::Updated(updated));
894
895    // Drain fills buffered before the binding advanced. Bypasses
896    // `handle_execution_report`; FIFO-bounded caches make any residue benign.
897    let buffered = state.drain_buffered_fills(&client_order_id);
898    for fill in buffered {
899        dispatch_order_fill(&fill, state, emitter, ts_init);
900    }
901}
902
903/// Promotes a cancel-replace replacement surfaced by a query during an in-flight modify.
904///
905/// When the query returns the replacement leg (`Accepted`, `venue_order_id` diverging from the
906/// cached one, modify tracked), emits the `OrderUpdated` that rebinds the order, so a dropped
907/// replacement `Accepted` with no fill cannot strand the binding on the canceled leg. Returns
908/// `true` when promoted; the caller still forwards the report so the engine confirms the order.
909pub fn promote_replacement_from_query(
910    report: &OrderStatusReport,
911    state: &WsDispatchState,
912    emitter: &ExecutionEventEmitter,
913    ts_init: UnixNanos,
914) -> bool {
915    if report.order_status != OrderStatus::Accepted {
916        return false;
917    }
918
919    let Some(client_order_id) = report.client_order_id else {
920        return false;
921    };
922
923    if state.pending_modify(&client_order_id).is_none() {
924        return false;
925    }
926
927    let Some(cached_voi) = state.cached_venue_order_id(&client_order_id) else {
928        return false;
929    };
930
931    if report.venue_order_id == cached_voi {
932        return false;
933    }
934
935    let Some(identity) = state.lookup_identity(&client_order_id) else {
936        return false;
937    };
938
939    let Some(price) = report.price.or(identity.price) else {
940        log::warn!(
941            "Cannot promote cancel-replace from query for {client_order_id}: \
942             no price on report and no cached price on identity",
943        );
944        return false;
945    };
946
947    // Prefer the user target over the venue's remaining-only `report.quantity`
948    let updated_quantity = state
949        .pending_modify_target_qty(&client_order_id)
950        .unwrap_or(report.quantity);
951
952    promote_cancel_replace(
953        client_order_id,
954        &identity,
955        state,
956        emitter,
957        report.venue_order_id,
958        report.account_id,
959        price,
960        updated_quantity,
961        report.trigger_price,
962        report.ts_last,
963        ts_init,
964    );
965
966    log::debug!("Promoted cancel-replace replacement for {client_order_id} from query");
967
968    true
969}
970
971// Queue a corrective reduce when a fill that raced the modify left the replacement
972// oversized. Reached from both promotion paths; the engine overfill guard backstops.
973fn maybe_queue_corrective_reduce(
974    state: &WsDispatchState,
975    client_order_id: ClientOrderId,
976    venue_order_id: VenueOrderId,
977    target: Quantity,
978    sent_request: HyperliquidExecPlaceOrderRequest,
979) {
980    let Ok(new_oid) = venue_order_id.as_str().parse::<u64>() else {
981        return;
982    };
983
984    let filled = state
985        .previous_filled_qty(&client_order_id)
986        .unwrap_or_else(|| Quantity::zero(target.precision));
987    if filled >= target {
988        return;
989    }
990
991    let remaining = (target - filled).as_decimal().normalize();
992
993    let sent_size = sent_request.size;
994    if sent_size > remaining {
995        let mut corrective = sent_request;
996        corrective.size = remaining;
997
998        state.mark_pending_modify(client_order_id, venue_order_id, target);
999        state.stash_modify_request(client_order_id, corrective.clone());
1000        state.queue_corrective(client_order_id, new_oid, corrective);
1001
1002        log::warn!(
1003            "Cancel-replace left {client_order_id} oversized on {venue_order_id} \
1004             (sent {sent_size}, remaining {remaining}); queuing corrective reduce",
1005        );
1006    }
1007}
1008
1009fn handle_triggered(
1010    report: &OrderStatusReport,
1011    client_order_id: ClientOrderId,
1012    identity: &OrderIdentity,
1013    state: &WsDispatchState,
1014    emitter: &ExecutionEventEmitter,
1015    ts_init: UnixNanos,
1016) -> DispatchOutcome {
1017    if !matches!(
1018        identity.order_type,
1019        OrderType::StopLimit | OrderType::TrailingStopLimit | OrderType::LimitIfTouched
1020    ) {
1021        log::debug!(
1022            "Ignoring TRIGGERED status for non-triggerable order type {:?}: {client_order_id}",
1023            identity.order_type,
1024        );
1025        return DispatchOutcome::Tracked;
1026    }
1027
1028    ensure_accepted_emitted(
1029        client_order_id,
1030        report.venue_order_id,
1031        report.account_id,
1032        identity,
1033        state,
1034        emitter,
1035        report.ts_last,
1036        ts_init,
1037    );
1038
1039    let triggered = OrderTriggered::new(
1040        emitter.trader_id(),
1041        identity.strategy_id,
1042        identity.instrument_id,
1043        client_order_id,
1044        UUID4::new(),
1045        report.ts_last,
1046        ts_init,
1047        false,
1048        Some(report.venue_order_id),
1049        Some(report.account_id),
1050    );
1051    emitter.send_order_event(OrderEventAny::Triggered(triggered));
1052    DispatchOutcome::Tracked
1053}
1054
1055fn handle_canceled(
1056    report: &OrderStatusReport,
1057    client_order_id: ClientOrderId,
1058    identity: &OrderIdentity,
1059    state: &WsDispatchState,
1060    emitter: &ExecutionEventEmitter,
1061    ts_init: UnixNanos,
1062) -> DispatchOutcome {
1063    let venue_order_id = report.venue_order_id;
1064
1065    // Stale cancel suppression: if the cached venue_order_id has already
1066    // been advanced by a cancel-replace promotion, this CANCELED refers to
1067    // the old leg and has already been handled as OrderUpdated. See GH-3827.
1068    if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
1069        && cached_voi != venue_order_id
1070    {
1071        log::debug!(
1072            "Skipping stale CANCELED for {venue_order_id} (cached {cached_voi}) on {client_order_id}",
1073        );
1074        return DispatchOutcome::Skip;
1075    }
1076
1077    // Cancel-before-accept race: an in-flight modify may deliver
1078    // CANCELED(old_voi) before the replacement ACCEPTED(new_voi). The
1079    // pending marker (set before the modify HTTP call and cleared on
1080    // failure) lets us suppress the old leg so the later ACCEPTED can route
1081    // through OrderUpdated. See GH-3827.
1082    if let Some(pending_old) = state.pending_modify(&client_order_id)
1083        && pending_old == venue_order_id
1084    {
1085        log::debug!(
1086            "Skipping cancel-before-accept leg for {client_order_id}: venue_order_id={venue_order_id}",
1087        );
1088        return DispatchOutcome::Skip;
1089    }
1090
1091    if !claim_terminal_order(client_order_id, state, report.order_status) {
1092        return DispatchOutcome::Skip;
1093    }
1094
1095    ensure_accepted_emitted(
1096        client_order_id,
1097        venue_order_id,
1098        report.account_id,
1099        identity,
1100        state,
1101        emitter,
1102        report.ts_last,
1103        ts_init,
1104    );
1105
1106    let canceled = OrderCanceled::new(
1107        emitter.trader_id(),
1108        identity.strategy_id,
1109        identity.instrument_id,
1110        client_order_id,
1111        UUID4::new(),
1112        report.ts_last,
1113        ts_init,
1114        false,
1115        Some(venue_order_id),
1116        Some(report.account_id),
1117    );
1118    emitter.send_order_event(OrderEventAny::Canceled(canceled));
1119
1120    state.cleanup_terminal(&client_order_id);
1121    DispatchOutcome::Tracked
1122}
1123
1124fn handle_expired(
1125    report: &OrderStatusReport,
1126    client_order_id: ClientOrderId,
1127    identity: &OrderIdentity,
1128    state: &WsDispatchState,
1129    emitter: &ExecutionEventEmitter,
1130    ts_init: UnixNanos,
1131) -> DispatchOutcome {
1132    if !claim_terminal_order(client_order_id, state, report.order_status) {
1133        return DispatchOutcome::Skip;
1134    }
1135
1136    ensure_accepted_emitted(
1137        client_order_id,
1138        report.venue_order_id,
1139        report.account_id,
1140        identity,
1141        state,
1142        emitter,
1143        report.ts_last,
1144        ts_init,
1145    );
1146
1147    let expired = OrderExpired::new(
1148        emitter.trader_id(),
1149        identity.strategy_id,
1150        identity.instrument_id,
1151        client_order_id,
1152        UUID4::new(),
1153        report.ts_last,
1154        ts_init,
1155        false,
1156        Some(report.venue_order_id),
1157        Some(report.account_id),
1158    );
1159    emitter.send_order_event(OrderEventAny::Expired(expired));
1160    state.cleanup_terminal(&client_order_id);
1161    DispatchOutcome::Tracked
1162}
1163
1164fn handle_rejected(
1165    report: &OrderStatusReport,
1166    client_order_id: ClientOrderId,
1167    identity: &OrderIdentity,
1168    state: &WsDispatchState,
1169    emitter: &ExecutionEventEmitter,
1170    ts_init: UnixNanos,
1171) -> DispatchOutcome {
1172    if !claim_terminal_order(client_order_id, state, report.order_status) {
1173        return DispatchOutcome::Skip;
1174    }
1175
1176    let reason = report
1177        .cancel_reason
1178        .clone()
1179        .unwrap_or_else(|| "Order rejected by exchange".to_string());
1180    let rejected = OrderRejected::new(
1181        emitter.trader_id(),
1182        identity.strategy_id,
1183        identity.instrument_id,
1184        client_order_id,
1185        report.account_id,
1186        Ustr::from(&reason),
1187        UUID4::new(),
1188        report.ts_last,
1189        ts_init,
1190        false,
1191        false,
1192    );
1193    emitter.send_order_event(OrderEventAny::Rejected(rejected));
1194    state.cleanup_terminal(&client_order_id);
1195    DispatchOutcome::Tracked
1196}
1197
1198fn claim_terminal_order(
1199    client_order_id: ClientOrderId,
1200    state: &WsDispatchState,
1201    status: OrderStatus,
1202) -> bool {
1203    let claimed = state.insert_filled(client_order_id);
1204    if !claimed {
1205        log::debug!("Skipping duplicate terminal event for {client_order_id}: status={status:?}",);
1206    }
1207
1208    claimed
1209}
1210
1211fn handle_filled_marker(
1212    _client_order_id: ClientOrderId,
1213    _state: &WsDispatchState,
1214) -> DispatchOutcome {
1215    // A status-only `FILLED` marker does not carry fill data; the actual
1216    // `OrderFilled` is emitted from `dispatch_order_fill` when the matching
1217    // trade arrives. Do *not* set `filled_orders` here, otherwise the
1218    // follow-up fill would be classified as a stale replay and dropped
1219    // before the terminal `OrderFilled` event can be emitted. The fill
1220    // path installs the marker itself once the cumulative fill quantity
1221    // matches the tracked order quantity.
1222    DispatchOutcome::Tracked
1223}
1224
1225/// Synthesizes and emits an `OrderAccepted` event when one has not yet been
1226/// emitted for the given order.
1227///
1228/// Used before emitting non-Accepted events so strategies always observe the
1229/// canonical `Submitted -> Accepted -> ...` lifecycle even when the venue
1230/// compresses the placement and follow-up event into a single message (fast
1231/// fills).
1232#[allow(clippy::too_many_arguments)]
1233fn ensure_accepted_emitted(
1234    client_order_id: ClientOrderId,
1235    venue_order_id: VenueOrderId,
1236    account_id: AccountId,
1237    identity: &OrderIdentity,
1238    state: &WsDispatchState,
1239    emitter: &ExecutionEventEmitter,
1240    ts_event: UnixNanos,
1241    ts_init: UnixNanos,
1242) {
1243    if state.emitted_accepted.contains(&client_order_id) {
1244        return;
1245    }
1246    state.insert_accepted(client_order_id);
1247    state.record_venue_order_id(client_order_id, venue_order_id);
1248
1249    let accepted = OrderAccepted::new(
1250        emitter.trader_id(),
1251        identity.strategy_id,
1252        identity.instrument_id,
1253        client_order_id,
1254        venue_order_id,
1255        account_id,
1256        UUID4::new(),
1257        ts_event,
1258        ts_init,
1259        false,
1260    );
1261    emitter.send_order_event(OrderEventAny::Accepted(accepted));
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266    use nautilus_model::identifiers::{ClientOrderId, InstrumentId, StrategyId, TradeId};
1267    use rstest::rstest;
1268    use rust_decimal::Decimal;
1269
1270    use super::*;
1271    use crate::http::models::{
1272        HyperliquidExecLimitParams, HyperliquidExecOrderKind, HyperliquidExecTif,
1273    };
1274
1275    fn make_identity() -> OrderIdentity {
1276        OrderIdentity {
1277            strategy_id: StrategyId::from("S-001"),
1278            instrument_id: InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
1279            order_side: OrderSide::Buy,
1280            order_type: OrderType::Limit,
1281            quantity: Quantity::from("0.0001"),
1282            price: None,
1283        }
1284    }
1285
1286    #[rstest]
1287    fn test_register_and_lookup_identity() {
1288        let state = WsDispatchState::new();
1289        let cid = ClientOrderId::new("O-001");
1290        state.register_identity(cid, make_identity());
1291
1292        let found = state.lookup_identity(&cid);
1293        assert!(found.is_some());
1294        let identity = found.unwrap();
1295        assert_eq!(identity.strategy_id.as_str(), "S-001");
1296        assert_eq!(identity.order_side, OrderSide::Buy);
1297    }
1298
1299    #[rstest]
1300    fn test_lookup_identity_missing_returns_none() {
1301        let state = WsDispatchState::new();
1302        let cid = ClientOrderId::new("not-tracked");
1303        assert!(state.lookup_identity(&cid).is_none());
1304    }
1305
1306    #[rstest]
1307    fn test_insert_accepted_dedup() {
1308        let state = WsDispatchState::new();
1309        let cid = ClientOrderId::new("O-002");
1310        assert!(!state.emitted_accepted.contains(&cid));
1311        state.insert_accepted(cid);
1312        assert!(state.emitted_accepted.contains(&cid));
1313        state.insert_accepted(cid);
1314        assert!(state.emitted_accepted.contains(&cid));
1315    }
1316
1317    #[rstest]
1318    fn test_check_and_insert_trade_detects_duplicates() {
1319        let state = WsDispatchState::new();
1320        let trade = TradeId::new("trade-1");
1321        assert!(!state.check_and_insert_trade(trade));
1322        assert!(state.check_and_insert_trade(trade));
1323    }
1324
1325    #[rstest]
1326    fn test_bounded_dedup_fifo_eviction_preserves_recent_ids() {
1327        let mut dedup: BoundedDedup<TradeId> = BoundedDedup::new(3);
1328        assert!(!dedup.insert(TradeId::new("t-0")));
1329        assert!(!dedup.insert(TradeId::new("t-1")));
1330        assert!(!dedup.insert(TradeId::new("t-2")));
1331        assert_eq!(dedup.len(), 3);
1332
1333        // Overflow evicts the oldest.
1334        assert!(!dedup.insert(TradeId::new("t-3")));
1335        assert_eq!(dedup.len(), 3);
1336        assert!(!dedup.contains(&TradeId::new("t-0")));
1337        assert!(dedup.contains(&TradeId::new("t-1")));
1338        assert!(dedup.contains(&TradeId::new("t-3")));
1339    }
1340
1341    #[rstest]
1342    fn test_pending_modify_roundtrip() {
1343        let state = WsDispatchState::new();
1344        let cid = ClientOrderId::new("O-010");
1345        let voi = VenueOrderId::new("v-1");
1346        let target_qty = Quantity::from("0.0001");
1347
1348        assert!(state.pending_modify(&cid).is_none());
1349        assert!(state.pending_modify_target_qty(&cid).is_none());
1350        state.mark_pending_modify(cid, voi, target_qty);
1351        assert_eq!(state.pending_modify(&cid), Some(voi));
1352        assert_eq!(state.pending_modify_target_qty(&cid), Some(target_qty));
1353        state.clear_pending_modify(&cid);
1354        assert!(state.pending_modify(&cid).is_none());
1355        assert!(state.pending_modify_target_qty(&cid).is_none());
1356    }
1357
1358    #[rstest]
1359    fn test_cleanup_terminal_preserves_filled_marker() {
1360        let state = WsDispatchState::new();
1361        let cid = ClientOrderId::new("O-020");
1362        state.register_identity(cid, make_identity());
1363        state.insert_accepted(cid);
1364        state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.0001"));
1365        state.insert_filled(cid);
1366        state.cleanup_terminal(&cid);
1367
1368        assert!(state.lookup_identity(&cid).is_none());
1369        assert!(!state.emitted_accepted.contains(&cid));
1370        assert!(state.pending_modify(&cid).is_none());
1371        assert!(state.pending_modify_target_qty(&cid).is_none());
1372        // `filled_orders` outlives `cleanup_terminal` so replays stay suppressed.
1373        assert!(state.filled_orders.contains(&cid));
1374    }
1375
1376    #[rstest]
1377    fn test_cleanup_terminal_clears_corrective_state() {
1378        let state = WsDispatchState::new();
1379        let cid = ClientOrderId::new("O-021");
1380        let request = HyperliquidExecPlaceOrderRequest {
1381            asset: 0,
1382            is_buy: true,
1383            price: "100".parse::<Decimal>().unwrap(),
1384            size: Decimal::from(1),
1385            reduce_only: false,
1386            kind: HyperliquidExecOrderKind::Limit {
1387                limit: HyperliquidExecLimitParams {
1388                    tif: HyperliquidExecTif::Gtc,
1389                },
1390            },
1391            cloid: None,
1392        };
1393        state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("1"));
1394        state.stash_modify_request(cid, request.clone());
1395        state.queue_corrective(cid, 1, request);
1396        assert!(state.modify_request(&cid).is_some());
1397
1398        state.cleanup_terminal(&cid);
1399
1400        assert!(state.modify_request(&cid).is_none());
1401        assert!(state.take_corrective(&cid).is_none());
1402        assert!(state.pending_modify(&cid).is_none());
1403    }
1404}