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 [`OrderContext`] 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 contexts 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//! context 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    sync::{
70        Mutex,
71        atomic::{AtomicBool, Ordering},
72    },
73};
74
75use dashmap::{DashMap, DashSet};
76use nautilus_common::cache::fifo::FifoCache;
77use nautilus_core::{MUTEX_POISONED, UUID4, UnixNanos};
78use nautilus_live::{ExecutionEventEmitter, execution::context::OrderContext};
79use nautilus_model::{
80    enums::{OrderStatus, OrderType},
81    events::{
82        OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
83        OrderTriggered, OrderUpdated,
84    },
85    identifiers::{AccountId, ClientOrderId, TradeId, VenueOrderId},
86    reports::{FillReport, OrderStatusReport},
87    types::{Price, Quantity},
88};
89use ustr::Ustr;
90
91use crate::{
92    common::consts::HYPERLIQUID_POST_ONLY_WOULD_MATCH,
93    http::models::HyperliquidExecPlaceOrderRequest,
94};
95
96pub const DEDUP_CAPACITY: usize = 10_000;
97
98/// Maximum in-flight modify intents tracked per order. Rapid repricing rarely
99/// queues more than one or two unacknowledged modifies at once; the cap bounds
100/// memory if replacement acks stall. On overflow the oldest intent is evicted.
101pub const MAX_PENDING_MODIFY_INTENTS: usize = 32;
102
103/// A single in-flight Hyperliquid modify awaiting its replacement leg.
104///
105/// Rapid repeated modifies under one stable CLOID queue as a chain of intents
106/// so a later modify cannot overwrite an earlier pending old-leg marker, and a
107/// failed modify clears only its own generation rather than a newer one's
108/// state. Each intent carries the venue leg it cancel-replaces
109/// (`old_venue_order_id`), the user-intended absolute total quantity, and the
110/// exact request sent (used to size a corrective reduce).
111#[derive(Debug, Clone)]
112pub struct ModifyIntent {
113    /// Monotonic per-order generation, used to clear a specific intent on failure.
114    pub generation: u64,
115    /// Venue order id this modify cancel-replaces, once known.
116    pub old_venue_order_id: Option<VenueOrderId>,
117    /// User-intended absolute total quantity for the replacement.
118    pub target_qty: Quantity,
119    /// Exact venue request sent, used to size a corrective reduce.
120    pub sent_request: Option<HyperliquidExecPlaceOrderRequest>,
121}
122
123/// Bounded FIFO chain of in-flight modify intents for one order.
124///
125/// The venue processes chained modifies in submission order, so the front
126/// (oldest) intent is the next to promote; on promotion the next intent's old
127/// leg advances to the replacement just accepted.
128#[derive(Debug, Default)]
129struct ModifyChain {
130    intents: VecDeque<ModifyIntent>,
131    next_generation: u64,
132}
133
134/// Per-client dispatch state shared between order submission and the
135/// WebSocket consumer task.
136///
137/// Tracks which orders were submitted through this client (so we can route
138/// venue events to typed [`OrderEventAny`] emissions for tracked orders and
139/// fall back to reports for external orders), provides cross-stream dedup
140/// for `OrderAccepted` and `OrderFilled` emissions, and carries the
141/// GH-3827 cancel-replace state (`cached_venue_order_ids` and
142/// `pending_modify_keys`).
143#[derive(Debug)]
144pub struct WsDispatchState {
145    /// Tracked orders keyed by full Nautilus [`ClientOrderId`].
146    ///
147    /// The dispatch functions read the context to build typed order events for
148    /// tracked orders without needing access to the engine cache (which is
149    /// `!Send` and unreachable from the spawned WebSocket task). `quantity` and
150    /// `price` are refreshed from subsequent status reports so a cancel-replace
151    /// `ACCEPTED` that omits `price` can still produce an `OrderUpdated`
152    /// carrying an accurate value.
153    pub order_contexts: DashMap<ClientOrderId, OrderContext>,
154    /// Client order IDs for which an `OrderAccepted` event has been emitted.
155    pub emitted_accepted: DashSet<ClientOrderId>,
156    /// Tracked submissions whose POST response has not resolved yet.
157    pending_submissions: DashSet<ClientOrderId>,
158    /// Submission-time rejections held until the POST path can preserve its
159    /// more detailed venue error string.
160    pending_submission_rejections: DashMap<ClientOrderId, OrderStatusReport>,
161    /// Client order IDs that have reached the filled terminal state.
162    ///
163    /// Retained past `cleanup_terminal` so that late replay of the same
164    /// status or fill does not re-emit events.
165    pub filled_orders: DashSet<ClientOrderId>,
166    /// Trade IDs for which an `OrderFilled` event has been emitted.
167    ///
168    /// Bounded FIFO dedup to bound memory while keeping recent trade ids
169    /// deduped across reconnects.
170    emitted_trades: Mutex<FifoCache<TradeId, DEDUP_CAPACITY>>,
171    /// Raw Hyperliquid CLOIDs that reached a terminal state through the post
172    /// response path before the matching `orderUpdates` event arrived.
173    terminal_cloids: Mutex<FifoCache<Ustr, DEDUP_CAPACITY>>,
174    /// Last venue order id observed for a tracked client order id.
175    ///
176    /// Populated on the first `OrderAccepted` and refreshed on every
177    /// cancel-replace promotion. A later `ACCEPTED` with a different venue
178    /// order id under the same client order id is treated as the
179    /// replacement leg of a Hyperliquid modify and emitted as `OrderUpdated`.
180    pub cached_venue_order_ids: DashMap<ClientOrderId, VenueOrderId>,
181    /// Per-order chain of in-flight modify intents, keyed by `client_order_id`.
182    ///
183    /// Rapid repeated modifies under one stable CLOID queue as a chain so a
184    /// later modify cannot overwrite an earlier pending old-leg marker, and a
185    /// failed modify clears only its own generation rather than a newer one's
186    /// state. Populated by `modify_order` before the HTTP call so the WS cancel
187    /// handler sees an intent even when `CANCELED(old_voi)` arrives before the
188    /// HTTP response. A `CANCELED(old_voi)` matching any queued intent's old
189    /// leg is suppressed so the later `ACCEPTED(new_voi)` can flow through the
190    /// `OrderUpdated` path; the front intent is claimed on promotion and the
191    /// next intent's old leg advances to the promoted replacement.
192    pending_modify_chains: DashMap<ClientOrderId, ModifyChain>,
193    /// `FillReport`s buffered only when a cancel-replace fill cannot be promoted
194    /// (the context carries no price); drained by the cancel-replace branch of
195    /// `handle_accepted`. The common path promotes on the fill instead. See
196    /// GH-3972.
197    pub buffered_fills: DashMap<ClientOrderId, Vec<FillReport>>,
198    /// Cumulative filled quantity per tracked order. Compared against
199    /// `OrderContext::quantity` to decide when to clean up tracked state.
200    pub order_filled_qty: DashMap<ClientOrderId, Quantity>,
201    /// Corrective reduce queued by the cancel-replace promotion: client order
202    /// id to (new venue order id, reduced request). Drained by the WS loop.
203    pub pending_corrective: DashMap<ClientOrderId, (u64, HyperliquidExecPlaceOrderRequest)>,
204    clearing: AtomicBool,
205}
206
207impl Default for WsDispatchState {
208    fn default() -> Self {
209        Self {
210            order_contexts: DashMap::new(),
211            emitted_accepted: DashSet::default(),
212            pending_submissions: DashSet::default(),
213            pending_submission_rejections: DashMap::new(),
214            filled_orders: DashSet::default(),
215            emitted_trades: Mutex::new(FifoCache::new()),
216            terminal_cloids: Mutex::new(FifoCache::new()),
217            cached_venue_order_ids: DashMap::new(),
218            pending_modify_chains: DashMap::new(),
219            buffered_fills: DashMap::new(),
220            order_filled_qty: DashMap::new(),
221            pending_corrective: DashMap::new(),
222            clearing: AtomicBool::new(false),
223        }
224    }
225}
226
227impl WsDispatchState {
228    /// Creates a new empty dispatch state.
229    #[must_use]
230    pub fn new() -> Self {
231        Self::default()
232    }
233
234    /// Registers an order context. Called by the execution client at order
235    /// submission time, before any WebSocket events for the order can arrive.
236    pub fn register_context(&self, context: OrderContext) {
237        self.order_contexts
238            .insert(context.identity.client_order_id, context);
239    }
240
241    /// Returns a copy of the context for the given client order id, if any.
242    #[must_use]
243    pub fn lookup_context(&self, client_order_id: &ClientOrderId) -> Option<OrderContext> {
244        self.order_contexts.get(client_order_id).map(|r| *r)
245    }
246
247    /// Marks a tracked order as awaiting its submission POST response.
248    pub fn mark_submission_pending(&self, client_order_id: ClientOrderId) {
249        self.pending_submissions.insert(client_order_id);
250    }
251
252    /// Returns whether the order still awaits its submission POST response.
253    #[must_use]
254    pub fn submission_pending(&self, client_order_id: &ClientOrderId) -> bool {
255        self.pending_submissions.contains(client_order_id)
256    }
257
258    /// Holds a submission-time rejection until the POST response resolves.
259    pub fn buffer_submission_rejection(
260        &self,
261        client_order_id: ClientOrderId,
262        report: OrderStatusReport,
263    ) {
264        self.pending_submission_rejections
265            .insert(client_order_id, report);
266    }
267
268    /// Resolves submission tracking and returns any early rejection report.
269    #[must_use]
270    pub fn resolve_submission(&self, client_order_id: &ClientOrderId) -> Option<OrderStatusReport> {
271        self.pending_submissions.remove(client_order_id);
272        self.pending_submission_rejections
273            .remove(client_order_id)
274            .map(|(_, report)| report)
275    }
276
277    /// Refreshes the tracked price for a modify ack when the new report
278    /// carries an updated price.
279    pub fn update_context_price(&self, client_order_id: &ClientOrderId, price: Option<Price>) {
280        if let Some(price) = price
281            && let Some(mut entry) = self.order_contexts.get_mut(client_order_id)
282        {
283            entry.price = Some(price);
284        }
285    }
286
287    /// Refreshes the tracked quantity for a modify ack.
288    pub fn update_context_quantity(&self, client_order_id: &ClientOrderId, quantity: Quantity) {
289        if let Some(mut entry) = self.order_contexts.get_mut(client_order_id) {
290            entry.quantity = quantity;
291        }
292    }
293
294    /// Marks an `OrderAccepted` event as emitted for this order.
295    pub fn insert_accepted(&self, cid: ClientOrderId) {
296        self.evict_if_full(&self.emitted_accepted);
297        self.emitted_accepted.insert(cid);
298    }
299
300    /// Marks an order as having reached a terminal state.
301    ///
302    /// Returns `true` when this call claimed the terminal state, and `false`
303    /// when another path had already claimed it.
304    pub fn insert_filled(&self, cid: ClientOrderId) -> bool {
305        self.evict_if_full(&self.filled_orders);
306        self.filled_orders.insert(cid)
307    }
308
309    /// Atomically inserts a trade id into the dedup set.
310    ///
311    /// Returns `true` when the trade was already present (i.e. it is a
312    /// duplicate), `false` otherwise.
313    #[allow(
314        clippy::missing_panics_doc,
315        reason = "dedup mutex poisoning is not expected"
316    )]
317    pub fn check_and_insert_trade(&self, trade_id: TradeId) -> bool {
318        let mut set = self.emitted_trades.lock().expect(MUTEX_POISONED);
319        !set.insert(trade_id)
320    }
321
322    /// Records a terminal raw Hyperliquid CLOID.
323    ///
324    /// Used when the post response rejects an order before the WebSocket
325    /// `orderUpdates` message. The normal CLOID mapping can be removed while a
326    /// late unresolved order update still gets suppressed instead of forwarded
327    /// as an external report.
328    #[allow(
329        clippy::missing_panics_doc,
330        reason = "terminal cloid mutex poisoning is not expected"
331    )]
332    pub fn insert_terminal_cloid(&self, cloid: Ustr) {
333        let mut set = self.terminal_cloids.lock().expect(MUTEX_POISONED);
334        let _ = set.insert(cloid);
335    }
336
337    /// Returns whether a raw Hyperliquid CLOID reached a terminal state through
338    /// the post response path.
339    #[allow(
340        clippy::missing_panics_doc,
341        reason = "terminal cloid mutex poisoning is not expected"
342    )]
343    #[must_use]
344    pub fn terminal_cloid_seen(&self, cloid: &Ustr) -> bool {
345        let set = self.terminal_cloids.lock().expect(MUTEX_POISONED);
346        set.contains(cloid)
347    }
348
349    /// Caches the venue order id observed for a tracked client order id.
350    pub fn record_venue_order_id(
351        &self,
352        client_order_id: ClientOrderId,
353        venue_order_id: VenueOrderId,
354    ) {
355        self.cached_venue_order_ids
356            .insert(client_order_id, venue_order_id);
357    }
358
359    /// Returns the previously cached venue order id, if any.
360    #[must_use]
361    pub fn cached_venue_order_id(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
362        self.cached_venue_order_ids.get(client_order_id).map(|r| *r)
363    }
364
365    /// Queues an in-flight modify intent for cancel-before-accept suppression
366    /// and records the target absolute total qty for the cancel-replace
367    /// promotion. Returns the intent's generation.
368    ///
369    /// The generation lets the submission path clear only this modify on
370    /// failure via [`Self::clear_modify_generation`], leaving newer queued
371    /// modifies intact. Chained modifies append rather than overwrite, so a
372    /// later modify cannot drop an earlier pending old-leg marker.
373    pub fn mark_pending_modify(
374        &self,
375        client_order_id: ClientOrderId,
376        old_venue_order_id: VenueOrderId,
377        target_qty: Quantity,
378    ) -> u64 {
379        let mut chain = self
380            .pending_modify_chains
381            .entry(client_order_id)
382            .or_default();
383        let generation = chain.next_generation;
384        chain.next_generation += 1;
385        chain.intents.push_back(ModifyIntent {
386            generation,
387            old_venue_order_id: Some(old_venue_order_id),
388            target_qty,
389            sent_request: None,
390        });
391
392        if chain.intents.len() > MAX_PENDING_MODIFY_INTENTS {
393            chain.intents.pop_front();
394            log::warn!(
395                "Modify chain for {client_order_id} exceeded {MAX_PENDING_MODIFY_INTENTS}; \
396                 evicting oldest intent",
397            );
398        }
399        generation
400    }
401
402    /// Clears the entire pending modify chain for a client order id.
403    pub fn clear_pending_modify(&self, client_order_id: &ClientOrderId) {
404        self.pending_modify_chains.remove(client_order_id);
405    }
406
407    /// Removes a single modify intent by generation, leaving newer queued
408    /// modifies intact. Drops the chain entry when it empties.
409    ///
410    /// When the removed intent is the front, the next queued modify inherits
411    /// its old leg: a rejected modify does not cancel-replace, so the resting
412    /// leg it targeted is still live and the next modify cancel-replaces the
413    /// same one. A non-front removal needs no reparenting; the front's
414    /// promotion (or its own removal) advances the chain.
415    pub fn clear_modify_generation(&self, client_order_id: &ClientOrderId, generation: u64) {
416        let Some(mut chain) = self.pending_modify_chains.get_mut(client_order_id) else {
417            return;
418        };
419        let removed_front_old = chain
420            .intents
421            .front()
422            .filter(|front| front.generation == generation)
423            .and_then(|front| front.old_venue_order_id);
424        chain
425            .intents
426            .retain(|intent| intent.generation != generation);
427
428        if let Some(old) = removed_front_old
429            && let Some(new_front) = chain.intents.front_mut()
430        {
431            new_front.old_venue_order_id = Some(old);
432        }
433        drop(chain);
434        // Remove only if still empty: a concurrent mark for the same order may
435        // queue a new intent between the drop above and this remove
436        self.pending_modify_chains
437            .remove_if(client_order_id, |_, chain| chain.intents.is_empty());
438    }
439
440    /// Stashes the exact venue request sent onto the most recently queued
441    /// modify intent for the order.
442    pub fn stash_modify_request(
443        &self,
444        client_order_id: ClientOrderId,
445        request: HyperliquidExecPlaceOrderRequest,
446    ) {
447        if let Some(mut chain) = self.pending_modify_chains.get_mut(&client_order_id)
448            && let Some(back) = chain.intents.back_mut()
449        {
450            back.sent_request = Some(request);
451        } else {
452            log::debug!(
453                "Stash modify request for {client_order_id} with no pending intent; ignoring"
454            );
455        }
456    }
457
458    /// Returns a clone of the front intent's stashed modify request, if any.
459    #[must_use]
460    pub fn modify_request(
461        &self,
462        client_order_id: &ClientOrderId,
463    ) -> Option<HyperliquidExecPlaceOrderRequest> {
464        self.pending_modify_chains
465            .get(client_order_id)
466            .and_then(|chain| chain.intents.front().and_then(|i| i.sent_request.clone()))
467    }
468
469    /// Claims the front (oldest) modify intent for promotion.
470    ///
471    /// Advances the next queued intent's old leg to `new_venue_order_id`: its
472    /// cancel-replace targets the replacement just promoted, not the leg it was
473    /// queued against. Returns the claimed intent, or `None` when no intent is
474    /// queued (an external modify with no local marker). Drops the chain entry
475    /// when it empties.
476    pub fn claim_front_modify(
477        &self,
478        client_order_id: &ClientOrderId,
479        new_venue_order_id: VenueOrderId,
480    ) -> Option<ModifyIntent> {
481        let mut chain = self.pending_modify_chains.get_mut(client_order_id)?;
482        let claimed = chain.intents.pop_front();
483        if let Some(next) = chain.intents.front_mut() {
484            next.old_venue_order_id = Some(new_venue_order_id);
485        }
486        drop(chain);
487        // Remove only if still empty: a concurrent mark for the same order may
488        // queue a new intent between the drop above and this remove
489        self.pending_modify_chains
490            .remove_if(client_order_id, |_, chain| chain.intents.is_empty());
491        claimed
492    }
493
494    /// Queues a corrective reduce for the WebSocket consumer loop to post.
495    pub fn queue_corrective(
496        &self,
497        client_order_id: ClientOrderId,
498        oid: u64,
499        request: HyperliquidExecPlaceOrderRequest,
500    ) {
501        self.pending_corrective
502            .insert(client_order_id, (oid, request));
503    }
504
505    /// Removes and returns a queued corrective reduce, if any.
506    #[must_use]
507    pub fn take_corrective(
508        &self,
509        client_order_id: &ClientOrderId,
510    ) -> Option<(u64, HyperliquidExecPlaceOrderRequest)> {
511        self.pending_corrective
512            .remove(client_order_id)
513            .map(|(_, v)| v)
514    }
515
516    /// Returns whether any modify intent is queued for the client order id.
517    #[must_use]
518    pub fn has_pending_modify(&self, client_order_id: &ClientOrderId) -> bool {
519        self.pending_modify_chains
520            .get(client_order_id)
521            .is_some_and(|chain| !chain.intents.is_empty())
522    }
523
524    /// Returns the front intent's old venue order id, if any.
525    #[must_use]
526    pub fn pending_modify(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
527        self.pending_modify_chains
528            .get(client_order_id)
529            .and_then(|chain| chain.intents.front().and_then(|i| i.old_venue_order_id))
530    }
531
532    /// Returns whether any queued intent cancel-replaces `venue_order_id`.
533    ///
534    /// Used to suppress the `CANCELED(old_voi)` leg of any in-flight modify in
535    /// the chain, not only the oldest.
536    #[must_use]
537    pub fn pending_modify_contains_old(
538        &self,
539        client_order_id: &ClientOrderId,
540        venue_order_id: VenueOrderId,
541    ) -> bool {
542        self.pending_modify_chains
543            .get(client_order_id)
544            .is_some_and(|chain| {
545                chain
546                    .intents
547                    .iter()
548                    .any(|i| i.old_venue_order_id == Some(venue_order_id))
549            })
550    }
551
552    /// Returns the front intent's recorded target absolute total qty, if any.
553    #[must_use]
554    pub fn pending_modify_target_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
555        self.pending_modify_chains
556            .get(client_order_id)
557            .and_then(|chain| chain.intents.front().map(|i| i.target_qty))
558    }
559
560    /// Buffers a `FillReport` arrived during an in-flight cancel-replace.
561    pub fn buffer_fill(&self, client_order_id: ClientOrderId, fill: FillReport) {
562        self.buffered_fills
563            .entry(client_order_id)
564            .or_default()
565            .push(fill);
566    }
567
568    /// Removes and returns buffered fills for the cid, in arrival order.
569    #[must_use]
570    pub fn drain_buffered_fills(&self, client_order_id: &ClientOrderId) -> Vec<FillReport> {
571        self.buffered_fills
572            .remove(client_order_id)
573            .map(|(_, v)| v)
574            .unwrap_or_default()
575    }
576
577    /// Number of buffered fills for the cid.
578    #[must_use]
579    pub fn buffered_fill_count(&self, client_order_id: &ClientOrderId) -> usize {
580        self.buffered_fills
581            .get(client_order_id)
582            .map_or(0, |r| r.len())
583    }
584
585    /// Records cumulative filled quantity for a tracked order.
586    pub fn record_filled_qty(&self, client_order_id: ClientOrderId, qty: Quantity) {
587        self.order_filled_qty.insert(client_order_id, qty);
588    }
589
590    /// Returns the previously recorded cumulative filled quantity, if any.
591    #[must_use]
592    pub fn previous_filled_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
593        self.order_filled_qty.get(client_order_id).map(|r| *r)
594    }
595
596    /// Removes all dispatch state for an order that has reached a terminal state.
597    ///
598    /// `filled_orders` is intentionally *not* cleared here: the marker is
599    /// used to suppress stale replays and must outlive the context cleanup.
600    pub fn cleanup_terminal(&self, client_order_id: &ClientOrderId) {
601        self.order_contexts.remove(client_order_id);
602        self.emitted_accepted.remove(client_order_id);
603        self.pending_submissions.remove(client_order_id);
604        self.pending_submission_rejections.remove(client_order_id);
605        self.cached_venue_order_ids.remove(client_order_id);
606        self.pending_modify_chains.remove(client_order_id);
607        self.pending_corrective.remove(client_order_id);
608        self.buffered_fills.remove(client_order_id);
609        self.order_filled_qty.remove(client_order_id);
610    }
611
612    fn evict_if_full(&self, set: &DashSet<ClientOrderId>) {
613        if set.len() >= DEDUP_CAPACITY
614            && self
615                .clearing
616                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
617                .is_ok()
618        {
619            set.clear();
620            self.clearing.store(false, Ordering::Release);
621        }
622    }
623}
624
625/// Outcome of a single dispatch call.
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
627pub enum DispatchOutcome {
628    /// The report was for a tracked order. Typed events have been emitted
629    /// (or intentionally skipped, e.g. dedup hit). The caller must not
630    /// forward the report as a fallback.
631    Tracked,
632    /// The report is for an external / untracked order. The caller should
633    /// forward the report via [`ExecutionEventEmitter::send_order_status_report`]
634    /// or [`ExecutionEventEmitter::send_fill_report`] so the engine can
635    /// reconcile.
636    External,
637    /// The report was recognised as stale (e.g. cancel leg of a
638    /// cancel-replace modify, or replay after terminal state). The caller
639    /// must drop it without forwarding.
640    Skip,
641}
642
643/// Dispatches an [`OrderStatusReport`] using the two-tier routing contract.
644///
645/// Returns [`DispatchOutcome::Tracked`] when the report maps to a tracked
646/// order (typed events have been emitted or dedup hit), [`External`] when
647/// the caller should forward the report as an untracked fallback, or
648/// [`Skip`] when the report is a stale / race leg that must be dropped.
649///
650/// [`External`]: DispatchOutcome::External
651/// [`Skip`]: DispatchOutcome::Skip
652pub fn dispatch_order_event(
653    report: &OrderStatusReport,
654    state: &WsDispatchState,
655    emitter: &ExecutionEventEmitter,
656    ts_init: UnixNanos,
657) -> DispatchOutcome {
658    let Some(client_order_id) = report.client_order_id else {
659        return DispatchOutcome::External;
660    };
661
662    if state.filled_orders.contains(&client_order_id) {
663        log::debug!(
664            "Skipping stale report for filled order: cid={client_order_id}, status={:?}",
665            report.order_status,
666        );
667        return DispatchOutcome::Skip;
668    }
669
670    let client_order_id_str = client_order_id.as_str();
671    if client_order_id_str.starts_with("0x")
672        && state.terminal_cloid_seen(&Ustr::from(client_order_id_str))
673    {
674        log::debug!(
675            "Skipping stale terminal report for raw cloid: cid={client_order_id}, status={:?}",
676            report.order_status,
677        );
678        return DispatchOutcome::Skip;
679    }
680
681    let Some(context) = state.lookup_context(&client_order_id) else {
682        return DispatchOutcome::External;
683    };
684
685    match report.order_status {
686        OrderStatus::Accepted => {
687            handle_accepted(report, client_order_id, &context, state, emitter, ts_init)
688        }
689        OrderStatus::Triggered => {
690            handle_triggered(report, client_order_id, &context, state, emitter, ts_init)
691        }
692        OrderStatus::Canceled => {
693            handle_canceled(report, client_order_id, &context, state, emitter, ts_init)
694        }
695        OrderStatus::Expired => {
696            handle_expired(report, client_order_id, &context, state, emitter, ts_init)
697        }
698        OrderStatus::Rejected => {
699            handle_rejected(report, client_order_id, &context, state, emitter, ts_init)
700        }
701        OrderStatus::Filled => handle_filled_marker(client_order_id, state),
702        OrderStatus::PartiallyFilled => {
703            // Fills come via `FillReport`; nothing to emit from the status path.
704            DispatchOutcome::Tracked
705        }
706        OrderStatus::PendingUpdate
707        | OrderStatus::PendingCancel
708        | OrderStatus::Submitted
709        | OrderStatus::Initialized
710        | OrderStatus::Denied
711        | OrderStatus::Released
712        | OrderStatus::Emulated
713        | OrderStatus::Voided => DispatchOutcome::Tracked,
714    }
715}
716
717/// Dispatches a [`FillReport`] using the two-tier routing contract.
718///
719/// Returns [`DispatchOutcome::Tracked`] when the fill has been emitted as
720/// an `OrderFilled` event (or skipped via trade dedup), [`External`] when
721/// the caller should forward the fill via
722/// [`ExecutionEventEmitter::send_fill_report`], or [`Skip`] when the fill
723/// is a replay for an already-terminal order and must be dropped.
724///
725/// [`External`]: DispatchOutcome::External
726/// [`Skip`]: DispatchOutcome::Skip
727pub fn dispatch_order_fill(
728    report: &FillReport,
729    state: &WsDispatchState,
730    emitter: &ExecutionEventEmitter,
731    ts_init: UnixNanos,
732) -> DispatchOutcome {
733    let Some(client_order_id) = report.client_order_id else {
734        return DispatchOutcome::External;
735    };
736
737    if state.filled_orders.contains(&client_order_id) {
738        log::debug!(
739            "Skipping stale fill for filled order: cid={client_order_id}, trade_id={}",
740            report.trade_id,
741        );
742        return DispatchOutcome::Skip;
743    }
744
745    let Some(mut context) = state.lookup_context(&client_order_id) else {
746        return DispatchOutcome::External;
747    };
748
749    // Set when a fill promotes, so the corrective-reduce runs after the fill applies
750    let mut promoted_corrective: Option<(Quantity, HyperliquidExecPlaceOrderRequest)> = None;
751
752    // Promote the binding from the fill so a dropped replacement ACCEPTED cannot
753    // strand it (see module docs).
754    if state.has_pending_modify(&client_order_id)
755        && let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
756        && report.venue_order_id != cached_voi
757    {
758        let target = state.pending_modify_target_qty(&client_order_id);
759        let sent_request = state.modify_request(&client_order_id);
760        // Prefer the modify target price over the stale cached context price
761        let price = sent_request
762            .as_ref()
763            .zip(context.price)
764            .and_then(|(r, cached)| Price::from_decimal_dp(r.price, cached.precision).ok())
765            .or(context.price);
766        let Some(price) = price else {
767            log::warn!(
768                "Cannot promote cancel-replace for {client_order_id} from fill: no target \
769                 or cached price; buffering until the replacement ACCEPTED arrives",
770            );
771            state.buffer_fill(client_order_id, report.clone());
772            return DispatchOutcome::Tracked;
773        };
774        let updated_quantity = target.unwrap_or(context.quantity);
775        promote_cancel_replace(
776            client_order_id,
777            &context,
778            state,
779            emitter,
780            report.venue_order_id,
781            report.account_id,
782            price,
783            updated_quantity,
784            None,
785            report.ts_event,
786            ts_init,
787        );
788        // Re-read the context advanced by the promotion (quantity and price)
789        if let Some(updated) = state.lookup_context(&client_order_id) {
790            context = updated;
791        }
792
793        if let (Some(target), Some(sent_request)) = (target, sent_request) {
794            promoted_corrective = Some((target, sent_request));
795        }
796    }
797
798    if state.check_and_insert_trade(report.trade_id) {
799        log::debug!(
800            "Skipping duplicate fill for {client_order_id}: trade_id={}",
801            report.trade_id
802        );
803        return DispatchOutcome::Tracked;
804    }
805
806    let previous = state
807        .previous_filled_qty(&client_order_id)
808        .unwrap_or_else(|| Quantity::zero(report.last_qty.precision));
809    let cumulative = previous + report.last_qty;
810
811    let is_terminal_fill = cumulative >= context.quantity;
812    if is_terminal_fill && !claim_terminal_order(client_order_id, state, OrderStatus::Filled) {
813        return DispatchOutcome::Skip;
814    }
815
816    ensure_accepted_emitted(
817        client_order_id,
818        report.venue_order_id,
819        report.account_id,
820        &context,
821        state,
822        emitter,
823        report.ts_event,
824        ts_init,
825    );
826
827    let filled = OrderFilled::new(
828        emitter.trader_id(),
829        context.identity.strategy_id,
830        context.identity.instrument_id,
831        client_order_id,
832        report.venue_order_id,
833        report.account_id,
834        report.trade_id,
835        context.identity.order_side,
836        context.identity.order_type,
837        report.last_qty,
838        report.last_px,
839        report.commission.currency,
840        report.liquidity_side,
841        UUID4::new(),
842        report.ts_event,
843        ts_init,
844        false,
845        report.venue_position_id,
846        Some(report.commission),
847        None,
848    );
849    emitter.send_order_event(OrderEventAny::Filled(filled));
850
851    state.record_filled_qty(client_order_id, cumulative);
852
853    // Cumulative now includes this fill, so the reduce sizes against the true remaining
854    if let Some((target, sent_request)) = promoted_corrective {
855        maybe_queue_corrective_reduce(
856            state,
857            client_order_id,
858            report.venue_order_id,
859            target,
860            sent_request,
861        );
862    }
863
864    if is_terminal_fill {
865        state.cleanup_terminal(&client_order_id);
866    }
867
868    DispatchOutcome::Tracked
869}
870
871fn handle_accepted(
872    report: &OrderStatusReport,
873    client_order_id: ClientOrderId,
874    context: &OrderContext,
875    state: &WsDispatchState,
876    emitter: &ExecutionEventEmitter,
877    ts_init: UnixNanos,
878) -> DispatchOutcome {
879    let venue_order_id = report.venue_order_id;
880    let ts_event = report.ts_last;
881    let account_id = report.account_id;
882
883    // Cancel-replace detection: if an earlier ACCEPTED cached a different
884    // venue_order_id under the same client_order_id, this ACCEPTED is the
885    // replacement leg of a Hyperliquid modify and must be promoted to
886    // OrderUpdated. See GH-3827.
887    if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
888        && cached_voi != venue_order_id
889    {
890        let price = report.price.or(context.price);
891        let Some(price) = price else {
892            log::warn!(
893                "Cannot emit OrderUpdated for cancel-replace {client_order_id}: \
894                 no price on report and no cached price on context",
895            );
896            return DispatchOutcome::Skip;
897        };
898
899        // Prefer user target over venue's remaining-only `report.quantity`;
900        // fall back when no marker (external modify).
901        let target_total_qty = state.pending_modify_target_qty(&client_order_id);
902        let updated_quantity = target_total_qty.unwrap_or(report.quantity);
903        let sent_request = state.modify_request(&client_order_id);
904
905        promote_cancel_replace(
906            client_order_id,
907            context,
908            state,
909            emitter,
910            venue_order_id,
911            account_id,
912            price,
913            updated_quantity,
914            report.trigger_price,
915            ts_event,
916            ts_init,
917        );
918
919        if let (Some(target), Some(sent_request)) = (target_total_qty, sent_request) {
920            maybe_queue_corrective_reduce(
921                state,
922                client_order_id,
923                venue_order_id,
924                target,
925                sent_request,
926            );
927        }
928
929        return DispatchOutcome::Tracked;
930    }
931
932    if state.emitted_accepted.contains(&client_order_id) {
933        // Repeat ACCEPTED for an already-accepted order. Nothing to emit;
934        // refresh the cached price so a subsequent cancel-replace without a
935        // report price can still recover an accurate value.
936        state.update_context_price(&client_order_id, report.price);
937        return DispatchOutcome::Tracked;
938    }
939
940    state.insert_accepted(client_order_id);
941    state.record_venue_order_id(client_order_id, venue_order_id);
942    state.update_context_price(&client_order_id, report.price);
943
944    let accepted = OrderAccepted::new(
945        emitter.trader_id(),
946        context.identity.strategy_id,
947        context.identity.instrument_id,
948        client_order_id,
949        venue_order_id,
950        account_id,
951        UUID4::new(),
952        ts_event,
953        ts_init,
954        false,
955    );
956    emitter.send_order_event(OrderEventAny::Accepted(accepted));
957    DispatchOutcome::Tracked
958}
959
960// Shared by the ACCEPTED branch and the fill path (dropped-ACCEPTED recovery) so the
961// cancel-replace binding is recovered from whichever arrives first. See GH-3827, GH-3972.
962#[allow(
963    clippy::too_many_arguments,
964    reason = "promotion needs the full OrderUpdated field set, sourced from two report shapes"
965)]
966fn promote_cancel_replace(
967    client_order_id: ClientOrderId,
968    context: &OrderContext,
969    state: &WsDispatchState,
970    emitter: &ExecutionEventEmitter,
971    venue_order_id: VenueOrderId,
972    account_id: AccountId,
973    price: Price,
974    quantity: Quantity,
975    trigger_price: Option<Price>,
976    ts_event: UnixNanos,
977    ts_init: UnixNanos,
978) {
979    state.record_venue_order_id(client_order_id, venue_order_id);
980    state.update_context_quantity(&client_order_id, quantity);
981    state.update_context_price(&client_order_id, Some(price));
982    // Claim the front intent; the next queued modify advances to this replacement
983    state.claim_front_modify(&client_order_id, venue_order_id);
984
985    let updated = OrderUpdated::new(
986        emitter.trader_id(),
987        context.identity.strategy_id,
988        context.identity.instrument_id,
989        client_order_id,
990        quantity,
991        UUID4::new(),
992        ts_event,
993        ts_init,
994        false,
995        Some(venue_order_id),
996        Some(account_id),
997        Some(price),
998        trigger_price,
999        None,
1000        false,
1001    );
1002    emitter.send_order_event(OrderEventAny::Updated(updated));
1003
1004    // Drain fills buffered before the binding advanced. Bypasses
1005    // `handle_execution_report`; FIFO-bounded caches make any residue benign.
1006    let buffered = state.drain_buffered_fills(&client_order_id);
1007    for fill in buffered {
1008        dispatch_order_fill(&fill, state, emitter, ts_init);
1009    }
1010}
1011
1012/// Promotes a cancel-replace replacement surfaced by a query during an in-flight modify.
1013///
1014/// When the query returns the replacement leg (`Accepted`, `venue_order_id` diverging from the
1015/// cached one, modify tracked), emits the `OrderUpdated` that rebinds the order, so a dropped
1016/// replacement `Accepted` with no fill cannot strand the binding on the canceled leg. Returns
1017/// `true` when promoted; the caller still forwards the report so the engine confirms the order.
1018pub fn promote_replacement_from_query(
1019    report: &OrderStatusReport,
1020    state: &WsDispatchState,
1021    emitter: &ExecutionEventEmitter,
1022    ts_init: UnixNanos,
1023) -> bool {
1024    if report.order_status != OrderStatus::Accepted {
1025        return false;
1026    }
1027
1028    let Some(client_order_id) = report.client_order_id else {
1029        return false;
1030    };
1031
1032    if !state.has_pending_modify(&client_order_id) {
1033        return false;
1034    }
1035
1036    let Some(cached_voi) = state.cached_venue_order_id(&client_order_id) else {
1037        return false;
1038    };
1039
1040    if report.venue_order_id == cached_voi {
1041        return false;
1042    }
1043
1044    let Some(context) = state.lookup_context(&client_order_id) else {
1045        return false;
1046    };
1047
1048    let Some(price) = report.price.or(context.price) else {
1049        log::warn!(
1050            "Cannot promote cancel-replace from query for {client_order_id}: \
1051             no price on report and no cached price on context",
1052        );
1053        return false;
1054    };
1055
1056    // Prefer the user target over the venue's remaining-only `report.quantity`
1057    let updated_quantity = state
1058        .pending_modify_target_qty(&client_order_id)
1059        .unwrap_or(report.quantity);
1060
1061    promote_cancel_replace(
1062        client_order_id,
1063        &context,
1064        state,
1065        emitter,
1066        report.venue_order_id,
1067        report.account_id,
1068        price,
1069        updated_quantity,
1070        report.trigger_price,
1071        report.ts_last,
1072        ts_init,
1073    );
1074
1075    log::debug!("Promoted cancel-replace replacement for {client_order_id} from query");
1076
1077    true
1078}
1079
1080// Queue a corrective reduce when a fill that raced the modify left the replacement
1081// oversized. Reached from both promotion paths; the engine overfill guard backstops.
1082fn maybe_queue_corrective_reduce(
1083    state: &WsDispatchState,
1084    client_order_id: ClientOrderId,
1085    venue_order_id: VenueOrderId,
1086    target: Quantity,
1087    sent_request: HyperliquidExecPlaceOrderRequest,
1088) {
1089    let Ok(new_oid) = venue_order_id.as_str().parse::<u64>() else {
1090        return;
1091    };
1092
1093    let filled = state
1094        .previous_filled_qty(&client_order_id)
1095        .unwrap_or_else(|| Quantity::zero(target.precision));
1096    if filled >= target {
1097        return;
1098    }
1099
1100    let remaining = (target - filled).as_decimal().normalize();
1101
1102    let sent_size = sent_request.size;
1103    if sent_size > remaining {
1104        let mut corrective = sent_request;
1105        corrective.size = remaining;
1106
1107        state.mark_pending_modify(client_order_id, venue_order_id, target);
1108        state.stash_modify_request(client_order_id, corrective.clone());
1109        state.queue_corrective(client_order_id, new_oid, corrective);
1110
1111        log::warn!(
1112            "Cancel-replace left {client_order_id} oversized on {venue_order_id} \
1113             (sent {sent_size}, remaining {remaining}); queuing corrective reduce",
1114        );
1115    }
1116}
1117
1118fn handle_triggered(
1119    report: &OrderStatusReport,
1120    client_order_id: ClientOrderId,
1121    context: &OrderContext,
1122    state: &WsDispatchState,
1123    emitter: &ExecutionEventEmitter,
1124    ts_init: UnixNanos,
1125) -> DispatchOutcome {
1126    if !matches!(
1127        context.identity.order_type,
1128        OrderType::StopLimit | OrderType::TrailingStopLimit | OrderType::LimitIfTouched
1129    ) {
1130        log::debug!(
1131            "Ignoring TRIGGERED status for non-triggerable order type {:?}: {client_order_id}",
1132            context.identity.order_type,
1133        );
1134        return DispatchOutcome::Tracked;
1135    }
1136
1137    ensure_accepted_emitted(
1138        client_order_id,
1139        report.venue_order_id,
1140        report.account_id,
1141        context,
1142        state,
1143        emitter,
1144        report.ts_last,
1145        ts_init,
1146    );
1147
1148    let triggered = OrderTriggered::new(
1149        emitter.trader_id(),
1150        context.identity.strategy_id,
1151        context.identity.instrument_id,
1152        client_order_id,
1153        UUID4::new(),
1154        report.ts_last,
1155        ts_init,
1156        false,
1157        Some(report.venue_order_id),
1158        Some(report.account_id),
1159    );
1160    emitter.send_order_event(OrderEventAny::Triggered(triggered));
1161    DispatchOutcome::Tracked
1162}
1163
1164fn handle_canceled(
1165    report: &OrderStatusReport,
1166    client_order_id: ClientOrderId,
1167    context: &OrderContext,
1168    state: &WsDispatchState,
1169    emitter: &ExecutionEventEmitter,
1170    ts_init: UnixNanos,
1171) -> DispatchOutcome {
1172    let venue_order_id = report.venue_order_id;
1173
1174    // Stale cancel suppression: if the cached venue_order_id has already
1175    // been advanced by a cancel-replace promotion, this CANCELED refers to
1176    // the old leg and has already been handled as OrderUpdated. See GH-3827.
1177    if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
1178        && cached_voi != venue_order_id
1179    {
1180        log::debug!(
1181            "Skipping stale CANCELED for {venue_order_id} (cached {cached_voi}) on {client_order_id}",
1182        );
1183        return DispatchOutcome::Skip;
1184    }
1185
1186    // Cancel-before-accept race: an in-flight modify may deliver
1187    // CANCELED(old_voi) before the replacement ACCEPTED(new_voi). Any queued
1188    // intent whose old leg matches (marked before the HTTP call, cleared on
1189    // failure) suppresses that cancel so the later ACCEPTED routes through
1190    // OrderUpdated. See GH-3827.
1191    if state.pending_modify_contains_old(&client_order_id, venue_order_id) {
1192        log::debug!(
1193            "Skipping cancel-before-accept leg for {client_order_id}: venue_order_id={venue_order_id}",
1194        );
1195        return DispatchOutcome::Skip;
1196    }
1197
1198    if !claim_terminal_order(client_order_id, state, report.order_status) {
1199        return DispatchOutcome::Skip;
1200    }
1201
1202    ensure_accepted_emitted(
1203        client_order_id,
1204        venue_order_id,
1205        report.account_id,
1206        context,
1207        state,
1208        emitter,
1209        report.ts_last,
1210        ts_init,
1211    );
1212
1213    let canceled = OrderCanceled::new(
1214        emitter.trader_id(),
1215        context.identity.strategy_id,
1216        context.identity.instrument_id,
1217        client_order_id,
1218        UUID4::new(),
1219        report.ts_last,
1220        ts_init,
1221        false,
1222        Some(venue_order_id),
1223        Some(report.account_id),
1224    );
1225    emitter.send_order_event(OrderEventAny::Canceled(canceled));
1226
1227    state.cleanup_terminal(&client_order_id);
1228    DispatchOutcome::Tracked
1229}
1230
1231fn handle_expired(
1232    report: &OrderStatusReport,
1233    client_order_id: ClientOrderId,
1234    context: &OrderContext,
1235    state: &WsDispatchState,
1236    emitter: &ExecutionEventEmitter,
1237    ts_init: UnixNanos,
1238) -> DispatchOutcome {
1239    if !claim_terminal_order(client_order_id, state, report.order_status) {
1240        return DispatchOutcome::Skip;
1241    }
1242
1243    ensure_accepted_emitted(
1244        client_order_id,
1245        report.venue_order_id,
1246        report.account_id,
1247        context,
1248        state,
1249        emitter,
1250        report.ts_last,
1251        ts_init,
1252    );
1253
1254    let expired = OrderExpired::new(
1255        emitter.trader_id(),
1256        context.identity.strategy_id,
1257        context.identity.instrument_id,
1258        client_order_id,
1259        UUID4::new(),
1260        report.ts_last,
1261        ts_init,
1262        false,
1263        Some(report.venue_order_id),
1264        Some(report.account_id),
1265    );
1266    emitter.send_order_event(OrderEventAny::Expired(expired));
1267    state.cleanup_terminal(&client_order_id);
1268    DispatchOutcome::Tracked
1269}
1270
1271fn handle_rejected(
1272    report: &OrderStatusReport,
1273    client_order_id: ClientOrderId,
1274    context: &OrderContext,
1275    state: &WsDispatchState,
1276    emitter: &ExecutionEventEmitter,
1277    ts_init: UnixNanos,
1278) -> DispatchOutcome {
1279    if state.submission_pending(&client_order_id) {
1280        state.buffer_submission_rejection(client_order_id, report.clone());
1281        return DispatchOutcome::Skip;
1282    }
1283
1284    if !claim_terminal_order(client_order_id, state, report.order_status) {
1285        return DispatchOutcome::Skip;
1286    }
1287
1288    let reason = report
1289        .cancel_reason
1290        .clone()
1291        .unwrap_or_else(|| "Order rejected by exchange".to_string());
1292    let rejected = OrderRejected::new(
1293        emitter.trader_id(),
1294        context.identity.strategy_id,
1295        context.identity.instrument_id,
1296        client_order_id,
1297        report.account_id,
1298        Ustr::from(&reason),
1299        UUID4::new(),
1300        report.ts_last,
1301        ts_init,
1302        false,
1303        report.post_only && reason.contains(HYPERLIQUID_POST_ONLY_WOULD_MATCH),
1304    );
1305    emitter.send_order_event(OrderEventAny::Rejected(rejected));
1306    state.cleanup_terminal(&client_order_id);
1307    DispatchOutcome::Tracked
1308}
1309
1310fn claim_terminal_order(
1311    client_order_id: ClientOrderId,
1312    state: &WsDispatchState,
1313    status: OrderStatus,
1314) -> bool {
1315    let claimed = state.insert_filled(client_order_id);
1316    if !claimed {
1317        log::debug!("Skipping duplicate terminal event for {client_order_id}: status={status:?}",);
1318    }
1319
1320    claimed
1321}
1322
1323fn handle_filled_marker(
1324    _client_order_id: ClientOrderId,
1325    _state: &WsDispatchState,
1326) -> DispatchOutcome {
1327    // A status-only `FILLED` marker does not carry fill data; the actual
1328    // `OrderFilled` is emitted from `dispatch_order_fill` when the matching
1329    // trade arrives. Do *not* set `filled_orders` here, otherwise the
1330    // follow-up fill would be classified as a stale replay and dropped
1331    // before the terminal `OrderFilled` event can be emitted. The fill
1332    // path installs the marker itself once the cumulative fill quantity
1333    // matches the tracked order quantity.
1334    DispatchOutcome::Tracked
1335}
1336
1337/// Synthesizes and emits an `OrderAccepted` event when one has not yet been
1338/// emitted for the given order.
1339///
1340/// Used before emitting non-Accepted events so strategies always observe the
1341/// canonical `Submitted -> Accepted -> ...` lifecycle even when the venue
1342/// compresses the placement and follow-up event into a single message (fast
1343/// fills).
1344#[allow(clippy::too_many_arguments)]
1345fn ensure_accepted_emitted(
1346    client_order_id: ClientOrderId,
1347    venue_order_id: VenueOrderId,
1348    account_id: AccountId,
1349    context: &OrderContext,
1350    state: &WsDispatchState,
1351    emitter: &ExecutionEventEmitter,
1352    ts_event: UnixNanos,
1353    ts_init: UnixNanos,
1354) {
1355    if state.emitted_accepted.contains(&client_order_id) {
1356        return;
1357    }
1358    state.insert_accepted(client_order_id);
1359    state.record_venue_order_id(client_order_id, venue_order_id);
1360
1361    let accepted = OrderAccepted::new(
1362        emitter.trader_id(),
1363        context.identity.strategy_id,
1364        context.identity.instrument_id,
1365        client_order_id,
1366        venue_order_id,
1367        account_id,
1368        UUID4::new(),
1369        ts_event,
1370        ts_init,
1371        false,
1372    );
1373    emitter.send_order_event(OrderEventAny::Accepted(accepted));
1374}
1375
1376#[cfg(test)]
1377mod tests {
1378    use nautilus_live::execution::context::OrderIdentity;
1379    use nautilus_model::{
1380        enums::{OrderSide, TimeInForce},
1381        identifiers::{ClientOrderId, InstrumentId, StrategyId, TradeId},
1382    };
1383    use rstest::rstest;
1384    use rust_decimal::Decimal;
1385
1386    use super::*;
1387    use crate::http::models::{
1388        HyperliquidExecLimitParams, HyperliquidExecOrderKind, HyperliquidExecTif,
1389    };
1390
1391    fn make_context(client_order_id: ClientOrderId) -> OrderContext {
1392        OrderContext {
1393            identity: OrderIdentity {
1394                client_order_id,
1395                strategy_id: StrategyId::from("S-001"),
1396                instrument_id: InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
1397                order_side: OrderSide::Buy,
1398                order_type: OrderType::Limit,
1399            },
1400            quantity: Quantity::from("0.0001"),
1401            price: None,
1402            trigger_price: None,
1403            trigger_type: None,
1404            time_in_force: TimeInForce::Gtc,
1405            is_post_only: false,
1406            is_reduce_only: false,
1407            is_quote_quantity: false,
1408        }
1409    }
1410
1411    #[rstest]
1412    fn test_register_and_lookup_context() {
1413        let state = WsDispatchState::new();
1414        let cid = ClientOrderId::new("O-001");
1415        state.register_context(make_context(cid));
1416
1417        assert_eq!(state.lookup_context(&cid), Some(make_context(cid)));
1418    }
1419
1420    #[rstest]
1421    fn test_lookup_context_missing_returns_none() {
1422        let state = WsDispatchState::new();
1423        let cid = ClientOrderId::new("not-tracked");
1424        assert!(state.lookup_context(&cid).is_none());
1425    }
1426
1427    #[rstest]
1428    fn test_update_context_refreshes_price_and_quantity() {
1429        let state = WsDispatchState::new();
1430        let cid = ClientOrderId::new("O-003");
1431        state.register_context(make_context(cid));
1432
1433        state.update_context_price(&cid, Some(Price::from("56731.5")));
1434        state.update_context_quantity(&cid, Quantity::from("0.0002"));
1435
1436        assert_eq!(
1437            state.lookup_context(&cid),
1438            Some(OrderContext {
1439                price: Some(Price::from("56731.5")),
1440                quantity: Quantity::from("0.0002"),
1441                ..make_context(cid)
1442            }),
1443        );
1444    }
1445
1446    #[rstest]
1447    fn test_update_context_price_without_price_keeps_cached_price() {
1448        let state = WsDispatchState::new();
1449        let cid = ClientOrderId::new("O-004");
1450        let cached = OrderContext {
1451            price: Some(Price::from("56730.0")),
1452            ..make_context(cid)
1453        };
1454        state.register_context(cached);
1455
1456        state.update_context_price(&cid, None);
1457
1458        assert_eq!(state.lookup_context(&cid), Some(cached));
1459    }
1460
1461    #[rstest]
1462    fn test_insert_accepted_dedup() {
1463        let state = WsDispatchState::new();
1464        let cid = ClientOrderId::new("O-002");
1465        assert!(!state.emitted_accepted.contains(&cid));
1466        state.insert_accepted(cid);
1467        assert!(state.emitted_accepted.contains(&cid));
1468        state.insert_accepted(cid);
1469        assert!(state.emitted_accepted.contains(&cid));
1470    }
1471
1472    #[rstest]
1473    fn test_check_and_insert_trade_detects_duplicates() {
1474        let state = WsDispatchState::new();
1475        let trade = TradeId::new("trade-1");
1476        assert!(!state.check_and_insert_trade(trade));
1477        assert!(state.check_and_insert_trade(trade));
1478    }
1479
1480    #[rstest]
1481    fn test_pending_modify_roundtrip() {
1482        let state = WsDispatchState::new();
1483        let cid = ClientOrderId::new("O-010");
1484        let voi = VenueOrderId::new("v-1");
1485        let target_qty = Quantity::from("0.0001");
1486
1487        assert!(state.pending_modify(&cid).is_none());
1488        assert!(state.pending_modify_target_qty(&cid).is_none());
1489        state.mark_pending_modify(cid, voi, target_qty);
1490        assert_eq!(state.pending_modify(&cid), Some(voi));
1491        assert_eq!(state.pending_modify_target_qty(&cid), Some(target_qty));
1492        state.clear_pending_modify(&cid);
1493        assert!(state.pending_modify(&cid).is_none());
1494        assert!(state.pending_modify_target_qty(&cid).is_none());
1495    }
1496
1497    #[rstest]
1498    fn test_cleanup_terminal_preserves_filled_marker() {
1499        let state = WsDispatchState::new();
1500        let cid = ClientOrderId::new("O-020");
1501        state.register_context(make_context(cid));
1502        state.insert_accepted(cid);
1503        state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.0001"));
1504        state.insert_filled(cid);
1505        state.cleanup_terminal(&cid);
1506
1507        assert!(state.lookup_context(&cid).is_none());
1508        assert!(!state.emitted_accepted.contains(&cid));
1509        assert!(state.pending_modify(&cid).is_none());
1510        assert!(state.pending_modify_target_qty(&cid).is_none());
1511        // `filled_orders` outlives `cleanup_terminal` so replays stay suppressed.
1512        assert!(state.filled_orders.contains(&cid));
1513    }
1514
1515    #[rstest]
1516    fn test_cleanup_terminal_clears_corrective_state() {
1517        let state = WsDispatchState::new();
1518        let cid = ClientOrderId::new("O-021");
1519        let request = sample_request(Decimal::from(1));
1520        state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("1"));
1521        state.stash_modify_request(cid, request.clone());
1522        state.queue_corrective(cid, 1, request);
1523        assert!(state.modify_request(&cid).is_some());
1524
1525        state.cleanup_terminal(&cid);
1526
1527        assert!(state.modify_request(&cid).is_none());
1528        assert!(state.take_corrective(&cid).is_none());
1529        assert!(state.pending_modify(&cid).is_none());
1530    }
1531
1532    fn sample_request(size: Decimal) -> HyperliquidExecPlaceOrderRequest {
1533        HyperliquidExecPlaceOrderRequest {
1534            asset: 0,
1535            is_buy: true,
1536            price: "100".parse::<Decimal>().unwrap(),
1537            size,
1538            reduce_only: false,
1539            kind: HyperliquidExecOrderKind::Limit {
1540                limit: HyperliquidExecLimitParams {
1541                    tif: HyperliquidExecTif::Gtc,
1542                },
1543            },
1544            cloid: None,
1545        }
1546    }
1547
1548    #[rstest]
1549    fn test_modify_chain_keeps_both_intents_on_rapid_modifies() {
1550        let state = WsDispatchState::new();
1551        let cid = ClientOrderId::new("O-100");
1552        let g0 =
1553            state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1554        let g1 =
1555            state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00030"));
1556
1557        assert_ne!(g0, g1);
1558        assert!(state.has_pending_modify(&cid));
1559        // Front is the oldest intent
1560        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
1561        assert_eq!(
1562            state.pending_modify_target_qty(&cid),
1563            Some(Quantity::from("0.00020")),
1564        );
1565        // Both queued old legs suppress their cancel-before-accept
1566        assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1567        assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
1568    }
1569
1570    #[rstest]
1571    fn test_clear_modify_generation_preserves_newer_intent() {
1572        let state = WsDispatchState::new();
1573        let cid = ClientOrderId::new("O-101");
1574        // Two rapid modifies queued before either acked, both against the live
1575        // leg v-0.
1576        let g0 =
1577            state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1578        state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
1579
1580        // Failure of the first modify clears only its generation; the second
1581        // stays, still targeting the live leg v-0.
1582        state.clear_modify_generation(&cid, g0);
1583
1584        assert!(state.has_pending_modify(&cid));
1585        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
1586        assert_eq!(
1587            state.pending_modify_target_qty(&cid),
1588            Some(Quantity::from("0.00030")),
1589        );
1590        assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1591    }
1592
1593    #[rstest]
1594    fn test_claim_front_modify_advances_next_old_id() {
1595        let state = WsDispatchState::new();
1596        let cid = ClientOrderId::new("O-102");
1597        // Both queued against the same stale old leg (M2 fired before M1 acked)
1598        state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1599        state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
1600
1601        // Promoting the first replacement claims the front and advances the
1602        // next intent's old leg to the replacement id.
1603        let claimed = state.claim_front_modify(&cid, VenueOrderId::new("v-1"));
1604        assert_eq!(
1605            claimed.map(|i| i.target_qty),
1606            Some(Quantity::from("0.00020"))
1607        );
1608
1609        assert!(state.has_pending_modify(&cid));
1610        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
1611        assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
1612        // The stale leg no longer matches once advanced
1613        assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1614
1615        // Claiming the last intent empties the chain
1616        let claimed2 = state.claim_front_modify(&cid, VenueOrderId::new("v-2"));
1617        assert_eq!(
1618            claimed2.map(|i| i.target_qty),
1619            Some(Quantity::from("0.00030"))
1620        );
1621        assert!(!state.has_pending_modify(&cid));
1622        assert!(state.pending_modify(&cid).is_none());
1623    }
1624
1625    #[rstest]
1626    fn test_modify_chain_caps_and_evicts_oldest() {
1627        let state = WsDispatchState::new();
1628        let cid = ClientOrderId::new("O-106");
1629        // Queue one past the cap with no promotion or clear to drain them
1630        for i in 0..=MAX_PENDING_MODIFY_INTENTS {
1631            let voi = format!("v-{i}");
1632            state.mark_pending_modify(cid, VenueOrderId::new(&voi), Quantity::from("0.00020"));
1633        }
1634
1635        // The oldest intent was evicted; the newest remains and the front
1636        // advanced to the second-oldest.
1637        assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1638        let newest = format!("v-{MAX_PENDING_MODIFY_INTENTS}");
1639        assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new(&newest)));
1640        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
1641    }
1642
1643    #[rstest]
1644    fn test_clear_front_modify_reparents_next_old() {
1645        let state = WsDispatchState::new();
1646        let cid = ClientOrderId::new("O-104");
1647        // Three rapid modifies where the first already promoted to v-1
1648        // (advancing the front to old=v-1); the third still holds stale v-0.
1649        let g_front =
1650            state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00020"));
1651        state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
1652
1653        // The front is rejected; the next intent must inherit the live leg
1654        // (v-1), not keep stale v-0, or CANCELED(v-1) would surface as a real
1655        // cancel.
1656        state.clear_modify_generation(&cid, g_front);
1657
1658        assert!(state.has_pending_modify(&cid));
1659        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
1660        assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
1661        assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1662    }
1663
1664    #[rstest]
1665    fn test_clear_non_front_modify_leaves_front_old() {
1666        let state = WsDispatchState::new();
1667        let cid = ClientOrderId::new("O-105");
1668        state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1669        let g_back =
1670            state.mark_pending_modify(cid, VenueOrderId::new("v-9"), Quantity::from("0.00030"));
1671
1672        // Removing a non-front intent must not disturb the front's old leg
1673        state.clear_modify_generation(&cid, g_back);
1674
1675        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
1676        assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-9")));
1677    }
1678
1679    #[rstest]
1680    fn test_stash_modify_request_targets_latest_intent() {
1681        let state = WsDispatchState::new();
1682        let cid = ClientOrderId::new("O-103");
1683        state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1684        state.stash_modify_request(cid, sample_request(Decimal::from(1)));
1685        state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00030"));
1686        state.stash_modify_request(cid, sample_request(Decimal::from(2)));
1687
1688        // Front intent keeps its own request
1689        assert_eq!(
1690            state.modify_request(&cid).map(|r| r.size),
1691            Some(Decimal::from(1)),
1692        );
1693        // After claiming the front, the next intent's request surfaces
1694        state.claim_front_modify(&cid, VenueOrderId::new("v-1"));
1695        assert_eq!(
1696            state.modify_request(&cid).map(|r| r.size),
1697            Some(Decimal::from(2)),
1698        );
1699    }
1700}