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 `CANCELED` for the bound leg while an intent targets that leg may instead
55//! come from a user or venue cancel, which makes the modify fail. Dispatch holds
56//! that cancel on the chain: a promotion drops it as the modify's own cancel
57//! leg, and clearing the last intent on its leg releases it to the WebSocket
58//! consumer loop, which applies it as `OrderCanceled`.
59//!
60//! A fill carrying the replacement `venue_order_id` during an in-flight modify
61//! promotes the binding directly (the same `OrderUpdated` path as the
62//! replacement `ACCEPTED`), so a dropped `ACCEPTED` does not strand the fill.
63//! A fill is buffered into [`WsDispatchState::buffered_fills`] only when the
64//! context has no price to promote with; `handle_accepted` drains the buffer
65//! on the replacement `ACCEPTED`. A delayed earlier-leg fill during a chained
66//! modify is a known limitation. See GH-3972.
67//!
68//! When neither the replacement `ACCEPTED` nor a fill arrives, a query that
69//! resolves the replacement by `cloid` promotes the binding the same way via
70//! [`promote_replacement_from_query`], so a dropped `ACCEPTED` with no fill
71//! cannot leave the order bound to the canceled leg.
72
73use std::{
74    collections::VecDeque,
75    sync::atomic::{AtomicBool, Ordering},
76};
77
78use ahash::AHashMap;
79use dashmap::{DashMap, DashSet};
80use nautilus_common::cache::fifo::FifoCache;
81use nautilus_core::{UUID4, UnixNanos};
82use nautilus_live::{ExecutionEventEmitter, execution::context::OrderContext};
83use nautilus_model::{
84    enums::{OrderStatus, OrderType},
85    events::{
86        OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
87        OrderTriggered, OrderUpdated,
88    },
89    identifiers::{AccountId, ClientOrderId, TradeId, VenueOrderId},
90    orders::LIMIT_ORDER_TYPES,
91    reports::{FillReport, OrderStatusReport},
92    types::{Price, Quantity},
93};
94use parking_lot::Mutex;
95use ustr::Ustr;
96
97use crate::{
98    common::consts::HYPERLIQUID_POST_ONLY_WOULD_MATCH,
99    http::models::HyperliquidExchangePlaceOrderRequest,
100};
101
102pub const DEDUP_CAPACITY: usize = 10_000;
103
104/// Maximum in-flight modify intents tracked per order. Rapid repricing rarely
105/// queues more than one or two unacknowledged modifies at once; the cap bounds
106/// memory if replacement acks stall. On overflow the oldest intent is evicted.
107pub const MAX_PENDING_MODIFY_INTENTS: usize = 32;
108
109/// A single in-flight Hyperliquid modify awaiting its replacement leg.
110///
111/// Rapid repeated modifies under one stable CLOID queue as a chain of intents
112/// so a later modify cannot overwrite an earlier pending old-leg marker, and a
113/// failed modify clears only its own generation rather than a newer one's
114/// state. Each intent carries the venue leg it cancel-replaces
115/// (`old_venue_order_id`), the user-intended absolute total quantity, and the
116/// exact request sent (used to size a corrective reduce).
117#[derive(Debug, Clone)]
118pub struct ModifyIntent {
119    /// Monotonic per-order generation, used to clear a specific intent on failure.
120    pub generation: u64,
121    /// Venue order id this modify cancel-replaces, once known.
122    pub old_venue_order_id: Option<VenueOrderId>,
123    /// User-intended absolute total quantity for the replacement.
124    pub target_qty: Quantity,
125    /// Exact venue request sent, used to size a corrective reduce.
126    pub sent_request: Option<HyperliquidExchangePlaceOrderRequest>,
127}
128
129/// A corrective reduce queued for the WebSocket consumer loop to post.
130///
131/// The reduce registers its own modify intent, so a failed post clears only
132/// that intent and leaves other modifies of the order queued.
133#[derive(Debug, Clone)]
134pub struct CorrectiveReduce {
135    /// Venue order id of the oversized replacement the reduce modifies.
136    pub oid: u64,
137    /// Generation of the modify intent the reduce registered.
138    pub generation: u64,
139    /// Reduced request to send.
140    pub request: HyperliquidExchangePlaceOrderRequest,
141}
142
143/// Bounded FIFO chain of in-flight modify intents for one order.
144///
145/// The venue processes chained modifies in submission order, so the front
146/// (oldest) intent is the next to promote; on promotion the next intent's old
147/// leg advances to the replacement just accepted. `held_cancel` keeps a
148/// cancel of a targeted leg until the chain shows whether a modify caused it.
149#[derive(Debug, Default)]
150struct ModifyChain {
151    intents: VecDeque<ModifyIntent>,
152    next_generation: u64,
153    held_cancel: Option<OrderStatusReport>,
154}
155
156impl ModifyChain {
157    fn targets(&self, venue_order_id: VenueOrderId) -> bool {
158        self.intents
159            .iter()
160            .any(|intent| intent.old_venue_order_id == Some(venue_order_id))
161    }
162
163    // Once no queued modify can cancel-replace the leg, its cancel came from elsewhere
164    fn release_held_cancel(&mut self) -> Option<OrderStatusReport> {
165        let venue_order_id = self.held_cancel.as_ref()?.venue_order_id;
166
167        if self.targets(venue_order_id) {
168            return None;
169        }
170
171        self.held_cancel.take()
172    }
173}
174
175/// Per-client dispatch state shared between order submission and the
176/// WebSocket consumer task.
177///
178/// Tracks which orders were submitted through this client (so we can route
179/// venue events to typed [`OrderEventAny`] emissions for tracked orders and
180/// fall back to reports for external orders), provides cross-stream dedup
181/// for `OrderAccepted`, `OrderTriggered`, and `OrderFilled` emissions, and
182/// carries the GH-3827 cancel-replace state (`cached_venue_order_ids` and
183/// `pending_modify_keys`). Cancels released from a modify chain queue in
184/// `released_cancels` until the WebSocket consumer loop applies them. A rejection
185/// with undispatched fills waits for them, then releases the unfilled remainder
186/// through the same cancel queue. Fill totals are retained per venue order ID so
187/// fills on an earlier leg cannot satisfy a later leg's reported filled quantity.
188#[derive(Debug)]
189pub struct WsDispatchState {
190    /// Tracked orders keyed by full Nautilus [`ClientOrderId`].
191    ///
192    /// The dispatch functions read the context to build typed order events for
193    /// tracked orders without needing access to the engine cache (which is
194    /// `!Send` and unreachable from the spawned WebSocket task). `quantity` and
195    /// `price` are refreshed from subsequent status reports so a cancel-replace
196    /// `ACCEPTED` that omits `price` can still produce an `OrderUpdated`
197    /// carrying an accurate value.
198    pub order_contexts: DashMap<ClientOrderId, OrderContext>,
199    /// Client order IDs for which an `OrderAccepted` event has been emitted.
200    pub emitted_accepted: DashSet<ClientOrderId>,
201    emitted_triggered: DashSet<ClientOrderId>,
202    /// Tracked submissions whose POST response has not resolved yet.
203    pending_submissions: DashSet<ClientOrderId>,
204    /// Submission-time rejections held until the POST path can preserve its
205    /// more detailed venue error string.
206    pending_submission_rejections: DashMap<ClientOrderId, OrderStatusReport>,
207    /// Client order IDs that have reached the filled terminal state.
208    ///
209    /// Retained past `cleanup_terminal` so that late replay of the same
210    /// status or fill does not re-emit events.
211    pub filled_orders: DashSet<ClientOrderId>,
212    /// Trade IDs for which an `OrderFilled` event has been emitted.
213    ///
214    /// Bounded FIFO dedup to bound memory while keeping recent trade ids
215    /// deduped across reconnects.
216    emitted_trades: Mutex<FifoCache<TradeId, DEDUP_CAPACITY>>,
217    /// Raw Hyperliquid CLOIDs whose terminal state has already been dispatched.
218    terminal_cloids: Mutex<FifoCache<Ustr, DEDUP_CAPACITY>>,
219    closed_venue_order_ids: Mutex<FifoCache<VenueOrderId, DEDUP_CAPACITY>>,
220    /// Last venue order id observed for a tracked client order id.
221    ///
222    /// Populated on the first `OrderAccepted` and refreshed on every
223    /// cancel-replace promotion. A later `ACCEPTED` with a different venue
224    /// order id under the same client order id is treated as the
225    /// replacement leg of a Hyperliquid modify and emitted as `OrderUpdated`.
226    pub cached_venue_order_ids: DashMap<ClientOrderId, VenueOrderId>,
227    /// Per-order chain of in-flight modify intents, keyed by `client_order_id`.
228    ///
229    /// Rapid repeated modifies under one stable CLOID queue as a chain so a
230    /// later modify cannot overwrite an earlier pending old-leg marker, and a
231    /// failed modify clears only its own generation rather than a newer one's
232    /// state. Populated by `modify_order` before the HTTP call so the WS cancel
233    /// handler sees an intent even when `CANCELED(old_voi)` arrives before the
234    /// HTTP response. A `CANCELED(old_voi)` matching any queued intent's old
235    /// leg is suppressed so the later `ACCEPTED(new_voi)` can flow through the
236    /// `OrderUpdated` path; the front intent is claimed on promotion and the
237    /// next intent's old leg advances to the promoted replacement.
238    pending_modify_chains: DashMap<ClientOrderId, ModifyChain>,
239    /// `FillReport`s buffered only when a cancel-replace fill cannot be promoted
240    /// (the context carries no price); drained by the cancel-replace branch of
241    /// `handle_accepted`. The common path promotes on the fill instead. See
242    /// GH-3972.
243    pub buffered_fills: DashMap<ClientOrderId, Vec<FillReport>>,
244    /// Cumulative filled quantity per tracked order. Compared against
245    /// `OrderContext::quantity` to decide when to clean up tracked state.
246    pub order_filled_qty: DashMap<ClientOrderId, Quantity>,
247    order_filled_qty_by_venue: Mutex<AHashMap<ClientOrderId, AHashMap<VenueOrderId, Quantity>>>,
248    pending_rejections: Mutex<AHashMap<ClientOrderId, OrderStatusReport>>,
249    /// Corrective reduce queued by the cancel-replace promotion, keyed by
250    /// client order id. Drained by the WS loop.
251    pub pending_corrective: DashMap<ClientOrderId, CorrectiveReduce>,
252    released_cancels: Mutex<Vec<OrderStatusReport>>,
253    released_cancel_notify: tokio::sync::Notify,
254    clearing: AtomicBool,
255}
256
257impl Default for WsDispatchState {
258    fn default() -> Self {
259        Self {
260            order_contexts: DashMap::new(),
261            emitted_accepted: DashSet::default(),
262            emitted_triggered: DashSet::default(),
263            pending_submissions: DashSet::default(),
264            pending_submission_rejections: DashMap::new(),
265            filled_orders: DashSet::default(),
266            emitted_trades: Mutex::new(FifoCache::new()),
267            terminal_cloids: Mutex::new(FifoCache::new()),
268            closed_venue_order_ids: Mutex::new(FifoCache::new()),
269            cached_venue_order_ids: DashMap::new(),
270            pending_modify_chains: DashMap::new(),
271            buffered_fills: DashMap::new(),
272            order_filled_qty: DashMap::new(),
273            order_filled_qty_by_venue: Mutex::new(AHashMap::new()),
274            pending_rejections: Mutex::new(AHashMap::new()),
275            pending_corrective: DashMap::new(),
276            released_cancels: Mutex::new(Vec::new()),
277            released_cancel_notify: tokio::sync::Notify::new(),
278            clearing: AtomicBool::new(false),
279        }
280    }
281}
282
283impl WsDispatchState {
284    /// Creates a new empty dispatch state.
285    #[must_use]
286    pub fn new() -> Self {
287        Self::default()
288    }
289
290    /// Registers an order context. Called by the execution client at order
291    /// submission time, before any WebSocket events for the order can arrive.
292    pub fn register_context(&self, context: OrderContext) {
293        self.order_contexts
294            .insert(context.identity.client_order_id, context);
295    }
296
297    /// Returns a copy of the context for the given client order id, if any.
298    #[must_use]
299    pub fn lookup_context(&self, client_order_id: &ClientOrderId) -> Option<OrderContext> {
300        self.order_contexts.get(client_order_id).map(|r| *r)
301    }
302
303    /// Marks a tracked order as awaiting its submission POST response.
304    pub fn mark_submission_pending(&self, client_order_id: ClientOrderId) {
305        self.pending_submissions.insert(client_order_id);
306    }
307
308    /// Returns whether the order still awaits its submission POST response.
309    #[must_use]
310    pub fn submission_pending(&self, client_order_id: &ClientOrderId) -> bool {
311        self.pending_submissions.contains(client_order_id)
312    }
313
314    /// Holds a submission-time rejection until the POST response resolves.
315    /// A replay for the same venue order ID cannot reduce its reported filled quantity.
316    pub fn buffer_submission_rejection(
317        &self,
318        client_order_id: ClientOrderId,
319        report: OrderStatusReport,
320    ) {
321        self.pending_submission_rejections
322            .entry(client_order_id)
323            .and_modify(|pending| retain_rejection_report(pending, &report))
324            .or_insert(report);
325    }
326
327    /// Resolves submission tracking and returns any early rejection report.
328    #[must_use]
329    pub fn resolve_submission(&self, client_order_id: &ClientOrderId) -> Option<OrderStatusReport> {
330        self.pending_submissions.remove(client_order_id);
331        self.pending_submission_rejections
332            .remove(client_order_id)
333            .map(|(_, report)| report)
334    }
335
336    /// Refreshes the tracked price for a modify ack when the new report
337    /// carries an updated price.
338    pub fn update_context_price(&self, client_order_id: &ClientOrderId, price: Option<Price>) {
339        if let Some(price) = price
340            && let Some(mut entry) = self.order_contexts.get_mut(client_order_id)
341        {
342            entry.price = Some(price);
343        }
344    }
345
346    /// Refreshes the tracked quantity for a modify ack.
347    pub fn update_context_quantity(&self, client_order_id: &ClientOrderId, quantity: Quantity) {
348        if let Some(mut entry) = self.order_contexts.get_mut(client_order_id) {
349            entry.quantity = quantity;
350        }
351    }
352
353    /// Marks an `OrderAccepted` event as emitted for this order.
354    pub fn insert_accepted(&self, cid: ClientOrderId) {
355        self.evict_if_full(&self.emitted_accepted);
356        self.emitted_accepted.insert(cid);
357    }
358
359    // Returns false when the order's `OrderTriggered` was already emitted
360    fn insert_triggered(&self, cid: ClientOrderId) -> bool {
361        self.evict_if_full(&self.emitted_triggered);
362        self.emitted_triggered.insert(cid)
363    }
364
365    /// Marks an order as having reached a terminal state.
366    ///
367    /// Returns `true` when this call claimed the terminal state, and `false`
368    /// when another path had already claimed it.
369    pub fn insert_filled(&self, cid: ClientOrderId) -> bool {
370        self.evict_if_full(&self.filled_orders);
371        self.filled_orders.insert(cid)
372    }
373
374    /// Atomically inserts a trade id into the dedup set.
375    ///
376    /// Returns `true` when the trade was already present (i.e. it is a
377    /// duplicate), `false` otherwise.
378    pub fn check_and_insert_trade(&self, trade_id: TradeId) -> bool {
379        let mut set = self.emitted_trades.lock();
380        !set.insert(trade_id)
381    }
382
383    #[must_use]
384    pub(crate) fn trade_emitted(&self, trade_id: &TradeId) -> bool {
385        self.emitted_trades.lock().contains(trade_id)
386    }
387
388    /// Records a terminal raw Hyperliquid CLOID.
389    ///
390    /// Recording it lets the normal CLOID mapping be removed while a late
391    /// unresolved order update is suppressed instead of forwarded as an external report.
392    pub fn insert_terminal_cloid(&self, cloid: Ustr) {
393        let mut set = self.terminal_cloids.lock();
394        let _ = set.insert(cloid);
395    }
396
397    /// Returns whether a raw Hyperliquid CLOID already reached a terminal state.
398    #[must_use]
399    pub fn terminal_cloid_seen(&self, cloid: &Ustr) -> bool {
400        let set = self.terminal_cloids.lock();
401        set.contains(cloid)
402    }
403
404    /// Caches the venue order id observed for a tracked client order id.
405    pub fn record_venue_order_id(
406        &self,
407        client_order_id: ClientOrderId,
408        venue_order_id: VenueOrderId,
409    ) {
410        self.cached_venue_order_ids
411            .insert(client_order_id, venue_order_id);
412    }
413
414    /// Returns the previously cached venue order id, if any.
415    #[must_use]
416    pub fn cached_venue_order_id(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
417        self.cached_venue_order_ids.get(client_order_id).map(|r| *r)
418    }
419
420    /// Queues an in-flight modify intent for cancel-before-accept suppression
421    /// and records the target absolute total qty for the cancel-replace
422    /// promotion. Returns the intent's generation.
423    ///
424    /// The generation lets the submission path clear only this modify on
425    /// failure via [`Self::clear_modify_generation`], leaving newer queued
426    /// modifies intact. Chained modifies append rather than overwrite, so a
427    /// later modify cannot drop an earlier pending old-leg marker.
428    pub fn mark_pending_modify(
429        &self,
430        client_order_id: ClientOrderId,
431        old_venue_order_id: VenueOrderId,
432        target_qty: Quantity,
433    ) -> u64 {
434        let mut chain = self
435            .pending_modify_chains
436            .entry(client_order_id)
437            .or_default();
438        let generation = chain.next_generation;
439        chain.next_generation += 1;
440        chain.intents.push_back(ModifyIntent {
441            generation,
442            old_venue_order_id: Some(old_venue_order_id),
443            target_qty,
444            sent_request: None,
445        });
446
447        if chain.intents.len() > MAX_PENDING_MODIFY_INTENTS {
448            chain.intents.pop_front();
449            log::warn!(
450                "Modify chain for {client_order_id} exceeded {MAX_PENDING_MODIFY_INTENTS}; \
451                 evicting oldest intent",
452            );
453        }
454        generation
455    }
456
457    /// Clears the entire pending modify chain for a client order id.
458    pub fn clear_pending_modify(&self, client_order_id: &ClientOrderId) {
459        self.pending_modify_chains.remove(client_order_id);
460    }
461
462    /// Removes a single modify intent by generation, leaving newer queued
463    /// modifies intact. Drops the chain entry when it empties.
464    ///
465    /// When the removed intent is the front, the next queued modify inherits
466    /// its old leg: a rejected modify does not cancel-replace, so the resting
467    /// leg it targeted is still live and the next modify cancel-replaces the
468    /// same one. A non-front removal needs no reparenting; the front's
469    /// promotion (or its own removal) advances the chain. A held cancel whose
470    /// leg no remaining intent targets is released to the WebSocket consumer loop.
471    pub fn clear_modify_generation(&self, client_order_id: &ClientOrderId, generation: u64) {
472        let Some(mut chain) = self.pending_modify_chains.get_mut(client_order_id) else {
473            return;
474        };
475        let removed_front_old = chain
476            .intents
477            .front()
478            .filter(|front| front.generation == generation)
479            .and_then(|front| front.old_venue_order_id);
480        chain
481            .intents
482            .retain(|intent| intent.generation != generation);
483
484        if let Some(old) = removed_front_old
485            && let Some(new_front) = chain.intents.front_mut()
486        {
487            new_front.old_venue_order_id = Some(old);
488        }
489
490        let released = chain.release_held_cancel();
491        drop(chain);
492        // Remove only if still empty: a concurrent mark for the same order may
493        // queue a new intent between the drop above and this remove
494        self.pending_modify_chains
495            .remove_if(client_order_id, |_, chain| chain.intents.is_empty());
496
497        if let Some(report) = released {
498            self.release_cancel(client_order_id, report);
499        }
500    }
501
502    /// Stashes the exact venue request sent onto the most recently queued
503    /// modify intent for the order.
504    pub fn stash_modify_request(
505        &self,
506        client_order_id: ClientOrderId,
507        request: HyperliquidExchangePlaceOrderRequest,
508    ) {
509        if let Some(mut chain) = self.pending_modify_chains.get_mut(&client_order_id)
510            && let Some(back) = chain.intents.back_mut()
511        {
512            back.sent_request = Some(request);
513        } else {
514            log::debug!(
515                "Stash modify request for {client_order_id} with no pending intent; ignoring"
516            );
517        }
518    }
519
520    /// Returns a clone of the front intent's stashed modify request, if any.
521    #[must_use]
522    pub fn modify_request(
523        &self,
524        client_order_id: &ClientOrderId,
525    ) -> Option<HyperliquidExchangePlaceOrderRequest> {
526        self.pending_modify_chains
527            .get(client_order_id)
528            .and_then(|chain| chain.intents.front().and_then(|i| i.sent_request.clone()))
529    }
530
531    /// Claims the front (oldest) modify intent for promotion.
532    ///
533    /// Advances the next queued intent's old leg to `new_venue_order_id`: its
534    /// cancel-replace targets the replacement just promoted, not the leg it was
535    /// queued against. Drops a held cancel, which the promotion shows was the
536    /// cancel leg of a modify. Returns the claimed intent, or `None` when no
537    /// intent is queued (an external modify with no local marker). Drops the
538    /// chain entry when it empties.
539    pub fn claim_front_modify(
540        &self,
541        client_order_id: &ClientOrderId,
542        new_venue_order_id: VenueOrderId,
543    ) -> Option<ModifyIntent> {
544        let mut chain = self.pending_modify_chains.get_mut(client_order_id)?;
545        let claimed = chain.intents.pop_front();
546        chain.held_cancel = None;
547        if let Some(next) = chain.intents.front_mut() {
548            next.old_venue_order_id = Some(new_venue_order_id);
549        }
550        drop(chain);
551        // Remove only if still empty: a concurrent mark for the same order may
552        // queue a new intent between the drop above and this remove
553        self.pending_modify_chains
554            .remove_if(client_order_id, |_, chain| chain.intents.is_empty());
555        claimed
556    }
557
558    pub(crate) fn record_closed_venue_order_id(&self, venue_order_id: VenueOrderId) {
559        self.closed_venue_order_ids.lock().add(venue_order_id);
560    }
561
562    /// Queues a corrective reduce for the WebSocket consumer loop to post.
563    pub fn queue_corrective(&self, client_order_id: ClientOrderId, corrective: CorrectiveReduce) {
564        self.pending_corrective.insert(client_order_id, corrective);
565    }
566
567    /// Removes and returns a queued corrective reduce, if any.
568    #[must_use]
569    pub fn take_corrective(&self, client_order_id: &ClientOrderId) -> Option<CorrectiveReduce> {
570        self.pending_corrective
571            .remove(client_order_id)
572            .map(|(_, v)| v)
573    }
574
575    /// Returns whether any modify intent is queued for the client order id.
576    #[must_use]
577    pub fn has_pending_modify(&self, client_order_id: &ClientOrderId) -> bool {
578        self.pending_modify_chains
579            .get(client_order_id)
580            .is_some_and(|chain| !chain.intents.is_empty())
581    }
582
583    /// Returns the front intent's old venue order id, if any.
584    #[must_use]
585    pub fn pending_modify(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
586        self.pending_modify_chains
587            .get(client_order_id)
588            .and_then(|chain| chain.intents.front().and_then(|i| i.old_venue_order_id))
589    }
590
591    /// Returns whether any queued intent cancel-replaces `venue_order_id`.
592    ///
593    /// Used to suppress the `CANCELED(old_voi)` leg of any in-flight modify in
594    /// the chain, not only the oldest.
595    #[must_use]
596    pub fn pending_modify_contains_old(
597        &self,
598        client_order_id: &ClientOrderId,
599        venue_order_id: VenueOrderId,
600    ) -> bool {
601        self.pending_modify_chains
602            .get(client_order_id)
603            .is_some_and(|chain| chain.targets(venue_order_id))
604    }
605
606    // A cancel of a leg a queued modify targets is either that modify's cancel leg or a cancel
607    // from elsewhere that fails the modify, so the chain keeps it until it can tell them apart.
608    // Checks and holds under one lock, so a modify cleared concurrently cannot drop the cancel
609    pub(crate) fn hold_cancel(
610        &self,
611        client_order_id: &ClientOrderId,
612        report: &OrderStatusReport,
613    ) -> bool {
614        let Some(mut chain) = self.pending_modify_chains.get_mut(client_order_id) else {
615            return false;
616        };
617
618        if !chain.targets(report.venue_order_id) {
619            return false;
620        }
621
622        chain.held_cancel = Some(report.clone());
623        true
624    }
625
626    fn release_cancel(&self, client_order_id: &ClientOrderId, report: OrderStatusReport) {
627        log::debug!(
628            "Releasing held terminal report for {} on {client_order_id}: status={:?}",
629            report.venue_order_id,
630            report.order_status,
631        );
632        self.released_cancels.lock().push(report);
633        self.released_cancel_notify.notify_one();
634    }
635
636    // Cancel safe: released cancels stay queued until a call returns them
637    pub(crate) async fn recv_released_cancels(&self) -> Vec<OrderStatusReport> {
638        loop {
639            let released = std::mem::take(&mut *self.released_cancels.lock());
640
641            if !released.is_empty() {
642                return released;
643            }
644
645            self.released_cancel_notify.notified().await;
646        }
647    }
648
649    /// Returns the front intent's recorded target absolute total qty, if any.
650    #[must_use]
651    pub fn pending_modify_target_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
652        self.pending_modify_chains
653            .get(client_order_id)
654            .and_then(|chain| chain.intents.front().map(|i| i.target_qty))
655    }
656
657    /// Buffers a `FillReport` arrived during an in-flight cancel-replace.
658    pub fn buffer_fill(&self, client_order_id: ClientOrderId, fill: FillReport) {
659        self.buffered_fills
660            .entry(client_order_id)
661            .or_default()
662            .push(fill);
663    }
664
665    /// Removes and returns buffered fills for the cid, in arrival order.
666    #[must_use]
667    pub fn drain_buffered_fills(&self, client_order_id: &ClientOrderId) -> Vec<FillReport> {
668        self.buffered_fills
669            .remove(client_order_id)
670            .map(|(_, v)| v)
671            .unwrap_or_default()
672    }
673
674    /// Number of buffered fills for the cid.
675    #[must_use]
676    pub fn buffered_fill_count(&self, client_order_id: &ClientOrderId) -> usize {
677        self.buffered_fills
678            .get(client_order_id)
679            .map_or(0, |r| r.len())
680    }
681
682    /// Records cumulative filled quantity for a tracked order.
683    pub fn record_filled_qty(&self, client_order_id: ClientOrderId, qty: Quantity) {
684        self.order_filled_qty.insert(client_order_id, qty);
685    }
686
687    /// Returns the previously recorded cumulative filled quantity, if any.
688    #[must_use]
689    pub fn previous_filled_qty(&self, client_order_id: &ClientOrderId) -> Option<Quantity> {
690        self.order_filled_qty.get(client_order_id).map(|r| *r)
691    }
692
693    /// Removes all dispatch state for an order that has reached a terminal state.
694    ///
695    /// `filled_orders` is intentionally *not* cleared here: the marker is
696    /// used to suppress stale replays and must outlive the context cleanup.
697    pub fn cleanup_terminal(&self, client_order_id: &ClientOrderId) {
698        self.order_contexts.remove(client_order_id);
699        self.emitted_accepted.remove(client_order_id);
700        self.emitted_triggered.remove(client_order_id);
701        self.pending_submissions.remove(client_order_id);
702        self.pending_submission_rejections.remove(client_order_id);
703        self.cached_venue_order_ids.remove(client_order_id);
704        self.pending_modify_chains.remove(client_order_id);
705        self.pending_corrective.remove(client_order_id);
706        self.buffered_fills.remove(client_order_id);
707        self.order_filled_qty.remove(client_order_id);
708        self.order_filled_qty_by_venue
709            .lock()
710            .remove(client_order_id);
711        self.pending_rejections.lock().remove(client_order_id);
712    }
713
714    fn evict_if_full(&self, set: &DashSet<ClientOrderId>) {
715        if set.len() >= DEDUP_CAPACITY
716            && self
717                .clearing
718                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
719                .is_ok()
720        {
721            set.clear();
722            self.clearing.store(false, Ordering::Release);
723        }
724    }
725}
726
727/// Outcome of a single dispatch call.
728#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729pub enum DispatchOutcome {
730    /// The report was for a tracked order. Typed events have been emitted
731    /// (or intentionally skipped, e.g. dedup hit). The caller must not
732    /// forward the report as a fallback.
733    Tracked,
734    /// The report is for an external / untracked order. The caller should
735    /// forward the report via [`ExecutionEventEmitter::send_order_status_report`]
736    /// or [`ExecutionEventEmitter::send_fill_report`] so the engine can
737    /// reconcile.
738    External,
739    /// The report was recognized as stale (e.g. cancel leg of a
740    /// cancel-replace modify, or replay after terminal state). The caller
741    /// must drop it without forwarding.
742    Skip,
743}
744
745/// Dispatches an [`OrderStatusReport`] using the two-tier routing contract.
746///
747/// Returns [`DispatchOutcome::Tracked`] when the report maps to a tracked
748/// order (typed events have been emitted or dedup hit), [`External`] when
749/// the caller should forward the report as an untracked fallback, or
750/// [`Skip`] when the report is a stale / race leg that must be dropped.
751///
752/// [`External`]: DispatchOutcome::External
753/// [`Skip`]: DispatchOutcome::Skip
754pub fn dispatch_order_event(
755    report: &OrderStatusReport,
756    state: &WsDispatchState,
757    emitter: &ExecutionEventEmitter,
758    ts_init: UnixNanos,
759) -> DispatchOutcome {
760    let Some(client_order_id) = report.client_order_id else {
761        return DispatchOutcome::External;
762    };
763
764    if state.filled_orders.contains(&client_order_id) {
765        log::debug!(
766            "Skipping stale report for filled order: cid={client_order_id}, status={:?}",
767            report.order_status,
768        );
769        return DispatchOutcome::Skip;
770    }
771
772    let client_order_id_str = client_order_id.as_str();
773    if client_order_id_str.starts_with("0x")
774        && state.terminal_cloid_seen(&Ustr::from(client_order_id_str))
775    {
776        log::debug!(
777            "Skipping stale terminal report for raw cloid: cid={client_order_id}, status={:?}",
778            report.order_status,
779        );
780        return DispatchOutcome::Skip;
781    }
782
783    let Some(context) = state.lookup_context(&client_order_id) else {
784        return DispatchOutcome::External;
785    };
786
787    match report.order_status {
788        OrderStatus::Accepted => {
789            handle_accepted(report, client_order_id, &context, state, emitter, ts_init)
790        }
791        OrderStatus::Triggered => {
792            handle_triggered(report, client_order_id, &context, state, emitter, ts_init)
793        }
794        OrderStatus::Canceled => {
795            handle_canceled(report, client_order_id, &context, state, emitter, ts_init)
796        }
797        OrderStatus::Expired => {
798            handle_expired(report, client_order_id, &context, state, emitter, ts_init)
799        }
800        OrderStatus::Rejected => {
801            handle_rejected(report, client_order_id, &context, state, emitter, ts_init)
802        }
803        OrderStatus::Filled => handle_filled_marker(client_order_id, state),
804        OrderStatus::PartiallyFilled => {
805            // Fills come via `FillReport`; nothing to emit from the status path.
806            DispatchOutcome::Tracked
807        }
808        OrderStatus::PendingUpdate
809        | OrderStatus::PendingCancel
810        | OrderStatus::Submitted
811        | OrderStatus::Initialized
812        | OrderStatus::Denied
813        | OrderStatus::Released
814        | OrderStatus::Emulated
815        | OrderStatus::Voided => DispatchOutcome::Tracked,
816    }
817}
818
819/// Dispatches a [`FillReport`] using the two-tier routing contract.
820///
821/// Returns [`DispatchOutcome::Tracked`] when the fill has been emitted as
822/// an `OrderFilled` event (or skipped via trade dedup), [`External`] when
823/// the caller should forward the fill via
824/// [`ExecutionEventEmitter::send_fill_report`], or [`Skip`] when the fill
825/// is a replay for an already-terminal order and must be dropped.
826///
827/// [`External`]: DispatchOutcome::External
828/// [`Skip`]: DispatchOutcome::Skip
829pub fn dispatch_order_fill(
830    report: &FillReport,
831    state: &WsDispatchState,
832    emitter: &ExecutionEventEmitter,
833    ts_init: UnixNanos,
834) -> DispatchOutcome {
835    let Some(client_order_id) = report.client_order_id else {
836        return DispatchOutcome::External;
837    };
838
839    if state.filled_orders.contains(&client_order_id) {
840        return if state.check_and_insert_trade(report.trade_id) {
841            DispatchOutcome::Skip
842        } else {
843            DispatchOutcome::External
844        };
845    }
846
847    let Some(mut context) = state.lookup_context(&client_order_id) else {
848        return DispatchOutcome::External;
849    };
850
851    // Set when a fill promotes, so the corrective-reduce runs after the fill applies
852    let mut promoted_corrective: Option<(Quantity, HyperliquidExchangePlaceOrderRequest)> = None;
853
854    // Promote the binding from the fill so a dropped replacement ACCEPTED cannot
855    // strand it (see module docs).
856    // An older leg's fill must not move the binding back to that leg
857    if state.has_pending_modify(&client_order_id)
858        && let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
859        && report.venue_order_id != cached_voi
860        && venue_oid(report.venue_order_id) >= venue_oid(cached_voi)
861    {
862        let target = state.pending_modify_target_qty(&client_order_id);
863        let sent_request = state.modify_request(&client_order_id);
864        // Prefer the modify target price over the stale cached context price
865        let price = sent_request
866            .as_ref()
867            .zip(context.price)
868            .and_then(|(r, cached)| Price::from_decimal_dp(r.price, cached.precision).ok())
869            .or(context.price);
870        let Some(price) = price else {
871            log::warn!(
872                "Cannot promote cancel-replace for {client_order_id} from fill: no target \
873                 or cached price; buffering until the replacement ACCEPTED arrives",
874            );
875            state.buffer_fill(client_order_id, report.clone());
876            return DispatchOutcome::Tracked;
877        };
878        let updated_quantity = target.unwrap_or(context.quantity);
879        promote_cancel_replace(
880            client_order_id,
881            &context,
882            state,
883            emitter,
884            report.venue_order_id,
885            report.account_id,
886            price,
887            updated_quantity,
888            None,
889            report.ts_event,
890            ts_init,
891        );
892        // Re-read the context advanced by the promotion (quantity and price)
893        context = state.lookup_context(&client_order_id).unwrap_or(context);
894
895        if let (Some(target), Some(sent_request)) = (target, sent_request) {
896            promoted_corrective = Some((target, sent_request));
897        }
898    }
899
900    if state.check_and_insert_trade(report.trade_id) {
901        log::debug!(
902            "Skipping duplicate fill for {client_order_id}: trade_id={}",
903            report.trade_id
904        );
905        return DispatchOutcome::Tracked;
906    }
907
908    let previous = state
909        .previous_filled_qty(&client_order_id)
910        .unwrap_or_else(|| Quantity::zero(report.last_qty.precision));
911    let cumulative = previous + report.last_qty;
912
913    let is_terminal_fill = cumulative >= context.quantity;
914    if is_terminal_fill && !claim_terminal_order(client_order_id, state, OrderStatus::Filled) {
915        return DispatchOutcome::Skip;
916    }
917
918    ensure_accepted_emitted(
919        client_order_id,
920        report.venue_order_id,
921        report.account_id,
922        &context,
923        state,
924        emitter,
925        report.ts_event,
926        ts_init,
927    );
928
929    let filled = OrderFilled::new(
930        emitter.trader_id(),
931        context.identity.strategy_id,
932        context.identity.instrument_id,
933        client_order_id,
934        report.venue_order_id,
935        report.account_id,
936        report.trade_id,
937        context.identity.order_side,
938        context.identity.order_type,
939        report.last_qty,
940        report.last_px,
941        report.commission.currency,
942        report.liquidity_side,
943        UUID4::new(),
944        report.ts_event,
945        ts_init,
946        false,
947        report.venue_position_id,
948        Some(report.commission),
949        None,
950    );
951    emitter.send_order_event(OrderEventAny::Filled(filled));
952
953    state.record_filled_qty(client_order_id, cumulative);
954    {
955        let mut orders = state.order_filled_qty_by_venue.lock();
956        let fills = orders.entry(client_order_id).or_default();
957        let quantity = fills
958            .entry(report.venue_order_id)
959            .or_insert_with(|| Quantity::zero(report.last_qty.precision));
960        *quantity = *quantity + report.last_qty;
961    }
962
963    // Cumulative now includes this fill, so the reduce sizes against the true remaining
964    if let Some((target, sent_request)) = promoted_corrective {
965        maybe_queue_corrective_reduce(
966            state,
967            client_order_id,
968            report.venue_order_id,
969            target,
970            sent_request,
971        );
972    }
973
974    if is_terminal_fill {
975        state.cleanup_terminal(&client_order_id);
976        return DispatchOutcome::Tracked;
977    }
978
979    release_rejection_after_fill(client_order_id, state);
980    DispatchOutcome::Tracked
981}
982
983fn release_rejection_after_fill(client_order_id: ClientOrderId, state: &WsDispatchState) {
984    let mut pending = state.pending_rejections.lock();
985
986    let Some(rejection) = pending.get(&client_order_id) else {
987        return;
988    };
989
990    if filled_qty_for_venue(state, client_order_id, rejection.venue_order_id) < rejection.filled_qty
991    {
992        return;
993    }
994
995    let mut canceled = rejection.clone();
996    pending.remove(&client_order_id);
997    drop(pending);
998    canceled.order_status = OrderStatus::Canceled;
999    state.release_cancel(&client_order_id, canceled);
1000}
1001
1002fn handle_accepted(
1003    report: &OrderStatusReport,
1004    client_order_id: ClientOrderId,
1005    context: &OrderContext,
1006    state: &WsDispatchState,
1007    emitter: &ExecutionEventEmitter,
1008    ts_init: UnixNanos,
1009) -> DispatchOutcome {
1010    let venue_order_id = report.venue_order_id;
1011    let ts_event = report.ts_last;
1012    let account_id = report.account_id;
1013
1014    // Cancel-replace detection: if an earlier ACCEPTED cached a different
1015    // venue_order_id under the same client_order_id, this ACCEPTED is the
1016    // replacement leg of a Hyperliquid modify and must be promoted to
1017    // OrderUpdated. See GH-3827.
1018    if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
1019        && cached_voi != venue_order_id
1020    {
1021        // A replayed accept for an older leg must not restore a superseded binding
1022        if venue_oid(venue_order_id) < venue_oid(cached_voi) {
1023            log::debug!(
1024                "Skipping ACCEPTED for superseded leg {venue_order_id} on {client_order_id}"
1025            );
1026            return DispatchOutcome::Skip;
1027        }
1028
1029        let price = report.price.or(context.price);
1030        let Some(price) = price else {
1031            log::warn!(
1032                "Cannot emit OrderUpdated for cancel-replace {client_order_id}: \
1033                 no price on report and no cached price on context",
1034            );
1035            return DispatchOutcome::Skip;
1036        };
1037
1038        // Prefer user target over venue's remaining-only `report.quantity`;
1039        // fall back when no marker (external modify).
1040        let target_total_qty = state.pending_modify_target_qty(&client_order_id);
1041        let updated_quantity = target_total_qty.unwrap_or(report.quantity);
1042        let sent_request = state.modify_request(&client_order_id);
1043
1044        promote_cancel_replace(
1045            client_order_id,
1046            context,
1047            state,
1048            emitter,
1049            venue_order_id,
1050            account_id,
1051            price,
1052            updated_quantity,
1053            report.trigger_price,
1054            ts_event,
1055            ts_init,
1056        );
1057
1058        if let (Some(target), Some(sent_request)) = (target_total_qty, sent_request) {
1059            maybe_queue_corrective_reduce(
1060                state,
1061                client_order_id,
1062                venue_order_id,
1063                target,
1064                sent_request,
1065            );
1066        }
1067
1068        return DispatchOutcome::Tracked;
1069    }
1070
1071    if state.emitted_accepted.contains(&client_order_id) {
1072        // Repeat ACCEPTED for an already-accepted order. Nothing to emit;
1073        // refresh the cached price so a subsequent cancel-replace without a
1074        // report price can still recover an accurate value.
1075        state.update_context_price(&client_order_id, report.price);
1076        return DispatchOutcome::Tracked;
1077    }
1078
1079    state.insert_accepted(client_order_id);
1080    state.record_venue_order_id(client_order_id, venue_order_id);
1081    state.update_context_price(&client_order_id, report.price);
1082
1083    let accepted = OrderAccepted::new(
1084        emitter.trader_id(),
1085        context.identity.strategy_id,
1086        context.identity.instrument_id,
1087        client_order_id,
1088        venue_order_id,
1089        account_id,
1090        UUID4::new(),
1091        ts_event,
1092        ts_init,
1093        false,
1094    );
1095    emitter.send_order_event(OrderEventAny::Accepted(accepted));
1096    DispatchOutcome::Tracked
1097}
1098
1099// Shared by the ACCEPTED branch and the fill path (dropped-ACCEPTED recovery) so the
1100// cancel-replace binding is recovered from whichever arrives first. See GH-3827, GH-3972.
1101#[allow(
1102    clippy::too_many_arguments,
1103    reason = "promotion needs the full OrderUpdated field set, sourced from two report shapes"
1104)]
1105fn promote_cancel_replace(
1106    client_order_id: ClientOrderId,
1107    context: &OrderContext,
1108    state: &WsDispatchState,
1109    emitter: &ExecutionEventEmitter,
1110    venue_order_id: VenueOrderId,
1111    account_id: AccountId,
1112    price: Price,
1113    quantity: Quantity,
1114    trigger_price: Option<Price>,
1115    ts_event: UnixNanos,
1116    ts_init: UnixNanos,
1117) {
1118    state.record_venue_order_id(client_order_id, venue_order_id);
1119    state.update_context_quantity(&client_order_id, quantity);
1120    state.update_context_price(&client_order_id, Some(price));
1121    // Claim the front intent; the next queued modify advances to this replacement
1122    state.claim_front_modify(&client_order_id, venue_order_id);
1123
1124    let updated = OrderUpdated::new(
1125        emitter.trader_id(),
1126        context.identity.strategy_id,
1127        context.identity.instrument_id,
1128        client_order_id,
1129        quantity,
1130        UUID4::new(),
1131        ts_event,
1132        ts_init,
1133        false,
1134        Some(venue_order_id),
1135        Some(account_id),
1136        LIMIT_ORDER_TYPES
1137            .contains(&context.identity.order_type)
1138            .then_some(price),
1139        trigger_price,
1140        None,
1141        false,
1142    );
1143    emitter.send_order_event(OrderEventAny::Updated(updated));
1144
1145    // Drain fills buffered before the binding advanced. Bypasses
1146    // `handle_execution_report`; FIFO-bounded caches make any residue benign.
1147    let buffered = state.drain_buffered_fills(&client_order_id);
1148    for fill in buffered {
1149        dispatch_order_fill(&fill, state, emitter, ts_init);
1150    }
1151}
1152
1153/// Promotes a cancel-replace replacement surfaced by a query during an in-flight modify.
1154///
1155/// When the query returns the replacement leg (`Accepted`, `venue_order_id` diverging from the
1156/// cached one, modify tracked), emits the `OrderUpdated` that rebinds the order, so a dropped
1157/// replacement `Accepted` with no fill cannot strand the binding on the canceled leg. Returns
1158/// `true` when promoted; the caller still forwards the report so the engine confirms the order.
1159pub fn promote_replacement_from_query(
1160    report: &OrderStatusReport,
1161    state: &WsDispatchState,
1162    emitter: &ExecutionEventEmitter,
1163    ts_init: UnixNanos,
1164) -> bool {
1165    if report.order_status != OrderStatus::Accepted {
1166        return false;
1167    }
1168
1169    let Some(client_order_id) = report.client_order_id else {
1170        return false;
1171    };
1172
1173    if !state.has_pending_modify(&client_order_id) {
1174        return false;
1175    }
1176
1177    let Some(cached_voi) = state.cached_venue_order_id(&client_order_id) else {
1178        return false;
1179    };
1180
1181    if report.venue_order_id == cached_voi {
1182        return false;
1183    }
1184
1185    let Some(context) = state.lookup_context(&client_order_id) else {
1186        return false;
1187    };
1188
1189    let Some(price) = report.price.or(context.price) else {
1190        log::warn!(
1191            "Cannot promote cancel-replace from query for {client_order_id}: \
1192             no price on report and no cached price on context",
1193        );
1194        return false;
1195    };
1196
1197    // Prefer the user target over the venue's remaining-only `report.quantity`
1198    let updated_quantity = state
1199        .pending_modify_target_qty(&client_order_id)
1200        .unwrap_or(report.quantity);
1201
1202    promote_cancel_replace(
1203        client_order_id,
1204        &context,
1205        state,
1206        emitter,
1207        report.venue_order_id,
1208        report.account_id,
1209        price,
1210        updated_quantity,
1211        report.trigger_price,
1212        report.ts_last,
1213        ts_init,
1214    );
1215
1216    log::debug!("Promoted cancel-replace replacement for {client_order_id} from query");
1217
1218    true
1219}
1220
1221// Queue a corrective reduce when a fill that raced the modify left the replacement
1222// oversized. Reached from both promotion paths; the engine overfill guard backstops.
1223fn maybe_queue_corrective_reduce(
1224    state: &WsDispatchState,
1225    client_order_id: ClientOrderId,
1226    venue_order_id: VenueOrderId,
1227    target: Quantity,
1228    sent_request: HyperliquidExchangePlaceOrderRequest,
1229) {
1230    // Recovery opens closed legs before replaying their fills, those legs cannot be reduced
1231    if state
1232        .closed_venue_order_ids
1233        .lock()
1234        .contains(&venue_order_id)
1235    {
1236        return;
1237    }
1238
1239    let Ok(new_oid) = venue_order_id.as_str().parse::<u64>() else {
1240        return;
1241    };
1242
1243    let filled = state
1244        .previous_filled_qty(&client_order_id)
1245        .unwrap_or_else(|| Quantity::zero(target.precision));
1246    if filled >= target {
1247        return;
1248    }
1249
1250    let remaining = (target - filled).as_decimal().normalize();
1251
1252    let sent_size = sent_request.size;
1253    if sent_size > remaining {
1254        let mut corrective = sent_request;
1255        corrective.size = remaining;
1256
1257        let generation = state.mark_pending_modify(client_order_id, venue_order_id, target);
1258        state.stash_modify_request(client_order_id, corrective.clone());
1259        state.queue_corrective(
1260            client_order_id,
1261            CorrectiveReduce {
1262                oid: new_oid,
1263                generation,
1264                request: corrective,
1265            },
1266        );
1267
1268        log::warn!(
1269            "Cancel-replace left {client_order_id} oversized on {venue_order_id} \
1270             (sent {sent_size}, remaining {remaining}); queuing corrective reduce",
1271        );
1272    }
1273}
1274
1275fn handle_triggered(
1276    report: &OrderStatusReport,
1277    client_order_id: ClientOrderId,
1278    context: &OrderContext,
1279    state: &WsDispatchState,
1280    emitter: &ExecutionEventEmitter,
1281    ts_init: UnixNanos,
1282) -> DispatchOutcome {
1283    if !matches!(
1284        context.identity.order_type,
1285        OrderType::StopLimit | OrderType::TrailingStopLimit | OrderType::LimitIfTouched
1286    ) {
1287        log::debug!(
1288            "Ignoring TRIGGERED status for non-triggerable order type {:?}: {client_order_id}",
1289            context.identity.order_type,
1290        );
1291        return DispatchOutcome::Tracked;
1292    }
1293
1294    // A recovery read replays a trigger the stream or an earlier read already applied
1295    if !state.insert_triggered(client_order_id) {
1296        log::debug!("Skipping repeated TRIGGERED for {client_order_id}");
1297        return DispatchOutcome::Skip;
1298    }
1299
1300    ensure_accepted_emitted(
1301        client_order_id,
1302        report.venue_order_id,
1303        report.account_id,
1304        context,
1305        state,
1306        emitter,
1307        report.ts_last,
1308        ts_init,
1309    );
1310
1311    let triggered = OrderTriggered::new(
1312        emitter.trader_id(),
1313        context.identity.strategy_id,
1314        context.identity.instrument_id,
1315        client_order_id,
1316        UUID4::new(),
1317        report.ts_last,
1318        ts_init,
1319        false,
1320        Some(report.venue_order_id),
1321        Some(report.account_id),
1322    );
1323    emitter.send_order_event(OrderEventAny::Triggered(triggered));
1324    DispatchOutcome::Tracked
1325}
1326
1327fn handle_canceled(
1328    report: &OrderStatusReport,
1329    client_order_id: ClientOrderId,
1330    context: &OrderContext,
1331    state: &WsDispatchState,
1332    emitter: &ExecutionEventEmitter,
1333    ts_init: UnixNanos,
1334) -> DispatchOutcome {
1335    let venue_order_id = report.venue_order_id;
1336
1337    if let Some(cached_voi) = state.cached_venue_order_id(&client_order_id)
1338        && cached_voi != venue_order_id
1339    {
1340        if !state.has_pending_modify(&client_order_id)
1341            || venue_oid(venue_order_id) <= venue_oid(cached_voi)
1342        {
1343            log::debug!(
1344                "Skipping stale CANCELED for {venue_order_id} (cached {cached_voi}) on {client_order_id}",
1345            );
1346            return DispatchOutcome::Skip;
1347        }
1348
1349        let Some(price) = report.price.or(context.price) else {
1350            log::warn!(
1351                "Cannot promote canceled replacement for {client_order_id}: no report or cached price",
1352            );
1353            return DispatchOutcome::Skip;
1354        };
1355
1356        let quantity = state
1357            .pending_modify_target_qty(&client_order_id)
1358            .unwrap_or(report.quantity);
1359
1360        // The replacement can close before its acceptance arrives, promote without queuing
1361        // a corrective reduce against the already closed leg.
1362        promote_cancel_replace(
1363            client_order_id,
1364            context,
1365            state,
1366            emitter,
1367            venue_order_id,
1368            report.account_id,
1369            price,
1370            quantity,
1371            report.trigger_price,
1372            report.ts_last,
1373            ts_init,
1374        );
1375    }
1376
1377    // Cancel-before-accept race: an in-flight modify may deliver
1378    // CANCELED(old_voi) before the replacement ACCEPTED(new_voi). Any queued
1379    // intent whose old leg matches (marked before the HTTP call, cleared on
1380    // failure) holds that cancel so the later ACCEPTED routes through
1381    // OrderUpdated, and a failed modify releases it. See GH-3827.
1382    if state.hold_cancel(&client_order_id, report) {
1383        log::debug!(
1384            "Holding cancel-before-accept leg for {client_order_id}: venue_order_id={venue_order_id}",
1385        );
1386        return DispatchOutcome::Skip;
1387    }
1388
1389    if !claim_terminal_order(client_order_id, state, report.order_status) {
1390        return DispatchOutcome::Skip;
1391    }
1392
1393    ensure_accepted_emitted(
1394        client_order_id,
1395        venue_order_id,
1396        report.account_id,
1397        context,
1398        state,
1399        emitter,
1400        report.ts_last,
1401        ts_init,
1402    );
1403
1404    let canceled = OrderCanceled::new(
1405        emitter.trader_id(),
1406        context.identity.strategy_id,
1407        context.identity.instrument_id,
1408        client_order_id,
1409        UUID4::new(),
1410        report.ts_last,
1411        ts_init,
1412        false,
1413        Some(venue_order_id),
1414        Some(report.account_id),
1415        None,
1416    );
1417    emitter.send_order_event(OrderEventAny::Canceled(canceled));
1418
1419    state.cleanup_terminal(&client_order_id);
1420    DispatchOutcome::Tracked
1421}
1422
1423fn handle_expired(
1424    report: &OrderStatusReport,
1425    client_order_id: ClientOrderId,
1426    context: &OrderContext,
1427    state: &WsDispatchState,
1428    emitter: &ExecutionEventEmitter,
1429    ts_init: UnixNanos,
1430) -> DispatchOutcome {
1431    if !claim_terminal_order(client_order_id, state, report.order_status) {
1432        return DispatchOutcome::Skip;
1433    }
1434
1435    ensure_accepted_emitted(
1436        client_order_id,
1437        report.venue_order_id,
1438        report.account_id,
1439        context,
1440        state,
1441        emitter,
1442        report.ts_last,
1443        ts_init,
1444    );
1445
1446    let expired = OrderExpired::new(
1447        emitter.trader_id(),
1448        context.identity.strategy_id,
1449        context.identity.instrument_id,
1450        client_order_id,
1451        UUID4::new(),
1452        report.ts_last,
1453        ts_init,
1454        false,
1455        Some(report.venue_order_id),
1456        Some(report.account_id),
1457    );
1458    emitter.send_order_event(OrderEventAny::Expired(expired));
1459    state.cleanup_terminal(&client_order_id);
1460    DispatchOutcome::Tracked
1461}
1462
1463fn handle_rejected(
1464    report: &OrderStatusReport,
1465    client_order_id: ClientOrderId,
1466    context: &OrderContext,
1467    state: &WsDispatchState,
1468    emitter: &ExecutionEventEmitter,
1469    ts_init: UnixNanos,
1470) -> DispatchOutcome {
1471    if state.submission_pending(&client_order_id) {
1472        state.buffer_submission_rejection(client_order_id, report.clone());
1473        return DispatchOutcome::Skip;
1474    }
1475
1476    // A rejected replacement never became the bound order. Its original leg's
1477    // cancellation closes the order once the failed modify releases any held cancel.
1478    if let Some(bound) = state.cached_venue_order_id(&client_order_id)
1479        && bound != report.venue_order_id
1480    {
1481        return DispatchOutcome::Skip;
1482    }
1483
1484    {
1485        // Check and park atomically so the final fill cannot miss the pending rejection
1486        let mut pending = state.pending_rejections.lock();
1487        if !report.filled_qty.is_zero()
1488            && filled_qty_for_venue(state, client_order_id, report.venue_order_id)
1489                < report.filled_qty
1490        {
1491            pending
1492                .entry(client_order_id)
1493                .and_modify(|pending| retain_rejection_report(pending, report))
1494                .or_insert_with(|| report.clone());
1495            return DispatchOutcome::Skip;
1496        }
1497
1498        if pending.contains_key(&client_order_id) {
1499            return DispatchOutcome::Skip;
1500        }
1501    }
1502
1503    if !report.filled_qty.is_zero()
1504        || state
1505            .previous_filled_qty(&client_order_id)
1506            .is_some_and(|qty| !qty.is_zero())
1507    {
1508        return handle_canceled(report, client_order_id, context, state, emitter, ts_init);
1509    }
1510
1511    if !claim_terminal_order(client_order_id, state, report.order_status) {
1512        return DispatchOutcome::Skip;
1513    }
1514
1515    let reason = report
1516        .cancel_reason
1517        .clone()
1518        .unwrap_or_else(|| "Order rejected by exchange".to_string());
1519    let rejected = OrderRejected::new(
1520        emitter.trader_id(),
1521        context.identity.strategy_id,
1522        context.identity.instrument_id,
1523        client_order_id,
1524        report.account_id,
1525        Ustr::from(&reason),
1526        UUID4::new(),
1527        report.ts_last,
1528        ts_init,
1529        false,
1530        report.post_only && reason.contains(HYPERLIQUID_POST_ONLY_WOULD_MATCH),
1531    );
1532    emitter.send_order_event(OrderEventAny::Rejected(rejected));
1533    state.cleanup_terminal(&client_order_id);
1534    DispatchOutcome::Tracked
1535}
1536
1537fn retain_rejection_report(pending: &mut OrderStatusReport, report: &OrderStatusReport) {
1538    if report.venue_order_id != pending.venue_order_id || report.filled_qty >= pending.filled_qty {
1539        *pending = report.clone();
1540    }
1541}
1542
1543fn filled_qty_for_venue(
1544    state: &WsDispatchState,
1545    client_order_id: ClientOrderId,
1546    venue_order_id: VenueOrderId,
1547) -> Quantity {
1548    state
1549        .order_filled_qty_by_venue
1550        .lock()
1551        .get(&client_order_id)
1552        .and_then(|fills| fills.get(&venue_order_id).copied())
1553        .unwrap_or_else(|| Quantity::zero(0))
1554}
1555
1556fn claim_terminal_order(
1557    client_order_id: ClientOrderId,
1558    state: &WsDispatchState,
1559    status: OrderStatus,
1560) -> bool {
1561    let claimed = state.insert_filled(client_order_id);
1562    if !claimed {
1563        log::debug!("Skipping duplicate terminal event for {client_order_id}: status={status:?}",);
1564    }
1565
1566    claimed
1567}
1568
1569fn handle_filled_marker(
1570    _client_order_id: ClientOrderId,
1571    _state: &WsDispatchState,
1572) -> DispatchOutcome {
1573    // A status-only `FILLED` marker does not carry fill data; the actual
1574    // `OrderFilled` is emitted from `dispatch_order_fill` when the matching
1575    // trade arrives. Do *not* set `filled_orders` here, otherwise the
1576    // follow-up fill would be classified as a stale replay and dropped
1577    // before the terminal `OrderFilled` event can be emitted. The fill
1578    // path installs the marker itself once the cumulative fill quantity
1579    // matches the tracked order quantity.
1580    DispatchOutcome::Tracked
1581}
1582
1583/// Synthesizes and emits an `OrderAccepted` event when one has not yet been
1584/// emitted for the given order.
1585///
1586/// Used before emitting non-Accepted events so strategies always observe the
1587/// canonical `Submitted -> Accepted -> ...` lifecycle even when the venue
1588/// compresses the placement and follow-up event into a single message (fast
1589/// fills).
1590#[allow(clippy::too_many_arguments)]
1591fn ensure_accepted_emitted(
1592    client_order_id: ClientOrderId,
1593    venue_order_id: VenueOrderId,
1594    account_id: AccountId,
1595    context: &OrderContext,
1596    state: &WsDispatchState,
1597    emitter: &ExecutionEventEmitter,
1598    ts_event: UnixNanos,
1599    ts_init: UnixNanos,
1600) {
1601    if state.emitted_accepted.contains(&client_order_id) {
1602        return;
1603    }
1604    state.insert_accepted(client_order_id);
1605    state.record_venue_order_id(client_order_id, venue_order_id);
1606
1607    let accepted = OrderAccepted::new(
1608        emitter.trader_id(),
1609        context.identity.strategy_id,
1610        context.identity.instrument_id,
1611        client_order_id,
1612        venue_order_id,
1613        account_id,
1614        UUID4::new(),
1615        ts_event,
1616        ts_init,
1617        false,
1618    );
1619    emitter.send_order_event(OrderEventAny::Accepted(accepted));
1620}
1621
1622// Hyperliquid assigns venue order IDs in increasing order, so a higher one is a newer leg
1623pub(crate) fn venue_oid(venue_order_id: VenueOrderId) -> u64 {
1624    venue_order_id.as_str().parse().unwrap_or_default()
1625}
1626
1627#[cfg(test)]
1628mod tests {
1629    use std::{sync::Arc, time::Duration};
1630
1631    use futures_util::FutureExt;
1632    use nautilus_common::{messages::ExecutionEvent, testing::wait_until};
1633    use nautilus_core::time::get_atomic_clock_realtime;
1634    use nautilus_live::execution::context::OrderIdentity;
1635    use nautilus_model::{
1636        enums::{AccountType, LiquiditySide, OrderSide, TimeInForce},
1637        identifiers::{ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId},
1638        types::Money,
1639    };
1640    use rstest::rstest;
1641    use rust_decimal::Decimal;
1642
1643    use super::*;
1644    use crate::http::models::{
1645        HyperliquidExchangeLimitParams, HyperliquidExchangeOrderKind, HyperliquidExchangeTif,
1646    };
1647
1648    fn make_context(client_order_id: ClientOrderId) -> OrderContext {
1649        OrderContext {
1650            identity: OrderIdentity {
1651                client_order_id,
1652                strategy_id: StrategyId::from("S-001"),
1653                instrument_id: InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
1654                order_side: OrderSide::Buy,
1655                order_type: OrderType::Limit,
1656            },
1657            quantity: Quantity::from("0.0001"),
1658            price: None,
1659            trigger_price: None,
1660            trigger_type: None,
1661            time_in_force: TimeInForce::Gtc,
1662            is_post_only: false,
1663            is_reduce_only: false,
1664            is_quote_quantity: false,
1665        }
1666    }
1667
1668    #[rstest]
1669    fn test_rejection_registers_atomically_with_fill_completion() {
1670        let state = Arc::new(WsDispatchState::new());
1671        let (emitter, mut rx) = test_emitter();
1672        let cid = ClientOrderId::new("O-REJECTION-RACE");
1673        let context = make_context(cid);
1674        state.register_context(context);
1675
1676        let first = FillReport::new(
1677            AccountId::new("HYPERLIQUID-001"),
1678            context.identity.instrument_id,
1679            VenueOrderId::new("100"),
1680            TradeId::new("T-RACE-1"),
1681            OrderSide::Buy,
1682            Quantity::from("0.00003"),
1683            Price::from("56730.0"),
1684            Money::from("0.17 USD"),
1685            LiquiditySide::Taker,
1686            Some(cid),
1687            None,
1688            UnixNanos::default(),
1689            UnixNanos::default(),
1690            None,
1691        );
1692        dispatch_order_fill(&first, &state, &emitter, UnixNanos::default());
1693
1694        let rejection = OrderStatusReport::new(
1695            first.account_id,
1696            first.instrument_id,
1697            Some(cid),
1698            first.venue_order_id,
1699            OrderSide::Buy.into(),
1700            OrderType::Limit,
1701            TimeInForce::Gtc,
1702            OrderStatus::Rejected,
1703            context.quantity,
1704            Quantity::from("0.00005"),
1705            UnixNanos::default(),
1706            UnixNanos::default(),
1707            UnixNanos::default(),
1708            None,
1709        );
1710        let fills = state.order_filled_qty_by_venue.lock();
1711        let reject_state = state.clone();
1712        let reject_emitter = emitter.clone();
1713
1714        let rejected = std::thread::spawn(move || {
1715            dispatch_order_event(
1716                &rejection,
1717                &reject_state,
1718                &reject_emitter,
1719                UnixNanos::default(),
1720            )
1721        });
1722
1723        let timeout = Duration::from_secs(5);
1724        wait_until(|| state.pending_rejections.is_locked(), timeout);
1725        let mut last = first;
1726        last.trade_id = TradeId::new("T-RACE-2");
1727        last.last_qty = Quantity::from("0.00002");
1728        last.commission = Money::from("0.29 USD");
1729        let fill_state = state.clone();
1730        let fill_emitter = emitter.clone();
1731        let filled = std::thread::spawn(move || {
1732            dispatch_order_fill(&last, &fill_state, &fill_emitter, UnixNanos::default())
1733        });
1734
1735        wait_until(
1736            || state.previous_filled_qty(&cid) == Some(Quantity::from("0.00005")),
1737            timeout,
1738        );
1739        drop(fills);
1740        rejected.join().unwrap();
1741        assert_eq!(filled.join().unwrap(), DispatchOutcome::Tracked);
1742
1743        if let Some(released) = state.recv_released_cancels().now_or_never() {
1744            assert_eq!(released.len(), 1);
1745            dispatch_order_event(&released[0], &state, &emitter, UnixNanos::default());
1746        }
1747
1748        let mut events = Vec::new();
1749        while let Ok(event) = rx.try_recv() {
1750            events.push(event);
1751        }
1752
1753        let [
1754            ExecutionEvent::Order(OrderEventAny::Accepted(_)),
1755            ExecutionEvent::Order(OrderEventAny::Filled(first)),
1756            ExecutionEvent::Order(OrderEventAny::Filled(last)),
1757            ExecutionEvent::Order(OrderEventAny::Canceled(_)),
1758        ] = events.as_slice()
1759        else {
1760            panic!("expected two fills followed by cancellation: {events:?}");
1761        };
1762
1763        assert_eq!(first.trade_id, TradeId::new("T-RACE-1"));
1764        assert_eq!(first.last_qty, Quantity::from("0.00003"));
1765        assert_eq!(first.commission, Some(Money::from("0.17 USD")));
1766        assert_eq!(last.trade_id, TradeId::new("T-RACE-2"));
1767        assert_eq!(last.last_qty, Quantity::from("0.00002"));
1768        assert_eq!(last.commission, Some(Money::from("0.29 USD")));
1769        assert_eq!(state.lookup_context(&cid), None);
1770        assert!(state.filled_orders.contains(&cid));
1771    }
1772
1773    #[rstest]
1774    fn test_register_and_lookup_context() {
1775        let state = WsDispatchState::new();
1776        let cid = ClientOrderId::new("O-001");
1777        state.register_context(make_context(cid));
1778
1779        assert_eq!(state.lookup_context(&cid), Some(make_context(cid)));
1780    }
1781
1782    #[rstest]
1783    fn test_lookup_context_missing_returns_none() {
1784        let state = WsDispatchState::new();
1785        let cid = ClientOrderId::new("not-tracked");
1786        assert!(state.lookup_context(&cid).is_none());
1787    }
1788
1789    #[rstest]
1790    fn test_update_context_refreshes_price_and_quantity() {
1791        let state = WsDispatchState::new();
1792        let cid = ClientOrderId::new("O-003");
1793        state.register_context(make_context(cid));
1794
1795        state.update_context_price(&cid, Some(Price::from("56731.5")));
1796        state.update_context_quantity(&cid, Quantity::from("0.0002"));
1797
1798        assert_eq!(
1799            state.lookup_context(&cid),
1800            Some(OrderContext {
1801                price: Some(Price::from("56731.5")),
1802                quantity: Quantity::from("0.0002"),
1803                ..make_context(cid)
1804            }),
1805        );
1806    }
1807
1808    #[rstest]
1809    fn test_update_context_price_without_price_keeps_cached_price() {
1810        let state = WsDispatchState::new();
1811        let cid = ClientOrderId::new("O-004");
1812        let cached = OrderContext {
1813            price: Some(Price::from("56730.0")),
1814            ..make_context(cid)
1815        };
1816        state.register_context(cached);
1817
1818        state.update_context_price(&cid, None);
1819
1820        assert_eq!(state.lookup_context(&cid), Some(cached));
1821    }
1822
1823    #[rstest]
1824    fn test_insert_accepted_dedup() {
1825        let state = WsDispatchState::new();
1826        let cid = ClientOrderId::new("O-002");
1827        assert!(!state.emitted_accepted.contains(&cid));
1828        state.insert_accepted(cid);
1829        assert!(state.emitted_accepted.contains(&cid));
1830        state.insert_accepted(cid);
1831        assert!(state.emitted_accepted.contains(&cid));
1832    }
1833
1834    #[rstest]
1835    fn test_check_and_insert_trade_detects_duplicates() {
1836        let state = WsDispatchState::new();
1837        let trade = TradeId::new("trade-1");
1838        assert!(!state.check_and_insert_trade(trade));
1839        assert!(state.check_and_insert_trade(trade));
1840    }
1841
1842    #[rstest]
1843    fn test_pending_modify_roundtrip() {
1844        let state = WsDispatchState::new();
1845        let cid = ClientOrderId::new("O-010");
1846        let voi = VenueOrderId::new("v-1");
1847        let target_qty = Quantity::from("0.0001");
1848
1849        assert!(state.pending_modify(&cid).is_none());
1850        assert!(state.pending_modify_target_qty(&cid).is_none());
1851        state.mark_pending_modify(cid, voi, target_qty);
1852        assert_eq!(state.pending_modify(&cid), Some(voi));
1853        assert_eq!(state.pending_modify_target_qty(&cid), Some(target_qty));
1854        state.clear_pending_modify(&cid);
1855        assert!(state.pending_modify(&cid).is_none());
1856        assert!(state.pending_modify_target_qty(&cid).is_none());
1857    }
1858
1859    #[rstest]
1860    fn test_cleanup_terminal_preserves_filled_marker() {
1861        let state = WsDispatchState::new();
1862        let cid = ClientOrderId::new("O-020");
1863        state.register_context(make_context(cid));
1864        state.insert_accepted(cid);
1865        state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.0001"));
1866        state.insert_filled(cid);
1867        state.cleanup_terminal(&cid);
1868
1869        assert!(state.lookup_context(&cid).is_none());
1870        assert!(!state.emitted_accepted.contains(&cid));
1871        assert!(state.pending_modify(&cid).is_none());
1872        assert!(state.pending_modify_target_qty(&cid).is_none());
1873        // `filled_orders` outlives `cleanup_terminal` so replays stay suppressed.
1874        assert!(state.filled_orders.contains(&cid));
1875    }
1876
1877    #[rstest]
1878    fn test_cleanup_terminal_clears_corrective_state() {
1879        let state = WsDispatchState::new();
1880        let cid = ClientOrderId::new("O-021");
1881        let request = sample_request(Decimal::from(1));
1882        state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("1"));
1883        state.stash_modify_request(cid, request.clone());
1884        state.queue_corrective(
1885            cid,
1886            CorrectiveReduce {
1887                oid: 1,
1888                generation: 0,
1889                request,
1890            },
1891        );
1892
1893        assert!(state.modify_request(&cid).is_some());
1894
1895        state.cleanup_terminal(&cid);
1896
1897        assert!(state.modify_request(&cid).is_none());
1898        assert!(state.take_corrective(&cid).is_none());
1899        assert!(state.pending_modify(&cid).is_none());
1900    }
1901
1902    fn sample_request(size: Decimal) -> HyperliquidExchangePlaceOrderRequest {
1903        HyperliquidExchangePlaceOrderRequest {
1904            asset: 0,
1905            is_buy: true,
1906            price: "100".parse::<Decimal>().unwrap(),
1907            size,
1908            reduce_only: false,
1909            kind: HyperliquidExchangeOrderKind::Limit {
1910                limit: HyperliquidExchangeLimitParams {
1911                    tif: HyperliquidExchangeTif::Gtc,
1912                },
1913            },
1914            cloid: None,
1915        }
1916    }
1917
1918    #[rstest]
1919    fn test_modify_chain_keeps_both_intents_on_rapid_modifies() {
1920        let state = WsDispatchState::new();
1921        let cid = ClientOrderId::new("O-100");
1922        let g0 =
1923            state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1924        let g1 =
1925            state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00030"));
1926
1927        assert_ne!(g0, g1);
1928        assert!(state.has_pending_modify(&cid));
1929        // Front is the oldest intent
1930        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
1931        assert_eq!(
1932            state.pending_modify_target_qty(&cid),
1933            Some(Quantity::from("0.00020")),
1934        );
1935        // Both queued old legs suppress their cancel-before-accept
1936        assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1937        assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
1938    }
1939
1940    #[rstest]
1941    fn test_clear_modify_generation_preserves_newer_intent() {
1942        let state = WsDispatchState::new();
1943        let cid = ClientOrderId::new("O-101");
1944        // Two rapid modifies queued before either acked, both against the live
1945        // leg v-0.
1946        let g0 =
1947            state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1948        state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
1949
1950        // Failure of the first modify clears only its generation; the second
1951        // stays, still targeting the live leg v-0.
1952        state.clear_modify_generation(&cid, g0);
1953
1954        assert!(state.has_pending_modify(&cid));
1955        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
1956        assert_eq!(
1957            state.pending_modify_target_qty(&cid),
1958            Some(Quantity::from("0.00030")),
1959        );
1960        assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1961    }
1962
1963    #[rstest]
1964    fn test_claim_front_modify_advances_next_old_id() {
1965        let state = WsDispatchState::new();
1966        let cid = ClientOrderId::new("O-102");
1967        // Both queued against the same stale old leg (M2 fired before M1 acked)
1968        state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
1969        state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
1970
1971        // Promoting the first replacement claims the front and advances the
1972        // next intent's old leg to the replacement id.
1973        let claimed = state.claim_front_modify(&cid, VenueOrderId::new("v-1"));
1974        assert_eq!(
1975            claimed.map(|i| i.target_qty),
1976            Some(Quantity::from("0.00020"))
1977        );
1978
1979        assert!(state.has_pending_modify(&cid));
1980        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
1981        assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
1982        // The stale leg no longer matches once advanced
1983        assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
1984
1985        // Claiming the last intent empties the chain
1986        let claimed2 = state.claim_front_modify(&cid, VenueOrderId::new("v-2"));
1987        assert_eq!(
1988            claimed2.map(|i| i.target_qty),
1989            Some(Quantity::from("0.00030"))
1990        );
1991        assert!(!state.has_pending_modify(&cid));
1992        assert!(state.pending_modify(&cid).is_none());
1993    }
1994
1995    #[rstest]
1996    fn test_modify_chain_caps_and_evicts_oldest() {
1997        let state = WsDispatchState::new();
1998        let cid = ClientOrderId::new("O-106");
1999        // Queue one past the cap with no promotion or clear to drain them
2000        for i in 0..=MAX_PENDING_MODIFY_INTENTS {
2001            let voi = format!("v-{i}");
2002            state.mark_pending_modify(cid, VenueOrderId::new(&voi), Quantity::from("0.00020"));
2003        }
2004
2005        // The oldest intent was evicted; the newest remains and the front
2006        // advanced to the second-oldest.
2007        assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
2008        let newest = format!("v-{MAX_PENDING_MODIFY_INTENTS}");
2009        assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new(&newest)));
2010        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
2011    }
2012
2013    #[rstest]
2014    fn test_clear_front_modify_reparents_next_old() {
2015        let state = WsDispatchState::new();
2016        let cid = ClientOrderId::new("O-104");
2017        // Three rapid modifies where the first already promoted to v-1
2018        // (advancing the front to old=v-1); the third still holds stale v-0.
2019        let g_front =
2020            state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00020"));
2021        state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
2022
2023        // The front is rejected; the next intent must inherit the live leg
2024        // (v-1), not keep stale v-0, or CANCELED(v-1) would surface as a real
2025        // cancel.
2026        state.clear_modify_generation(&cid, g_front);
2027
2028        assert!(state.has_pending_modify(&cid));
2029        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-1")));
2030        assert!(state.pending_modify_contains_old(&cid, VenueOrderId::new("v-1")));
2031        assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-0")));
2032    }
2033
2034    #[rstest]
2035    fn test_clear_non_front_modify_leaves_front_old() {
2036        let state = WsDispatchState::new();
2037        let cid = ClientOrderId::new("O-105");
2038        state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
2039        let g_back =
2040            state.mark_pending_modify(cid, VenueOrderId::new("v-9"), Quantity::from("0.00030"));
2041
2042        // Removing a non-front intent must not disturb the front's old leg
2043        state.clear_modify_generation(&cid, g_back);
2044
2045        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new("v-0")));
2046        assert!(!state.pending_modify_contains_old(&cid, VenueOrderId::new("v-9")));
2047    }
2048
2049    #[rstest]
2050    fn test_stash_modify_request_targets_latest_intent() {
2051        let state = WsDispatchState::new();
2052        let cid = ClientOrderId::new("O-103");
2053        state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
2054        state.stash_modify_request(cid, sample_request(Decimal::from(1)));
2055        state.mark_pending_modify(cid, VenueOrderId::new("v-1"), Quantity::from("0.00030"));
2056        state.stash_modify_request(cid, sample_request(Decimal::from(2)));
2057
2058        // Front intent keeps its own request
2059        assert_eq!(
2060            state.modify_request(&cid).map(|r| r.size),
2061            Some(Decimal::from(1)),
2062        );
2063        // After claiming the front, the next intent's request surfaces
2064        state.claim_front_modify(&cid, VenueOrderId::new("v-1"));
2065        assert_eq!(
2066            state.modify_request(&cid).map(|r| r.size),
2067            Some(Decimal::from(2)),
2068        );
2069    }
2070
2071    fn canceled_report(client_order_id: ClientOrderId, venue_order_id: &str) -> OrderStatusReport {
2072        OrderStatusReport::new(
2073            AccountId::from("HYPERLIQUID-001"),
2074            InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
2075            Some(client_order_id),
2076            VenueOrderId::new(venue_order_id),
2077            Some(OrderSide::Buy),
2078            OrderType::Limit,
2079            TimeInForce::Gtc,
2080            OrderStatus::Canceled,
2081            Quantity::from("0.0001"),
2082            Quantity::from("0"),
2083            UnixNanos::default(),
2084            UnixNanos::default(),
2085            UnixNanos::default(),
2086            Some(UUID4::new()),
2087        )
2088    }
2089
2090    fn released_cancels(state: &WsDispatchState) -> Vec<OrderStatusReport> {
2091        state
2092            .recv_released_cancels()
2093            .now_or_never()
2094            .unwrap_or_default()
2095    }
2096
2097    fn test_emitter() -> (
2098        ExecutionEventEmitter,
2099        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2100    ) {
2101        let mut emitter = ExecutionEventEmitter::new(
2102            get_atomic_clock_realtime(),
2103            TraderId::from("TESTER-001"),
2104            AccountId::from("HYPERLIQUID-001"),
2105            AccountType::Margin,
2106            None,
2107        );
2108        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2109        emitter.set_sender(tx);
2110        (emitter, rx)
2111    }
2112
2113    #[rstest]
2114    #[case::bound_leg(Some("v-0"), true)]
2115    #[case::unbound_leg(None, true)]
2116    #[case::superseded_leg(Some("v-1"), false)]
2117    fn test_canceled_for_modified_leg_is_held_unless_superseded(
2118        #[case] bound_venue_order_id: Option<&str>,
2119        #[case] expected_held: bool,
2120    ) {
2121        let (emitter, mut rx) = test_emitter();
2122        let state = WsDispatchState::new();
2123        let cid = ClientOrderId::new("O-107");
2124        state.register_context(make_context(cid));
2125
2126        if let Some(voi) = bound_venue_order_id {
2127            state.record_venue_order_id(cid, VenueOrderId::new(voi));
2128        }
2129
2130        let generation =
2131            state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.0001"));
2132        let canceled = canceled_report(cid, "v-0");
2133
2134        let outcome = dispatch_order_event(&canceled, &state, &emitter, UnixNanos::default());
2135        state.clear_modify_generation(&cid, generation);
2136
2137        assert_eq!(outcome, DispatchOutcome::Skip);
2138        assert!(rx.try_recv().is_err());
2139        assert_eq!(
2140            released_cancels(&state),
2141            expected_held
2142                .then_some(canceled)
2143                .into_iter()
2144                .collect::<Vec<_>>(),
2145        );
2146    }
2147
2148    #[rstest]
2149    fn test_held_cancel_releases_when_last_modify_of_its_leg_fails() {
2150        let state = WsDispatchState::new();
2151        let cid = ClientOrderId::new("O-108");
2152        let leg = VenueOrderId::new("v-0");
2153        state.record_venue_order_id(cid, leg);
2154        let g0 = state.mark_pending_modify(cid, leg, Quantity::from("0.00020"));
2155        let g1 = state.mark_pending_modify(cid, leg, Quantity::from("0.00030"));
2156        let canceled = canceled_report(cid, "v-0");
2157
2158        let held = state.hold_cancel(&cid, &canceled);
2159        state.clear_modify_generation(&cid, g0);
2160        let released_while_targeted = released_cancels(&state);
2161        state.clear_modify_generation(&cid, g1);
2162
2163        assert!(held);
2164        assert_eq!(released_while_targeted, vec![]);
2165        assert_eq!(released_cancels(&state), vec![canceled]);
2166        assert!(!state.has_pending_modify(&cid));
2167    }
2168
2169    #[rstest]
2170    #[case::response_cancel_reject(["response", "cancel", "reject"])]
2171    #[case::response_reject_cancel(["response", "reject", "cancel"])]
2172    #[case::cancel_response_reject(["cancel", "response", "reject"])]
2173    #[case::cancel_reject_response(["cancel", "reject", "response"])]
2174    #[case::reject_response_cancel(["reject", "response", "cancel"])]
2175    #[case::reject_cancel_response(["reject", "cancel", "response"])]
2176    fn test_failed_modify_closes_original_once(
2177        #[case] sequence: [&str; 3],
2178        #[values(false, true)] filled: bool,
2179    ) {
2180        let (emitter, mut rx) = test_emitter();
2181        let state = WsDispatchState::new();
2182        let cid = ClientOrderId::new("O-MODIFY-REJECT");
2183        let original = VenueOrderId::new("100");
2184        state.register_context(make_context(cid));
2185        state.record_venue_order_id(cid, original);
2186        state.insert_accepted(cid);
2187        if filled {
2188            state.record_filled_qty(cid, Quantity::from("0.00004"));
2189        }
2190
2191        let generation = state.mark_pending_modify(cid, original, Quantity::from("0.0001"));
2192        let mut canceled = canceled_report(cid, "100");
2193        canceled.ts_last = UnixNanos::from(100);
2194        let mut rejected = canceled_report(cid, "101");
2195        rejected.order_status = OrderStatus::Rejected;
2196        rejected.ts_last = UnixNanos::from(200);
2197
2198        for action in sequence {
2199            match action {
2200                "response" => state.clear_modify_generation(&cid, generation),
2201                "cancel" => {
2202                    dispatch_order_event(&canceled, &state, &emitter, UnixNanos::default());
2203                }
2204                "reject" => {
2205                    dispatch_order_event(&rejected, &state, &emitter, UnixNanos::default());
2206                }
2207                _ => unreachable!(),
2208            }
2209
2210            for report in released_cancels(&state) {
2211                dispatch_order_event(&report, &state, &emitter, UnixNanos::default());
2212            }
2213        }
2214
2215        let canceled_replay =
2216            dispatch_order_event(&canceled, &state, &emitter, UnixNanos::default());
2217        let rejected_replay =
2218            dispatch_order_event(&rejected, &state, &emitter, UnixNanos::default());
2219        let event = rx.try_recv().unwrap();
2220
2221        let ExecutionEvent::Order(OrderEventAny::Canceled(event)) = event else {
2222            panic!("expected OrderCanceled, received {event:?}");
2223        };
2224
2225        assert_eq!(event.trader_id, emitter.trader_id());
2226        assert_eq!(event.strategy_id, StrategyId::from("S-001"));
2227        assert_eq!(
2228            event.instrument_id,
2229            InstrumentId::from("BTC-USD-PERP.HYPERLIQUID")
2230        );
2231        assert_eq!(event.client_order_id, cid);
2232        assert_eq!(event.venue_order_id, Some(original));
2233        assert_eq!(event.account_id, Some(canceled.account_id));
2234        assert_eq!(event.ts_event, canceled.ts_last);
2235        assert_eq!(event.ts_init, UnixNanos::default());
2236        assert!(!event.reconciliation);
2237        assert_eq!(event.reason, None);
2238        assert_eq!(event.causation_id, None);
2239        assert!(rx.try_recv().is_err());
2240        assert_eq!(canceled_replay, DispatchOutcome::Skip);
2241        assert_eq!(rejected_replay, DispatchOutcome::Skip);
2242        assert!(state.lookup_context(&cid).is_none());
2243        assert!(!state.has_pending_modify(&cid));
2244    }
2245
2246    #[rstest]
2247    fn test_promotion_drops_held_cancel() {
2248        let state = WsDispatchState::new();
2249        let cid = ClientOrderId::new("O-109");
2250        state.record_venue_order_id(cid, VenueOrderId::new("v-0"));
2251        state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00020"));
2252        let g1 =
2253            state.mark_pending_modify(cid, VenueOrderId::new("v-0"), Quantity::from("0.00030"));
2254
2255        // The replacement shows the cancel was the first modify's cancel leg, so the second
2256        // modify failing later must not apply it
2257        let held = state.hold_cancel(&cid, &canceled_report(cid, "v-0"));
2258        state.claim_front_modify(&cid, VenueOrderId::new("v-1"));
2259        state.clear_modify_generation(&cid, g1);
2260
2261        assert!(held);
2262        assert_eq!(released_cancels(&state), vec![]);
2263        assert!(!state.has_pending_modify(&cid));
2264    }
2265}