Skip to main content

nautilus_hyperliquid/
execution.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//! Live execution client implementation for the Hyperliquid adapter.
17
18use std::{
19    sync::{Arc, Mutex},
20    time::{Duration, Instant},
21};
22
23use anyhow::Context;
24use async_trait::async_trait;
25use nautilus_common::{
26    cache::fifo::FifoCache,
27    clients::ExecutionClient,
28    live::{runner::get_exec_event_sender, runtime::get_runtime},
29    messages::execution::{
30        BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
31        GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
32        ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
33    },
34};
35use nautilus_core::{
36    MUTEX_POISONED, Params, UUID4, UnixNanos,
37    time::{AtomicTime, get_atomic_clock_realtime},
38};
39use nautilus_live::{ExecutionClientCore, ExecutionEventEmitter};
40use nautilus_model::{
41    accounts::AccountAny,
42    enums::{AccountType, OmsType, OrderSide, OrderStatus, OrderType},
43    identifiers::{
44        AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, Venue, VenueOrderId,
45    },
46    orders::{Order, any::OrderAny},
47    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
48    types::{AccountBalance, MarginBalance},
49};
50use tokio::task::JoinHandle;
51use ustr::Ustr;
52
53use crate::{
54    account::resolve_execution_account_address,
55    common::{
56        consts::{
57            HYPERLIQUID_BUILDER_APPROVAL_DOCS_URL, HYPERLIQUID_BUILDER_FEE_NOT_APPROVED,
58            HYPERLIQUID_VENUE,
59        },
60        credential::Secrets,
61        enums::HyperliquidProductType,
62        parse::{
63            clamp_price_to_precision, derive_limit_from_trigger, derive_market_order_price,
64            extract_error_message, extract_inner_error, extract_inner_errors, normalize_price,
65            order_to_hyperliquid_request_with_asset_and_cloid,
66            parse_combined_account_balances_and_margins, round_to_sig_figs,
67        },
68    },
69    config::HyperliquidExecClientConfig,
70    http::{
71        client::HyperliquidHttpClient,
72        models::{
73            ClearinghouseState, Cloid, HyperliquidExecAction, HyperliquidExecCancelByCloidRequest,
74            HyperliquidExecCancelOrderRequest, HyperliquidExecGrouping,
75            HyperliquidExecModifyOrderRequest, HyperliquidExecOrderKind,
76            HyperliquidExecPlaceOrderRequest, SpotClearinghouseState,
77        },
78        parse::derive_outcome_settlements,
79    },
80    outcome_settlement::{OutcomeSettlementTracker, build_settlement_fills},
81    websocket::{
82        ExecutionReport, NautilusWsMessage,
83        client::HyperliquidWebSocketClient,
84        dispatch::{
85            DispatchOutcome, OrderIdentity, WsDispatchState, dispatch_order_event,
86            dispatch_order_fill, promote_replacement_from_query,
87        },
88    },
89};
90
91#[derive(Debug)]
92pub struct HyperliquidExecutionClient {
93    core: ExecutionClientCore,
94    clock: &'static AtomicTime,
95    config: HyperliquidExecClientConfig,
96    emitter: ExecutionEventEmitter,
97    http_client: HyperliquidHttpClient,
98    ws_client: HyperliquidWebSocketClient,
99    pending_tasks: Mutex<Vec<JoinHandle<()>>>,
100    ws_stream_handle: Mutex<Option<JoinHandle<()>>>,
101    settlement_poll_handle: Mutex<Option<JoinHandle<()>>>,
102    ws_dispatch_state: Arc<WsDispatchState>,
103    outcome_settlement_tracker: Arc<Mutex<OutcomeSettlementTracker>>,
104}
105
106impl HyperliquidExecutionClient {
107    /// Returns a reference to the configuration.
108    pub fn config(&self) -> &HyperliquidExecClientConfig {
109        &self.config
110    }
111
112    /// Returns a reference to the shared WebSocket dispatch state.
113    ///
114    /// Exposes the identity map, pending-modify markers, and cached venue
115    /// order ids used by the two-tier dispatch contract. The state is
116    /// read-write via an [`Arc`]; callers must not mutate it directly, but
117    /// it is useful for inspection in tests and for live debugging.
118    #[must_use]
119    pub fn ws_dispatch_state(&self) -> &Arc<WsDispatchState> {
120        &self.ws_dispatch_state
121    }
122
123    /// Returns `true` when every background task spawned via `spawn_task`
124    /// has completed.
125    ///
126    /// Used in tests to wait for submit / modify / cancel action round-trips
127    /// that fire on the runtime to finish before asserting on dispatch
128    /// state, avoiding bare `sleep` calls when a negative condition needs
129    /// to be checked after the spawned work is done.
130    #[allow(
131        clippy::missing_panics_doc,
132        reason = "pending_tasks mutex poisoning is not expected"
133    )]
134    #[must_use]
135    pub fn pending_tasks_all_finished(&self) -> bool {
136        let tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
137        tasks.iter().all(|h| h.is_finished())
138    }
139
140    fn resolve_slippage_bps(&self, params: Option<&Params>) -> u32 {
141        params
142            .and_then(|p| p.get_u64("market_order_slippage_bps"))
143            .map_or(self.config.market_order_slippage_bps, |v| v as u32)
144    }
145
146    fn validate_order_submission(&self, order: &OrderAny) -> anyhow::Result<()> {
147        validate_order_for_hyperliquid(order)
148    }
149
150    /// Creates a new [`HyperliquidExecutionClient`].
151    ///
152    /// # Errors
153    ///
154    /// Returns an error if either the HTTP or WebSocket client fail to construct.
155    pub fn new(
156        core: ExecutionClientCore,
157        config: HyperliquidExecClientConfig,
158    ) -> anyhow::Result<Self> {
159        let secrets = Secrets::resolve(
160            config.private_key.as_deref(),
161            config.vault_address.as_deref(),
162            config.environment,
163        )
164        .context("Hyperliquid execution client requires private key")?;
165
166        let account_address = resolve_execution_account_address(
167            config.private_key.as_deref(),
168            config.vault_address.as_deref(),
169            config.account_address.as_deref(),
170            config.environment,
171        )?;
172
173        let mut http_client = HyperliquidHttpClient::with_secrets(
174            &secrets,
175            config.http_timeout_secs,
176            config.proxy_url.clone(),
177        )
178        .context("failed to create Hyperliquid HTTP client")?;
179
180        http_client.set_account_id(core.account_id);
181        http_client.set_account_address(account_address);
182        http_client.set_normalize_prices(config.normalize_prices);
183        http_client.set_market_order_slippage_bps(config.market_order_slippage_bps);
184        http_client.set_include_builder_attribution(config.include_builder_attribution);
185
186        // Apply URL overrides from config (used for testing with mock servers)
187        if let Some(url) = &config.base_url_http {
188            http_client.set_base_info_url(url.clone());
189        }
190
191        if let Some(url) = &config.base_url_exchange {
192            http_client.set_base_exchange_url(url.clone());
193        }
194
195        let ws_url = config.base_url_ws.clone();
196        let mut ws_client = HyperliquidWebSocketClient::new(
197            ws_url,
198            config.environment,
199            Some(core.account_id),
200            config.transport_backend,
201            config.proxy_url.clone(),
202        );
203        ws_client.set_post_timeout(Duration::from_secs(config.ws_post_timeout_secs));
204
205        let clock = get_atomic_clock_realtime();
206        let emitter = ExecutionEventEmitter::new(
207            clock,
208            core.trader_id,
209            core.account_id,
210            AccountType::Margin,
211            None,
212        );
213
214        Ok(Self {
215            core,
216            clock,
217            config,
218            emitter,
219            http_client,
220            ws_client,
221            pending_tasks: Mutex::new(Vec::new()),
222            ws_stream_handle: Mutex::new(None),
223            settlement_poll_handle: Mutex::new(None),
224            ws_dispatch_state: Arc::new(WsDispatchState::new()),
225            outcome_settlement_tracker: Arc::new(Mutex::new(OutcomeSettlementTracker::new())),
226        })
227    }
228
229    fn register_order_identity(&self, order: &OrderAny) {
230        register_order_identity_into(&self.ws_dispatch_state, order);
231    }
232
233    async fn ensure_instruments_initialized_async(&self) -> anyhow::Result<()> {
234        if self.core.instruments_initialized() {
235            return Ok(());
236        }
237
238        let instruments = self
239            .http_client
240            .request_instruments()
241            .await
242            .context("failed to request Hyperliquid instruments")?;
243
244        if instruments.is_empty() {
245            log::warn!(
246                "Instrument bootstrap yielded no instruments; WebSocket submissions may fail"
247            );
248        } else {
249            log::debug!("Initialized {} instruments", instruments.len());
250
251            for instrument in &instruments {
252                self.http_client.cache_instrument(instrument);
253            }
254        }
255
256        self.core.set_instruments_initialized();
257        Ok(())
258    }
259
260    async fn refresh_account_state(&self) -> anyhow::Result<()> {
261        let account_address = self.get_account_address()?;
262
263        let (perp_state, spot_state) = self
264            .fetch_combined_clearinghouse_state(&account_address)
265            .await?;
266
267        log::debug!(
268            "Received clearinghouse state: cross_margin_summary={:?}, asset_positions={}, spot_balances={}",
269            perp_state.cross_margin_summary,
270            perp_state.asset_positions.len(),
271            spot_state.balances.len(),
272        );
273
274        let (balances, margins) =
275            parse_combined_account_balances_and_margins(&perp_state, &spot_state)
276                .context("failed to parse combined account balances and margins")?;
277
278        // Emit even when both sides are empty so the account registers for
279        // await_account_registered on unfunded wallets.
280        let ts_event = self.clock.get_time_ns();
281        self.emitter
282            .emit_account_state(balances, margins, true, ts_event);
283
284        log::debug!("Account state updated successfully");
285        Ok(())
286    }
287
288    async fn fetch_combined_clearinghouse_state(
289        &self,
290        account_address: &str,
291    ) -> anyhow::Result<(ClearinghouseState, SpotClearinghouseState)> {
292        let perp_json = self
293            .http_client
294            .info_clearinghouse_state(account_address)
295            .await
296            .context("failed to fetch clearinghouse state")?;
297        let perp_state: ClearinghouseState = serde_json::from_value(perp_json)
298            .context("failed to deserialize clearinghouse state")?;
299
300        let spot_json = self
301            .http_client
302            .info_spot_clearinghouse_state(account_address)
303            .await
304            .context("failed to fetch spot clearinghouse state")?;
305        let spot_state: SpotClearinghouseState = serde_json::from_value(spot_json)
306            .context("failed to deserialize spot clearinghouse state")?;
307
308        Ok((perp_state, spot_state))
309    }
310
311    async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
312        let account_id = self.core.account_id;
313
314        if self.core.cache().account(&account_id).is_some() {
315            log::info!("Account {account_id} registered");
316            return Ok(());
317        }
318
319        let start = Instant::now();
320        let timeout = Duration::from_secs_f64(timeout_secs);
321        let interval = Duration::from_millis(10);
322
323        loop {
324            tokio::time::sleep(interval).await;
325
326            if self.core.cache().account(&account_id).is_some() {
327                log::info!("Account {account_id} registered");
328                return Ok(());
329            }
330
331            if start.elapsed() >= timeout {
332                anyhow::bail!(
333                    "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
334                );
335            }
336        }
337    }
338
339    fn get_account_address(&self) -> anyhow::Result<String> {
340        self.http_client
341            .get_account_address()
342            .context("failed to get account address from HTTP client")
343    }
344
345    fn spawn_task<F>(&self, description: &'static str, fut: F)
346    where
347        F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
348    {
349        let runtime = get_runtime();
350        let handle = runtime.spawn(async move {
351            if let Err(e) = fut.await {
352                log::warn!("{description} failed: {e:?}");
353            }
354        });
355
356        let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
357        tasks.retain(|handle| !handle.is_finished());
358        tasks.push(handle);
359    }
360
361    fn start_outcome_settlement_poll(&self) -> anyhow::Result<()> {
362        let poll_secs = self.config.outcome_settlement_poll_secs;
363        if poll_secs == 0 {
364            log::debug!("Outcome settlement polling disabled by config");
365            return Ok(());
366        }
367
368        let http_client = self.http_client.clone();
369        let emitter = self.emitter.clone();
370        let tracker = self.outcome_settlement_tracker.clone();
371        let account_id = self.core.account_id;
372        let account_address = self.get_account_address()?;
373        let clock = self.clock;
374
375        // Stored on a dedicated handle so this long-running loop does not block
376        // `pending_tasks_all_finished` used by tests for short-lived RPCs.
377        let handle = get_runtime().spawn(async move {
378            let mut interval = tokio::time::interval(Duration::from_secs(poll_secs));
379            interval.tick().await;
380
381            loop {
382                interval.tick().await;
383
384                let meta = match http_client.get_outcome_meta().await {
385                    Ok(meta) => meta,
386                    Err(e) => {
387                        log::warn!("Outcome meta poll failed: {e}");
388                        continue;
389                    }
390                };
391
392                let settlements = derive_outcome_settlements(&meta);
393                if settlements.is_empty() {
394                    continue;
395                }
396
397                let spot_json = match http_client
398                    .info_spot_clearinghouse_state(&account_address)
399                    .await
400                {
401                    Ok(value) => value,
402                    Err(e) => {
403                        log::warn!("Settlement dispatch skipped: spot state fetch failed: {e}");
404                        continue;
405                    }
406                };
407                let spot_state: SpotClearinghouseState = match serde_json::from_value(spot_json) {
408                    Ok(state) => state,
409                    Err(e) => {
410                        log::warn!("Settlement dispatch skipped: spot state parse failed: {e}");
411                        continue;
412                    }
413                };
414
415                let ts = clock.get_time_ns();
416                let fills = {
417                    let mut guard = tracker.lock().expect(MUTEX_POISONED);
418                    build_settlement_fills(&settlements, &spot_state, &mut guard, account_id, ts)
419                };
420
421                for fill in fills {
422                    log::debug!(
423                        "Dispatching outcome settlement fill: instrument={}, price={}, qty={}",
424                        fill.instrument_id,
425                        fill.last_px,
426                        fill.last_qty,
427                    );
428                    emitter.send_fill_report(fill);
429                }
430            }
431        });
432
433        let mut slot = self.settlement_poll_handle.lock().expect(MUTEX_POISONED);
434        if let Some(previous) = slot.replace(handle) {
435            previous.abort();
436        }
437
438        Ok(())
439    }
440
441    fn abort_pending_tasks(&self) {
442        let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
443        for handle in tasks.drain(..) {
444            handle.abort();
445        }
446    }
447}
448
449#[async_trait(?Send)]
450impl ExecutionClient for HyperliquidExecutionClient {
451    fn is_connected(&self) -> bool {
452        self.core.is_connected()
453    }
454
455    fn client_id(&self) -> ClientId {
456        self.core.client_id
457    }
458
459    fn account_id(&self) -> AccountId {
460        self.core.account_id
461    }
462
463    fn venue(&self) -> Venue {
464        *HYPERLIQUID_VENUE
465    }
466
467    fn oms_type(&self) -> OmsType {
468        self.core.oms_type
469    }
470
471    fn get_account(&self) -> Option<AccountAny> {
472        self.core.cache().account_owned(&self.core.account_id)
473    }
474
475    fn generate_account_state(
476        &self,
477        balances: Vec<AccountBalance>,
478        margins: Vec<MarginBalance>,
479        reported: bool,
480        ts_event: UnixNanos,
481    ) -> anyhow::Result<()> {
482        self.emitter
483            .emit_account_state(balances, margins, reported, ts_event);
484        Ok(())
485    }
486
487    fn start(&mut self) -> anyhow::Result<()> {
488        if self.core.is_started() {
489            return Ok(());
490        }
491
492        let sender = get_exec_event_sender();
493        self.emitter.set_sender(sender);
494        self.core.set_started();
495
496        log::info!(
497            "Started: client_id={}, account_id={}, environment={:?}, vault_address={:?}, proxy_url={:?}",
498            self.core.client_id,
499            self.core.account_id,
500            self.config.environment,
501            self.config.vault_address,
502            self.config.proxy_url,
503        );
504
505        Ok(())
506    }
507
508    fn stop(&mut self) -> anyhow::Result<()> {
509        if self.core.is_stopped() {
510            return Ok(());
511        }
512
513        log::info!("Stopping Hyperliquid execution client");
514
515        if let Some(handle) = self.ws_stream_handle.lock().expect(MUTEX_POISONED).take() {
516            handle.abort();
517        }
518
519        if let Some(handle) = self
520            .settlement_poll_handle
521            .lock()
522            .expect(MUTEX_POISONED)
523            .take()
524        {
525            handle.abort();
526        }
527
528        self.abort_pending_tasks();
529        self.ws_client.abort();
530
531        self.core.set_disconnected();
532        self.core.set_stopped();
533
534        log::info!("Hyperliquid execution client stopped");
535        Ok(())
536    }
537
538    fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
539        let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
540
541        if order.is_closed() {
542            log::warn!("Cannot submit closed order {}", order.client_order_id());
543            return Ok(());
544        }
545
546        if let Err(e) = self.validate_order_submission(&order) {
547            self.emitter
548                .emit_order_denied(&order, &format!("Validation failed: {e}"));
549            return Err(e);
550        }
551
552        let http_client = self.http_client.clone();
553        let symbol = order.instrument_id().symbol.inner();
554
555        // Validate asset index exists before marking as submitted
556        let asset = match http_client.get_asset_index_for_symbol(symbol) {
557            Some(a) => a,
558            None => {
559                self.emitter
560                    .emit_order_denied(&order, &format!("Asset index not found for {symbol}"));
561                return Ok(());
562            }
563        };
564
565        // Validate order conversion before marking as submitted
566        let price_decimals = http_client
567            .get_price_precision_for_symbol(symbol)
568            .unwrap_or(2);
569        let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
570        let mut hyperliquid_order = match order_to_hyperliquid_request_with_asset_and_cloid(
571            &order,
572            asset,
573            price_decimals,
574            self.config.normalize_prices,
575            slippage_bps,
576            None,
577        ) {
578            Ok(req) => req,
579            Err(e) => {
580                self.emitter
581                    .emit_order_denied(&order, &format!("Order conversion failed: {e}"));
582                return Ok(());
583            }
584        };
585        let cloid = http_client.get_or_generate_client_order_id_cloid(order.client_order_id());
586        hyperliquid_order.cloid = Some(cloid);
587        // Market orders need a limit price derived from the cached quote
588        if order.order_type() == OrderType::Market {
589            let instrument_id = order.instrument_id();
590            let cache = self.core.cache();
591            match cache.quote(&instrument_id) {
592                Some(quote) => {
593                    let is_buy = order.order_side() == OrderSide::Buy;
594                    hyperliquid_order.price =
595                        derive_market_order_price(quote, is_buy, price_decimals, slippage_bps);
596                }
597                None => {
598                    self.emitter.emit_order_denied(
599                        &order,
600                        &format!(
601                            "No cached quote for {instrument_id}: \
602                             subscribe to quote data before submitting market orders"
603                        ),
604                    );
605                    return Ok(());
606                }
607            }
608        }
609
610        log::debug!(
611            "Submitting order: id={}, type={:?}, side={:?}, price={}, size={}, kind={:?}",
612            order.client_order_id(),
613            order.order_type(),
614            order.order_side(),
615            hyperliquid_order.price,
616            hyperliquid_order.size,
617            hyperliquid_order.kind,
618        );
619
620        // Cache cloid mapping before emitting submitted so WS handler
621        // can resolve order/fill reports back to this client_order_id.
622        let cloid = hyperliquid_order
623            .cloid
624            .expect("order conversion must set a CLOID");
625        self.http_client
626            .cache_client_order_id_cloid(order.client_order_id(), cloid);
627        self.ws_client
628            .cache_cloid_mapping(Ustr::from(&cloid.to_hex()), order.client_order_id());
629
630        self.register_order_identity(&order);
631
632        self.emitter.emit_order_submitted(&order);
633
634        let emitter = self.emitter.clone();
635        let clock = self.clock;
636        let ws_client = self.ws_client.clone();
637        let cloid_hex = Ustr::from(&cloid.to_hex());
638        let dispatch_state = self.ws_dispatch_state.clone();
639
640        let builder = self.http_client.builder_attribution();
641
642        self.spawn_task("submit_order", async move {
643            let action = HyperliquidExecAction::Order {
644                orders: vec![hyperliquid_order],
645                grouping: HyperliquidExecGrouping::Na,
646                builder,
647            };
648
649            match ws_client.post_action_exec(&http_client, &action).await {
650                Ok(response) => {
651                    let rejection_route = PostRejectionRoute::new(
652                        &emitter,
653                        &ws_client,
654                        &http_client,
655                        dispatch_state.clone(),
656                    );
657
658                    if response.is_ok() {
659                        if let Some(inner_error) = extract_inner_error(&response) {
660                            log::warn!("Order submission rejected by exchange: {inner_error}");
661                            let ts = clock.get_time_ns();
662                            rejection_route.emit_once(&order, &inner_error, ts, &cloid_hex);
663                        } else {
664                            log::debug!("Order submitted successfully: {response:?}");
665                        }
666                    } else {
667                        let error_msg = extract_error_message(&response);
668                        log::warn!("Order submission rejected by exchange: {error_msg}");
669                        let ts = clock.get_time_ns();
670                        rejection_route.emit_once(&order, &error_msg, ts, &cloid_hex);
671                    }
672                }
673                Err(e) => {
674                    // Don't reject on transport errors: the order may have
675                    // landed and WS events will drive the lifecycle. If it
676                    // didn't land, reconciliation on reconnect resolves it.
677                    log::error!("Order submission WebSocket post request failed: {e}");
678                }
679            }
680
681            Ok(())
682        });
683
684        Ok(())
685    }
686
687    fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
688        log::debug!(
689            "Submitting order list with {} orders",
690            cmd.order_list.client_order_ids.len()
691        );
692
693        let http_client = self.http_client.clone();
694        let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
695
696        let orders = self.core.get_orders_for_list(&cmd.order_list)?;
697
698        // Validate all orders synchronously and collect valid ones
699        let mut valid_orders = Vec::new();
700        let mut hyperliquid_orders = Vec::new();
701
702        for order in &orders {
703            if let Err(e) = validate_order_for_hyperliquid(order) {
704                self.emitter
705                    .emit_order_denied(order, &format!("Validation failed: {e}"));
706                continue;
707            }
708
709            let symbol = order.instrument_id().symbol.inner();
710            let asset = match http_client.get_asset_index_for_symbol(symbol) {
711                Some(a) => a,
712                None => {
713                    self.emitter
714                        .emit_order_denied(order, &format!("Asset index not found for {symbol}"));
715                    continue;
716                }
717            };
718
719            let price_decimals = http_client
720                .get_price_precision_for_symbol(symbol)
721                .unwrap_or(2);
722
723            match order_to_hyperliquid_request_with_asset_and_cloid(
724                order,
725                asset,
726                price_decimals,
727                self.config.normalize_prices,
728                slippage_bps,
729                None,
730            ) {
731                Ok(mut req) => {
732                    let cloid = self
733                        .http_client
734                        .get_or_generate_client_order_id_cloid(order.client_order_id());
735                    req.cloid = Some(cloid);
736                    hyperliquid_orders.push(req);
737                    valid_orders.push(order.clone());
738                }
739                Err(e) => {
740                    self.emitter
741                        .emit_order_denied(order, &format!("Order conversion failed: {e}"));
742                }
743            }
744        }
745
746        if valid_orders.is_empty() {
747            log::warn!("No valid orders to submit in order list");
748            return Ok(());
749        }
750
751        let grouping = determine_order_list_grouping(&valid_orders);
752        log::debug!("Order list grouping: {grouping:?}");
753
754        for (order, request) in valid_orders.iter().zip(hyperliquid_orders.iter()) {
755            let cloid = request.cloid.expect("order conversion must set a CLOID");
756            self.http_client
757                .cache_client_order_id_cloid(order.client_order_id(), cloid);
758            self.ws_client
759                .cache_cloid_mapping(Ustr::from(&cloid.to_hex()), order.client_order_id());
760            self.register_order_identity(order);
761            self.emitter.emit_order_submitted(order);
762        }
763
764        let emitter = self.emitter.clone();
765        let clock = self.clock;
766        let ws_client = self.ws_client.clone();
767        let dispatch_state = self.ws_dispatch_state.clone();
768        let cloid_hexes: Vec<Ustr> = valid_orders
769            .iter()
770            .zip(hyperliquid_orders.iter())
771            .map(|(_, request)| {
772                Ustr::from(
773                    &request
774                        .cloid
775                        .expect("order conversion must set a CLOID")
776                        .to_hex(),
777                )
778            })
779            .collect();
780        let builder = self.http_client.builder_attribution();
781
782        self.spawn_task("submit_order_list", async move {
783            let action = HyperliquidExecAction::Order {
784                orders: hyperliquid_orders,
785                grouping,
786                builder,
787            };
788
789            match ws_client.post_action_exec(&http_client, &action).await {
790                Ok(response) => {
791                    let rejection_route = PostRejectionRoute::new(
792                        &emitter,
793                        &ws_client,
794                        &http_client,
795                        dispatch_state.clone(),
796                    );
797
798                    if response.is_ok() {
799                        let inner_errors = extract_inner_errors(&response);
800
801                        // For grouped orders (NormalTpsl/PositionTpsl), the
802                        // exchange returns a single status for the whole group
803                        // rather than one per order. If fewer statuses than
804                        // orders are returned, broadcast the first error (if
805                        // any) to all orders, or treat all as successful.
806                        if inner_errors.len() < valid_orders.len() {
807                            if let Some(error_msg) = inner_errors.iter().find_map(|e| e.as_ref()) {
808                                let ts = clock.get_time_ns();
809
810                                for (order, cloid_hex) in
811                                    valid_orders.iter().zip(cloid_hexes.iter())
812                                {
813                                    log::warn!(
814                                        "Order {} rejected by exchange: {error_msg}",
815                                        order.client_order_id(),
816                                    );
817                                    rejection_route.emit_once(order, error_msg, ts, cloid_hex);
818                                }
819                            } else {
820                                log::debug!("Order list submitted successfully: {response:?}");
821                            }
822                        } else if inner_errors.iter().any(|e| e.is_some()) {
823                            let ts = clock.get_time_ns();
824
825                            for (i, error) in inner_errors.iter().enumerate() {
826                                if let Some(error_msg) = error
827                                    && let Some(order) = valid_orders.get(i)
828                                    && let Some(cloid_hex) = cloid_hexes.get(i)
829                                {
830                                    log::warn!(
831                                        "Order {} rejected by exchange: {error_msg}",
832                                        order.client_order_id(),
833                                    );
834
835                                    rejection_route.emit_once(order, error_msg, ts, cloid_hex);
836                                }
837                            }
838                        } else {
839                            log::debug!("Order list submitted successfully: {response:?}");
840                        }
841                    } else {
842                        let error_msg = extract_error_message(&response);
843                        log::warn!("Order list submission rejected by exchange: {error_msg}");
844                        let ts = clock.get_time_ns();
845
846                        for (order, cloid_hex) in valid_orders.iter().zip(cloid_hexes.iter()) {
847                            rejection_route.emit_once(order, &error_msg, ts, cloid_hex);
848                        }
849                    }
850                }
851                Err(e) => {
852                    // Don't reject on transport errors: orders may have
853                    // landed and WS events will drive the lifecycle. If they
854                    // didn't land, reconciliation on reconnect resolves it.
855                    log::error!("Order list submission WebSocket post request failed: {e}");
856                }
857            }
858
859            Ok(())
860        });
861
862        Ok(())
863    }
864
865    fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
866        log::debug!("Modifying order: {cmd:?}");
867
868        let venue_order_id = match cmd.venue_order_id {
869            Some(id) => id,
870            None => {
871                let reason = "venue_order_id is required for modify";
872                log::warn!("Cannot modify order {}: {reason}", cmd.client_order_id);
873                self.emitter.emit_order_modify_rejected_event(
874                    cmd.strategy_id,
875                    cmd.instrument_id,
876                    cmd.client_order_id,
877                    None,
878                    reason,
879                    self.clock.get_time_ns(),
880                );
881                return Ok(());
882            }
883        };
884
885        let oid: u64 = match venue_order_id.as_str().parse() {
886            Ok(id) => id,
887            Err(e) => {
888                let reason = format!("Failed to parse venue_order_id '{venue_order_id}': {e}");
889                log::warn!("{reason}");
890                self.emitter.emit_order_modify_rejected_event(
891                    cmd.strategy_id,
892                    cmd.instrument_id,
893                    cmd.client_order_id,
894                    Some(venue_order_id),
895                    &reason,
896                    self.clock.get_time_ns(),
897                );
898                return Ok(());
899            }
900        };
901
902        // Look up cached order to get side, reduce_only, post_only, TIF
903        let order = match self
904            .core
905            .cache()
906            .order(&cmd.client_order_id)
907            .map(|o| o.clone())
908        {
909            Some(o) => o,
910            None => {
911                let reason = "order not found in cache";
912                log::warn!("Cannot modify order {}: {reason}", cmd.client_order_id);
913                self.emitter.emit_order_modify_rejected_event(
914                    cmd.strategy_id,
915                    cmd.instrument_id,
916                    cmd.client_order_id,
917                    Some(venue_order_id),
918                    reason,
919                    self.clock.get_time_ns(),
920                );
921                return Ok(());
922            }
923        };
924
925        let http_client = self.http_client.clone();
926        let symbol = cmd.instrument_id.symbol.inner();
927        let should_normalize = self.config.normalize_prices;
928        let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
929
930        // Hyperliquid modify is cancel-replace; subtract filled to avoid overfill.
931        let target_total_qty = cmd.quantity.unwrap_or(order.quantity());
932        let filled_qty = order.filled_qty();
933        if target_total_qty <= filled_qty {
934            let reason =
935                format!("modify quantity {target_total_qty} not greater than filled {filled_qty}",);
936            log::warn!("Cannot modify order {}: {reason}", cmd.client_order_id);
937
938            self.emitter.emit_order_modify_rejected_event(
939                cmd.strategy_id,
940                cmd.instrument_id,
941                cmd.client_order_id,
942                Some(venue_order_id),
943                &reason,
944                self.clock.get_time_ns(),
945            );
946            return Ok(());
947        }
948
949        let quantity = target_total_qty - filled_qty;
950        let price_decimals = http_client
951            .get_price_precision_for_symbol(symbol)
952            .unwrap_or(2);
953        let asset = match http_client.get_asset_index_for_symbol(symbol) {
954            Some(a) => a,
955            None => {
956                log::warn!(
957                    "Asset index not found for symbol {symbol}, ensure instruments are loaded",
958                );
959                return Ok(());
960            }
961        };
962
963        // Build base request from cached order (derives slippage-adjusted
964        // limit for trigger-market types like StopMarket/MarketIfTouched)
965        let mut hyperliquid_order = match order_to_hyperliquid_request_with_asset_and_cloid(
966            &order,
967            asset,
968            price_decimals,
969            should_normalize,
970            slippage_bps,
971            None,
972        ) {
973            Ok(mut req) => {
974                // Only override price when explicitly provided
975                if let Some(p) = cmd.price.or(order.price()) {
976                    let price_dec = p.as_decimal();
977                    req.price = if should_normalize {
978                        normalize_price(price_dec, price_decimals).normalize()
979                    } else {
980                        price_dec.normalize()
981                    };
982                } else if let Some(tp) = cmd.trigger_price {
983                    // Trigger changed but no explicit price: re-derive the
984                    // slippage-adjusted limit from the new trigger
985                    let is_buy = order.order_side() == OrderSide::Buy;
986                    let base = tp.as_decimal().normalize();
987                    let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
988                    let sig_rounded = round_to_sig_figs(derived, 5);
989                    req.price =
990                        clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
991                }
992                // else: keep the derived price from order_to_hyperliquid_request
993
994                req.size = quantity.as_decimal().normalize();
995
996                // Update trigger_px if the command provides a new trigger
997                if let (Some(tp), HyperliquidExecOrderKind::Trigger { trigger }) =
998                    (cmd.trigger_price, &mut req.kind)
999                {
1000                    let tp_dec = tp.as_decimal();
1001                    trigger.trigger_px = if should_normalize {
1002                        normalize_price(tp_dec, price_decimals).normalize()
1003                    } else {
1004                        tp_dec.normalize()
1005                    };
1006                }
1007
1008                req
1009            }
1010            Err(e) => {
1011                log::warn!("Order conversion failed for modify: {e}");
1012                return Ok(());
1013            }
1014        };
1015        let cloid = http_client.get_or_generate_client_order_id_cloid(order.client_order_id());
1016        hyperliquid_order.cloid = Some(cloid);
1017
1018        let dispatch_state = self.ws_dispatch_state.clone();
1019        let client_order_id = cmd.client_order_id;
1020        let old_venue_order_id = venue_order_id;
1021        let ws_client = self.ws_client.clone();
1022
1023        if let Some(cloid) = hyperliquid_order.cloid {
1024            http_client.cache_client_order_id_cloid(client_order_id, cloid);
1025            ws_client.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
1026        }
1027
1028        // Mark before the post await so an early CANCELED(old_voi) on the WS is suppressed.
1029        dispatch_state.mark_pending_modify(client_order_id, old_venue_order_id, target_total_qty);
1030        // Stashed so the cancel-replace promotion can reduce the replacement on an in-flight fill
1031        dispatch_state.stash_modify_request(client_order_id, hyperliquid_order.clone());
1032
1033        self.spawn_task("modify_order", async move {
1034            let action = HyperliquidExecAction::Modify {
1035                modify: HyperliquidExecModifyOrderRequest {
1036                    oid,
1037                    order: hyperliquid_order,
1038                },
1039            };
1040
1041            match ws_client.post_action_exec(&http_client, &action).await {
1042                Ok(response) => {
1043                    if response.is_ok() {
1044                        if let Some(inner_error) = extract_inner_error(&response) {
1045                            log::warn!("Order modification rejected by exchange: {inner_error}");
1046                            dispatch_state.clear_pending_modify(&client_order_id);
1047                        } else {
1048                            log::debug!("Order modified successfully: {response:?}");
1049                        }
1050                    } else {
1051                        let error_msg = extract_error_message(&response);
1052                        log::warn!("Order modification rejected by exchange: {error_msg}");
1053                        dispatch_state.clear_pending_modify(&client_order_id);
1054                    }
1055                }
1056                Err(e) => {
1057                    if e.is_transport_error() {
1058                        // Keep pending state so WS can reconcile target qty if the modify landed
1059                        log::warn!(
1060                            "Order modification transport failure for {client_order_id}: {e}; \
1061                             awaiting WS reconciliation",
1062                        );
1063                    } else {
1064                        log::warn!("Order modification WebSocket post request failed: {e}");
1065                        dispatch_state.clear_pending_modify(&client_order_id);
1066                    }
1067                }
1068            }
1069
1070            Ok(())
1071        });
1072
1073        Ok(())
1074    }
1075
1076    fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1077        log::debug!("Cancelling order: {cmd:?}");
1078
1079        let http_client = self.http_client.clone();
1080        let emitter = self.emitter.clone();
1081        let clock = self.clock;
1082        let client_order_id = cmd.client_order_id;
1083        let strategy_id = cmd.strategy_id;
1084        let instrument_id = cmd.instrument_id;
1085        let venue_order_id = cmd.venue_order_id;
1086        let symbol = cmd.instrument_id.symbol.inner();
1087        let ws_client = self.ws_client.clone();
1088
1089        self.spawn_task("cancel_order", async move {
1090            let asset = match http_client.get_asset_index_for_symbol(symbol) {
1091                Some(a) => a,
1092                None => {
1093                    log::warn!(
1094                        "Local cancel validation failed for {client_order_id}: Asset index not found for symbol {symbol}"
1095                    );
1096                    return Ok(());
1097                }
1098            };
1099
1100            let action =
1101                if let Some(cloid) = http_client.cached_client_order_id_cloid(&client_order_id) {
1102                    HyperliquidExecAction::CancelByCloid {
1103                        cancels: vec![HyperliquidExecCancelByCloidRequest { asset, cloid }],
1104                    }
1105                } else if let Some(venue_order_id) = venue_order_id {
1106                    match venue_order_id.as_str().parse::<u64>() {
1107                        Ok(oid) => HyperliquidExecAction::Cancel {
1108                            cancels: vec![HyperliquidExecCancelOrderRequest { asset, oid }],
1109                        },
1110                        Err(_) => {
1111                            log::warn!(
1112                                "Local cancel validation failed for {client_order_id}: Invalid venue order ID format"
1113                            );
1114                            return Ok(());
1115                        }
1116                    }
1117                } else {
1118                    let cloid = http_client.get_or_generate_client_order_id_cloid(client_order_id);
1119                    HyperliquidExecAction::CancelByCloid {
1120                        cancels: vec![HyperliquidExecCancelByCloidRequest { asset, cloid }],
1121                    }
1122                };
1123
1124            match ws_client.post_action_exec(&http_client, &action).await {
1125                Ok(response) => {
1126                    if response.is_ok() {
1127                        if let Some(inner_error) = extract_inner_error(&response) {
1128                            emitter.emit_order_cancel_rejected_event(
1129                                strategy_id,
1130                                instrument_id,
1131                                client_order_id,
1132                                venue_order_id,
1133                                &inner_error,
1134                                clock.get_time_ns(),
1135                            );
1136                        } else {
1137                            log::debug!("Order cancelled successfully: {response:?}");
1138                        }
1139                    } else {
1140                        let error_msg = extract_error_message(&response);
1141                        log::warn!(
1142                            "Cancel failed without per-order result for {client_order_id}, awaiting WS reconciliation: {error_msg}"
1143                        );
1144                    }
1145                }
1146                Err(e) => {
1147                    if e.is_transport_error() {
1148                        log::warn!(
1149                            "Cancel transport failure for {client_order_id}: {e}; \
1150                             awaiting WS reconciliation",
1151                        );
1152                    } else {
1153                        log::warn!(
1154                            "Ambiguous cancel failure for {client_order_id}, awaiting WS reconciliation: {e}"
1155                        );
1156                    }
1157                }
1158            }
1159
1160            Ok(())
1161        });
1162
1163        Ok(())
1164    }
1165
1166    fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
1167        log::debug!("Cancelling all orders: {cmd:?}");
1168
1169        let cache = self.core.cache();
1170        let open_orders = cache.orders_open(
1171            Some(&self.core.venue),
1172            Some(&cmd.instrument_id),
1173            None,
1174            None,
1175            Some(cmd.order_side),
1176        );
1177
1178        if open_orders.is_empty() {
1179            log::debug!("No open orders to cancel for {:?}", cmd.instrument_id);
1180            return Ok(());
1181        }
1182
1183        let symbol = cmd.instrument_id.symbol.inner();
1184        let instrument_id = cmd.instrument_id;
1185        let strategy_id = cmd.strategy_id;
1186        let entries: Vec<CancelEntry> = open_orders
1187            .iter()
1188            .map(|o| CancelEntry {
1189                strategy_id,
1190                instrument_id,
1191                client_order_id: o.client_order_id(),
1192                venue_order_id: o.venue_order_id(),
1193                symbol,
1194            })
1195            .collect();
1196
1197        let http_client = self.http_client.clone();
1198        let emitter = self.emitter.clone();
1199        let clock = self.clock;
1200        let ws_client = self.ws_client.clone();
1201
1202        self.spawn_task("cancel_all_orders", async move {
1203            let asset = match http_client.get_asset_index_for_symbol(symbol) {
1204                Some(a) => a,
1205                None => {
1206                    log::warn!(
1207                        "Local cancel-all validation failed: Asset index not found for symbol {symbol}"
1208                    );
1209                    return Ok(());
1210                }
1211            };
1212
1213            let mut cancel_dispatch = CancelDispatch::new();
1214
1215            for entry in &entries {
1216                cancel_dispatch.push(entry, asset, &http_client);
1217            }
1218
1219            if cancel_dispatch.is_empty() {
1220                return Ok(());
1221            }
1222
1223            submit_cancel_dispatch(
1224                "Cancel-all",
1225                cancel_dispatch,
1226                &ws_client,
1227                &http_client,
1228                &emitter,
1229                clock,
1230            )
1231            .await;
1232
1233            Ok(())
1234        });
1235
1236        Ok(())
1237    }
1238
1239    fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
1240        log::debug!("Batch cancelling orders: {cmd:?}");
1241
1242        if cmd.cancels.is_empty() {
1243            log::debug!("No orders to cancel in batch");
1244            return Ok(());
1245        }
1246
1247        let entries: Vec<CancelEntry> = cmd
1248            .cancels
1249            .iter()
1250            .map(|c| CancelEntry {
1251                strategy_id: c.strategy_id,
1252                instrument_id: c.instrument_id,
1253                client_order_id: c.client_order_id,
1254                venue_order_id: c.venue_order_id,
1255                symbol: c.instrument_id.symbol.inner(),
1256            })
1257            .collect();
1258
1259        let http_client = self.http_client.clone();
1260        let emitter = self.emitter.clone();
1261        let clock = self.clock;
1262        let ws_client = self.ws_client.clone();
1263
1264        self.spawn_task("batch_cancel_orders", async move {
1265            let mut cancel_dispatch = CancelDispatch::new();
1266
1267            for entry in &entries {
1268                let asset = match http_client.get_asset_index_for_symbol(entry.symbol) {
1269                    Some(a) => a,
1270                    None => {
1271                        log::warn!(
1272                            "Local batch cancel validation failed for {}: Asset index not found for symbol {}",
1273                            entry.client_order_id,
1274                            entry.symbol,
1275                        );
1276                        continue;
1277                    }
1278                };
1279
1280                cancel_dispatch.push(entry, asset, &http_client);
1281            }
1282
1283            if cancel_dispatch.is_empty() {
1284                log::warn!("No valid cancel requests in batch");
1285                return Ok(());
1286            }
1287
1288            submit_cancel_dispatch(
1289                "Batch cancel",
1290                cancel_dispatch,
1291                &ws_client,
1292                &http_client,
1293                &emitter,
1294                clock,
1295            )
1296            .await;
1297
1298            Ok(())
1299        });
1300
1301        Ok(())
1302    }
1303
1304    fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
1305        let http_client = self.http_client.clone();
1306        let account_address = self.get_account_address()?;
1307        let emitter = self.emitter.clone();
1308        let clock = self.clock;
1309
1310        self.spawn_task("query_account", async move {
1311            let perp_json = http_client
1312                .info_clearinghouse_state(&account_address)
1313                .await
1314                .context("failed to fetch clearinghouse state")?;
1315
1316            let perp_state: ClearinghouseState = serde_json::from_value(perp_json)
1317                .context("failed to deserialize clearinghouse state")?;
1318
1319            let spot_json = http_client
1320                .info_spot_clearinghouse_state(&account_address)
1321                .await
1322                .context("failed to fetch spot clearinghouse state")?;
1323            let spot_state: SpotClearinghouseState = serde_json::from_value(spot_json)
1324                .context("failed to deserialize spot clearinghouse state")?;
1325
1326            let (balances, margins) =
1327                parse_combined_account_balances_and_margins(&perp_state, &spot_state)
1328                    .context("failed to parse combined account balances and margins")?;
1329            let ts_event = clock.get_time_ns();
1330            emitter.emit_account_state(balances, margins, true, ts_event);
1331
1332            Ok(())
1333        });
1334
1335        Ok(())
1336    }
1337
1338    fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
1339        log::debug!("Querying order: {cmd:?}");
1340
1341        let client_order_id = cmd.client_order_id;
1342        let venue_order_id = match cmd.venue_order_id {
1343            Some(voi) => Some(voi),
1344            None => self.core.cache().venue_order_id(&client_order_id).copied(),
1345        };
1346
1347        let account_address = self.get_account_address()?;
1348        let http_client = self.http_client.clone();
1349        let emitter = self.emitter.clone();
1350        let dispatch_state = self.ws_dispatch_state.clone();
1351        let clock = self.clock;
1352
1353        self.spawn_task("query_order", async move {
1354            // Search open orders by cloid first so modify/cancel-replace
1355            // resolves to the live replacement rather than a stale cached oid.
1356            // Request errors here are logged, not propagated, so a transient
1357            // frontendOpenOrders failure does not abort the whole query.
1358            match http_client
1359                .request_order_status_report_by_client_order_id(&account_address, &client_order_id)
1360                .await
1361            {
1362                Ok(Some(report)) => {
1363                    promote_replacement_from_query(
1364                        &report,
1365                        &dispatch_state,
1366                        &emitter,
1367                        clock.get_time_ns(),
1368                    );
1369                    log::debug!("Queried order status for {client_order_id}");
1370                    emitter.send_order_status_report(report);
1371                    return Ok(());
1372                }
1373                Ok(None) => {}
1374                Err(e) => {
1375                    log::warn!(
1376                        "Failed to query order status for {client_order_id}: {e}; falling back to oid lookup"
1377                    );
1378                }
1379            }
1380
1381            let Some(venue_order_id) = venue_order_id else {
1382                log::debug!("No order status report found for {client_order_id}");
1383                return Ok(());
1384            };
1385
1386            let oid: u64 = match venue_order_id.as_str().parse() {
1387                Ok(oid) => oid,
1388                Err(e) => {
1389                    log::warn!("Failed to parse venue order ID {venue_order_id}: {e}");
1390                    return Ok(());
1391                }
1392            };
1393
1394            match http_client
1395                .request_order_status_report(&account_address, oid)
1396                .await
1397            {
1398                Ok(Some(report)) => {
1399                    if is_inflight_modify_old_leg_cancel(
1400                        &dispatch_state,
1401                        &client_order_id,
1402                        &report,
1403                    ) {
1404                        log::debug!(
1405                            "Suppressing stale old-leg Canceled for {client_order_id}: modify in flight"
1406                        );
1407                    } else {
1408                        log::debug!("Queried order status for oid {oid}");
1409                        emitter.send_order_status_report(report);
1410                    }
1411                }
1412                Ok(None) => {
1413                    log::debug!("No order status report found for oid {oid}");
1414                }
1415                Err(e) => {
1416                    log::warn!("Failed to query order status for oid {oid}: {e}");
1417                }
1418            }
1419
1420            Ok(())
1421        });
1422
1423        Ok(())
1424    }
1425
1426    async fn connect(&mut self) -> anyhow::Result<()> {
1427        if self.core.is_connected() {
1428            return Ok(());
1429        }
1430
1431        log::info!("Connecting Hyperliquid execution client");
1432
1433        // Ensure instruments are initialized
1434        self.ensure_instruments_initialized_async().await?;
1435
1436        // Start WebSocket stream (connects and subscribes to user channels)
1437        self.start_ws_stream().await?;
1438
1439        // Post-WS setup: if any step fails, tear down WS before returning
1440        let post_ws = async {
1441            self.refresh_account_state().await?;
1442            self.await_account_registered(30.0).await?;
1443
1444            Ok::<(), anyhow::Error>(())
1445        };
1446
1447        if let Err(e) = post_ws.await {
1448            log::warn!("Connect failed after WS started, tearing down: {e}");
1449            let _ = self.ws_client.disconnect().await;
1450            self.abort_pending_tasks();
1451            return Err(e);
1452        }
1453
1454        if let Err(e) = self.start_outcome_settlement_poll() {
1455            log::warn!("Outcome settlement polling not started: {e}");
1456        }
1457
1458        self.core.set_connected();
1459
1460        log::info!("Connected: client_id={}", self.core.client_id);
1461        Ok(())
1462    }
1463
1464    async fn disconnect(&mut self) -> anyhow::Result<()> {
1465        if self.core.is_disconnected() {
1466            return Ok(());
1467        }
1468
1469        log::info!("Disconnecting Hyperliquid execution client");
1470
1471        // Disconnect WebSocket
1472        self.ws_client.disconnect().await?;
1473
1474        if let Some(handle) = self
1475            .settlement_poll_handle
1476            .lock()
1477            .expect(MUTEX_POISONED)
1478            .take()
1479        {
1480            handle.abort();
1481        }
1482
1483        // Abort any pending tasks
1484        self.abort_pending_tasks();
1485
1486        self.core.set_disconnected();
1487
1488        log::info!("Disconnected: client_id={}", self.core.client_id);
1489        Ok(())
1490    }
1491
1492    async fn generate_order_status_report(
1493        &self,
1494        cmd: &GenerateOrderStatusReport,
1495    ) -> anyhow::Result<Option<OrderStatusReport>> {
1496        let account_address = self.get_account_address()?;
1497
1498        if cmd.venue_order_id.is_none() && cmd.client_order_id.is_none() {
1499            log::warn!(
1500                "Cannot generate order status report without venue_order_id or client_order_id"
1501            );
1502            return Ok(None);
1503        }
1504
1505        // Search open orders by cloid first when supplied. Hyperliquid modify
1506        // produces a new venue oid while preserving cloid, so a cached oid can
1507        // point at the canceled leg rather than the live replacement.
1508        if let Some(client_order_id) = &cmd.client_order_id {
1509            match self
1510                .http_client
1511                .request_order_status_report_by_client_order_id(&account_address, client_order_id)
1512                .await
1513            {
1514                Ok(Some(report)) => {
1515                    promote_replacement_from_query(
1516                        &report,
1517                        &self.ws_dispatch_state,
1518                        &self.emitter,
1519                        self.clock.get_time_ns(),
1520                    );
1521                    log::debug!("Generated order status report for {client_order_id}");
1522                    return Ok(Some(report));
1523                }
1524                Ok(None) => {}
1525                Err(e) => {
1526                    log::warn!(
1527                        "Failed to generate order status report for {client_order_id}: {e}; \
1528                         falling back to oid lookup"
1529                    );
1530                }
1531            }
1532        }
1533
1534        let oid = match &cmd.venue_order_id {
1535            Some(venue_order_id) => venue_order_id
1536                .as_str()
1537                .parse::<u64>()
1538                .context("failed to parse venue_order_id as oid")?,
1539            None => match &cmd.client_order_id {
1540                Some(client_order_id) => {
1541                    let cached_oid: Option<u64> = self
1542                        .core
1543                        .cache()
1544                        .venue_order_id(client_order_id)
1545                        .and_then(|v| v.as_str().parse::<u64>().ok());
1546
1547                    match cached_oid {
1548                        Some(oid) => oid,
1549                        None => {
1550                            log::debug!("No order status report found for {client_order_id}");
1551                            return Ok(None);
1552                        }
1553                    }
1554                }
1555                None => unreachable!("cmd must carry at least one identifier"),
1556            },
1557        };
1558
1559        let report = self
1560            .http_client
1561            .request_order_status_report(&account_address, oid)
1562            .await
1563            .context("failed to generate order status report")?;
1564
1565        if let Some(report) = &report
1566            && let Some(client_order_id) = &cmd.client_order_id
1567            && is_inflight_modify_old_leg_cancel(&self.ws_dispatch_state, client_order_id, report)
1568        {
1569            log::debug!(
1570                "Suppressing stale old-leg Canceled for {client_order_id}: modify in flight"
1571            );
1572            return Ok(None);
1573        }
1574
1575        if report.is_some() {
1576            log::debug!("Generated order status report for oid {oid}");
1577        } else {
1578            log::debug!("No order status report found for oid {oid}");
1579        }
1580        Ok(report)
1581    }
1582
1583    async fn generate_order_status_reports(
1584        &self,
1585        cmd: &GenerateOrderStatusReports,
1586    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1587        let account_address = self.get_account_address()?;
1588
1589        let reports = self
1590            .http_client
1591            .request_order_status_reports(&account_address, cmd.instrument_id)
1592            .await
1593            .context("failed to generate order status reports")?;
1594
1595        let reports = filter_order_status_reports_for_command(reports, cmd);
1596
1597        log::debug!("Generated {} order status reports", reports.len());
1598        Ok(reports)
1599    }
1600
1601    async fn generate_fill_reports(
1602        &self,
1603        cmd: GenerateFillReports,
1604    ) -> anyhow::Result<Vec<FillReport>> {
1605        let account_address = self.get_account_address()?;
1606
1607        let reports = self
1608            .http_client
1609            .request_fill_reports(&account_address, cmd.instrument_id)
1610            .await
1611            .context("failed to generate fill reports")?;
1612
1613        // Filter by time range if specified
1614        let reports = if let (Some(start), Some(end)) = (cmd.start, cmd.end) {
1615            reports
1616                .into_iter()
1617                .filter(|r| r.ts_event >= start && r.ts_event <= end)
1618                .collect()
1619        } else if let Some(start) = cmd.start {
1620            reports
1621                .into_iter()
1622                .filter(|r| r.ts_event >= start)
1623                .collect()
1624        } else if let Some(end) = cmd.end {
1625            reports.into_iter().filter(|r| r.ts_event <= end).collect()
1626        } else {
1627            reports
1628        };
1629
1630        log::debug!("Generated {} fill reports", reports.len());
1631        Ok(reports)
1632    }
1633
1634    async fn generate_position_status_reports(
1635        &self,
1636        cmd: &GeneratePositionStatusReports,
1637    ) -> anyhow::Result<Vec<PositionStatusReport>> {
1638        let account_address = self.get_account_address()?;
1639
1640        // request_position_status_reports already merges spot holdings
1641        let reports = self
1642            .http_client
1643            .request_position_status_reports(&account_address, cmd.instrument_id)
1644            .await
1645            .context("failed to generate position status reports")?;
1646
1647        log::debug!("Generated {} position status reports", reports.len());
1648        Ok(reports)
1649    }
1650
1651    async fn generate_mass_status(
1652        &self,
1653        lookback_mins: Option<u64>,
1654    ) -> anyhow::Result<Option<ExecutionMassStatus>> {
1655        let ts_init = self.clock.get_time_ns();
1656
1657        let order_cmd = GenerateOrderStatusReports::new(
1658            UUID4::new(),
1659            ts_init,
1660            true, // open_only
1661            None,
1662            None,
1663            None,
1664            None,
1665            None,
1666        );
1667        let fill_cmd =
1668            GenerateFillReports::new(UUID4::new(), ts_init, None, None, None, None, None, None);
1669        let position_cmd =
1670            GeneratePositionStatusReports::new(UUID4::new(), ts_init, None, None, None, None, None);
1671
1672        let order_reports = self.generate_order_status_reports(&order_cmd).await?;
1673        let mut fill_reports = self.generate_fill_reports(fill_cmd).await?;
1674        let position_reports = self.generate_position_status_reports(&position_cmd).await?;
1675
1676        // Apply lookback filter to fills only (positions are current state,
1677        // and open orders must always be included for correct reconciliation)
1678        if let Some(mins) = lookback_mins {
1679            let cutoff_ns = ts_init
1680                .as_u64()
1681                .saturating_sub(mins.saturating_mul(60).saturating_mul(1_000_000_000));
1682            let cutoff = UnixNanos::from(cutoff_ns);
1683
1684            fill_reports.retain(|r| r.ts_event >= cutoff);
1685        }
1686
1687        let mut mass_status = ExecutionMassStatus::new(
1688            self.core.client_id,
1689            self.core.account_id,
1690            self.core.venue,
1691            ts_init,
1692            None,
1693        );
1694        mass_status.add_order_reports(order_reports);
1695        mass_status.add_fill_reports(fill_reports);
1696        mass_status.add_position_reports(position_reports);
1697
1698        log::info!(
1699            "Generated mass status: {} orders, {} fills, {} positions",
1700            mass_status.order_reports().len(),
1701            mass_status.fill_reports().len(),
1702            mass_status.position_reports().len(),
1703        );
1704
1705        Ok(Some(mass_status))
1706    }
1707}
1708
1709impl HyperliquidExecutionClient {
1710    async fn start_ws_stream(&mut self) -> anyhow::Result<()> {
1711        {
1712            let handle_guard = self.ws_stream_handle.lock().expect(MUTEX_POISONED);
1713            if handle_guard.is_some() {
1714                return Ok(());
1715            }
1716        }
1717
1718        // Must match REST queries; mismatch silently drops fills on agent wallets
1719        let subscription_address = self.get_account_address()?;
1720
1721        let mut ws_client = self.ws_client.clone();
1722
1723        let instruments = self
1724            .http_client
1725            .request_instruments()
1726            .await
1727            .unwrap_or_default();
1728
1729        for instrument in instruments {
1730            ws_client.cache_instrument(instrument);
1731        }
1732
1733        // Connect and subscribe before spawning the event loop
1734        ws_client.connect().await?;
1735        ws_client
1736            .subscribe_order_updates(&subscription_address)
1737            .await?;
1738        ws_client
1739            .subscribe_user_events(&subscription_address)
1740            .await?;
1741        log::debug!("Subscribed to Hyperliquid execution updates for {subscription_address}");
1742
1743        // Transfer task handle to original so disconnect() can await it
1744        if let Some(handle) = ws_client.take_task_handle() {
1745            self.ws_client.set_task_handle(handle);
1746        }
1747
1748        let emitter = self.emitter.clone();
1749        let dispatch_state = self.ws_dispatch_state.clone();
1750        let http_client = self.http_client.clone();
1751        let clock = self.clock;
1752        let runtime = get_runtime();
1753        let handle = runtime.spawn(async move {
1754            // Cloids for external / untracked orders that reach a terminal
1755            // state: we evict their mapping immediately so long-running
1756            // sessions do not leak. Tracked orders clear their own cloid
1757            // mapping from the dispatch `cleanup_terminal` path below.
1758            //
1759            // For a tracked order that hits a status-only `FILLED` marker
1760            // without an accompanying fill, we defer the cloid cleanup until
1761            // the matching `FillReport` arrives so partial fills do not lose
1762            // their `client_order_id` link. The bounded FIFO cache keeps
1763            // orphaned entries from growing unbounded.
1764            let mut pending_filled_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
1765
1766            loop {
1767                let event = ws_client.next_event().await;
1768
1769                match event {
1770                    Some(msg) => match msg {
1771                        NautilusWsMessage::ExecutionReports(reports) => {
1772                            for report in reports {
1773                                if let Some((cid, oid, order)) = handle_execution_report(
1774                                    report,
1775                                    &dispatch_state,
1776                                    &emitter,
1777                                    &ws_client,
1778                                    &http_client,
1779                                    &mut pending_filled_cloids,
1780                                    clock.get_time_ns(),
1781                                ) {
1782                                    spawn_corrective_reduce(
1783                                        &ws_client,
1784                                        &http_client,
1785                                        &dispatch_state,
1786                                        cid,
1787                                        oid,
1788                                        order,
1789                                    );
1790                                }
1791                            }
1792                        }
1793                        // Reconnected is handled by WS client internally
1794                        // (resubscribe_all) and never forwarded here
1795                        NautilusWsMessage::Reconnected => {}
1796                        NautilusWsMessage::Error(e) => {
1797                            log::warn!("WebSocket error: {e}");
1798                        }
1799                        // Handled by data client
1800                        NautilusWsMessage::Trades(_)
1801                        | NautilusWsMessage::Quote(_)
1802                        | NautilusWsMessage::Deltas(_)
1803                        | NautilusWsMessage::Depth10(_)
1804                        | NautilusWsMessage::Candle(_)
1805                        | NautilusWsMessage::MarkPrice(_)
1806                        | NautilusWsMessage::IndexPrice(_)
1807                        | NautilusWsMessage::FundingRate(_)
1808                        | NautilusWsMessage::CustomData(_) => {}
1809                    },
1810                    None => {
1811                        log::debug!("WebSocket next_event returned None, stream closed");
1812                        break;
1813                    }
1814                }
1815            }
1816        });
1817
1818        *self.ws_stream_handle.lock().expect(MUTEX_POISONED) = Some(handle);
1819        log::debug!("Hyperliquid WebSocket execution stream started");
1820        Ok(())
1821    }
1822}
1823
1824fn filter_order_status_reports_for_command(
1825    reports: Vec<OrderStatusReport>,
1826    cmd: &GenerateOrderStatusReports,
1827) -> Vec<OrderStatusReport> {
1828    let reports = if cmd.open_only {
1829        reports
1830            .into_iter()
1831            .filter(|r| r.order_status.is_open())
1832            .collect()
1833    } else {
1834        reports
1835    };
1836
1837    match (cmd.start, cmd.end) {
1838        (Some(start), Some(end)) => reports
1839            .into_iter()
1840            .filter(|r| r.ts_last >= start && r.ts_last <= end)
1841            .collect(),
1842        (Some(start), None) => reports.into_iter().filter(|r| r.ts_last >= start).collect(),
1843        (None, Some(end)) => reports.into_iter().filter(|r| r.ts_last <= end).collect(),
1844        (None, None) => reports,
1845    }
1846}
1847
1848// During a tracked cancel-replace the cached venue_order_id is still the old
1849// leg, so only its `Canceled` must be dropped (it would wrongly terminate the
1850// live order); a late `Filled` or any other status is forwarded for recovery.
1851fn is_inflight_modify_old_leg_cancel(
1852    dispatch_state: &WsDispatchState,
1853    client_order_id: &ClientOrderId,
1854    report: &OrderStatusReport,
1855) -> bool {
1856    report.order_status == OrderStatus::Canceled
1857        && dispatch_state.pending_modify(client_order_id) == Some(report.venue_order_id)
1858}
1859
1860#[derive(Clone)]
1861struct CancelEntry {
1862    strategy_id: StrategyId,
1863    instrument_id: InstrumentId,
1864    client_order_id: ClientOrderId,
1865    venue_order_id: Option<VenueOrderId>,
1866    symbol: Ustr,
1867}
1868
1869struct CancelDispatch {
1870    cloid_requests: Vec<HyperliquidExecCancelByCloidRequest>,
1871    cloid_entries: Vec<CancelEntry>,
1872    oid_requests: Vec<HyperliquidExecCancelOrderRequest>,
1873    oid_entries: Vec<CancelEntry>,
1874}
1875
1876impl CancelDispatch {
1877    fn new() -> Self {
1878        Self {
1879            cloid_requests: Vec::new(),
1880            cloid_entries: Vec::new(),
1881            oid_requests: Vec::new(),
1882            oid_entries: Vec::new(),
1883        }
1884    }
1885
1886    fn is_empty(&self) -> bool {
1887        self.cloid_requests.is_empty() && self.oid_requests.is_empty()
1888    }
1889
1890    fn push(&mut self, entry: &CancelEntry, asset: u32, http_client: &HyperliquidHttpClient) {
1891        if let Some(cloid) = http_client.cached_client_order_id_cloid(&entry.client_order_id) {
1892            self.cloid_requests
1893                .push(HyperliquidExecCancelByCloidRequest { asset, cloid });
1894            self.cloid_entries.push(entry.clone());
1895        } else if let Some(venue_order_id) = entry.venue_order_id {
1896            match venue_order_id.as_str().parse::<u64>() {
1897                Ok(oid) => {
1898                    self.oid_requests
1899                        .push(HyperliquidExecCancelOrderRequest { asset, oid });
1900                    self.oid_entries.push(entry.clone());
1901                }
1902                Err(_) => {
1903                    log::warn!(
1904                        "Local cancel validation failed for {}: Invalid venue order ID format",
1905                        entry.client_order_id,
1906                    );
1907                }
1908            }
1909        } else {
1910            let cloid = http_client.get_or_generate_client_order_id_cloid(entry.client_order_id);
1911            self.cloid_requests
1912                .push(HyperliquidExecCancelByCloidRequest { asset, cloid });
1913            self.cloid_entries.push(entry.clone());
1914        }
1915    }
1916}
1917
1918async fn submit_cancel_dispatch(
1919    label: &str,
1920    dispatch: CancelDispatch,
1921    ws_client: &HyperliquidWebSocketClient,
1922    http_client: &HyperliquidHttpClient,
1923    emitter: &ExecutionEventEmitter,
1924    clock: &'static AtomicTime,
1925) {
1926    let CancelDispatch {
1927        cloid_requests,
1928        cloid_entries,
1929        oid_requests,
1930        oid_entries,
1931    } = dispatch;
1932
1933    if !cloid_requests.is_empty() {
1934        let action = HyperliquidExecAction::CancelByCloid {
1935            cancels: cloid_requests,
1936        };
1937        submit_cancel_action(
1938            label,
1939            action,
1940            &cloid_entries,
1941            ws_client,
1942            http_client,
1943            emitter,
1944            clock,
1945        )
1946        .await;
1947    }
1948
1949    if !oid_requests.is_empty() {
1950        let action = HyperliquidExecAction::Cancel {
1951            cancels: oid_requests,
1952        };
1953        submit_cancel_action(
1954            label,
1955            action,
1956            &oid_entries,
1957            ws_client,
1958            http_client,
1959            emitter,
1960            clock,
1961        )
1962        .await;
1963    }
1964}
1965
1966async fn submit_cancel_action(
1967    label: &str,
1968    action: HyperliquidExecAction,
1969    sent_entries: &[CancelEntry],
1970    ws_client: &HyperliquidWebSocketClient,
1971    http_client: &HyperliquidHttpClient,
1972    emitter: &ExecutionEventEmitter,
1973    clock: &'static AtomicTime,
1974) {
1975    match ws_client.post_action_exec(http_client, &action).await {
1976        Ok(response) => {
1977            if response.is_ok() {
1978                let inner_errors = extract_inner_errors(&response);
1979                let ts = clock.get_time_ns();
1980
1981                if inner_errors.is_empty() {
1982                    log::debug!("{label} submitted successfully: {response:?}");
1983                } else if let Some(reason) = cancel_status_count_mismatch_reason(
1984                    label,
1985                    sent_entries.len(),
1986                    inner_errors.len(),
1987                ) {
1988                    log::warn!("{reason}");
1989                } else {
1990                    for (i, entry) in sent_entries.iter().enumerate() {
1991                        if let Some(Some(error_msg)) = inner_errors.get(i) {
1992                            log::warn!(
1993                                "Cancel for {} rejected by exchange: {error_msg}",
1994                                entry.client_order_id,
1995                            );
1996                            emitter.emit_order_cancel_rejected_event(
1997                                entry.strategy_id,
1998                                entry.instrument_id,
1999                                entry.client_order_id,
2000                                entry.venue_order_id,
2001                                error_msg,
2002                                ts,
2003                            );
2004                        }
2005                    }
2006                }
2007            } else {
2008                let error_msg = extract_error_message(&response);
2009                log::warn!(
2010                    "{label} failed without per-order results, awaiting WS reconciliation: {error_msg}"
2011                );
2012            }
2013        }
2014        Err(e) => {
2015            if e.is_transport_error() {
2016                log::warn!("{label} transport failure: {e}; awaiting WS reconciliation");
2017            } else {
2018                log::warn!("{label} ambiguous failure, awaiting WS reconciliation: {e}");
2019            }
2020        }
2021    }
2022}
2023
2024/// Registers an order's identity in the dispatch state so its subsequent
2025/// WebSocket lifecycle can route through the typed-event path.
2026///
2027/// Quote-quantity orders submit a quote amount (e.g. 100 USD) but the venue
2028/// reports fills in base units. Comparing those two when deciding whether an
2029/// order is fully filled would leave the order stuck "open" forever, so they
2030/// flow through the untracked path and the engine reconciles them from
2031/// status reports instead.
2032fn register_order_identity_into(state: &WsDispatchState, order: &OrderAny) {
2033    if order.is_quote_quantity() {
2034        return;
2035    }
2036    state.register_identity(
2037        order.client_order_id(),
2038        OrderIdentity {
2039            strategy_id: order.strategy_id(),
2040            instrument_id: order.instrument_id(),
2041            order_side: order.order_side(),
2042            order_type: order.order_type(),
2043            quantity: order.quantity(),
2044            price: order.price(),
2045        },
2046    );
2047}
2048
2049/// Validates that an order is acceptable for submission to Hyperliquid.
2050///
2051/// Checks symbol format, order type support, and HIP-4-specific restrictions
2052/// (no reduce-only, no trigger order types on outcome side tokens).
2053///
2054/// # Errors
2055///
2056/// Returns an error describing the first validation failure encountered.
2057pub fn validate_order_for_hyperliquid(order: &OrderAny) -> anyhow::Result<()> {
2058    let instrument_id = order.instrument_id();
2059    let symbol = instrument_id.symbol.as_str();
2060    let product_type = HyperliquidProductType::from_symbol(symbol).map_err(|_| {
2061        anyhow::anyhow!(
2062            "Unsupported instrument symbol format for Hyperliquid: {symbol} \
2063             (expected -PERP, -SPOT, or HIP-4 outcome `{{N}}-{{YES|NO}}-OUTCOME`)"
2064        )
2065    })?;
2066
2067    match order.order_type() {
2068        OrderType::Market
2069        | OrderType::Limit
2070        | OrderType::StopMarket
2071        | OrderType::StopLimit
2072        | OrderType::MarketIfTouched
2073        | OrderType::LimitIfTouched => {}
2074        _ => anyhow::bail!(
2075            "Unsupported order type for Hyperliquid: {:?}",
2076            order.order_type()
2077        ),
2078    }
2079
2080    // HIP-4 outcomes are fully-collateralized side tokens with no margin,
2081    // funding, or trigger machinery. Reject features that don't apply.
2082    if product_type == HyperliquidProductType::Outcome {
2083        if order.is_reduce_only() {
2084            anyhow::bail!("Reduce-only is not supported for Hyperliquid HIP-4 outcomes: {symbol}");
2085        }
2086
2087        if !matches!(order.order_type(), OrderType::Market | OrderType::Limit) {
2088            anyhow::bail!(
2089                "Trigger order types are not supported for Hyperliquid HIP-4 outcomes: \
2090                 {symbol} (received {:?})",
2091                order.order_type()
2092            );
2093        }
2094    }
2095
2096    if matches!(
2097        order.order_type(),
2098        OrderType::StopMarket
2099            | OrderType::StopLimit
2100            | OrderType::MarketIfTouched
2101            | OrderType::LimitIfTouched
2102    ) && order.trigger_price().is_none()
2103    {
2104        anyhow::bail!(
2105            "Conditional orders require a trigger price for Hyperliquid: {:?}",
2106            order.order_type()
2107        );
2108    }
2109
2110    if matches!(
2111        order.order_type(),
2112        OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
2113    ) && order.price().is_none()
2114    {
2115        anyhow::bail!(
2116            "Limit orders require a limit price for Hyperliquid: {:?}",
2117            order.order_type()
2118        );
2119    }
2120
2121    Ok(())
2122}
2123
2124fn cancel_status_count_mismatch_reason(
2125    label: &str,
2126    expected_count: usize,
2127    actual_count: usize,
2128) -> Option<String> {
2129    (actual_count != 0 && actual_count != expected_count).then(|| {
2130        format!(
2131            "{label} response status count mismatch: expected {expected_count}, received {actual_count}"
2132        )
2133    })
2134}
2135
2136struct PostRejectionRoute {
2137    emitter: ExecutionEventEmitter,
2138    ws_client: HyperliquidWebSocketClient,
2139    http_client: HyperliquidHttpClient,
2140    dispatch_state: Arc<WsDispatchState>,
2141}
2142
2143impl PostRejectionRoute {
2144    fn new(
2145        emitter: &ExecutionEventEmitter,
2146        ws_client: &HyperliquidWebSocketClient,
2147        http_client: &HyperliquidHttpClient,
2148        dispatch_state: Arc<WsDispatchState>,
2149    ) -> Self {
2150        Self {
2151            emitter: emitter.clone(),
2152            ws_client: ws_client.clone(),
2153            http_client: http_client.clone(),
2154            dispatch_state,
2155        }
2156    }
2157
2158    fn emit_once(
2159        &self,
2160        order: &OrderAny,
2161        reason: &str,
2162        ts_event: UnixNanos,
2163        cloid_hex: &Ustr,
2164    ) -> bool {
2165        let client_order_id = order.client_order_id();
2166
2167        if !self.dispatch_state.insert_filled(client_order_id) {
2168            log::debug!(
2169                "Skipping duplicate post rejection for terminal order {client_order_id}: {reason}",
2170            );
2171            self.ws_client.remove_cloid_mapping(cloid_hex);
2172            self.http_client
2173                .remove_client_order_id_cloid(&client_order_id);
2174            return false;
2175        }
2176
2177        if reason.contains(HYPERLIQUID_BUILDER_FEE_NOT_APPROVED) {
2178            log::warn!(
2179                "Builder fee not approved: complete the one-time 0% builder approval \
2180                 (signed by the master wallet). See: {HYPERLIQUID_BUILDER_APPROVAL_DOCS_URL}",
2181            );
2182        }
2183
2184        self.emitter
2185            .emit_order_rejected(order, reason, ts_event, false);
2186        self.dispatch_state
2187            .insert_terminal_cloid(Ustr::from(cloid_hex.as_str()));
2188        self.dispatch_state.cleanup_terminal(&client_order_id);
2189        self.ws_client.remove_cloid_mapping(cloid_hex);
2190        self.http_client
2191            .remove_client_order_id_cloid(&client_order_id);
2192
2193        true
2194    }
2195}
2196
2197/// Routes a single execution report through the two-tier dispatch.
2198///
2199/// For tracked orders this emits typed `OrderEventAny` events via the
2200/// dispatch module; external / untracked orders fall back to the raw report
2201/// so the engine can reconcile. Cloid-mapping cleanup is handled here so
2202/// long-running sessions do not leak mapping entries.
2203fn handle_execution_report(
2204    report: ExecutionReport,
2205    dispatch_state: &WsDispatchState,
2206    emitter: &ExecutionEventEmitter,
2207    ws_client: &HyperliquidWebSocketClient,
2208    http_client: &HyperliquidHttpClient,
2209    pending_filled_cloids: &mut FifoCache<ClientOrderId, 10_000>,
2210    ts_init: UnixNanos,
2211) -> Option<(ClientOrderId, u64, HyperliquidExecPlaceOrderRequest)> {
2212    match report {
2213        ExecutionReport::Order(order_report) => {
2214            let is_filled_marker = matches!(order_report.order_status, OrderStatus::Filled);
2215            let is_open = order_report.order_status.is_open();
2216            let client_order_id = order_report.client_order_id;
2217
2218            let outcome = dispatch_order_event(&order_report, dispatch_state, emitter, ts_init);
2219
2220            if outcome == DispatchOutcome::External {
2221                emitter.send_order_status_report(order_report);
2222            }
2223
2224            // Cloid cleanup:
2225            //
2226            // * `Skip` (stale cancel leg of a cancel-replace, cancel-before-accept
2227            //   race, or replay after terminal): leave the mapping intact. The
2228            //   still-open replacement order depends on it for subsequent events,
2229            //   and a genuinely terminal replay had its mapping evicted earlier.
2230            // * `Tracked` + status-only FILLED marker: defer the eviction until
2231            //   the matching `FillReport` lands so the partial fill preceding it
2232            //   keeps its client-order-id link.
2233            // * `Tracked` non-marker terminal and `External` terminal: evict now
2234            //   so long-running sessions do not leak cloid mappings.
2235            if let Some(id) = client_order_id
2236                && !is_open
2237            {
2238                match outcome {
2239                    DispatchOutcome::Skip => {}
2240                    DispatchOutcome::Tracked if is_filled_marker => {
2241                        pending_filled_cloids.add(id);
2242                    }
2243                    DispatchOutcome::Tracked | DispatchOutcome::External => {
2244                        remove_cloid_mapping_for_client_order_id(ws_client, http_client, &id);
2245                    }
2246                }
2247            }
2248
2249            // Hand any queued corrective reduce to the loop to post; this
2250            // cache-free task cannot rebuild the order spec itself.
2251            client_order_id.and_then(|id| {
2252                dispatch_state
2253                    .take_corrective(&id)
2254                    .map(|(oid, order)| (id, oid, order))
2255            })
2256        }
2257        ExecutionReport::Fill(fill_report) => {
2258            let client_order_id = fill_report.client_order_id;
2259
2260            let outcome = dispatch_order_fill(&fill_report, dispatch_state, emitter, ts_init);
2261
2262            if outcome == DispatchOutcome::External {
2263                emitter.send_fill_report(fill_report);
2264            }
2265
2266            // Skip cleanup while a cancel-replace fill is buffered; the
2267            // replacement ACCEPTED still needs to resolve the cloid (GH-3972).
2268            if let Some(id) = client_order_id
2269                && pending_filled_cloids.contains(&id)
2270                && dispatch_state.buffered_fill_count(&id) == 0
2271            {
2272                pending_filled_cloids.remove(&id);
2273                remove_cloid_mapping_for_client_order_id(ws_client, http_client, &id);
2274            }
2275
2276            // Hand a fill-path promotion's corrective reduce to the loop to post
2277            client_order_id.and_then(|id| {
2278                dispatch_state
2279                    .take_corrective(&id)
2280                    .map(|(oid, order)| (id, oid, order))
2281            })
2282        }
2283    }
2284}
2285
2286/// Posts a corrective reduce queued by the cancel-replace promotion.
2287///
2288/// Runs on the runtime (not the WS receive loop) so the post does not block
2289/// event processing. Keeps the re-armed pending-modify marker only while the
2290/// reduce may still be live (a clean ack, or a transport failure the WS may
2291/// reconcile); clears it otherwise so a stale marker cannot suppress a later
2292/// real `CANCELED(oid)` as a cancel-before-accept leg.
2293fn spawn_corrective_reduce(
2294    ws_client: &HyperliquidWebSocketClient,
2295    http_client: &HyperliquidHttpClient,
2296    dispatch_state: &Arc<WsDispatchState>,
2297    client_order_id: ClientOrderId,
2298    oid: u64,
2299    order: HyperliquidExecPlaceOrderRequest,
2300) {
2301    let ws_client = ws_client.clone();
2302    let http_client = http_client.clone();
2303    let dispatch_state = dispatch_state.clone();
2304
2305    get_runtime().spawn(async move {
2306        let action = HyperliquidExecAction::Modify {
2307            modify: HyperliquidExecModifyOrderRequest { oid, order },
2308        };
2309
2310        let keep_marker = match ws_client.post_action_exec(&http_client, &action).await {
2311            Ok(resp) if resp.is_ok() && extract_inner_error(&resp).is_none() => {
2312                log::debug!("Corrective reduce acknowledged for {client_order_id} on oid {oid}");
2313                true
2314            }
2315            Ok(resp) => {
2316                let reason =
2317                    extract_inner_error(&resp).unwrap_or_else(|| extract_error_message(&resp));
2318                log::warn!(
2319                    "Corrective reduce rejected for {client_order_id} on oid {oid}: {reason}"
2320                );
2321                false
2322            }
2323            Err(e) if e.is_transport_error() => {
2324                log::warn!(
2325                    "Corrective reduce transport failure for {client_order_id} on oid {oid}: \
2326                     {e}; awaiting WS reconciliation",
2327                );
2328                true
2329            }
2330            Err(e) => {
2331                log::warn!("Corrective reduce failed for {client_order_id} on oid {oid}: {e}");
2332                false
2333            }
2334        };
2335
2336        if !keep_marker {
2337            dispatch_state.clear_pending_modify(&client_order_id);
2338        }
2339    });
2340}
2341
2342fn remove_cloid_mapping_for_client_order_id(
2343    ws_client: &HyperliquidWebSocketClient,
2344    http_client: &HyperliquidHttpClient,
2345    client_order_id: &ClientOrderId,
2346) {
2347    let generated_cloid = Cloid::from_client_order_id(*client_order_id);
2348
2349    if let Some(cloid) = http_client.remove_client_order_id_cloid(client_order_id) {
2350        ws_client.remove_cloid_mapping(&Ustr::from(&cloid.to_hex()));
2351        if cloid == generated_cloid {
2352            return;
2353        }
2354    }
2355
2356    ws_client.remove_cloid_mapping(&Ustr::from(&generated_cloid.to_hex()));
2357}
2358
2359use crate::common::parse::determine_order_list_grouping;
2360
2361#[cfg(test)]
2362mod tests {
2363    use std::sync::Arc;
2364
2365    use nautilus_common::messages::{ExecutionEvent, execution::GenerateOrderStatusReports};
2366    use nautilus_core::{UUID4, UnixNanos, time::get_atomic_clock_realtime};
2367    use nautilus_live::ExecutionEventEmitter;
2368    use nautilus_model::{
2369        enums::{
2370            AccountType, ContingencyType, LiquiditySide, OrderSide, OrderStatus, OrderType,
2371            TimeInForce, TriggerType,
2372        },
2373        events::OrderEventAny,
2374        identifiers::{
2375            AccountId, ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId, VenueOrderId,
2376        },
2377        orders::{OrderAny, limit::LimitOrder, stop_market::StopMarketOrder},
2378        reports::{FillReport, OrderStatusReport},
2379        types::{Currency, Money, Price, Quantity},
2380    };
2381    use nautilus_network::websocket::TransportBackend;
2382    use rstest::rstest;
2383    use rust_decimal::Decimal;
2384    use ustr::Ustr;
2385
2386    use super::{
2387        ExecutionReport, FifoCache, HyperliquidHttpClient, HyperliquidWebSocketClient,
2388        OrderIdentity, PostRejectionRoute, WsDispatchState, determine_order_list_grouping,
2389        filter_order_status_reports_for_command, handle_execution_report,
2390        register_order_identity_into, validate_order_for_hyperliquid,
2391    };
2392    use crate::{
2393        common::enums::HyperliquidEnvironment,
2394        http::models::{
2395            Cloid, HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecOrderKind,
2396            HyperliquidExecPlaceOrderRequest, HyperliquidExecTif,
2397        },
2398    };
2399
2400    const TEST_INSTRUMENT_ID: &str = "BTC-USD-PERP.HYPERLIQUID";
2401
2402    fn test_emitter() -> (
2403        ExecutionEventEmitter,
2404        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2405    ) {
2406        let clock = get_atomic_clock_realtime();
2407        let mut emitter = ExecutionEventEmitter::new(
2408            clock,
2409            TraderId::from("TESTER-001"),
2410            AccountId::from("HYPERLIQUID-001"),
2411            AccountType::Margin,
2412            None,
2413        );
2414        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2415        emitter.set_sender(tx);
2416        (emitter, rx)
2417    }
2418
2419    fn drain_events(
2420        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2421    ) -> Vec<ExecutionEvent> {
2422        let mut out = Vec::new();
2423        while let Ok(e) = rx.try_recv() {
2424            out.push(e);
2425        }
2426        out
2427    }
2428
2429    fn make_ws_client() -> HyperliquidWebSocketClient {
2430        // `HyperliquidWebSocketClient::new` does not connect, so this is a
2431        // cheap unit-test shim that still exercises the real `cloid_cache`
2432        // mapping APIs used by `handle_execution_report`.
2433        HyperliquidWebSocketClient::new(
2434            Some("wss://test.invalid".to_string()),
2435            HyperliquidEnvironment::Testnet,
2436            None,
2437            TransportBackend::default(),
2438            None,
2439        )
2440    }
2441
2442    fn make_http_client() -> HyperliquidHttpClient {
2443        HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 1, None).unwrap()
2444    }
2445
2446    fn test_identity() -> OrderIdentity {
2447        OrderIdentity {
2448            strategy_id: StrategyId::from("S-001"),
2449            instrument_id: InstrumentId::from(TEST_INSTRUMENT_ID),
2450            order_side: OrderSide::Buy,
2451            order_type: OrderType::Limit,
2452            quantity: Quantity::from("0.0001"),
2453            price: Some(Price::from("56730.0")),
2454        }
2455    }
2456
2457    fn make_status_report(
2458        client_order_id: Option<&str>,
2459        venue_order_id: &str,
2460        status: OrderStatus,
2461    ) -> OrderStatusReport {
2462        make_status_report_with_quantity(
2463            client_order_id,
2464            venue_order_id,
2465            status,
2466            Quantity::from("0.0001"),
2467        )
2468    }
2469
2470    fn make_status_report_with_quantity(
2471        client_order_id: Option<&str>,
2472        venue_order_id: &str,
2473        status: OrderStatus,
2474        quantity: Quantity,
2475    ) -> OrderStatusReport {
2476        OrderStatusReport::new(
2477            AccountId::from("HYPERLIQUID-001"),
2478            InstrumentId::from(TEST_INSTRUMENT_ID),
2479            client_order_id.map(ClientOrderId::new),
2480            VenueOrderId::new(venue_order_id),
2481            OrderSide::Buy,
2482            OrderType::Limit,
2483            TimeInForce::Gtc,
2484            status,
2485            quantity,
2486            Quantity::from("0"),
2487            UnixNanos::default(),
2488            UnixNanos::default(),
2489            UnixNanos::default(),
2490            Some(UUID4::new()),
2491        )
2492        .with_price(Price::from("56730.0"))
2493    }
2494
2495    fn make_fill_report(
2496        client_order_id: Option<&str>,
2497        venue_order_id: &str,
2498        trade_id: &str,
2499    ) -> FillReport {
2500        make_fill_report_with_qty(
2501            client_order_id,
2502            venue_order_id,
2503            trade_id,
2504            Quantity::from("0.0001"),
2505        )
2506    }
2507
2508    fn make_fill_report_with_qty(
2509        client_order_id: Option<&str>,
2510        venue_order_id: &str,
2511        trade_id: &str,
2512        last_qty: Quantity,
2513    ) -> FillReport {
2514        FillReport::new(
2515            AccountId::from("HYPERLIQUID-001"),
2516            InstrumentId::from(TEST_INSTRUMENT_ID),
2517            VenueOrderId::new(venue_order_id),
2518            TradeId::new(trade_id),
2519            OrderSide::Buy,
2520            last_qty,
2521            Price::from("56730.0"),
2522            Money::new(0.0, Currency::USD()),
2523            LiquiditySide::Taker,
2524            client_order_id.map(ClientOrderId::new),
2525            None,
2526            UnixNanos::default(),
2527            UnixNanos::default(),
2528            Some(UUID4::new()),
2529        )
2530    }
2531
2532    fn cloid_for(id: &str) -> Ustr {
2533        let cloid = Cloid::from_client_order_id(ClientOrderId::from(id));
2534        Ustr::from(&cloid.to_hex())
2535    }
2536
2537    #[rstest]
2538    fn test_filter_order_status_reports_for_command_filters_open_only() {
2539        let open_report =
2540            make_status_report(Some("O-HER-FILTER-OPEN"), "v-open", OrderStatus::Accepted);
2541        let closed_report =
2542            make_status_report(Some("O-HER-FILTER-CLOSED"), "v-closed", OrderStatus::Filled);
2543        let cmd = order_reports_command(true, None, None);
2544
2545        let filtered =
2546            filter_order_status_reports_for_command(vec![open_report, closed_report], &cmd);
2547
2548        assert_eq!(filtered.len(), 1);
2549        assert_eq!(
2550            filtered[0].client_order_id,
2551            Some(ClientOrderId::from("O-HER-FILTER-OPEN"))
2552        );
2553    }
2554
2555    #[rstest]
2556    fn test_filter_order_status_reports_for_command_filters_time_range_inclusively() {
2557        let mut before = make_status_report(
2558            Some("O-HER-FILTER-BEFORE"),
2559            "v-before",
2560            OrderStatus::Accepted,
2561        );
2562        let mut at_start =
2563            make_status_report(Some("O-HER-FILTER-START"), "v-start", OrderStatus::Accepted);
2564        let mut at_end =
2565            make_status_report(Some("O-HER-FILTER-END"), "v-end", OrderStatus::Accepted);
2566        let mut after =
2567            make_status_report(Some("O-HER-FILTER-AFTER"), "v-after", OrderStatus::Accepted);
2568        before.ts_last = UnixNanos::from(9);
2569        at_start.ts_last = UnixNanos::from(10);
2570        at_end.ts_last = UnixNanos::from(20);
2571        after.ts_last = UnixNanos::from(21);
2572        let cmd =
2573            order_reports_command(false, Some(UnixNanos::from(10)), Some(UnixNanos::from(20)));
2574
2575        let filtered =
2576            filter_order_status_reports_for_command(vec![before, at_start, at_end, after], &cmd);
2577        let filtered_ids: Vec<Option<ClientOrderId>> = filtered
2578            .iter()
2579            .map(|report| report.client_order_id)
2580            .collect();
2581
2582        assert_eq!(
2583            filtered_ids,
2584            vec![
2585                Some(ClientOrderId::from("O-HER-FILTER-START")),
2586                Some(ClientOrderId::from("O-HER-FILTER-END")),
2587            ]
2588        );
2589    }
2590
2591    #[rstest]
2592    fn test_filter_order_status_reports_for_command_without_filters_preserves_reports() {
2593        let open_report = make_status_report(
2594            Some("O-HER-FILTER-KEEP-OPEN"),
2595            "v-keep-open",
2596            OrderStatus::Accepted,
2597        );
2598        let closed_report = make_status_report(
2599            Some("O-HER-FILTER-KEEP-CLOSED"),
2600            "v-keep-closed",
2601            OrderStatus::Canceled,
2602        );
2603        let cmd = order_reports_command(false, None, None);
2604
2605        let filtered =
2606            filter_order_status_reports_for_command(vec![open_report, closed_report], &cmd);
2607        let filtered_ids: Vec<Option<ClientOrderId>> = filtered
2608            .iter()
2609            .map(|report| report.client_order_id)
2610            .collect();
2611
2612        assert_eq!(
2613            filtered_ids,
2614            vec![
2615                Some(ClientOrderId::from("O-HER-FILTER-KEEP-OPEN")),
2616                Some(ClientOrderId::from("O-HER-FILTER-KEEP-CLOSED")),
2617            ]
2618        );
2619    }
2620
2621    fn order_reports_command(
2622        open_only: bool,
2623        start: Option<UnixNanos>,
2624        end: Option<UnixNanos>,
2625    ) -> GenerateOrderStatusReports {
2626        GenerateOrderStatusReports::new(
2627            UUID4::new(),
2628            UnixNanos::default(),
2629            open_only,
2630            None,
2631            start,
2632            end,
2633            None,
2634            None,
2635        )
2636    }
2637
2638    fn limit_order(
2639        id: &str,
2640        reduce_only: bool,
2641        contingency: ContingencyType,
2642        linked_ids: Option<Vec<&str>>,
2643        parent_id: Option<&str>,
2644    ) -> OrderAny {
2645        OrderAny::Limit(LimitOrder::new(
2646            TraderId::from("TESTER-001"),
2647            StrategyId::from("S-001"),
2648            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
2649            ClientOrderId::from(id),
2650            OrderSide::Buy,
2651            Quantity::from(1),
2652            Price::from("3000.00"),
2653            TimeInForce::Gtc,
2654            None,  // expire_time
2655            false, // post_only
2656            reduce_only,
2657            false, // quote_quantity
2658            None,  // display_qty
2659            None,  // emulation_trigger
2660            None,  // trigger_instrument_id
2661            Some(contingency),
2662            None, // order_list_id
2663            linked_ids.map(|ids| ids.into_iter().map(ClientOrderId::from).collect()),
2664            parent_id.map(ClientOrderId::from),
2665            None, // exec_algorithm_id
2666            None, // exec_algorithm_params
2667            None, // exec_spawn_id
2668            None, // tags
2669            Default::default(),
2670            Default::default(),
2671        ))
2672    }
2673
2674    fn stop_order(
2675        id: &str,
2676        reduce_only: bool,
2677        contingency: ContingencyType,
2678        linked_ids: Option<Vec<&str>>,
2679        parent_id: Option<&str>,
2680    ) -> OrderAny {
2681        OrderAny::StopMarket(StopMarketOrder::new(
2682            TraderId::from("TESTER-001"),
2683            StrategyId::from("S-001"),
2684            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
2685            ClientOrderId::from(id),
2686            OrderSide::Sell,
2687            Quantity::from(1),
2688            Price::from("2800.00"),
2689            TriggerType::LastPrice,
2690            TimeInForce::Gtc,
2691            None, // expire_time
2692            reduce_only,
2693            false, // quote_quantity
2694            None,  // display_qty
2695            None,  // emulation_trigger
2696            None,  // trigger_instrument_id
2697            Some(contingency),
2698            None, // order_list_id
2699            linked_ids.map(|ids| ids.into_iter().map(ClientOrderId::from).collect()),
2700            parent_id.map(ClientOrderId::from),
2701            None, // exec_algorithm_id
2702            None, // exec_algorithm_params
2703            None, // exec_spawn_id
2704            None, // tags
2705            Default::default(),
2706            Default::default(),
2707        ))
2708    }
2709
2710    #[rstest]
2711    #[case::independent_orders(
2712        vec![
2713            limit_order("O-001", false, ContingencyType::NoContingency, None, None),
2714            limit_order("O-002", false, ContingencyType::NoContingency, None, None),
2715        ],
2716        HyperliquidExecGrouping::Na,
2717    )]
2718    #[case::bracket_oto(
2719        vec![
2720            limit_order("O-001", false, ContingencyType::Oto, Some(vec!["O-002", "O-003"]), None),
2721            limit_order("O-002", true, ContingencyType::Oco, Some(vec!["O-003"]), Some("O-001")),
2722            stop_order("O-003", true, ContingencyType::Oco, Some(vec!["O-002"]), Some("O-001")),
2723        ],
2724        HyperliquidExecGrouping::NormalTpsl,
2725    )]
2726    #[case::oto_not_bracket_shaped(
2727        vec![
2728            limit_order("O-001", false, ContingencyType::Oto, Some(vec!["O-002"]), None),
2729            limit_order("O-002", false, ContingencyType::Oto, Some(vec!["O-001"]), None),
2730        ],
2731        HyperliquidExecGrouping::Na,
2732    )]
2733    #[case::oco_all_reduce_only(
2734        vec![
2735            limit_order("O-001", true, ContingencyType::Oco, Some(vec!["O-002"]), None),
2736            stop_order("O-002", true, ContingencyType::Oco, Some(vec!["O-001"]), None),
2737        ],
2738        HyperliquidExecGrouping::PositionTpsl,
2739    )]
2740    #[case::oco_not_all_reduce_only(
2741        vec![
2742            limit_order("O-001", false, ContingencyType::Oco, Some(vec!["O-002"]), None),
2743            stop_order("O-002", true, ContingencyType::Oco, Some(vec!["O-001"]), None),
2744        ],
2745        HyperliquidExecGrouping::Na,
2746    )]
2747    #[case::oto_with_non_oco_children(
2748        vec![
2749            limit_order("O-001", false, ContingencyType::Oto, Some(vec!["O-002", "O-003"]), None),
2750            limit_order("O-002", true, ContingencyType::NoContingency, None, None),
2751            stop_order("O-003", true, ContingencyType::NoContingency, None, None),
2752        ],
2753        HyperliquidExecGrouping::Na,
2754    )]
2755    #[case::mixed_oco_and_plain_reduce_only(
2756        vec![
2757            limit_order("O-001", true, ContingencyType::Oco, Some(vec!["O-002"]), None),
2758            stop_order("O-002", true, ContingencyType::NoContingency, None, None),
2759        ],
2760        HyperliquidExecGrouping::Na,
2761    )]
2762    #[case::unlinked_oco_reduce_only(
2763        vec![
2764            limit_order("O-001", true, ContingencyType::Oco, Some(vec!["O-099"]), None),
2765            stop_order("O-002", true, ContingencyType::Oco, Some(vec!["O-098"]), None),
2766        ],
2767        HyperliquidExecGrouping::Na,
2768    )]
2769    #[case::single_order(
2770        vec![limit_order("O-001", false, ContingencyType::NoContingency, None, None)],
2771        HyperliquidExecGrouping::Na,
2772    )]
2773    fn test_determine_order_list_grouping(
2774        #[case] orders: Vec<OrderAny>,
2775        #[case] expected: HyperliquidExecGrouping,
2776    ) {
2777        let result = determine_order_list_grouping(&orders);
2778        assert_eq!(result, expected);
2779    }
2780
2781    fn limit_order_with_quote_quantity(id: &str, quote_quantity: bool) -> OrderAny {
2782        OrderAny::Limit(LimitOrder::new(
2783            TraderId::from("TESTER-001"),
2784            StrategyId::from("S-001"),
2785            InstrumentId::from(TEST_INSTRUMENT_ID),
2786            ClientOrderId::from(id),
2787            OrderSide::Buy,
2788            Quantity::from("0.0001"),
2789            Price::from("56730.0"),
2790            TimeInForce::Gtc,
2791            None,
2792            false,
2793            false,
2794            quote_quantity,
2795            None,
2796            None,
2797            None,
2798            Some(ContingencyType::NoContingency),
2799            None,
2800            None,
2801            None,
2802            None,
2803            None,
2804            None,
2805            None,
2806            Default::default(),
2807            Default::default(),
2808        ))
2809    }
2810
2811    #[rstest]
2812    fn test_register_order_identity_registers_regular_order() {
2813        let state = WsDispatchState::new();
2814        let order = limit_order_with_quote_quantity("O-REG-001", false);
2815
2816        register_order_identity_into(&state, &order);
2817
2818        let found = state
2819            .lookup_identity(&ClientOrderId::from("O-REG-001"))
2820            .expect("identity should be registered");
2821        assert_eq!(found.strategy_id, StrategyId::from("S-001"));
2822        assert_eq!(found.instrument_id, InstrumentId::from(TEST_INSTRUMENT_ID));
2823        assert_eq!(found.order_side, OrderSide::Buy);
2824        assert_eq!(found.order_type, OrderType::Limit);
2825        assert_eq!(found.quantity, Quantity::from("0.0001"));
2826        assert_eq!(found.price, Some(Price::from("56730.0")));
2827    }
2828
2829    #[rstest]
2830    fn test_register_order_identity_skips_quote_quantity_order() {
2831        let state = WsDispatchState::new();
2832        let order = limit_order_with_quote_quantity("O-QQ-001", true);
2833
2834        register_order_identity_into(&state, &order);
2835
2836        // Quote-quantity orders flow through the untracked path so the engine
2837        // reconciles them from status reports; registering would make the
2838        // cumulative-fill comparison mismatch base-unit fills against the
2839        // quote-unit tracked quantity and leave the order stuck "open".
2840        assert!(
2841            state
2842                .lookup_identity(&ClientOrderId::from("O-QQ-001"))
2843                .is_none()
2844        );
2845    }
2846
2847    #[rstest]
2848    fn test_handle_execution_report_skip_keeps_cloid_mapping() {
2849        // Regression guard for GH-3827: when the dispatch returns Skip (e.g.
2850        // the stale cancel leg of a cancel-replace), the cloid mapping must
2851        // stay in place so the still-open replacement order can still be
2852        // resolved by subsequent events.
2853        let ws_client = make_ws_client();
2854        let (emitter, mut rx) = test_emitter();
2855        let state = WsDispatchState::new();
2856        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
2857
2858        let cid = ClientOrderId::from("O-HER-SKIP");
2859        state.register_identity(cid, test_identity());
2860        // Prime state so the later CANCELED(old_voi) is classified as stale.
2861        state.insert_accepted(cid);
2862        state.record_venue_order_id(cid, VenueOrderId::new("new-voi"));
2863
2864        ws_client.cache_cloid_mapping(cloid_for("O-HER-SKIP"), cid);
2865
2866        let stale_cancel = make_status_report(Some("O-HER-SKIP"), "old-voi", OrderStatus::Canceled);
2867        handle_execution_report(
2868            ExecutionReport::Order(stale_cancel),
2869            &state,
2870            &emitter,
2871            &ws_client,
2872            &make_http_client(),
2873            &mut pending_cloids,
2874            UnixNanos::default(),
2875        );
2876
2877        assert!(drain_events(&mut rx).is_empty());
2878        // Cloid mapping preserved; the replacement order still resolves.
2879        assert_eq!(
2880            ws_client.get_cloid_mapping(&cloid_for("O-HER-SKIP")),
2881            Some(cid)
2882        );
2883        // Identity is still tracked (the skip path did not clean up).
2884        assert!(state.lookup_identity(&cid).is_some());
2885    }
2886
2887    #[rstest]
2888    fn test_handle_execution_report_tracked_terminal_evicts_cloid() {
2889        // A tracked CANCELED that reaches a genuine terminal state should
2890        // emit OrderCanceled and evict the cloid mapping so long-running
2891        // sessions do not leak.
2892        let ws_client = make_ws_client();
2893        let (emitter, mut rx) = test_emitter();
2894        let state = WsDispatchState::new();
2895        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
2896
2897        let cid = ClientOrderId::from("O-HER-CANCEL");
2898        state.register_identity(cid, test_identity());
2899        state.insert_accepted(cid);
2900        state.record_venue_order_id(cid, VenueOrderId::new("v-cancel"));
2901
2902        ws_client.cache_cloid_mapping(cloid_for("O-HER-CANCEL"), cid);
2903
2904        let report = make_status_report(Some("O-HER-CANCEL"), "v-cancel", OrderStatus::Canceled);
2905        handle_execution_report(
2906            ExecutionReport::Order(report),
2907            &state,
2908            &emitter,
2909            &ws_client,
2910            &make_http_client(),
2911            &mut pending_cloids,
2912            UnixNanos::default(),
2913        );
2914
2915        let events = drain_events(&mut rx);
2916        assert_eq!(events.len(), 1);
2917        assert!(matches!(
2918            events[0],
2919            ExecutionEvent::Order(OrderEventAny::Canceled(_))
2920        ));
2921        assert_eq!(
2922            ws_client.get_cloid_mapping(&cloid_for("O-HER-CANCEL")),
2923            None
2924        );
2925        assert!(state.filled_orders.contains(&cid));
2926    }
2927
2928    #[rstest]
2929    fn test_post_rejection_skips_after_ws_terminal() {
2930        let ws_client = make_ws_client();
2931        let (emitter, mut rx) = test_emitter();
2932        let state = Arc::new(WsDispatchState::new());
2933        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
2934
2935        let cid = ClientOrderId::from("O-HER-WS-REJ");
2936        state.register_identity(cid, test_identity());
2937        ws_client.cache_cloid_mapping(cloid_for("O-HER-WS-REJ"), cid);
2938
2939        let report = make_status_report(Some("O-HER-WS-REJ"), "v-rej", OrderStatus::Rejected);
2940        handle_execution_report(
2941            ExecutionReport::Order(report),
2942            &state,
2943            &emitter,
2944            &ws_client,
2945            &make_http_client(),
2946            &mut pending_cloids,
2947            UnixNanos::default(),
2948        );
2949
2950        let events = drain_events(&mut rx);
2951        assert_eq!(events.len(), 1);
2952        assert!(matches!(
2953            events[0],
2954            ExecutionEvent::Order(OrderEventAny::Rejected(_))
2955        ));
2956
2957        let order = limit_order_with_quote_quantity("O-HER-WS-REJ", false);
2958        let http_client = make_http_client();
2959        let rejection_route =
2960            PostRejectionRoute::new(&emitter, &ws_client, &http_client, state.clone());
2961        let emitted = rejection_route.emit_once(
2962            &order,
2963            "Post only order would have immediately matched",
2964            UnixNanos::default(),
2965            &cloid_for("O-HER-WS-REJ"),
2966        );
2967
2968        assert!(!emitted);
2969        assert!(drain_events(&mut rx).is_empty());
2970    }
2971
2972    #[rstest]
2973    fn test_post_rejection_suppresses_late_raw_cloid_reject() {
2974        let ws_client = make_ws_client();
2975        let (emitter, mut rx) = test_emitter();
2976        let state = Arc::new(WsDispatchState::new());
2977        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
2978
2979        let cid = ClientOrderId::from("O-HER-POST-REJ");
2980        let cloid = cloid_for("O-HER-POST-REJ");
2981        let order = limit_order_with_quote_quantity("O-HER-POST-REJ", false);
2982        state.register_identity(cid, test_identity());
2983        ws_client.cache_cloid_mapping(cloid, cid);
2984
2985        let http_client = make_http_client();
2986        let rejection_route =
2987            PostRejectionRoute::new(&emitter, &ws_client, &http_client, state.clone());
2988        let emitted = rejection_route.emit_once(
2989            &order,
2990            "Post only order would have immediately matched",
2991            UnixNanos::default(),
2992            &cloid,
2993        );
2994
2995        let events = drain_events(&mut rx);
2996        assert!(emitted);
2997        assert_eq!(events.len(), 1);
2998        assert!(matches!(
2999            events[0],
3000            ExecutionEvent::Order(OrderEventAny::Rejected(_))
3001        ));
3002        assert_eq!(ws_client.get_cloid_mapping(&cloid), None);
3003        assert!(state.filled_orders.contains(&cid));
3004        assert!(state.terminal_cloid_seen(&cloid));
3005
3006        let late_reject = make_status_report(Some(cloid.as_str()), "v-rej", OrderStatus::Rejected);
3007        handle_execution_report(
3008            ExecutionReport::Order(late_reject),
3009            &state,
3010            &emitter,
3011            &ws_client,
3012            &make_http_client(),
3013            &mut pending_cloids,
3014            UnixNanos::default(),
3015        );
3016
3017        assert!(drain_events(&mut rx).is_empty());
3018    }
3019
3020    #[rstest]
3021    fn test_handle_execution_report_filled_marker_then_fill_evicts_on_fill() {
3022        // The status-only FILLED marker defers the cloid eviction to the
3023        // pending cache; the matching FillReport emits OrderFilled and then
3024        // evicts the cloid mapping as part of the deferred-cleanup path.
3025        let ws_client = make_ws_client();
3026        let (emitter, mut rx) = test_emitter();
3027        let state = WsDispatchState::new();
3028        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
3029
3030        let cid = ClientOrderId::from("O-HER-FILL");
3031        state.register_identity(cid, test_identity());
3032        state.insert_accepted(cid);
3033        state.record_venue_order_id(cid, VenueOrderId::new("v-fill"));
3034
3035        ws_client.cache_cloid_mapping(cloid_for("O-HER-FILL"), cid);
3036
3037        let status_marker = make_status_report(Some("O-HER-FILL"), "v-fill", OrderStatus::Filled);
3038        handle_execution_report(
3039            ExecutionReport::Order(status_marker),
3040            &state,
3041            &emitter,
3042            &ws_client,
3043            &make_http_client(),
3044            &mut pending_cloids,
3045            UnixNanos::default(),
3046        );
3047
3048        // Marker arrived: no event, cloid cleanup deferred, mapping retained.
3049        assert!(drain_events(&mut rx).is_empty());
3050        assert_eq!(
3051            ws_client.get_cloid_mapping(&cloid_for("O-HER-FILL")),
3052            Some(cid)
3053        );
3054
3055        let fill = make_fill_report(Some("O-HER-FILL"), "v-fill", "trade-fill");
3056        handle_execution_report(
3057            ExecutionReport::Fill(fill),
3058            &state,
3059            &emitter,
3060            &ws_client,
3061            &make_http_client(),
3062            &mut pending_cloids,
3063            UnixNanos::default(),
3064        );
3065
3066        let events = drain_events(&mut rx);
3067        assert_eq!(events.len(), 1);
3068        assert!(matches!(
3069            events[0],
3070            ExecutionEvent::Order(OrderEventAny::Filled(_))
3071        ));
3072        // Deferred cleanup fires once the fill lands.
3073        assert_eq!(ws_client.get_cloid_mapping(&cloid_for("O-HER-FILL")), None);
3074    }
3075
3076    /// GH-4270: when a status-only `FILLED` marker arrives before the
3077    /// replacement fill and the `ACCEPTED(new_voi)` is dropped, the fill itself
3078    /// promotes the binding (OrderUpdated then OrderFilled) and, being terminal
3079    /// and no longer buffered, completes the deferred cloid eviction.
3080    #[rstest]
3081    fn test_handle_execution_report_fill_under_filled_marker_promotes_and_evicts_cloid() {
3082        let ws_client = make_ws_client();
3083        let (emitter, mut rx) = test_emitter();
3084        let state = WsDispatchState::new();
3085        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
3086
3087        let cid = ClientOrderId::from("O-HER-BUF");
3088        state.register_identity(cid, test_identity());
3089        state.insert_accepted(cid);
3090        state.record_venue_order_id(cid, VenueOrderId::new("old-voi"));
3091        state.mark_pending_modify(cid, VenueOrderId::new("old-voi"), test_identity().quantity);
3092
3093        ws_client.cache_cloid_mapping(cloid_for("O-HER-BUF"), cid);
3094
3095        // Status-only FILLED marker arrives first; defers cloid eviction.
3096        let status_marker = make_status_report(Some("O-HER-BUF"), "new-voi", OrderStatus::Filled);
3097        handle_execution_report(
3098            ExecutionReport::Order(status_marker),
3099            &state,
3100            &emitter,
3101            &ws_client,
3102            &make_http_client(),
3103            &mut pending_cloids,
3104            UnixNanos::default(),
3105        );
3106        assert!(pending_cloids.contains(&cid));
3107        assert_eq!(
3108            ws_client.get_cloid_mapping(&cloid_for("O-HER-BUF")),
3109            Some(cid)
3110        );
3111
3112        // The replacement fill arrives with the new venue_order_id; the ACCEPTED
3113        // was dropped. It promotes the binding, applies the fill, and -- being
3114        // terminal and no longer buffered -- completes the deferred eviction.
3115        let fill = make_fill_report(Some("O-HER-BUF"), "new-voi", "trade-buf");
3116        handle_execution_report(
3117            ExecutionReport::Fill(fill),
3118            &state,
3119            &emitter,
3120            &ws_client,
3121            &make_http_client(),
3122            &mut pending_cloids,
3123            UnixNanos::default(),
3124        );
3125
3126        let events = drain_events(&mut rx);
3127        assert_eq!(events.len(), 2);
3128        assert!(matches!(
3129            events[0],
3130            ExecutionEvent::Order(OrderEventAny::Updated(_))
3131        ));
3132        assert!(matches!(
3133            events[1],
3134            ExecutionEvent::Order(OrderEventAny::Filled(_))
3135        ));
3136        assert_eq!(state.buffered_fill_count(&cid), 0);
3137        assert!(
3138            !pending_cloids.contains(&cid),
3139            "deferred cleanup must complete once the promoting fill lands",
3140        );
3141        assert_eq!(
3142            ws_client.get_cloid_mapping(&cloid_for("O-HER-BUF")),
3143            None,
3144            "cloid mapping must be evicted after the terminal fill",
3145        );
3146    }
3147
3148    /// After a partial fill, the cancel-replace `OrderUpdated` must carry
3149    /// the user's absolute total, not the venue's remaining-only view.
3150    #[rstest]
3151    fn test_cancel_replace_emits_target_total_quantity() {
3152        let ws_client = make_ws_client();
3153        let (emitter, mut rx) = test_emitter();
3154        let state = WsDispatchState::new();
3155        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
3156
3157        let cid = ClientOrderId::from("O-HER-CR-QTY");
3158        let target_total = Quantity::from("0.00020");
3159        let venue_remaining = Quantity::from("0.00015");
3160
3161        let mut identity = test_identity();
3162        identity.quantity = target_total;
3163        state.register_identity(cid, identity);
3164        state.insert_accepted(cid);
3165        state.record_venue_order_id(cid, VenueOrderId::new("old-voi"));
3166        state.mark_pending_modify(cid, VenueOrderId::new("old-voi"), target_total);
3167
3168        ws_client.cache_cloid_mapping(cloid_for("O-HER-CR-QTY"), cid);
3169
3170        let accepted = make_status_report_with_quantity(
3171            Some("O-HER-CR-QTY"),
3172            "new-voi",
3173            OrderStatus::Accepted,
3174            venue_remaining,
3175        );
3176        handle_execution_report(
3177            ExecutionReport::Order(accepted),
3178            &state,
3179            &emitter,
3180            &ws_client,
3181            &make_http_client(),
3182            &mut pending_cloids,
3183            UnixNanos::default(),
3184        );
3185
3186        let events = drain_events(&mut rx);
3187        assert_eq!(events.len(), 1);
3188        match &events[0] {
3189            ExecutionEvent::Order(OrderEventAny::Updated(updated)) => {
3190                assert_eq!(
3191                    updated.quantity, target_total,
3192                    "OrderUpdated must carry the engine's absolute total quantity",
3193                );
3194                assert_eq!(updated.venue_order_id, Some(VenueOrderId::new("new-voi")));
3195            }
3196            other => panic!("expected OrderUpdated, found {other:?}"),
3197        }
3198
3199        // identity.quantity drives the terminal-fill threshold; must match target_total.
3200        let identity = state
3201            .lookup_identity(&cid)
3202            .expect("identity should still be tracked");
3203        assert_eq!(identity.quantity, target_total);
3204
3205        assert!(state.pending_modify(&cid).is_none());
3206        assert!(state.pending_modify_target_qty(&cid).is_none());
3207        assert_eq!(
3208            state.cached_venue_order_id(&cid),
3209            Some(VenueOrderId::new("new-voi")),
3210        );
3211    }
3212
3213    /// Without a target-qty marker (e.g. reconcile-driven modifies), the
3214    /// promotion falls back to `report.quantity`.
3215    #[rstest]
3216    fn test_cancel_replace_without_marker_falls_back_to_report_quantity() {
3217        let ws_client = make_ws_client();
3218        let (emitter, mut rx) = test_emitter();
3219        let state = WsDispatchState::new();
3220        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
3221
3222        let cid = ClientOrderId::from("O-HER-CR-EXT");
3223        state.register_identity(cid, test_identity());
3224        state.insert_accepted(cid);
3225        state.record_venue_order_id(cid, VenueOrderId::new("old-voi"));
3226
3227        ws_client.cache_cloid_mapping(cloid_for("O-HER-CR-EXT"), cid);
3228
3229        let report_qty = Quantity::from("0.0005");
3230        let accepted = make_status_report_with_quantity(
3231            Some("O-HER-CR-EXT"),
3232            "new-voi",
3233            OrderStatus::Accepted,
3234            report_qty,
3235        );
3236        handle_execution_report(
3237            ExecutionReport::Order(accepted),
3238            &state,
3239            &emitter,
3240            &ws_client,
3241            &make_http_client(),
3242            &mut pending_cloids,
3243            UnixNanos::default(),
3244        );
3245
3246        let events = drain_events(&mut rx);
3247        assert_eq!(events.len(), 1);
3248        match &events[0] {
3249            ExecutionEvent::Order(OrderEventAny::Updated(updated)) => {
3250                assert_eq!(updated.quantity, report_qty);
3251            }
3252            other => panic!("expected OrderUpdated, found {other:?}"),
3253        }
3254    }
3255
3256    fn limit_request(size: Decimal) -> HyperliquidExecPlaceOrderRequest {
3257        HyperliquidExecPlaceOrderRequest {
3258            asset: 0,
3259            is_buy: true,
3260            price: "88.949".parse::<Decimal>().unwrap(),
3261            size,
3262            reduce_only: false,
3263            kind: HyperliquidExecOrderKind::Limit {
3264                limit: HyperliquidExecLimitParams {
3265                    tif: HyperliquidExecTif::Gtc,
3266                },
3267            },
3268            cloid: None,
3269        }
3270    }
3271
3272    /// A partial fill landing mid-modify leaves the cancel-replace replacement
3273    /// oversized (sized at the full target). The promotion must queue a
3274    /// corrective reduce to `target - filled` and re-arm the marker.
3275    #[rstest]
3276    fn test_cancel_replace_queues_corrective_reduce_on_in_flight_fill() {
3277        let ws_client = make_ws_client();
3278        let (emitter, mut rx) = test_emitter();
3279        let state = WsDispatchState::new();
3280        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
3281
3282        let cid = ClientOrderId::from("O-HER-4154");
3283        let target_total = Quantity::from("1.000");
3284        let old_voi = "445117664938";
3285        let new_voi = "445117686214";
3286
3287        let mut identity = test_identity();
3288        identity.quantity = target_total;
3289        state.register_identity(cid, identity);
3290        state.insert_accepted(cid);
3291        state.record_venue_order_id(cid, VenueOrderId::new(old_voi));
3292
3293        // Modify dispatched while nothing had filled: marker plus the exact
3294        // request sent to the venue, sized at the full target.
3295        state.mark_pending_modify(cid, VenueOrderId::new(old_voi), target_total);
3296        state.stash_modify_request(cid, limit_request(Decimal::from(1)));
3297
3298        // A 0.165 fill lands on the old leg after the modify was dispatched
3299        state.record_filled_qty(cid, Quantity::from("0.165"));
3300
3301        // Replacement ACCEPTED(new_voi) arrives: promotion runs
3302        let accepted = make_status_report_with_quantity(
3303            Some("O-HER-4154"),
3304            new_voi,
3305            OrderStatus::Accepted,
3306            Quantity::from("0.835"),
3307        );
3308        let corrective = handle_execution_report(
3309            ExecutionReport::Order(accepted),
3310            &state,
3311            &emitter,
3312            &ws_client,
3313            &make_http_client(),
3314            &mut pending_cloids,
3315            UnixNanos::default(),
3316        );
3317
3318        // OrderUpdated still carries the absolute target total
3319        let events = drain_events(&mut rx);
3320        assert_eq!(events.len(), 1);
3321        match &events[0] {
3322            ExecutionEvent::Order(OrderEventAny::Updated(updated)) => {
3323                assert_eq!(updated.quantity, target_total);
3324                assert_eq!(updated.venue_order_id, Some(VenueOrderId::new(new_voi)));
3325            }
3326            other => panic!("expected OrderUpdated, found {other:?}"),
3327        }
3328
3329        let (corr_cid, oid, request) =
3330            corrective.expect("oversized replacement must queue a corrective reduce");
3331        assert_eq!(corr_cid, cid);
3332        assert_eq!(oid, 445_117_686_214);
3333        assert_eq!(request.size, "0.835".parse::<Decimal>().unwrap());
3334        // Marker re-armed on the new voi so the corrective's own cancel leg
3335        // is suppressed and a further in-flight fill chains another reduce.
3336        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new(new_voi)));
3337        assert_eq!(state.pending_modify_target_qty(&cid), Some(target_total));
3338    }
3339
3340    /// GH-4270: when the replacement ACCEPTED is dropped, a fill on the new leg
3341    /// promotes the binding and (parity with the ACCEPTED path) queues a corrective
3342    /// reduce when an earlier old-leg fill left the replacement oversized.
3343    #[rstest]
3344    fn test_cancel_replace_fill_promotion_queues_corrective_reduce() {
3345        let ws_client = make_ws_client();
3346        let (emitter, mut rx) = test_emitter();
3347        let state = WsDispatchState::new();
3348        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
3349
3350        let cid = ClientOrderId::from("O-HER-FILL-CORR");
3351        let target_total = Quantity::from("1.000");
3352        let old_voi = "445117664938";
3353        let new_voi = "445117686214";
3354
3355        let mut identity = test_identity();
3356        identity.quantity = target_total;
3357        state.register_identity(cid, identity);
3358        state.insert_accepted(cid);
3359        state.record_venue_order_id(cid, VenueOrderId::new(old_voi));
3360        // Modify dispatched while nothing had filled: request sized at the full target
3361        state.mark_pending_modify(cid, VenueOrderId::new(old_voi), target_total);
3362        state.stash_modify_request(cid, limit_request(Decimal::from(1)));
3363        // An old-leg fill raced the modify; the replacement ACCEPTED was dropped
3364        state.record_filled_qty(cid, Quantity::from("0.165"));
3365
3366        // A fill lands on the replacement leg: it must promote and queue the reduce
3367        let fill = make_fill_report_with_qty(
3368            Some("O-HER-FILL-CORR"),
3369            new_voi,
3370            "T-FILL-CORR",
3371            Quantity::from("0.100"),
3372        );
3373        let corrective = handle_execution_report(
3374            ExecutionReport::Fill(fill),
3375            &state,
3376            &emitter,
3377            &ws_client,
3378            &make_http_client(),
3379            &mut pending_cloids,
3380            UnixNanos::default(),
3381        );
3382
3383        // The fill promoted: OrderUpdated then OrderFilled
3384        let events = drain_events(&mut rx);
3385        assert_eq!(events.len(), 2);
3386        assert!(matches!(
3387            events[0],
3388            ExecutionEvent::Order(OrderEventAny::Updated(_))
3389        ));
3390        assert!(matches!(
3391            events[1],
3392            ExecutionEvent::Order(OrderEventAny::Filled(_))
3393        ));
3394
3395        // Corrective reduce queued to target - cumulative (1.000 - 0.265 = 0.735)
3396        let (corr_cid, oid, request) =
3397            corrective.expect("oversized replacement must queue a corrective reduce");
3398        assert_eq!(corr_cid, cid);
3399        assert_eq!(oid, 445_117_686_214);
3400        assert_eq!(request.size, "0.735".parse::<Decimal>().unwrap());
3401        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new(new_voi)));
3402    }
3403
3404    /// Without an in-flight fill the replacement is correctly sized, so the
3405    /// promotion must not queue a corrective reduce and must clear the marker.
3406    #[rstest]
3407    fn test_cancel_replace_no_corrective_without_in_flight_fill() {
3408        let ws_client = make_ws_client();
3409        let (emitter, mut rx) = test_emitter();
3410        let state = WsDispatchState::new();
3411        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
3412
3413        let cid = ClientOrderId::from("O-HER-4154-NOFILL");
3414        let target_total = Quantity::from("1.000");
3415
3416        let mut identity = test_identity();
3417        identity.quantity = target_total;
3418        state.register_identity(cid, identity);
3419        state.insert_accepted(cid);
3420        state.record_venue_order_id(cid, VenueOrderId::new("445117664938"));
3421        state.mark_pending_modify(cid, VenueOrderId::new("445117664938"), target_total);
3422        state.stash_modify_request(cid, limit_request(Decimal::from(1)));
3423
3424        let accepted = make_status_report_with_quantity(
3425            Some("O-HER-4154-NOFILL"),
3426            "445117686214",
3427            OrderStatus::Accepted,
3428            target_total,
3429        );
3430        let corrective = handle_execution_report(
3431            ExecutionReport::Order(accepted),
3432            &state,
3433            &emitter,
3434            &ws_client,
3435            &make_http_client(),
3436            &mut pending_cloids,
3437            UnixNanos::default(),
3438        );
3439
3440        let _ = drain_events(&mut rx);
3441        assert!(corrective.is_none());
3442        assert!(state.pending_modify(&cid).is_none());
3443        assert!(state.take_corrective(&cid).is_none());
3444        // Promotion clears the stashed request along with the marker
3445        assert!(state.modify_request(&cid).is_none());
3446    }
3447
3448    /// A fill buffered during the in-flight cancel-replace is drained before
3449    /// the corrective is computed, so the corrective must size from the
3450    /// post-drain cumulative. A pre-drain read would see 0 filled and skip it.
3451    #[rstest]
3452    fn test_cancel_replace_corrective_uses_post_drain_buffered_fill() {
3453        let ws_client = make_ws_client();
3454        let (emitter, mut rx) = test_emitter();
3455        let state = WsDispatchState::new();
3456        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
3457
3458        let cid = ClientOrderId::from("O-HER-4154-BUF");
3459        let target_total = Quantity::from("1.000");
3460        let new_voi = "445117686214";
3461
3462        let mut identity = test_identity();
3463        identity.quantity = target_total;
3464        state.register_identity(cid, identity);
3465        state.insert_accepted(cid);
3466        state.record_venue_order_id(cid, VenueOrderId::new("445117664938"));
3467        state.mark_pending_modify(cid, VenueOrderId::new("445117664938"), target_total);
3468        state.stash_modify_request(cid, limit_request(Decimal::from(1)));
3469
3470        // Nothing recorded yet; the only fill arrives buffered on the new leg
3471        let buffered = make_fill_report_with_qty(
3472            Some("O-HER-4154-BUF"),
3473            new_voi,
3474            "trade-buf-4154",
3475            Quantity::from("0.165"),
3476        );
3477        state.buffer_fill(cid, buffered);
3478
3479        let accepted = make_status_report_with_quantity(
3480            Some("O-HER-4154-BUF"),
3481            new_voi,
3482            OrderStatus::Accepted,
3483            Quantity::from("0.835"),
3484        );
3485        let corrective = handle_execution_report(
3486            ExecutionReport::Order(accepted),
3487            &state,
3488            &emitter,
3489            &ws_client,
3490            &make_http_client(),
3491            &mut pending_cloids,
3492            UnixNanos::default(),
3493        );
3494
3495        let _ = drain_events(&mut rx);
3496        let (_, _, request) =
3497            corrective.expect("buffered fill drained before compute must still queue a corrective");
3498        assert_eq!(request.size, "0.835".parse::<Decimal>().unwrap());
3499    }
3500
3501    /// When an in-flight fill brings cumulative filled to exactly the target,
3502    /// the remaining is zero, so no corrective (a reduce-to-zero is not valid)
3503    /// must be queued and the marker is cleared.
3504    #[rstest]
3505    fn test_cancel_replace_no_corrective_when_filled_equals_target() {
3506        let ws_client = make_ws_client();
3507        let (emitter, mut rx) = test_emitter();
3508        let state = WsDispatchState::new();
3509        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
3510
3511        let cid = ClientOrderId::from("O-HER-4154-EXACT");
3512        let target_total = Quantity::from("1.000");
3513
3514        let mut identity = test_identity();
3515        identity.quantity = target_total;
3516        state.register_identity(cid, identity);
3517        state.insert_accepted(cid);
3518        state.record_venue_order_id(cid, VenueOrderId::new("445117664938"));
3519        state.mark_pending_modify(cid, VenueOrderId::new("445117664938"), target_total);
3520        state.stash_modify_request(cid, limit_request(Decimal::from(1)));
3521        state.record_filled_qty(cid, target_total);
3522
3523        let accepted = make_status_report_with_quantity(
3524            Some("O-HER-4154-EXACT"),
3525            "445117686214",
3526            OrderStatus::Accepted,
3527            target_total,
3528        );
3529        let corrective = handle_execution_report(
3530            ExecutionReport::Order(accepted),
3531            &state,
3532            &emitter,
3533            &ws_client,
3534            &make_http_client(),
3535            &mut pending_cloids,
3536            UnixNanos::default(),
3537        );
3538
3539        let _ = drain_events(&mut rx);
3540        assert!(corrective.is_none());
3541        assert!(state.pending_modify(&cid).is_none());
3542    }
3543
3544    /// A further in-flight fill during the corrective's own modify chains
3545    /// another reduce: the second promotion sizes from the new cumulative.
3546    #[rstest]
3547    fn test_cancel_replace_chains_second_corrective_reduce() {
3548        let ws_client = make_ws_client();
3549        let (emitter, mut rx) = test_emitter();
3550        let state = WsDispatchState::new();
3551        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
3552
3553        let cid = ClientOrderId::from("O-HER-4154-CHAIN");
3554        let target_total = Quantity::from("1.000");
3555        let voi3 = "445117699999";
3556
3557        // State after the first corrective: marker re-armed on the prior
3558        // replacement, stashed request reduced to 0.835, 0.165 already filled.
3559        let mut identity = test_identity();
3560        identity.quantity = target_total;
3561        state.register_identity(cid, identity);
3562        state.insert_accepted(cid);
3563        state.record_venue_order_id(cid, VenueOrderId::new("445117686214"));
3564        state.mark_pending_modify(cid, VenueOrderId::new("445117686214"), target_total);
3565        state.stash_modify_request(cid, limit_request("0.835".parse::<Decimal>().unwrap()));
3566        // A further 0.300 lands in-flight: cumulative now 0.465
3567        state.record_filled_qty(cid, Quantity::from("0.465"));
3568
3569        let accepted = make_status_report_with_quantity(
3570            Some("O-HER-4154-CHAIN"),
3571            voi3,
3572            OrderStatus::Accepted,
3573            Quantity::from("0.535"),
3574        );
3575        let corrective = handle_execution_report(
3576            ExecutionReport::Order(accepted),
3577            &state,
3578            &emitter,
3579            &ws_client,
3580            &make_http_client(),
3581            &mut pending_cloids,
3582            UnixNanos::default(),
3583        );
3584
3585        let _ = drain_events(&mut rx);
3586        let (_, oid, request) =
3587            corrective.expect("a further in-flight fill must chain another corrective");
3588        assert_eq!(oid, 445_117_699_999);
3589        assert_eq!(request.size, "0.535".parse::<Decimal>().unwrap());
3590        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new(voi3)));
3591    }
3592
3593    #[rstest]
3594    fn test_handle_execution_report_external_terminal_evicts_cloid() {
3595        // External (untracked) terminal reports forward to the engine via
3596        // send_order_status_report and immediately evict the cloid mapping
3597        // so the client does not leak mappings for orders it does not own.
3598        let ws_client = make_ws_client();
3599        let (emitter, mut rx) = test_emitter();
3600        let state = WsDispatchState::new();
3601        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
3602
3603        let cid = ClientOrderId::from("O-HER-EXT");
3604        ws_client.cache_cloid_mapping(cloid_for("O-HER-EXT"), cid);
3605
3606        let report = make_status_report(Some("O-HER-EXT"), "v-ext", OrderStatus::Canceled);
3607        handle_execution_report(
3608            ExecutionReport::Order(report),
3609            &state,
3610            &emitter,
3611            &ws_client,
3612            &make_http_client(),
3613            &mut pending_cloids,
3614            UnixNanos::default(),
3615        );
3616
3617        let events = drain_events(&mut rx);
3618        assert_eq!(events.len(), 1);
3619        assert!(
3620            matches!(events[0], ExecutionEvent::Report(_)),
3621            "external terminal report should forward to the engine as a report",
3622        );
3623        assert_eq!(ws_client.get_cloid_mapping(&cloid_for("O-HER-EXT")), None);
3624    }
3625
3626    #[rstest]
3627    fn test_handle_execution_report_open_status_preserves_cloid() {
3628        // An open (non-terminal) status must never touch the cloid mapping.
3629        let ws_client = make_ws_client();
3630        let (emitter, _rx) = test_emitter();
3631        let state = WsDispatchState::new();
3632        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
3633
3634        let cid = ClientOrderId::from("O-HER-OPEN");
3635        state.register_identity(cid, test_identity());
3636        ws_client.cache_cloid_mapping(cloid_for("O-HER-OPEN"), cid);
3637
3638        let report = make_status_report(Some("O-HER-OPEN"), "v-open", OrderStatus::Accepted);
3639        handle_execution_report(
3640            ExecutionReport::Order(report),
3641            &state,
3642            &emitter,
3643            &ws_client,
3644            &make_http_client(),
3645            &mut pending_cloids,
3646            UnixNanos::default(),
3647        );
3648
3649        // Accepted is open, so no cloid eviction occurs regardless of outcome.
3650        assert_eq!(
3651            ws_client.get_cloid_mapping(&cloid_for("O-HER-OPEN")),
3652            Some(cid)
3653        );
3654    }
3655
3656    #[rstest]
3657    fn test_handle_execution_report_tracked_accepted_emits_typed_event() {
3658        // A tracked open ACCEPTED must flow through the typed-event path,
3659        // NOT the raw report fallback. Catches a mutation that swaps the
3660        // branch polarity inside `handle_execution_report`.
3661        let ws_client = make_ws_client();
3662        let (emitter, mut rx) = test_emitter();
3663        let state = WsDispatchState::new();
3664        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
3665
3666        let cid = ClientOrderId::from("O-HER-ACC");
3667        state.register_identity(cid, test_identity());
3668        ws_client.cache_cloid_mapping(cloid_for("O-HER-ACC"), cid);
3669
3670        let report = make_status_report(Some("O-HER-ACC"), "v-acc", OrderStatus::Accepted);
3671        handle_execution_report(
3672            ExecutionReport::Order(report),
3673            &state,
3674            &emitter,
3675            &ws_client,
3676            &make_http_client(),
3677            &mut pending_cloids,
3678            UnixNanos::default(),
3679        );
3680
3681        let events = drain_events(&mut rx);
3682        assert_eq!(events.len(), 1);
3683        assert!(
3684            matches!(events[0], ExecutionEvent::Order(OrderEventAny::Accepted(_))),
3685            "tracked accepted should route through the typed-event path",
3686        );
3687        // Mapping is unchanged because the status is still open.
3688        assert_eq!(
3689            ws_client.get_cloid_mapping(&cloid_for("O-HER-ACC")),
3690            Some(cid)
3691        );
3692    }
3693
3694    fn outcome_limit_order(id: &str, reduce_only: bool) -> OrderAny {
3695        outcome_limit_order_full(id, reduce_only, false, TimeInForce::Gtc)
3696    }
3697
3698    fn outcome_limit_order_full(
3699        id: &str,
3700        reduce_only: bool,
3701        post_only: bool,
3702        time_in_force: TimeInForce,
3703    ) -> OrderAny {
3704        OrderAny::Limit(LimitOrder::new(
3705            TraderId::from("TESTER-001"),
3706            StrategyId::from("S-001"),
3707            InstrumentId::from("1-YES-OUTCOME.HYPERLIQUID"),
3708            ClientOrderId::from(id),
3709            OrderSide::Buy,
3710            Quantity::from("1"),
3711            Price::from("0.5000"),
3712            time_in_force,
3713            None,
3714            post_only,
3715            reduce_only,
3716            false,
3717            None,
3718            None,
3719            None,
3720            Some(ContingencyType::NoContingency),
3721            None,
3722            None,
3723            None,
3724            None,
3725            None,
3726            None,
3727            None,
3728            Default::default(),
3729            Default::default(),
3730        ))
3731    }
3732
3733    fn outcome_stop_order(id: &str) -> OrderAny {
3734        OrderAny::StopMarket(StopMarketOrder::new(
3735            TraderId::from("TESTER-001"),
3736            StrategyId::from("S-001"),
3737            InstrumentId::from("1-YES-OUTCOME.HYPERLIQUID"),
3738            ClientOrderId::from(id),
3739            OrderSide::Sell,
3740            Quantity::from("1"),
3741            Price::from("0.4000"),
3742            TriggerType::LastPrice,
3743            TimeInForce::Gtc,
3744            None,
3745            false,
3746            false,
3747            None,
3748            None,
3749            None,
3750            Some(ContingencyType::NoContingency),
3751            None,
3752            None,
3753            None,
3754            None,
3755            None,
3756            None,
3757            None,
3758            Default::default(),
3759            Default::default(),
3760        ))
3761    }
3762
3763    fn perp_with_unsupported_symbol(id: &str) -> OrderAny {
3764        OrderAny::Limit(LimitOrder::new(
3765            TraderId::from("TESTER-001"),
3766            StrategyId::from("S-001"),
3767            InstrumentId::from("BTC-USD-FOO.HYPERLIQUID"),
3768            ClientOrderId::from(id),
3769            OrderSide::Buy,
3770            Quantity::from("1"),
3771            Price::from("100.0"),
3772            TimeInForce::Gtc,
3773            None,
3774            false,
3775            false,
3776            false,
3777            None,
3778            None,
3779            None,
3780            Some(ContingencyType::NoContingency),
3781            None,
3782            None,
3783            None,
3784            None,
3785            None,
3786            None,
3787            None,
3788            Default::default(),
3789            Default::default(),
3790        ))
3791    }
3792
3793    #[rstest]
3794    fn test_validate_accepts_perp_limit_order() {
3795        let order = limit_order(
3796            "O-VAL-PERP",
3797            false,
3798            ContingencyType::NoContingency,
3799            None,
3800            None,
3801        );
3802        validate_order_for_hyperliquid(&order).unwrap();
3803    }
3804
3805    #[rstest]
3806    #[case::gtc_post_only(true, TimeInForce::Gtc)]
3807    #[case::gtc_taker(false, TimeInForce::Gtc)]
3808    #[case::ioc_post_only(true, TimeInForce::Ioc)]
3809    #[case::ioc_taker(false, TimeInForce::Ioc)]
3810    fn test_validate_accepts_outcome_limit_order(
3811        #[case] post_only: bool,
3812        #[case] time_in_force: TimeInForce,
3813    ) {
3814        let order = outcome_limit_order_full(
3815            "O-VAL-OUTCOME",
3816            /* reduce_only */ false,
3817            post_only,
3818            time_in_force,
3819        );
3820        validate_order_for_hyperliquid(&order).unwrap();
3821    }
3822
3823    #[rstest]
3824    fn test_validate_rejects_outcome_reduce_only() {
3825        let order = outcome_limit_order("O-VAL-RO", true);
3826        let err = validate_order_for_hyperliquid(&order).unwrap_err();
3827        assert!(
3828            err.to_string().contains("Reduce-only is not supported"),
3829            "unexpected error: {err}",
3830        );
3831    }
3832
3833    #[rstest]
3834    fn test_validate_rejects_outcome_trigger_order() {
3835        let order = outcome_stop_order("O-VAL-TRIG");
3836        let err = validate_order_for_hyperliquid(&order).unwrap_err();
3837        assert!(
3838            err.to_string()
3839                .contains("Trigger order types are not supported"),
3840            "unexpected error: {err}",
3841        );
3842    }
3843
3844    #[rstest]
3845    fn test_validate_rejects_unsupported_symbol_suffix() {
3846        let order = perp_with_unsupported_symbol("O-VAL-BAD");
3847        let err = validate_order_for_hyperliquid(&order).unwrap_err();
3848        assert!(
3849            err.to_string()
3850                .contains("Unsupported instrument symbol format"),
3851            "unexpected error: {err}",
3852        );
3853    }
3854}