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#tracked-and-external-execution-updates`:
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//! Each in-flight modify is tracked as an intent in a per-order chain (keyed
44//! on `client_order_id`), pushed by `modify_order` before the HTTP call. An
45//! intent lets dispatch skip an early `CANCELED(old_voi)` that arrives before
46//! the replacement `ACCEPTED(new_voi)`, regardless of whether the WS message
47//! races ahead of the HTTP response. Rapid repeated modifies under one stable
48//! CLOID queue as a chain so a later modify cannot overwrite an earlier
49//! intent's old-leg suppression, and a failed modify clears only its own
50//! generation (leaving newer intents intact). The front intent is claimed on
51//! promotion, advancing the next intent's old leg to the promoted replacement;
52//! a rejected front reparents the next intent to the same still-live leg.
53//!
54//! A fill carrying the replacement `venue_order_id` during an in-flight modify
55//! promotes the binding directly (the same `OrderUpdated` path as the
56//! replacement `ACCEPTED`), so a dropped `ACCEPTED` does not strand the fill.
57//! A fill is buffered into [`WsDispatchState::buffered_fills`] only when the
58//! identity has no price to promote with; `handle_accepted` drains the buffer
59//! on the replacement `ACCEPTED`. A delayed earlier-leg fill during a chained
60//! modify is a known limitation. See GH-3972.
61//!
62//! When neither the replacement `ACCEPTED` nor a fill arrives, a query that
63//! resolves the replacement by `cloid` promotes the binding the same way via
64//! [`promote_replacement_from_query`], so a dropped `ACCEPTED` with no fill
65//! cannot leave the order bound to the canceled leg.
66
67use 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/// Identity metadata captured when an order is submitted through this client.
100///
101/// Stored in [`WsDispatchState::order_identities`] keyed by the full Nautilus
102/// [`ClientOrderId`]. The dispatch functions use the identity to build typed
103/// order events for tracked orders without needing access to the engine cache
104/// (which is `!Send` and unreachable from the spawned WebSocket task).
105#[derive(Debug, Clone)]
106pub struct OrderIdentity {
107    /// Strategy that owns the order.
108    pub strategy_id: StrategyId,
109    /// Instrument the order targets.
110    pub instrument_id: InstrumentId,
111    /// Order side captured at submission.
112    pub order_side: OrderSide,
113    /// Order type captured at submission.
114    pub order_type: OrderType,
115    /// Order quantity captured at submission.
116    pub quantity: Quantity,
117    /// Last known order price. Populated on submission and refreshed from
118    /// subsequent status reports so a cancel-replace `ACCEPTED` that omits
119    /// `price` can still produce an `OrderUpdated` carrying an accurate value.
120    pub price: Option<Price>,
121}
122
123/// Bounded FIFO deduplication set.
124///
125/// When the capacity is reached, the oldest entry is evicted on the next
126/// insert. A simple `clear()` at the threshold would drop every recent trade
127/// id at once, opening a window where a reconnect or replay right after the
128/// rollover could re-emit duplicate `OrderFilled` events; the FIFO window
129/// slides instead.
130#[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    /// Creates a new bounded dedup set with the given `capacity`.
145    #[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    /// Inserts a value. Returns `true` when the value was already present.
155    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    /// Returns the number of entries currently tracked.
172    #[must_use]
173    pub fn len(&self) -> usize {
174        self.set.len()
175    }
176
177    /// Returns whether the dedup set is empty.
178    #[must_use]
179    pub fn is_empty(&self) -> bool {
180        self.set.is_empty()
181    }
182
183    /// Returns whether the value is currently tracked.
184    #[must_use]
185    pub fn contains(&self, value: &T) -> bool {
186        self.set.contains(value)
187    }
188}
189
190/// Maximum in-flight modify intents tracked per order. Rapid repricing rarely
191/// queues more than one or two unacknowledged modifies at once; the cap bounds
192/// memory if replacement acks stall. On overflow the oldest intent is evicted.
193pub const MAX_PENDING_MODIFY_INTENTS: usize = 32;
194
195/// A single in-flight Hyperliquid modify awaiting its replacement leg.
196///
197/// Rapid repeated modifies under one stable CLOID queue as a chain of intents
198/// so a later modify cannot overwrite an earlier pending old-leg marker, and a
199/// failed modify clears only its own generation rather than a newer one's
200/// state. Each intent carries the venue leg it cancel-replaces
201/// (`old_venue_order_id`), the user-intended absolute total quantity, and the
202/// exact request sent (used to size a corrective reduce).
203#[derive(Debug, Clone)]
204pub struct ModifyIntent {
205    /// Monotonic per-order generation, used to clear a specific intent on failure.
206    pub generation: u64,
207    /// Venue order id this modify cancel-replaces, once known.
208    pub old_venue_order_id: Option<VenueOrderId>,
209    /// User-intended absolute total quantity for the replacement.
210    pub target_qty: Quantity,
211    /// Exact venue request sent, used to size a corrective reduce.
212    pub sent_request: Option<HyperliquidExecPlaceOrderRequest>,
213}
214
215/// Bounded FIFO chain of in-flight modify intents for one order.
216///
217/// The venue processes chained modifies in submission order, so the front
218/// (oldest) intent is the next to promote; on promotion the next intent's old
219/// leg advances to the replacement just accepted.
220#[derive(Debug, Default)]
221struct ModifyChain {
222    intents: VecDeque<ModifyIntent>,
223    next_generation: u64,
224}
225
226/// Per-client dispatch state shared between order submission and the
227/// WebSocket consumer task.
228///
229/// Tracks which orders were submitted through this client (so we can route
230/// venue events to typed [`OrderEventAny`] emissions for tracked orders and
231/// fall back to reports for external orders), provides cross-stream dedup
232/// for `OrderAccepted` and `OrderFilled` emissions, and carries the
233/// GH-3827 cancel-replace state (`cached_venue_order_ids` and
234/// `pending_modify_keys`).
235#[derive(Debug)]
236pub struct WsDispatchState {
237    /// Tracked orders keyed by full Nautilus [`ClientOrderId`].
238    pub order_identities: DashMap<ClientOrderId, OrderIdentity>,
239    /// Client order IDs for which an `OrderAccepted` event has been emitted.
240    pub emitted_accepted: DashSet<ClientOrderId>,
241    /// Tracked submissions whose POST response has not resolved yet.
242    pending_submissions: DashSet<ClientOrderId>,
243    /// Submission-time rejections held until the POST path can preserve its
244    /// more detailed venue error string.
245    pending_submission_rejections: DashMap<ClientOrderId, OrderStatusReport>,
246    /// Client order IDs that have reached the filled terminal state.
247    ///
248    /// Retained past `cleanup_terminal` so that late replay of the same
249    /// status or fill does not re-emit events.
250    pub filled_orders: DashSet<ClientOrderId>,
251    /// Trade IDs for which an `OrderFilled` event has been emitted.
252    ///
253    /// Bounded FIFO dedup to bound memory while keeping recent trade ids
254    /// deduped across reconnects.
255    pub emitted_trades: Mutex<BoundedDedup<TradeId>>,
256    /// Raw Hyperliquid CLOIDs that reached a terminal state through the post
257    /// response path before the matching `orderUpdates` event arrived.
258    pub terminal_cloids: Mutex<BoundedDedup<Ustr>>,
259    /// Last venue order id observed for a tracked client order id.
260    ///
261    /// Populated on the first `OrderAccepted` and refreshed on every
262    /// cancel-replace promotion. A later `ACCEPTED` with a different venue
263    /// order id under the same client order id is treated as the
264    /// replacement leg of a Hyperliquid modify and emitted as `OrderUpdated`.
265    pub cached_venue_order_ids: DashMap<ClientOrderId, VenueOrderId>,
266    /// Per-order chain of in-flight modify intents, keyed by `client_order_id`.
267    ///
268    /// Rapid repeated modifies under one stable CLOID queue as a chain so a
269    /// later modify cannot overwrite an earlier pending old-leg marker, and a
270    /// failed modify clears only its own generation rather than a newer one's
271    /// state. Populated by `modify_order` before the HTTP call so the WS cancel
272    /// handler sees an intent even when `CANCELED(old_voi)` arrives before the
273    /// HTTP response. A `CANCELED(old_voi)` matching any queued intent's old
274    /// leg is suppressed so the later `ACCEPTED(new_voi)` can flow through the
275    /// `OrderUpdated` path; the front intent is claimed on promotion and the
276    /// next intent's old leg advances to the promoted replacement.
277    pending_modify_chains: DashMap<ClientOrderId, ModifyChain>,
278    /// `FillReport`s buffered only when a cancel-replace fill cannot be promoted
279    /// (the identity carries no price); drained by the cancel-replace branch of
280    /// `handle_accepted`. The common path promotes on the fill instead. See
281    /// GH-3972.
282    pub buffered_fills: DashMap<ClientOrderId, Vec<FillReport>>,
283    /// Cumulative filled quantity per tracked order. Compared against
284    /// `OrderIdentity::quantity` to decide when to clean up tracked state.
285    pub order_filled_qty: DashMap<ClientOrderId, Quantity>,
286    /// Corrective reduce queued by the cancel-replace promotion: client order
287    /// id to (new venue order id, reduced request). Drained by the WS loop.
288    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    /// Creates a new empty dispatch state.
314    #[must_use]
315    pub fn new() -> Self {
316        Self::default()
317    }
318
319    /// Registers an order identity. Called by the execution client at order
320    /// submission time, before any WebSocket events for the order can arrive.
321    pub fn register_identity(&self, client_order_id: ClientOrderId, identity: OrderIdentity) {
322        self.order_identities.insert(client_order_id, identity);
323    }
324
325    /// Returns a clone of the identity for the given client order id, if any.
326    #[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    /// Marks a tracked order as awaiting its submission POST response.
334    pub fn mark_submission_pending(&self, client_order_id: ClientOrderId) {
335        self.pending_submissions.insert(client_order_id);
336    }
337
338    /// Returns whether the order still awaits its submission POST response.
339    #[must_use]
340    pub fn submission_pending(&self, client_order_id: &ClientOrderId) -> bool {
341        self.pending_submissions.contains(client_order_id)
342    }
343
344    /// Holds a submission-time rejection until the POST response resolves.
345    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    /// Resolves submission tracking and returns any early rejection report.
355    #[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    /// Refreshes the tracked price for a modify ack when the new report
364    /// carries an updated price.
365    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    /// Refreshes the tracked quantity for a modify ack.
374    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    /// Marks an `OrderAccepted` event as emitted for this order.
381    pub fn insert_accepted(&self, cid: ClientOrderId) {
382        self.evict_if_full(&self.emitted_accepted);
383        self.emitted_accepted.insert(cid);
384    }
385
386    /// Marks an order as having reached a terminal state.
387    ///
388    /// Returns `true` when this call claimed the terminal state, and `false`
389    /// when another path had already claimed it.
390    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    /// Atomically inserts a trade id into the dedup set.
396    ///
397    /// Returns `true` when the trade was already present (i.e. it is a
398    /// duplicate), `false` otherwise.
399    #[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    /// Records a terminal raw Hyperliquid CLOID.
409    ///
410    /// Used when the post response rejects an order before the WebSocket
411    /// `orderUpdates` message. The normal CLOID mapping can be removed while a
412    /// late unresolved order update still gets suppressed instead of forwarded
413    /// as an external report.
414    #[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    /// Returns whether a raw Hyperliquid CLOID reached a terminal state through
424    /// the post response path.
425    #[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    /// Caches the venue order id observed for a tracked client order id.
436    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    /// Returns the previously cached venue order id, if any.
446    #[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    /// Queues an in-flight modify intent for cancel-before-accept suppression
452    /// and records the target absolute total qty for the cancel-replace
453    /// promotion. Returns the intent's generation.
454    ///
455    /// The generation lets the submission path clear only this modify on
456    /// failure via [`Self::clear_modify_generation`], leaving newer queued
457    /// modifies intact. Chained modifies append rather than overwrite, so a
458    /// later modify cannot drop an earlier pending old-leg marker.
459    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    /// Clears the entire pending modify chain for a client order id.
489    pub fn clear_pending_modify(&self, client_order_id: &ClientOrderId) {
490        self.pending_modify_chains.remove(client_order_id);
491    }
492
493    /// Removes a single modify intent by generation, leaving newer queued
494    /// modifies intact. Drops the chain entry when it empties.
495    ///
496    /// When the removed intent is the front, the next queued modify inherits
497    /// its old leg: a rejected modify does not cancel-replace, so the resting
498    /// leg it targeted is still live and the next modify cancel-replaces the
499    /// same one. A non-front removal needs no reparenting; the front's
500    /// promotion (or its own removal) advances the chain.
501    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        // Remove only if still empty: a concurrent mark for the same order may
521        // queue a new intent between the drop above and this remove
522        self.pending_modify_chains
523            .remove_if(client_order_id, |_, chain| chain.intents.is_empty());
524    }
525
526    /// Stashes the exact venue request sent onto the most recently queued
527    /// modify intent for the order.
528    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    /// Returns a clone of the front intent's stashed modify request, if any.
545    #[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    /// Claims the front (oldest) modify intent for promotion.
556    ///
557    /// Advances the next queued intent's old leg to `new_venue_order_id`: its
558    /// cancel-replace targets the replacement just promoted, not the leg it was
559    /// queued against. Returns the claimed intent, or `None` when no intent is
560    /// queued (an external modify with no local marker). Drops the chain entry
561    /// when it empties.
562    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        // Remove only if still empty: a concurrent mark for the same order may
574        // queue a new intent between the drop above and this remove
575        self.pending_modify_chains
576            .remove_if(client_order_id, |_, chain| chain.intents.is_empty());
577        claimed
578    }
579
580    /// Queues a corrective reduce for the WebSocket consumer loop to post.
581    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    /// Removes and returns a queued corrective reduce, if any.
592    #[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    /// Returns whether any modify intent is queued for the client order id.
603    #[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    /// Returns the front intent's old venue order id, if any.
611    #[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    /// Returns whether any queued intent cancel-replaces `venue_order_id`.
619    ///
620    /// Used to suppress the `CANCELED(old_voi)` leg of any in-flight modify in
621    /// the chain, not only the oldest.
622    #[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    /// Returns the front intent's recorded target absolute total qty, if any.
639    #[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    /// Buffers a `FillReport` arrived during an in-flight cancel-replace.
647    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    /// Removes and returns buffered fills for the cid, in arrival order.
655    #[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    /// Number of buffered fills for the cid.
664    #[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    /// Records cumulative filled quantity for a tracked order.
672    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    /// Returns the previously recorded cumulative filled quantity, if any.
677    #[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    /// Removes all dispatch state for an order that has reached a terminal state.
683    ///
684    /// `filled_orders` is intentionally *not* cleared here: the marker is
685    /// used to suppress stale replays and must outlive the identity cleanup.
686    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/// Outcome of a single dispatch call.
712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
713pub enum DispatchOutcome {
714    /// The report was for a tracked order. Typed events have been emitted
715    /// (or intentionally skipped, e.g. dedup hit). The caller must not
716    /// forward the report as a fallback.
717    Tracked,
718    /// The report is for an external / untracked order. The caller should
719    /// forward the report via [`ExecutionEventEmitter::send_order_status_report`]
720    /// or [`ExecutionEventEmitter::send_fill_report`] so the engine can
721    /// reconcile.
722    External,
723    /// The report was recognised as stale (e.g. cancel leg of a
724    /// cancel-replace modify, or replay after terminal state). The caller
725    /// must drop it without forwarding.
726    Skip,
727}
728
729/// Dispatches an [`OrderStatusReport`] using the two-tier routing contract.
730///
731/// Returns [`DispatchOutcome::Tracked`] when the report maps to a tracked
732/// order (typed events have been emitted or dedup hit), [`External`] when
733/// the caller should forward the report as an untracked fallback, or
734/// [`Skip`] when the report is a stale / race leg that must be dropped.
735///
736/// [`External`]: DispatchOutcome::External
737/// [`Skip`]: DispatchOutcome::Skip
738pub 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            // Fills come via `FillReport`; nothing to emit from the status path.
790            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
803/// Dispatches a [`FillReport`] using the two-tier routing contract.
804///
805/// Returns [`DispatchOutcome::Tracked`] when the fill has been emitted as
806/// an `OrderFilled` event (or skipped via trade dedup), [`External`] when
807/// the caller should forward the fill via
808/// [`ExecutionEventEmitter::send_fill_report`], or [`Skip`] when the fill
809/// is a replay for an already-terminal order and must be dropped.
810///
811/// [`External`]: DispatchOutcome::External
812/// [`Skip`]: DispatchOutcome::Skip
813pub 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    // Set when a fill promotes, so the corrective-reduce runs after the fill applies
836    let mut promoted_corrective: Option<(Quantity, HyperliquidExecPlaceOrderRequest)> = None;
837
838    // Promote the binding from the fill so a dropped replacement ACCEPTED cannot
839    // strand it (see module docs).
840    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        // Prefer the modify target price over the stale cached identity price
847        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        // Re-read the identity advanced by the promotion (quantity and price)
875        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    // Cumulative now includes this fill, so the reduce sizes against the true remaining
940    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    // Cancel-replace detection: if an earlier ACCEPTED cached a different
970    // venue_order_id under the same client_order_id, this ACCEPTED is the
971    // replacement leg of a Hyperliquid modify and must be promoted to
972    // OrderUpdated. See GH-3827.
973    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        // Prefer user target over venue's remaining-only `report.quantity`;
986        // fall back when no marker (external modify).
987        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        // Repeat ACCEPTED for an already-accepted order. Nothing to emit;
1020        // refresh the cached price so a subsequent cancel-replace without a
1021        // report price can still recover an accurate value.
1022        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// Shared by the ACCEPTED branch and the fill path (dropped-ACCEPTED recovery) so the
1047// cancel-replace binding is recovered from whichever arrives first. See GH-3827, GH-3972.
1048#[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    // Claim the front intent; the next queued modify advances to this replacement
1069    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    // Drain fills buffered before the binding advanced. Bypasses
1091    // `handle_execution_report`; FIFO-bounded caches make any residue benign.
1092    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
1098/// Promotes a cancel-replace replacement surfaced by a query during an in-flight modify.
1099///
1100/// When the query returns the replacement leg (`Accepted`, `venue_order_id` diverging from the
1101/// cached one, modify tracked), emits the `OrderUpdated` that rebinds the order, so a dropped
1102/// replacement `Accepted` with no fill cannot strand the binding on the canceled leg. Returns
1103/// `true` when promoted; the caller still forwards the report so the engine confirms the order.
1104pub 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    // Prefer the user target over the venue's remaining-only `report.quantity`
1143    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
1166// Queue a corrective reduce when a fill that raced the modify left the replacement
1167// oversized. Reached from both promotion paths; the engine overfill guard backstops.
1168fn 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    // Stale cancel suppression: if the cached venue_order_id has already
1261    // been advanced by a cancel-replace promotion, this CANCELED refers to
1262    // the old leg and has already been handled as OrderUpdated. See GH-3827.
1263    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    // Cancel-before-accept race: an in-flight modify may deliver
1273    // CANCELED(old_voi) before the replacement ACCEPTED(new_voi). Any queued
1274    // intent whose old leg matches (marked before the HTTP call, cleared on
1275    // failure) suppresses that cancel so the later ACCEPTED routes through
1276    // OrderUpdated. See GH-3827.
1277    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    // A status-only `FILLED` marker does not carry fill data; the actual
1414    // `OrderFilled` is emitted from `dispatch_order_fill` when the matching
1415    // trade arrives. Do *not* set `filled_orders` here, otherwise the
1416    // follow-up fill would be classified as a stale replay and dropped
1417    // before the terminal `OrderFilled` event can be emitted. The fill
1418    // path installs the marker itself once the cumulative fill quantity
1419    // matches the tracked order quantity.
1420    DispatchOutcome::Tracked
1421}
1422
1423/// Synthesizes and emits an `OrderAccepted` event when one has not yet been
1424/// emitted for the given order.
1425///
1426/// Used before emitting non-Accepted events so strategies always observe the
1427/// canonical `Submitted -> Accepted -> ...` lifecycle even when the venue
1428/// compresses the placement and follow-up event into a single message (fast
1429/// fills).
1430#[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        // Overflow evicts the oldest.
1532        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        // `filled_orders` outlives `cleanup_terminal` so replays stay suppressed.
1571        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        // Front is the oldest intent
1619        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        // Both queued old legs suppress their cancel-before-accept
1625        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        // Two rapid modifies queued before either acked, both against the live
1634        // leg v-0.
1635        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        // Failure of the first modify clears only its generation; the second
1640        // stays, still targeting the live leg v-0.
1641        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        // Both queued against the same stale old leg (M2 fired before M1 acked)
1657        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        // Promoting the first replacement claims the front and advances the
1661        // next intent's old leg to the replacement id.
1662        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        // The stale leg no longer matches once advanced
1672        assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1673
1674        // Claiming the last intent empties the chain
1675        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        // Queue one past the cap with no promotion or clear to drain them
1689        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        // The oldest intent was evicted; the newest remains and the front
1695        // advanced to the second-oldest.
1696        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        // Three rapid modifies where the first already promoted to v-1
1707        // (advancing the front to old=v-1); the third still holds stale v-0.
1708        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        // The front is rejected; the next intent must inherit the live leg
1713        // (v-1), not keep stale v-0, or CANCELED(v-1) would surface as a real
1714        // cancel.
1715        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        // Removing a non-front intent must not disturb the front's old leg
1732        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        // Front intent keeps its own request
1748        assert_eq!(
1749            state.modify_request(&cid).map(|r| r.size),
1750            Some(Decimal::from(1)),
1751        );
1752        // After claiming the front, the next intent's request surfaces
1753        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}