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,
20    time::{Duration, Instant},
21};
22
23use ahash::AHashMap;
24use anyhow::Context;
25use async_trait::async_trait;
26use nautilus_common::{
27    cache::fifo::FifoCache,
28    clients::{ExecutionClient, ExecutionReportTask},
29    live::runner::get_exec_event_sender,
30    messages::execution::{
31        BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
32        GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
33        ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
34    },
35};
36use nautilus_core::{
37    DurationNanos, Params, UUID4, UnixNanos,
38    time::{AtomicTime, get_atomic_clock_realtime},
39};
40use nautilus_live::{
41    ExecutionClientCore, ExecutionEventEmitter, SocketControl,
42    execution::{
43        context::OrderContext, failure::CommandFailure, reports::retain_order_status_reports,
44    },
45    task::{TaskGroup, TaskGroupGuard, TaskSpawner},
46};
47use nautilus_model::{
48    accounts::AccountAny,
49    enums::{AccountType, OmsType, OrderSide, OrderStatus, OrderType},
50    events::{OrderDeniedReason, OrderEventAny},
51    identifiers::{
52        AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, Venue, VenueOrderId,
53    },
54    instruments::{Instrument, InstrumentAny},
55    orders::{Order, any::OrderAny},
56    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
57    types::{AccountBalance, MarginBalance, Quantity},
58};
59use parking_lot::Mutex;
60use rust_decimal::Decimal;
61use ustr::Ustr;
62
63use crate::{
64    account::resolve_execution_account_address,
65    common::{
66        consts::{
67            HYPERLIQUID_BUILDER_APPROVAL_DOCS_URL, HYPERLIQUID_BUILDER_FEE_NOT_APPROVED,
68            HYPERLIQUID_POST_ONLY_WOULD_MATCH, HYPERLIQUID_VENUE,
69        },
70        credential::Secrets,
71        enums::{HyperliquidAccountAbstraction, HyperliquidProductType},
72        parse::{
73            clamp_price_to_precision, derive_limit_from_trigger, derive_market_order_price,
74            extract_error_message, extract_inner_error, extract_inner_errors,
75            normalize_or_validate_wire_price, order_to_hyperliquid_request_with_optional_decimals,
76            parse_combined_account_balances_and_margins, round_to_sig_figs,
77        },
78    },
79    config::HyperliquidExecutionClientConfig,
80    http::{
81        client::{HYPERLIQUID_RECENT_HISTORY_LIMIT, HyperliquidHttpClient},
82        models::{
83            ClearinghouseState, Cloid, HyperliquidExchangeAction,
84            HyperliquidExchangeCancelByCloidRequest, HyperliquidExchangeCancelOrderRequest,
85            HyperliquidExchangeGrouping, HyperliquidExchangeModifyOrderRequest,
86            HyperliquidExchangeModifyTarget, HyperliquidExchangeOrderKind,
87            HyperliquidExchangePlaceOrderRequest, HyperliquidExchangeTpSl, HyperliquidFills,
88            HyperliquidOrderStatusEntry, SpotClearinghouseState,
89        },
90        parse::derive_outcome_settlements,
91    },
92    outcome_settlement::{OutcomeSettlementTracker, build_settlement_fills},
93    websocket::{
94        ExecutionReport, NautilusWsMessage, USER_STREAMS_ENDPOINT,
95        client::{HyperliquidWebSocketClient, PostRequestError},
96        dispatch::{
97            CorrectiveReduce, DispatchOutcome, WsDispatchState, dispatch_order_event,
98            dispatch_order_fill, promote_replacement_from_query, venue_oid,
99        },
100    },
101};
102
103const TASK_SHUTDOWN_DENIAL_REASON: &str = "Hyperliquid execution client is shutting down";
104const RECONNECT_SUBSCRIPTION_TIMEOUT: Duration = Duration::from_secs(10);
105
106// Covers timestamp disorder between `orderUpdates` and `userEvents` and clock skew in the
107// stream start time; recovered reports the stream already processed are deduplicated.
108const RECONNECT_RECOVERY_MARGIN: DurationNanos = DurationNanos::from_secs(30);
109
110// The history endpoints can lag the live state by seconds, so recovery reads them again after this
111const RECONNECT_FOLLOW_UP_DELAY: Duration = Duration::from_secs(5);
112
113#[derive(Debug)]
114pub struct HyperliquidExecutionClient {
115    core: ExecutionClientCore,
116    clock: &'static AtomicTime,
117    config: HyperliquidExecutionClientConfig,
118    emitter: ExecutionEventEmitter,
119    http_client: HyperliquidHttpClient,
120    ws_client: HyperliquidWebSocketClient,
121    session_tasks: TaskGroup,
122    pending_tasks: TaskGroup,
123    shutdown_errors: Vec<String>,
124    ws_dispatch_state: Arc<WsDispatchState>,
125    staged_brackets: Arc<Mutex<StagedBracketState>>,
126    outcome_settlement_tracker: Arc<Mutex<OutcomeSettlementTracker>>,
127}
128
129impl HyperliquidExecutionClient {
130    /// Arms, replaces, or clears the account-wide scheduled cancellation of all open orders.
131    ///
132    /// See [`HyperliquidHttpClient::schedule_cancel`] for timestamp and lifecycle requirements.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if [`HyperliquidHttpClient::schedule_cancel`] fails.
137    pub async fn schedule_cancel(&self, time: Option<u64>) -> anyhow::Result<()> {
138        self.http_client.schedule_cancel(time).await?;
139        Ok(())
140    }
141
142    /// Returns a reference to the configuration.
143    pub fn config(&self) -> &HyperliquidExecutionClientConfig {
144        &self.config
145    }
146
147    /// Returns a reference to the shared WebSocket dispatch state.
148    ///
149    /// Exposes the context map, pending-modify markers, and cached venue
150    /// order ids used by the two-tier dispatch contract. The state is
151    /// read-write via an [`Arc`]; callers must not mutate it directly, but
152    /// it is useful for inspection in tests and for live debugging.
153    #[must_use]
154    pub fn ws_dispatch_state(&self) -> &Arc<WsDispatchState> {
155        &self.ws_dispatch_state
156    }
157
158    /// Returns `true` when every background task spawned via `spawn_task`
159    /// has completed.
160    ///
161    /// Used in tests to wait for submit / modify / cancel action round-trips
162    /// that fire on the runtime to finish before asserting on dispatch
163    /// state, avoiding bare `sleep` calls when a negative condition needs
164    /// to be checked after the spawned work is done.
165    #[must_use]
166    pub fn pending_tasks_all_finished(&self) -> bool {
167        self.pending_tasks.all_finished()
168    }
169
170    fn resolve_slippage_bps(&self, params: Option<&Params>) -> u32 {
171        params
172            .and_then(|p| p.get_u64("market_order_slippage_bps"))
173            .map_or(self.config.market_order_slippage_bps, |v| v as u32)
174    }
175
176    fn validate_order_submission(&self, order: &OrderAny) -> anyhow::Result<()> {
177        validate_order_for_hyperliquid(order)
178    }
179
180    fn order_request(
181        &self,
182        order: &OrderAny,
183        slippage_bps: u32,
184    ) -> anyhow::Result<HyperliquidExchangePlaceOrderRequest> {
185        validate_order_for_hyperliquid(order)?;
186
187        let symbol = order.instrument_id().symbol.inner();
188        let asset = self
189            .http_client
190            .get_asset_index_for_symbol(symbol)
191            .with_context(|| format!("Asset index not found for {symbol}"))?;
192        let price_decimals = self.http_client.get_price_precision_for_symbol(symbol);
193        let cloid = self
194            .http_client
195            .cached_client_order_id_cloid(&order.client_order_id())
196            .unwrap_or_else(|| Cloid::from_client_order_id(order.client_order_id()));
197        let mut request = order_to_hyperliquid_request_with_optional_decimals(
198            order,
199            asset,
200            price_decimals,
201            self.config.normalize_prices,
202            slippage_bps,
203            None,
204        )?;
205        request.cloid = Some(cloid);
206
207        if let Some(base_size) = self
208            .quote_converted_size(order)
209            .map_err(|reason| anyhow::anyhow!("{reason}"))?
210        {
211            request.size = base_size;
212        }
213
214        // Market orders need a limit price derived from the cached quote,
215        // leaving the conversion's zero placeholder when none is cached.
216        if order.order_type() == OrderType::Market {
217            let instrument_id = order.instrument_id();
218            let cache = self.core.cache();
219
220            if let Some(quote) = cache.quote(&instrument_id) {
221                let is_buy = order.order_side() == OrderSide::Buy;
222                request.price = derive_market_order_price(
223                    quote,
224                    is_buy,
225                    price_decimals.unwrap_or(2),
226                    slippage_bps,
227                );
228            }
229        }
230
231        Ok(request)
232    }
233
234    // Hyperliquid accepts only base-denominated sizes on the wire, so a quote
235    // amount must convert (or deny) before it can reach the venue.
236    fn quote_converted_size(&self, order: &OrderAny) -> Result<Option<Decimal>, OrderDeniedReason> {
237        if !order.is_quote_quantity() {
238            return Ok(None);
239        }
240
241        let instrument_id = order.instrument_id();
242        let cache = self.core.cache();
243
244        let Some(instrument) = cache.instrument(&instrument_id) else {
245            return Err(OrderDeniedReason::InstrumentNotFound { instrument_id });
246        };
247
248        let Some(quote) = cache.quote(&instrument_id) else {
249            return Err(OrderDeniedReason::MarketPriceUnavailable {
250                order_type: order.order_type(),
251                instrument_id,
252            });
253        };
254
255        let reference_price = if order.order_side() == OrderSide::Buy {
256            quote.ask_price
257        } else {
258            quote.bid_price
259        };
260
261        let base_size = instrument
262            .try_calculate_base_quantity(order.quantity(), reference_price)
263            .map_err(|e| OrderDeniedReason::ValidationFailed {
264                detail: format!("Quote-denominated quantity conversion failed: {e}"),
265            })?;
266
267        if base_size.is_zero() {
268            return Err(OrderDeniedReason::ValidationFailed {
269                detail: format!(
270                    "Quote-denominated quantity {} converts to a zero base size at the instrument size precision",
271                    order.quantity()
272                ),
273            });
274        }
275
276        Ok(Some(base_size.as_decimal().normalize()))
277    }
278
279    fn restore_staged_brackets(&self) -> Vec<ClientOrderId> {
280        let order_lists = self
281            .core
282            .cache()
283            .order_lists(Some(&self.core.venue), None, None, None)
284            .into_iter()
285            .cloned()
286            .collect::<Vec<_>>();
287        let mut ready_parent_ids = Vec::new();
288
289        for order_list in order_lists {
290            let orders = {
291                let cache = self.core.cache();
292                order_list
293                    .client_order_ids
294                    .iter()
295                    .filter_map(|client_order_id| {
296                        cache.order(client_order_id).map(|order| order.clone())
297                    })
298                    .collect::<Vec<_>>()
299            };
300
301            if orders.len() != order_list.client_order_ids.len()
302                || determine_order_list_grouping(&orders) != HyperliquidExchangeGrouping::NormalTpsl
303            {
304                continue;
305            }
306
307            let (mut orders, mut requests) = match orders
308                .iter()
309                .map(|order| self.order_request(order, self.config.market_order_slippage_bps))
310                .collect::<anyhow::Result<Vec<_>>>()
311            {
312                Ok(requests) => order_normal_tpsl_submission(
313                    orders,
314                    requests,
315                    HyperliquidExchangeGrouping::NormalTpsl,
316                ),
317                Err(e) => {
318                    log::warn!("Cannot restore staged bracket {}: {e}", order_list.id,);
319                    continue;
320                }
321            };
322            let parent = orders.remove(0);
323            let parent_request = requests.remove(0);
324            let parent_id = parent.client_order_id();
325            let (staged_children, active_children): (Vec<_>, Vec<_>) = orders
326                .drain(..)
327                .zip(requests.drain(..))
328                .filter(|(order, _)| order.is_active_local())
329                .map(|(order, request)| StagedBracketChild { order, request })
330                .partition(|child| child.order.status() == OrderStatus::Initialized);
331
332            if (staged_children.is_empty() && active_children.is_empty())
333                || (!parent.is_open() && parent.filled_qty().is_zero())
334                || self.staged_brackets.lock().contains_parent(&parent_id)
335            {
336                continue;
337            }
338
339            self.restore_order_context(&parent, &parent_request);
340            for child in &active_children {
341                self.restore_order_context(&child.order, &child.request);
342            }
343
344            let has_staged_children = !staged_children.is_empty();
345            let mut state = self.staged_brackets.lock();
346            if has_staged_children {
347                state.stage(parent_id, staged_children);
348            }
349            state.restore_active(&active_children);
350            drop(state);
351
352            if has_staged_children && parent.filled_qty().non_zero() {
353                ready_parent_ids.push(parent_id);
354            }
355        }
356
357        if !ready_parent_ids.is_empty() {
358            log::info!(
359                "Restored {} staged bracket parent(s) with prior fills",
360                ready_parent_ids.len(),
361            );
362        }
363
364        ready_parent_ids
365    }
366
367    fn restore_order_context(
368        &self,
369        order: &OrderAny,
370        request: &HyperliquidExchangePlaceOrderRequest,
371    ) {
372        let client_order_id = order.client_order_id();
373        let cloid = request.cloid.expect("order conversion must set a CLOID");
374        self.http_client
375            .cache_client_order_id_cloid(client_order_id, cloid);
376        self.ws_client
377            .cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
378        self.ws_dispatch_state
379            .register_context(OrderContext::from(order));
380
381        if let Some(venue_order_id) = order.venue_order_id() {
382            self.ws_dispatch_state
383                .record_venue_order_id(client_order_id, venue_order_id);
384            self.ws_dispatch_state.insert_accepted(client_order_id);
385        }
386    }
387
388    /// Creates a new [`HyperliquidExecutionClient`].
389    ///
390    /// # Errors
391    ///
392    /// Returns an error if the configuration is invalid or either transport fails to construct.
393    pub fn new(
394        core: ExecutionClientCore,
395        config: HyperliquidExecutionClientConfig,
396    ) -> anyhow::Result<Self> {
397        config.validate()?;
398
399        let secrets = Secrets::resolve(
400            config
401                .private_key
402                .as_ref()
403                .map(|value| value.expose_secret()),
404            config.vault_address.as_deref(),
405            config.environment,
406        )
407        .context("Hyperliquid execution client requires private key")?;
408
409        let account_address = resolve_execution_account_address(
410            config
411                .private_key
412                .as_ref()
413                .map(|value| value.expose_secret()),
414            config.vault_address.as_deref(),
415            config.account_address.as_deref(),
416            config.environment,
417        )?;
418        let proxy_url = config
419            .proxy_url
420            .as_ref()
421            .map(|value| value.expose_secret().to_owned());
422
423        let mut http_client = HyperliquidHttpClient::with_secrets(
424            &secrets,
425            config.http_timeout_secs,
426            proxy_url.clone(),
427        )
428        .context("failed to create Hyperliquid HTTP client")?;
429
430        http_client.set_account_id(core.account_id);
431        http_client.set_account_address(account_address)?;
432        http_client.set_normalize_prices(config.normalize_prices);
433        http_client.set_market_order_slippage_bps(config.market_order_slippage_bps);
434        http_client.set_include_builder_attribution(config.include_builder_attribution);
435        http_client.set_info_retry_config(
436            config.max_retries,
437            Duration::from_millis(config.retry_delay_initial_ms),
438            Duration::from_millis(config.retry_delay_max_ms),
439        );
440
441        if let Some(url) = &config.base_url_http {
442            http_client.set_base_info_url(url.clone());
443        }
444
445        if let Some(url) = &config.base_url_exchange {
446            http_client.set_base_exchange_url(url.clone());
447        }
448
449        let ws_url = config.base_url_ws.clone();
450        let mut ws_client = HyperliquidWebSocketClient::new(
451            ws_url,
452            config.environment,
453            Some(core.account_id),
454            config.transport_backend,
455            proxy_url,
456        );
457        ws_client = ws_client.with_socket_control(SocketControl::new(
458            core.client_id,
459            Some(*HYPERLIQUID_VENUE),
460            USER_STREAMS_ENDPOINT,
461        ));
462        ws_client.set_post_timeout(Duration::from_secs(config.ws_post_timeout_secs));
463
464        let clock = get_atomic_clock_realtime();
465        let emitter = ExecutionEventEmitter::new(
466            clock,
467            core.trader_id,
468            core.account_id,
469            AccountType::Margin,
470            None,
471        );
472
473        let session_tasks = TaskGroup::new();
474        let pending_tasks = TaskGroup::new();
475
476        Ok(Self {
477            core,
478            clock,
479            config,
480            emitter,
481            http_client,
482            ws_client,
483            session_tasks,
484            pending_tasks,
485            shutdown_errors: Vec::new(),
486            ws_dispatch_state: Arc::new(WsDispatchState::new()),
487            staged_brackets: Arc::new(Mutex::new(StagedBracketState::default())),
488            outcome_settlement_tracker: Arc::new(Mutex::new(OutcomeSettlementTracker::new())),
489        })
490    }
491
492    async fn ensure_instruments_initialized_async(&self) -> anyhow::Result<()> {
493        if self.core.instruments_initialized() {
494            return Ok(());
495        }
496
497        let instruments = self
498            .http_client
499            .request_instruments()
500            .await
501            .context("failed to request Hyperliquid instruments")?;
502
503        if instruments.is_empty() {
504            log::warn!(
505                "Instrument bootstrap yielded no instruments; WebSocket submissions may fail"
506            );
507        } else {
508            log::debug!("Initialized {} instruments", instruments.len());
509
510            self.http_client.merge_instruments(&instruments);
511        }
512
513        self.core.set_instruments_initialized();
514        Ok(())
515    }
516
517    async fn refresh_account_state(&self) -> anyhow::Result<()> {
518        let account_address = self.get_account_address()?;
519
520        let (perp_state, spot_state, abstraction) = self
521            .fetch_combined_clearinghouse_state(&account_address)
522            .await?;
523
524        log::debug!(
525            "Received clearinghouse state: cross_margin_summary={:?}, asset_positions={}, spot_balances={}",
526            perp_state.cross_margin_summary,
527            perp_state.asset_positions.len(),
528            spot_state.balances.len(),
529        );
530
531        let (balances, margins) =
532            parse_combined_account_balances_and_margins(&perp_state, &spot_state, abstraction)
533                .context("failed to parse combined account balances and margins")?;
534
535        // Emit for unfunded wallets too, which carry only zero balances, so the account
536        // registers for await_account_registered.
537        let ts_event = self.clock.get_time_ns();
538        self.emitter
539            .emit_account_state(balances, margins, true, ts_event, None);
540
541        log::debug!("Account state updated successfully");
542        Ok(())
543    }
544
545    async fn fetch_combined_clearinghouse_state(
546        &self,
547        account_address: &str,
548    ) -> anyhow::Result<(
549        ClearinghouseState,
550        SpotClearinghouseState,
551        HyperliquidAccountAbstraction,
552    )> {
553        let perp_json = self
554            .http_client
555            .info_clearinghouse_state(account_address)
556            .await
557            .context("failed to fetch clearinghouse state")?;
558        let perp_state: ClearinghouseState = serde_json::from_value(perp_json)
559            .context("failed to deserialize clearinghouse state")?;
560
561        let spot_json = self
562            .http_client
563            .info_spot_clearinghouse_state(account_address)
564            .await
565            .context("failed to fetch spot clearinghouse state")?;
566        let spot_state: SpotClearinghouseState = serde_json::from_value(spot_json)
567            .context("failed to deserialize spot clearinghouse state")?;
568
569        let abstraction = self
570            .http_client
571            .info_user_abstraction(account_address)
572            .await
573            .context("failed to fetch user abstraction")?;
574
575        Ok((perp_state, spot_state, abstraction))
576    }
577
578    async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
579        let account_id = self.core.account_id;
580
581        if self.core.cache().account(&account_id).is_some() {
582            log::info!("Account {account_id} registered");
583            return Ok(());
584        }
585
586        let start = Instant::now();
587        let timeout = Duration::from_secs_f64(timeout_secs);
588        let interval = Duration::from_millis(10);
589
590        loop {
591            tokio::time::sleep(interval).await;
592
593            if self.core.cache().account(&account_id).is_some() {
594                log::info!("Account {account_id} registered");
595                return Ok(());
596            }
597
598            if start.elapsed() >= timeout {
599                anyhow::bail!(
600                    "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
601                );
602            }
603        }
604    }
605
606    fn report_client(&self) -> anyhow::Result<HyperliquidReportClient> {
607        Ok(HyperliquidReportClient {
608            http_client: self.http_client.clone(),
609            account_address: self.get_account_address()?,
610        })
611    }
612
613    fn cached_report_oid(&self, cmd: &GenerateOrderStatusReport) -> Option<u64> {
614        if cmd.venue_order_id.is_some() {
615            return None;
616        }
617
618        cmd.client_order_id.and_then(|client_order_id| {
619            self.core
620                .cache()
621                .venue_order_id(&client_order_id)
622                .and_then(|value| value.as_str().parse().ok())
623        })
624    }
625
626    fn get_account_address(&self) -> anyhow::Result<String> {
627        self.http_client
628            .get_account_address()
629            .context("failed to get account address from HTTP client")
630    }
631
632    fn spawn_task<F>(&self, description: &'static str, fut: F) -> bool
633    where
634        F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
635    {
636        let future = async move {
637            if let Err(e) = fut.await {
638                log::warn!("{description} failed: {e:?}");
639            }
640        };
641
642        match self.pending_tasks.spawn(future) {
643            Ok(()) => true,
644            Err(e) => {
645                log::warn!("Skipping Hyperliquid {description} after shutdown began: {e}");
646                false
647            }
648        }
649    }
650
651    async fn start_scheduled_cancel(&self) -> anyhow::Result<()> {
652        let Some(timeout_secs) = self.config.scheduled_cancel_timeout_secs else {
653            return Ok(());
654        };
655
656        let timeout = Duration::from_secs(timeout_secs);
657        let refresh_interval = timeout / 3;
658        refresh_scheduled_cancel(&self.http_client, self.clock, timeout, refresh_interval)
659            .await
660            .context("failed to arm Hyperliquid scheduled cancellation")?;
661
662        let http_client = self.http_client.clone();
663        let ws_client = self.ws_client.clone();
664        let clock = self.clock;
665        let cancellation = self.session_tasks.cancellation_token();
666
667        self.session_tasks.spawn(async move {
668            let mut interval = tokio::time::interval_at(
669                tokio::time::Instant::now() + refresh_interval,
670                refresh_interval,
671            );
672            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
673
674            loop {
675                tokio::select! {
676                    biased;
677                    () = cancellation.cancelled() => break,
678                    _ = interval.tick() => {}
679                }
680
681                if !ws_client.is_active() {
682                    continue;
683                }
684
685                tokio::select! {
686                    biased;
687                    () = cancellation.cancelled() => break,
688                    result = refresh_scheduled_cancel(&http_client, clock, timeout, refresh_interval) => {
689                        if let Err(e) = result {
690                            log::error!("Scheduled cancellation refresh failed; the last deadline may expire: {e:#}");
691                        }
692                    }
693                }
694            }
695        })?;
696
697        log::info!("Scheduled cancellation armed: timeout_secs={timeout_secs}");
698        Ok(())
699    }
700
701    fn start_outcome_settlement_poll(&self) -> anyhow::Result<()> {
702        let poll_secs = self.config.outcome_settlement_poll_secs;
703        if poll_secs == 0 {
704            log::debug!("Outcome settlement polling disabled by config");
705            return Ok(());
706        }
707
708        let http_client = self.http_client.clone();
709        let emitter = self.emitter.clone();
710        let tracker = self.outcome_settlement_tracker.clone();
711        let account_id = self.core.account_id;
712        let account_address = self.get_account_address()?;
713        let clock = self.clock;
714
715        self.session_tasks.spawn(async move {
716            let mut interval = tokio::time::interval(Duration::from_secs(poll_secs));
717            interval.tick().await;
718
719            loop {
720                interval.tick().await;
721
722                let meta = match http_client.get_outcome_meta().await {
723                    Ok(meta) => meta,
724                    Err(e) => {
725                        log::warn!("Outcome meta poll failed: {e}");
726                        continue;
727                    }
728                };
729
730                let settlements = derive_outcome_settlements(&meta);
731                if settlements.is_empty() {
732                    continue;
733                }
734
735                let spot_json = match http_client
736                    .info_spot_clearinghouse_state(&account_address)
737                    .await
738                {
739                    Ok(value) => value,
740                    Err(e) => {
741                        log::warn!("Settlement dispatch skipped: spot state fetch failed: {e}");
742                        continue;
743                    }
744                };
745                let spot_state: SpotClearinghouseState = match serde_json::from_value(spot_json) {
746                    Ok(state) => state,
747                    Err(e) => {
748                        log::warn!("Settlement dispatch skipped: spot state parse failed: {e}");
749                        continue;
750                    }
751                };
752
753                let ts = clock.get_time_ns();
754                let fills = {
755                    let mut guard = tracker.lock();
756                    build_settlement_fills(
757                        &settlements,
758                        &spot_state,
759                        &meta,
760                        &http_client,
761                        &mut guard,
762                        account_id,
763                        ts,
764                    )
765                };
766
767                for fill in fills {
768                    log::debug!(
769                        "Dispatching outcome settlement fill: instrument={}, price={}, qty={}",
770                        fill.instrument_id,
771                        fill.last_px,
772                        fill.last_qty,
773                    );
774                    emitter.send_fill_report(fill);
775                }
776            }
777        })?;
778
779        Ok(())
780    }
781
782    fn abort_pending_tasks(&self) {
783        self.pending_tasks.abort();
784    }
785
786    fn begin_session_shutdown(&self) {
787        self.session_tasks.begin_shutdown();
788        self.ws_client.begin_shutdown();
789    }
790
791    async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
792        self.begin_session_shutdown();
793        self.pending_tasks.begin_shutdown();
794
795        if let Err(e) = self.ws_client.disconnect().await {
796            self.shutdown_errors
797                .push(format!("Hyperliquid WebSocket shutdown failed: {e}"));
798        }
799
800        if let Err(e) = self.await_session_tasks().await {
801            self.shutdown_errors.push(e.to_string());
802        }
803
804        if let Err(e) = self.await_pending_tasks().await {
805            self.shutdown_errors.push(e.to_string());
806        }
807        self.core.set_disconnected();
808
809        if !self.shutdown_errors.is_empty() {
810            anyhow::bail!(std::mem::take(&mut self.shutdown_errors).join("; "));
811        }
812        Ok(())
813    }
814
815    async fn await_pending_tasks(&self) -> anyhow::Result<()> {
816        self.pending_tasks.begin_shutdown();
817        self.pending_tasks
818            .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
819            .await
820            .map_err(|e| anyhow::anyhow!("Failed to terminate Hyperliquid execution tasks: {e}"))?;
821        Ok(())
822    }
823
824    async fn await_session_tasks(&self) -> anyhow::Result<()> {
825        self.session_tasks.begin_shutdown();
826        self.session_tasks
827            .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
828            .await
829            .map_err(|e| {
830                anyhow::anyhow!("Failed to terminate Hyperliquid execution session tasks: {e}")
831            })?;
832        Ok(())
833    }
834}
835
836#[async_trait(?Send)]
837impl ExecutionClient for HyperliquidExecutionClient {
838    fn is_connected(&self) -> bool {
839        self.core.is_connected()
840    }
841
842    fn client_id(&self) -> ClientId {
843        self.core.client_id
844    }
845
846    fn account_id(&self) -> AccountId {
847        self.core.account_id
848    }
849
850    fn venue(&self) -> Venue {
851        *HYPERLIQUID_VENUE
852    }
853
854    fn oms_type(&self) -> OmsType {
855        self.core.oms_type
856    }
857
858    fn get_account(&self) -> Option<AccountAny> {
859        self.core.cache().account_owned(&self.core.account_id)
860    }
861
862    fn generate_account_state(
863        &self,
864        balances: Vec<AccountBalance>,
865        margins: Vec<MarginBalance>,
866        reported: bool,
867        ts_event: UnixNanos,
868        info: Option<Params>,
869    ) -> anyhow::Result<()> {
870        self.emitter
871            .emit_account_state(balances, margins, reported, ts_event, info);
872        Ok(())
873    }
874
875    /// Registers an instrument published by the data client so a market listed
876    /// after this client bootstrapped becomes submittable without a restart.
877    ///
878    /// The HTTP client takes the venue asset index from the instrument's `info`
879    /// map. WebSocket submissions sign through `&self.http_client`, so that one
880    /// write serves both submission paths.
881    fn on_instrument(&mut self, instrument: InstrumentAny) {
882        // this is the only step that makes a market listed after our bootstrap
883        // submittable, so it is traceable rather than silent
884        log::debug!(
885            "Applying instrument update: instrument_id={}",
886            instrument.id()
887        );
888        self.http_client.cache_instrument(&instrument);
889        self.ws_client.cache_instrument(instrument);
890    }
891
892    fn start(&mut self) -> anyhow::Result<()> {
893        if self.core.is_started() {
894            return Ok(());
895        }
896
897        let sender = get_exec_event_sender();
898        self.emitter.set_sender(sender);
899        self.core.set_started();
900
901        log::info!(
902            "Started: client_id={}, account_id={}, environment={:?}, vault_address={:?}, proxy_url={:?}",
903            self.core.client_id,
904            self.core.account_id,
905            self.config.environment,
906            self.config.vault_address,
907            self.config.proxy_url,
908        );
909
910        Ok(())
911    }
912
913    fn stop(&mut self) -> anyhow::Result<()> {
914        if self.core.is_stopped() {
915            return Ok(());
916        }
917
918        log::info!("Stopping Hyperliquid execution client");
919
920        self.session_tasks.abort();
921        self.abort_pending_tasks();
922        self.ws_client.begin_shutdown();
923
924        self.core.set_stopped();
925        self.core.set_disconnected();
926
927        log::info!("Hyperliquid execution client stopped");
928        Ok(())
929    }
930
931    fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
932        let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
933
934        if order.is_closed() {
935            log::warn!("Cannot submit closed order {}", order.client_order_id());
936            return Ok(());
937        }
938
939        if let Err(e) = self.validate_order_submission(&order) {
940            self.emitter.emit_order_denied(
941                &order,
942                &OrderDeniedReason::ValidationFailed {
943                    detail: e.to_string(),
944                }
945                .to_string(),
946            );
947            return Ok(());
948        }
949
950        let http_client = self.http_client.clone();
951        let symbol = order.instrument_id().symbol.inner();
952
953        // Complete venue conversion before emitting OrderSubmitted
954        let asset = match http_client.get_asset_index_for_symbol(symbol) {
955            Some(a) => a,
956            None => {
957                self.emitter.emit_order_denied(
958                    &order,
959                    &OrderDeniedReason::InstrumentNotFound {
960                        instrument_id: order.instrument_id(),
961                    }
962                    .to_string(),
963                );
964                return Ok(());
965            }
966        };
967
968        let price_decimals = http_client.get_price_precision_for_symbol(symbol);
969        let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
970        let mut hyperliquid_order = match order_to_hyperliquid_request_with_optional_decimals(
971            &order,
972            asset,
973            price_decimals,
974            self.config.normalize_prices,
975            slippage_bps,
976            None,
977        ) {
978            Ok(req) => req,
979            Err(e) => {
980                self.emitter.emit_order_denied(
981                    &order,
982                    &OrderDeniedReason::ValidationFailed {
983                        detail: format!("Order conversion failed: {e}"),
984                    }
985                    .to_string(),
986                );
987                return Ok(());
988            }
989        };
990        let task_spawner = match self.pending_tasks.spawner() {
991            Ok(spawner) => spawner,
992            Err(e) => {
993                log::warn!("Skipping Hyperliquid submit_order after shutdown began: {e}");
994                self.emitter.emit_order_denied(
995                    &order,
996                    &OrderDeniedReason::SubmitFailed {
997                        detail: TASK_SHUTDOWN_DENIAL_REASON.to_string(),
998                    }
999                    .to_string(),
1000                );
1001                return Ok(());
1002            }
1003        };
1004        let cloid = http_client
1005            .cached_client_order_id_cloid(&order.client_order_id())
1006            .unwrap_or_else(|| Cloid::from_client_order_id(order.client_order_id()));
1007        hyperliquid_order.cloid = Some(cloid);
1008
1009        match self.quote_converted_size(&order) {
1010            Ok(Some(base_size)) => hyperliquid_order.size = base_size,
1011            Ok(None) => {}
1012            Err(reason) => {
1013                self.emitter.emit_order_denied(&order, &reason.to_string());
1014                return Ok(());
1015            }
1016        }
1017
1018        if order.order_type() == OrderType::Market {
1019            let instrument_id = order.instrument_id();
1020            let cache = self.core.cache();
1021            match cache.quote(&instrument_id) {
1022                Some(quote) => {
1023                    let is_buy = order.order_side() == OrderSide::Buy;
1024                    hyperliquid_order.price = derive_market_order_price(
1025                        quote,
1026                        is_buy,
1027                        price_decimals.unwrap_or(2),
1028                        slippage_bps,
1029                    );
1030                }
1031                None => {
1032                    self.emitter.emit_order_denied(
1033                        &order,
1034                        &OrderDeniedReason::MarketPriceUnavailable {
1035                            order_type: order.order_type(),
1036                            instrument_id,
1037                        }
1038                        .to_string(),
1039                    );
1040                    return Ok(());
1041                }
1042            }
1043        }
1044
1045        log::debug!(
1046            "Submitting order: id={}, type={:?}, side={:?}, price={}, size={}, kind={:?}",
1047            order.client_order_id(),
1048            order.order_type(),
1049            order.order_side(),
1050            hyperliquid_order.price,
1051            hyperliquid_order.size,
1052            hyperliquid_order.kind,
1053        );
1054
1055        let emitter = self.emitter.clone();
1056        let clock = self.clock;
1057        let ws_client = self.ws_client.clone();
1058        let cloid_hex = Ustr::from(&cloid.to_hex());
1059        let dispatch_state = self.ws_dispatch_state.clone();
1060        let nested_spawner = task_spawner.clone();
1061        let builder = self.http_client.builder_attribution();
1062        let denied_order = order.clone();
1063
1064        if let Err(e) = task_spawner.spawn(async move {
1065            http_client.cache_client_order_id_cloid(order.client_order_id(), cloid);
1066            ws_client.cache_cloid_mapping(cloid_hex, order.client_order_id());
1067            register_order_context_into(&dispatch_state, &order);
1068            emitter.emit_order_submitted(&order);
1069
1070            let action = HyperliquidExchangeAction::Order {
1071                orders: vec![hyperliquid_order],
1072                grouping: HyperliquidExchangeGrouping::Na,
1073                builder,
1074            };
1075            let rejection_route = PostRejectionRoute::new(
1076                &emitter,
1077                &ws_client,
1078                &http_client,
1079                dispatch_state.clone(),
1080                nested_spawner,
1081            );
1082
1083            match ws_client
1084                .post_action_command(&http_client, &action)
1085                .await
1086                .map_err(classify_post_failure)
1087            {
1088                Ok(response) => {
1089                    if let Some(reason) = extract_inner_error(&response) {
1090                        rejection_route.emit_once(&order, &reason, clock.get_time_ns(), &cloid_hex);
1091                    }
1092                }
1093                Err(CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason)) => {
1094                    rejection_route.emit_once(&order, &reason, clock.get_time_ns(), &cloid_hex);
1095                }
1096                Err(CommandFailure::Ambiguous(reason)) => {
1097                    log::warn!(
1098                        "Submit outcome unknown for {}: {reason}; awaiting reconciliation",
1099                        order.client_order_id()
1100                    );
1101                }
1102            }
1103            rejection_route.resolve_without_post_rejection(&order, clock.get_time_ns(), &cloid_hex);
1104        }) {
1105            log::warn!("Skipping Hyperliquid submit_order after shutdown began: {e}");
1106            self.emitter.emit_order_denied(
1107                &denied_order,
1108                &OrderDeniedReason::SubmitFailed {
1109                    detail: TASK_SHUTDOWN_DENIAL_REASON.to_string(),
1110                }
1111                .to_string(),
1112            );
1113        }
1114
1115        Ok(())
1116    }
1117
1118    fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
1119        log::debug!(
1120            "Submitting order list with {} orders",
1121            cmd.order_list.client_order_ids.len()
1122        );
1123
1124        let http_client = self.http_client.clone();
1125        let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
1126
1127        let orders = self.core.get_orders_for_list(&cmd.order_list)?;
1128
1129        let mut valid_orders = Vec::new();
1130        let mut hyperliquid_orders = Vec::new();
1131
1132        for order in &orders {
1133            match self.order_request(order, slippage_bps) {
1134                Ok(request) => {
1135                    // Deny MARKET orders without a cached quote, matching
1136                    // the single-order path.
1137                    if order.order_type() == OrderType::Market {
1138                        let instrument_id = order.instrument_id();
1139                        if self.core.cache().quote(&instrument_id).is_none() {
1140                            self.emitter.emit_order_denied(
1141                                order,
1142                                &OrderDeniedReason::MarketPriceUnavailable {
1143                                    order_type: order.order_type(),
1144                                    instrument_id,
1145                                }
1146                                .to_string(),
1147                            );
1148                            continue;
1149                        }
1150                    }
1151
1152                    hyperliquid_orders.push(request);
1153                    valid_orders.push(order.clone());
1154                }
1155                Err(e) => {
1156                    self.emitter.emit_order_denied(
1157                        order,
1158                        &OrderDeniedReason::ValidationFailed {
1159                            detail: format!("Order conversion failed: {e}"),
1160                        }
1161                        .to_string(),
1162                    );
1163                }
1164            }
1165        }
1166
1167        // A bracket whose entry failed conversion must not submit its
1168        // children: they would rest at the venue as orphan exits.
1169        if determine_order_list_grouping(&orders) == HyperliquidExchangeGrouping::NormalTpsl
1170            && valid_orders
1171                .first()
1172                .is_none_or(|o| o.client_order_id() != orders[0].client_order_id())
1173        {
1174            for order in &valid_orders {
1175                self.emitter.emit_order_denied(
1176                    order,
1177                    &OrderDeniedReason::OrderListDenied {
1178                        order_list_id: cmd.order_list.id,
1179                    }
1180                    .to_string(),
1181                );
1182            }
1183            return Ok(());
1184        }
1185
1186        if valid_orders.is_empty() {
1187            log::warn!("No valid orders to submit in order list");
1188            return Ok(());
1189        }
1190
1191        let task_spawner = match self.pending_tasks.spawner() {
1192            Ok(spawner) => spawner,
1193            Err(e) => {
1194                log::warn!("Skipping Hyperliquid submit_order_list after shutdown began: {e}");
1195
1196                for order in &valid_orders {
1197                    self.emitter.emit_order_denied(
1198                        order,
1199                        &OrderDeniedReason::SubmitFailed {
1200                            detail: TASK_SHUTDOWN_DENIAL_REASON.to_string(),
1201                        }
1202                        .to_string(),
1203                    );
1204                }
1205                return Ok(());
1206            }
1207        };
1208        let denied_orders = valid_orders.clone();
1209
1210        let grouping = determine_order_list_grouping(&valid_orders);
1211        log::debug!("Order list grouping: {grouping:?}");
1212        let (mut valid_orders, mut hyperliquid_orders) =
1213            order_normal_tpsl_submission(valid_orders, hyperliquid_orders, grouping);
1214
1215        let (submission_grouping, staged_children) =
1216            if grouping == HyperliquidExchangeGrouping::NormalTpsl {
1217                let parent = valid_orders.remove(0);
1218                let parent_request = hyperliquid_orders.remove(0);
1219                let children = valid_orders
1220                    .drain(..)
1221                    .zip(hyperliquid_orders.drain(..))
1222                    .map(|(order, request)| StagedBracketChild { order, request })
1223                    .collect();
1224                let staged_children = Some((parent.client_order_id(), children));
1225                valid_orders.push(parent);
1226                hyperliquid_orders.push(parent_request);
1227                (HyperliquidExchangeGrouping::Na, staged_children)
1228            } else {
1229                (grouping, None)
1230            };
1231
1232        let emitter = self.emitter.clone();
1233        let clock = self.clock;
1234        let ws_client = self.ws_client.clone();
1235        let dispatch_state = self.ws_dispatch_state.clone();
1236        let staged_brackets = self.staged_brackets.clone();
1237        let builder = self.http_client.builder_attribution();
1238        let nested_spawner = task_spawner.clone();
1239
1240        if let Err(e) = task_spawner.spawn(async move {
1241            if let Some((parent_id, children)) = staged_children {
1242                staged_brackets.lock().stage(parent_id, children);
1243            }
1244
1245            for (order, request) in valid_orders.iter().zip(hyperliquid_orders.iter()) {
1246                let cloid = request.cloid.expect("order conversion must set a CLOID");
1247                http_client.cache_client_order_id_cloid(order.client_order_id(), cloid);
1248                ws_client.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), order.client_order_id());
1249                register_order_context_into(&dispatch_state, order);
1250                emitter.emit_order_submitted(order);
1251            }
1252
1253            post_order_batch(
1254                "Order list",
1255                valid_orders,
1256                hyperliquid_orders,
1257                submission_grouping,
1258                builder,
1259                &emitter,
1260                &ws_client,
1261                &http_client,
1262                dispatch_state,
1263                staged_brackets,
1264                clock,
1265                nested_spawner,
1266            )
1267            .await;
1268        }) {
1269            log::warn!("Skipping Hyperliquid submit_order_list after shutdown began: {e}");
1270
1271            for order in &denied_orders {
1272                self.emitter.emit_order_denied(
1273                    order,
1274                    &OrderDeniedReason::SubmitFailed {
1275                        detail: TASK_SHUTDOWN_DENIAL_REASON.to_string(),
1276                    }
1277                    .to_string(),
1278                );
1279            }
1280        }
1281
1282        Ok(())
1283    }
1284
1285    fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
1286        log::debug!("Modifying order: {cmd}");
1287
1288        let client_order_id = cmd.client_order_id;
1289        let venue_order_id = cmd
1290            .venue_order_id
1291            .or_else(|| self.core.cache().venue_order_id(&client_order_id).copied());
1292
1293        let order = match self.core.cache().order(&client_order_id).map(|o| o.clone()) {
1294            Some(o) => o,
1295            None => {
1296                let reason = "order not found in cache";
1297                log::warn!("Cannot modify order {client_order_id}: {reason}");
1298                self.emitter.emit_order_modify_rejected_event(
1299                    cmd.strategy_id,
1300                    cmd.instrument_id,
1301                    client_order_id,
1302                    venue_order_id,
1303                    reason,
1304                    self.clock.get_time_ns(),
1305                );
1306                return Ok(());
1307            }
1308        };
1309
1310        let http_client = self.http_client.clone();
1311        let symbol = cmd.instrument_id.symbol.inner();
1312        let should_normalize = self.config.normalize_prices;
1313        let slippage_bps = self.resolve_slippage_bps(cmd.params.as_ref());
1314
1315        // A modify replaces a base-denominated size, and a quote target cannot
1316        // be reconciled against the venue's base-denominated fill reports.
1317        if order.is_quote_quantity() {
1318            let reason =
1319                "quote-denominated quantity orders cannot be modified; cancel and resubmit";
1320            log::warn!("Cannot modify order {client_order_id}: {reason}");
1321            self.emitter.emit_order_modify_rejected_event(
1322                cmd.strategy_id,
1323                cmd.instrument_id,
1324                client_order_id,
1325                venue_order_id,
1326                reason,
1327                self.clock.get_time_ns(),
1328            );
1329            return Ok(());
1330        }
1331
1332        let modify_target = match http_client.unique_cached_client_order_id_cloid(&client_order_id)
1333        {
1334            Some(cloid) => HyperliquidExchangeModifyTarget::Cloid(cloid),
1335            None => {
1336                let Some(venue_order_id) = venue_order_id.as_ref() else {
1337                    let reason = "venue_order_id or unique cached CLOID is required for modify";
1338                    log::warn!("Cannot modify order {client_order_id}: {reason}");
1339                    self.emitter.emit_order_modify_rejected_event(
1340                        cmd.strategy_id,
1341                        cmd.instrument_id,
1342                        client_order_id,
1343                        None,
1344                        reason,
1345                        self.clock.get_time_ns(),
1346                    );
1347                    return Ok(());
1348                };
1349
1350                match HyperliquidExchangeModifyTarget::from_venue_order_id(venue_order_id) {
1351                    Ok(target) => target,
1352                    Err(e) => {
1353                        let reason =
1354                            format!("Failed to parse venue_order_id '{venue_order_id}': {e}");
1355                        log::warn!("{reason}");
1356                        self.emitter.emit_order_modify_rejected_event(
1357                            cmd.strategy_id,
1358                            cmd.instrument_id,
1359                            client_order_id,
1360                            Some(*venue_order_id),
1361                            &reason,
1362                            self.clock.get_time_ns(),
1363                        );
1364                        return Ok(());
1365                    }
1366                }
1367            }
1368        };
1369        let old_venue_order_id = venue_order_id.filter(|id| id.as_str().parse::<u64>().is_ok());
1370        if matches!(modify_target, HyperliquidExchangeModifyTarget::Cloid(_))
1371            && old_venue_order_id.is_none()
1372        {
1373            let reason = "cached venue_order_id is required for CLOID modify";
1374            log::warn!("Cannot modify order {client_order_id}: {reason}");
1375            self.emitter.emit_order_modify_rejected_event(
1376                cmd.strategy_id,
1377                cmd.instrument_id,
1378                client_order_id,
1379                venue_order_id,
1380                reason,
1381                self.clock.get_time_ns(),
1382            );
1383            return Ok(());
1384        }
1385
1386        // Hyperliquid modify is cancel-replace; subtract filled to avoid overfill.
1387        let target_total_qty = cmd.quantity.unwrap_or(order.quantity());
1388        let filled_qty = order.filled_qty();
1389        if target_total_qty <= filled_qty {
1390            let reason =
1391                format!("modify quantity {target_total_qty} not greater than filled {filled_qty}",);
1392            log::warn!("Cannot modify order {}: {reason}", cmd.client_order_id);
1393
1394            self.emitter.emit_order_modify_rejected_event(
1395                cmd.strategy_id,
1396                cmd.instrument_id,
1397                client_order_id,
1398                venue_order_id,
1399                &reason,
1400                self.clock.get_time_ns(),
1401            );
1402            return Ok(());
1403        }
1404
1405        let quantity = target_total_qty - filled_qty;
1406        let price_decimals = http_client.get_price_precision_for_symbol(symbol);
1407        let asset = match http_client.get_asset_index_for_symbol(symbol) {
1408            Some(a) => a,
1409            None => {
1410                emit_modify_failure(
1411                    &self.emitter,
1412                    &cmd,
1413                    venue_order_id,
1414                    CommandFailure::not_sent(format!("Asset index not found for symbol {symbol}")),
1415                    self.clock.get_time_ns(),
1416                );
1417                return Ok(());
1418            }
1419        };
1420
1421        // Build base request from cached order (derives slippage-adjusted
1422        // limit for trigger-market types like StopMarket/MarketIfTouched)
1423        let mut hyperliquid_order = match order_to_hyperliquid_request_with_optional_decimals(
1424            &order,
1425            asset,
1426            price_decimals,
1427            should_normalize,
1428            slippage_bps,
1429            None,
1430        ) {
1431            Ok(mut req) => {
1432                let applied = (|| -> anyhow::Result<()> {
1433                    if let Some(p) = cmd.price.or(order.price()) {
1434                        req.price = normalize_or_validate_wire_price(
1435                            p.as_decimal(),
1436                            "Price",
1437                            price_decimals,
1438                            should_normalize,
1439                        )?;
1440                    } else if let Some(tp) = cmd.trigger_price {
1441                        // Trigger changed but no explicit price: re-derive the
1442                        // slippage-adjusted limit from the new trigger
1443                        let is_buy = order.order_side() == OrderSide::Buy;
1444                        let base = tp.as_decimal().normalize();
1445                        let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
1446                        let sig_rounded = round_to_sig_figs(derived, 5);
1447                        req.price = clamp_price_to_precision(
1448                            sig_rounded,
1449                            price_decimals.unwrap_or(2),
1450                            is_buy,
1451                        )
1452                        .normalize();
1453                    }
1454                    req.size = quantity.as_decimal().normalize();
1455
1456                    if let (Some(tp), HyperliquidExchangeOrderKind::Trigger { trigger }) =
1457                        (cmd.trigger_price, &mut req.kind)
1458                    {
1459                        trigger.trigger_px = normalize_or_validate_wire_price(
1460                            tp.as_decimal(),
1461                            "Trigger price",
1462                            price_decimals,
1463                            should_normalize,
1464                        )?;
1465                    }
1466                    Ok(())
1467                })();
1468
1469                match applied {
1470                    Ok(()) => req,
1471                    Err(e) => {
1472                        emit_modify_failure(
1473                            &self.emitter,
1474                            &cmd,
1475                            venue_order_id,
1476                            CommandFailure::not_sent(format!(
1477                                "Order conversion failed for modify: {e}"
1478                            )),
1479                            self.clock.get_time_ns(),
1480                        );
1481                        return Ok(());
1482                    }
1483                }
1484            }
1485            Err(e) => {
1486                emit_modify_failure(
1487                    &self.emitter,
1488                    &cmd,
1489                    venue_order_id,
1490                    CommandFailure::not_sent(format!("Order conversion failed for modify: {e}")),
1491                    self.clock.get_time_ns(),
1492                );
1493                return Ok(());
1494            }
1495        };
1496        let cached_cloid_before_modify = http_client.cached_client_order_id_cloid(&client_order_id);
1497        let cloid = http_client.get_or_generate_client_order_id_cloid(order.client_order_id());
1498        let generated_modify_cloid = cached_cloid_before_modify
1499            .is_none()
1500            .then_some((client_order_id, cloid));
1501        hyperliquid_order.cloid = Some(cloid);
1502
1503        let dispatch_state = self.ws_dispatch_state.clone();
1504        let ws_client = self.ws_client.clone();
1505
1506        if let Some(cloid) = hyperliquid_order.cloid {
1507            http_client.cache_client_order_id_cloid(client_order_id, cloid);
1508            ws_client.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
1509        }
1510
1511        // Register on the command thread so concurrent tasks cannot stash a request
1512        // onto another modify intent. Failed admission clears only this generation.
1513        let modify_generation = old_venue_order_id.map(|old_venue_order_id| {
1514            let generation = dispatch_state.mark_pending_modify(
1515                client_order_id,
1516                old_venue_order_id,
1517                target_total_qty,
1518            );
1519            // The promotion uses this request to reduce a replacement after an in-flight fill
1520            dispatch_state.stash_modify_request(client_order_id, hyperliquid_order.clone());
1521            generation
1522        });
1523
1524        let emitter = self.emitter.clone();
1525        let clock = self.clock;
1526        let command = cmd.clone();
1527
1528        if !self.spawn_task("modify_order", async move {
1529            let action = HyperliquidExchangeAction::Modify {
1530                modify: HyperliquidExchangeModifyOrderRequest {
1531                    oid: modify_target,
1532                    order: hyperliquid_order,
1533                },
1534            };
1535
1536            let failure = match ws_client
1537                .post_action_command(&http_client, &action)
1538                .await
1539                .map_err(classify_post_failure)
1540            {
1541                Ok(response) => extract_inner_error(&response).map(CommandFailure::venue_rejected),
1542                Err(failure) => Some(failure),
1543            };
1544
1545            if let Some(failure) = failure {
1546                if !matches!(failure, CommandFailure::Ambiguous(_)) {
1547                    if let Some(generation) = modify_generation {
1548                        dispatch_state.clear_modify_generation(&client_order_id, generation);
1549                    }
1550                    remove_generated_modify_cloid(&http_client, &ws_client, generated_modify_cloid);
1551                }
1552                emit_modify_failure(
1553                    &emitter,
1554                    &command,
1555                    venue_order_id,
1556                    failure,
1557                    clock.get_time_ns(),
1558                );
1559            }
1560            Ok(())
1561        }) {
1562            if let Some(generation) = modify_generation {
1563                self.ws_dispatch_state
1564                    .clear_modify_generation(&client_order_id, generation);
1565            }
1566            remove_generated_modify_cloid(
1567                &self.http_client,
1568                &self.ws_client,
1569                generated_modify_cloid,
1570            );
1571            emit_modify_failure(
1572                &self.emitter,
1573                &cmd,
1574                venue_order_id,
1575                CommandFailure::not_sent(TASK_SHUTDOWN_DENIAL_REASON),
1576                self.clock.get_time_ns(),
1577            );
1578        }
1579
1580        Ok(())
1581    }
1582
1583    fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1584        log::debug!("Cancelling order: {cmd}");
1585
1586        if let Some(order) = self
1587            .staged_brackets
1588            .lock()
1589            .cancel_child(&cmd.client_order_id)
1590        {
1591            self.emitter
1592                .emit_order_canceled(&order, None, self.clock.get_time_ns());
1593            return Ok(());
1594        }
1595
1596        let http_client = self.http_client.clone();
1597        let emitter = self.emitter.clone();
1598        let clock = self.clock;
1599        let client_order_id = cmd.client_order_id;
1600        let strategy_id = cmd.strategy_id;
1601        let instrument_id = cmd.instrument_id;
1602        let venue_order_id = cmd.venue_order_id;
1603        let symbol = cmd.instrument_id.symbol.inner();
1604        let ws_client = self.ws_client.clone();
1605        let fast = can_fast_cancel_order(
1606            self.core
1607                .cache()
1608                .order(&client_order_id)
1609                .as_ref()
1610                .map(|order| order.order_type()),
1611        )
1612        .then_some(true);
1613        let entry = CancelEntry {
1614            strategy_id,
1615            instrument_id,
1616            client_order_id,
1617            venue_order_id,
1618            symbol,
1619            fast: fast.is_some(),
1620        };
1621
1622        let rejected_entry = entry.clone();
1623
1624        if !self.spawn_task("cancel_order", async move {
1625            let asset = match http_client.get_asset_index_for_symbol(symbol) {
1626                Some(a) => a,
1627                None => {
1628                    emit_cancel_failure(
1629                        &emitter,
1630                        &entry,
1631                        CommandFailure::not_sent(format!(
1632                            "Asset index not found for symbol {symbol}"
1633                        )),
1634                        clock.get_time_ns(),
1635                    );
1636                    return Ok(());
1637                }
1638            };
1639
1640            let action =
1641                if let Some(cloid) = http_client.cached_client_order_id_cloid(&client_order_id) {
1642                    HyperliquidExchangeAction::CancelByCloid {
1643                        cancels: vec![HyperliquidExchangeCancelByCloidRequest { asset, cloid }],
1644                        fast,
1645                    }
1646                } else if let Some(venue_order_id) = venue_order_id {
1647                    match venue_order_id.as_str().parse::<u64>() {
1648                        Ok(oid) => HyperliquidExchangeAction::Cancel {
1649                            cancels: vec![HyperliquidExchangeCancelOrderRequest { asset, oid }],
1650                            fast,
1651                        },
1652                        Err(_) => {
1653                            emit_cancel_failure(
1654                                &emitter,
1655                                &entry,
1656                                CommandFailure::not_sent("Invalid venue order ID format"),
1657                                clock.get_time_ns(),
1658                            );
1659                            return Ok(());
1660                        }
1661                    }
1662                } else {
1663                    let cloid = http_client.get_or_generate_client_order_id_cloid(client_order_id);
1664                    HyperliquidExchangeAction::CancelByCloid {
1665                        cancels: vec![HyperliquidExchangeCancelByCloidRequest { asset, cloid }],
1666                        fast,
1667                    }
1668                };
1669
1670            let failure = match ws_client
1671                .post_action_command(&http_client, &action)
1672                .await
1673                .map_err(classify_post_failure)
1674            {
1675                Ok(response) => extract_inner_error(&response).map(CommandFailure::venue_rejected),
1676                Err(failure) => Some(failure),
1677            };
1678
1679            if let Some(failure) = failure {
1680                emit_cancel_failure(&emitter, &entry, failure, clock.get_time_ns());
1681            }
1682            Ok(())
1683        }) {
1684            emit_cancel_failure(
1685                &self.emitter,
1686                &rejected_entry,
1687                CommandFailure::not_sent(TASK_SHUTDOWN_DENIAL_REASON),
1688                self.clock.get_time_ns(),
1689            );
1690        }
1691
1692        Ok(())
1693    }
1694
1695    fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
1696        log::debug!("Cancelling all orders: {cmd}");
1697
1698        let cache = self.core.cache();
1699        let open_orders = cache.orders_open(
1700            Some(&self.core.venue),
1701            Some(&cmd.instrument_id),
1702            None,
1703            None,
1704            cmd.order_side,
1705        );
1706
1707        if open_orders.is_empty() {
1708            log::debug!("No open orders to cancel for {:?}", cmd.instrument_id);
1709            return Ok(());
1710        }
1711
1712        let symbol = cmd.instrument_id.symbol.inner();
1713        let instrument_id = cmd.instrument_id;
1714        let strategy_id = cmd.strategy_id;
1715        let entries: Vec<CancelEntry> = open_orders
1716            .iter()
1717            .map(|o| CancelEntry {
1718                strategy_id,
1719                instrument_id,
1720                client_order_id: o.client_order_id(),
1721                venue_order_id: o.venue_order_id(),
1722                symbol,
1723                fast: can_fast_cancel_order(Some(o.order_type())),
1724            })
1725            .collect();
1726
1727        let http_client = self.http_client.clone();
1728        let emitter = self.emitter.clone();
1729        let clock = self.clock;
1730        let ws_client = self.ws_client.clone();
1731
1732        let rejected_entries = entries.clone();
1733
1734        if !self.spawn_task("cancel_all_orders", async move {
1735            let asset = match http_client.get_asset_index_for_symbol(symbol) {
1736                Some(a) => a,
1737                None => {
1738                    for entry in &entries {
1739                        emit_cancel_failure(
1740                            &emitter,
1741                            entry,
1742                            CommandFailure::not_sent(format!(
1743                                "Asset index not found for symbol {symbol}"
1744                            )),
1745                            clock.get_time_ns(),
1746                        );
1747                    }
1748                    return Ok(());
1749                }
1750            };
1751
1752            let mut cancel_dispatch = CancelDispatch::new();
1753
1754            for entry in &entries {
1755                if let Err(failure) = cancel_dispatch.push(entry, asset, &http_client) {
1756                    emit_cancel_failure(&emitter, entry, failure, clock.get_time_ns());
1757                }
1758            }
1759
1760            if cancel_dispatch.is_empty() {
1761                return Ok(());
1762            }
1763
1764            submit_cancel_dispatch(
1765                "Cancel-all",
1766                cancel_dispatch,
1767                &ws_client,
1768                &http_client,
1769                &emitter,
1770                clock,
1771            )
1772            .await;
1773
1774            Ok(())
1775        }) {
1776            for entry in &rejected_entries {
1777                emit_cancel_failure(
1778                    &self.emitter,
1779                    entry,
1780                    CommandFailure::not_sent(TASK_SHUTDOWN_DENIAL_REASON),
1781                    self.clock.get_time_ns(),
1782                );
1783            }
1784        }
1785
1786        Ok(())
1787    }
1788
1789    fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
1790        log::debug!("Batch cancelling orders: {cmd}");
1791
1792        if cmd.cancels.is_empty() {
1793            log::debug!("No orders to cancel in batch");
1794            return Ok(());
1795        }
1796
1797        let cache = self.core.cache();
1798        let entries: Vec<CancelEntry> = cmd
1799            .cancels
1800            .iter()
1801            .map(|c| CancelEntry {
1802                strategy_id: c.strategy_id,
1803                instrument_id: c.instrument_id,
1804                client_order_id: c.client_order_id,
1805                venue_order_id: c.venue_order_id,
1806                symbol: c.instrument_id.symbol.inner(),
1807                fast: can_fast_cancel_order(
1808                    cache
1809                        .order(&c.client_order_id)
1810                        .as_ref()
1811                        .map(|order| order.order_type()),
1812                ),
1813            })
1814            .collect();
1815
1816        let http_client = self.http_client.clone();
1817        let emitter = self.emitter.clone();
1818        let clock = self.clock;
1819        let ws_client = self.ws_client.clone();
1820
1821        let rejected_entries = entries.clone();
1822
1823        if !self.spawn_task("batch_cancel_orders", async move {
1824            let mut cancel_dispatch = CancelDispatch::new();
1825
1826            for entry in &entries {
1827                let asset = match http_client.get_asset_index_for_symbol(entry.symbol) {
1828                    Some(a) => a,
1829                    None => {
1830                        emit_cancel_failure(
1831                            &emitter,
1832                            entry,
1833                            CommandFailure::not_sent(format!(
1834                                "Asset index not found for symbol {}",
1835                                entry.symbol
1836                            )),
1837                            clock.get_time_ns(),
1838                        );
1839                        continue;
1840                    }
1841                };
1842
1843                if let Err(failure) = cancel_dispatch.push(entry, asset, &http_client) {
1844                    emit_cancel_failure(&emitter, entry, failure, clock.get_time_ns());
1845                }
1846            }
1847
1848            if cancel_dispatch.is_empty() {
1849                log::warn!("No valid cancel requests in batch");
1850                return Ok(());
1851            }
1852
1853            submit_cancel_dispatch(
1854                "Batch cancel",
1855                cancel_dispatch,
1856                &ws_client,
1857                &http_client,
1858                &emitter,
1859                clock,
1860            )
1861            .await;
1862
1863            Ok(())
1864        }) {
1865            for entry in &rejected_entries {
1866                emit_cancel_failure(
1867                    &self.emitter,
1868                    entry,
1869                    CommandFailure::not_sent(TASK_SHUTDOWN_DENIAL_REASON),
1870                    self.clock.get_time_ns(),
1871                );
1872            }
1873        }
1874
1875        Ok(())
1876    }
1877
1878    fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
1879        let http_client = self.http_client.clone();
1880        let account_address = self.get_account_address()?;
1881        let emitter = self.emitter.clone();
1882        let clock = self.clock;
1883
1884        self.spawn_task("query_account", async move {
1885            let perp_json = http_client
1886                .info_clearinghouse_state(&account_address)
1887                .await
1888                .context("failed to fetch clearinghouse state")?;
1889
1890            let perp_state: ClearinghouseState = serde_json::from_value(perp_json)
1891                .context("failed to deserialize clearinghouse state")?;
1892
1893            let spot_json = http_client
1894                .info_spot_clearinghouse_state(&account_address)
1895                .await
1896                .context("failed to fetch spot clearinghouse state")?;
1897            let spot_state: SpotClearinghouseState = serde_json::from_value(spot_json)
1898                .context("failed to deserialize spot clearinghouse state")?;
1899
1900            let abstraction = http_client
1901                .info_user_abstraction(&account_address)
1902                .await
1903                .context("failed to fetch user abstraction")?;
1904
1905            let (balances, margins) =
1906                parse_combined_account_balances_and_margins(&perp_state, &spot_state, abstraction)
1907                    .context("failed to parse combined account balances and margins")?;
1908            let ts_event = clock.get_time_ns();
1909            emitter.emit_account_state(balances, margins, true, ts_event, None);
1910
1911            Ok(())
1912        });
1913
1914        Ok(())
1915    }
1916
1917    fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
1918        log::debug!("Querying order: {cmd}");
1919
1920        let client_order_id = cmd.client_order_id;
1921        let venue_order_id = match cmd.venue_order_id {
1922            Some(voi) => Some(voi),
1923            None => self.core.cache().venue_order_id(&client_order_id).copied(),
1924        };
1925
1926        let account_address = self.get_account_address()?;
1927        let http_client = self.http_client.clone();
1928        let emitter = self.emitter.clone();
1929        let dispatch_state = self.ws_dispatch_state.clone();
1930        let clock = self.clock;
1931
1932        let recovery_context = self
1933            .core
1934            .cache()
1935            .order(&client_order_id)
1936            .filter(|order| {
1937                order.account_id() == Some(self.core.account_id)
1938                    && order.instrument_id() == cmd.instrument_id
1939                    && order.strategy_id() == cmd.strategy_id
1940            })
1941            .and_then(|order| {
1942                query_order_recovery_context(&order, venue_order_id, &dispatch_state)
1943            });
1944
1945        self.spawn_task("query_order", async move {
1946            // Search open orders by cloid first so modify/cancel-replace
1947            // resolves to the live replacement rather than a stale cached oid.
1948            // Request errors here are logged, not propagated, so a transient
1949            // frontendOpenOrders failure does not abort the whole query.
1950            match http_client
1951                .request_order_status_report_by_client_order_id(&account_address, &client_order_id)
1952                .await
1953            {
1954                Ok(Some(report)) => {
1955                    promote_replacement_from_query(
1956                        &report,
1957                        &dispatch_state,
1958                        &emitter,
1959                        clock.get_time_ns(),
1960                    );
1961                    log::debug!("Queried order status for {client_order_id}");
1962                    emitter.send_order_status_report(report);
1963                    return Ok(());
1964                }
1965                Ok(None) => {}
1966                Err(e) => {
1967                    log::warn!(
1968                        "Failed to query order status for {client_order_id}: {e}; falling back to oid lookup"
1969                    );
1970                }
1971            }
1972
1973            let Some(venue_order_id) = venue_order_id else {
1974                log::debug!("No order status report found for {client_order_id}");
1975                return Ok(());
1976            };
1977
1978            let oid: u64 = match venue_order_id.as_str().parse() {
1979                Ok(oid) => oid,
1980                Err(e) => {
1981                    log::warn!("Failed to parse venue order ID {venue_order_id}: {e}");
1982                    return Ok(());
1983                }
1984            };
1985
1986            match http_client
1987                .request_order_status_report(&account_address, oid)
1988                .await
1989            {
1990                Ok(Some(mut report)) => {
1991                    if is_inflight_modify_old_leg_cancel(
1992                        &dispatch_state,
1993                        &client_order_id,
1994                        &report,
1995                    ) {
1996                        log::debug!(
1997                            "Suppressing stale old-leg Canceled for {client_order_id}: modify in flight"
1998                        );
1999                    } else {
2000                        clamp_reduce_only_report_to_fills(&http_client, &account_address, &mut report)
2001                            .await;
2002
2003                        if let Some(context) = recovery_context {
2004                            recover_query_order_fills(
2005                                &http_client,
2006                                &account_address,
2007                                &report,
2008                                &context,
2009                                &dispatch_state,
2010                                &emitter,
2011                            )
2012                            .await;
2013                        }
2014
2015                        attach_known_client_order_id(&mut report, client_order_id);
2016                        log::debug!("Queried order status for oid {oid}");
2017                        emitter.send_order_status_report(report);
2018                    }
2019                }
2020                Ok(None) => {
2021                    log::debug!("No order status report found for oid {oid}");
2022                }
2023                Err(e) => {
2024                    log::warn!("Failed to query order status for oid {oid}: {e}");
2025                }
2026            }
2027
2028            Ok(())
2029        });
2030
2031        Ok(())
2032    }
2033
2034    async fn connect(&mut self) -> anyhow::Result<()> {
2035        if self.core.is_connected() && self.pending_tasks.is_open() && self.session_tasks.is_open()
2036        {
2037            return Ok(());
2038        }
2039
2040        log::info!("Connecting Hyperliquid execution client");
2041
2042        if !self.pending_tasks.is_open() || !self.session_tasks.is_open() {
2043            self.teardown_partial_connect().await?;
2044            self.pending_tasks
2045                .start_generation()
2046                .map_err(|e| anyhow::anyhow!("Failed to start Hyperliquid task generation: {e}"))?;
2047            self.session_tasks.start_generation().map_err(|e| {
2048                anyhow::anyhow!("Failed to start Hyperliquid execution session generation: {e}")
2049            })?;
2050        }
2051        let ws_client = self.ws_client.clone();
2052        let setup_guard =
2053            TaskGroupGuard::new(&[&self.session_tasks, &self.pending_tasks], move || {
2054                ws_client.begin_shutdown();
2055            });
2056
2057        self.ensure_instruments_initialized_async().await?;
2058        let ready_bracket_parents = self.restore_staged_brackets();
2059
2060        let setup = async {
2061            self.start_scheduled_cancel().await?;
2062            self.start_ws_stream().await
2063        }
2064        .await;
2065
2066        if let Err(e) = setup {
2067            if let Err(teardown_error) = self.teardown_partial_connect().await {
2068                return Err(e.context(format!(
2069                    "Hyperliquid execution startup teardown failed: {teardown_error}"
2070                )));
2071            }
2072            return Err(e);
2073        }
2074
2075        // Post-WS setup: if any step fails, tear down WS before returning
2076        let post_ws = async {
2077            self.refresh_account_state().await?;
2078            self.await_account_registered(30.0).await?;
2079
2080            Ok::<(), anyhow::Error>(())
2081        };
2082
2083        if let Err(e) = post_ws.await {
2084            log::warn!("Connect failed after WS started, tearing down: {e}");
2085            if let Err(teardown_error) = self.teardown_partial_connect().await {
2086                return Err(e.context(format!(
2087                    "Hyperliquid execution startup teardown failed: {teardown_error}"
2088                )));
2089            }
2090            return Err(e);
2091        }
2092
2093        let session_spawner = self
2094            .session_tasks
2095            .spawner()
2096            .map_err(|e| anyhow::anyhow!("Hyperliquid session task admission is closed: {e}"))?;
2097
2098        for parent_id in ready_bracket_parents {
2099            if let Some(children) = self.staged_brackets.lock().activate(&parent_id) {
2100                spawn_staged_children(
2101                    children,
2102                    &self.emitter,
2103                    &self.ws_client,
2104                    &self.http_client,
2105                    self.ws_dispatch_state.clone(),
2106                    self.staged_brackets.clone(),
2107                    self.http_client.builder_attribution(),
2108                    self.clock,
2109                    &session_spawner,
2110                );
2111            }
2112        }
2113
2114        if let Err(e) = self.start_outcome_settlement_poll() {
2115            log::warn!("Outcome settlement polling not started: {e}");
2116        }
2117
2118        self.core.set_connected();
2119        setup_guard.disarm();
2120
2121        log::info!("Connected: client_id={}", self.core.client_id);
2122        Ok(())
2123    }
2124
2125    async fn disconnect(&mut self) -> anyhow::Result<()> {
2126        log::info!("Disconnecting Hyperliquid execution client");
2127
2128        self.teardown_partial_connect().await?;
2129
2130        log::info!("Disconnected: client_id={}", self.core.client_id);
2131        Ok(())
2132    }
2133
2134    fn generate_order_status_report_task(
2135        &self,
2136        cmd: &GenerateOrderStatusReport,
2137    ) -> Option<ExecutionReportTask<Option<OrderStatusReport>>> {
2138        let client = self.report_client();
2139        let cached_oid = self.cached_report_oid(cmd);
2140        let command = cmd.clone();
2141        let dispatch_state = self.ws_dispatch_state.clone();
2142        let emitter = self.emitter.clone();
2143        let clock = self.clock;
2144        let client_order_id = cmd.client_order_id;
2145        Some(ExecutionReportTask::new(
2146            async move {
2147                client?
2148                    .collect_order_status_report(&command, cached_oid)
2149                    .await
2150            },
2151            move |report| {
2152                finish_order_status_report(
2153                    report,
2154                    client_order_id,
2155                    &dispatch_state,
2156                    &emitter,
2157                    clock.get_time_ns(),
2158                )
2159            },
2160        ))
2161    }
2162
2163    fn generate_order_status_reports_task(
2164        &self,
2165        cmd: &GenerateOrderStatusReports,
2166    ) -> Option<ExecutionReportTask<Vec<OrderStatusReport>>> {
2167        let client = self.report_client();
2168        let command = cmd.clone();
2169        Some(ExecutionReportTask::new(
2170            async move { client?.collect_order_status_reports(&command).await },
2171            Ok,
2172        ))
2173    }
2174
2175    fn generate_fill_reports_task(
2176        &self,
2177        cmd: &GenerateFillReports,
2178    ) -> Option<ExecutionReportTask<Vec<FillReport>>> {
2179        let client = self.report_client();
2180        let core = self.core.clone();
2181        let command = cmd.clone();
2182        Some(ExecutionReportTask::new(
2183            async move { client?.collect_fill_reports(command).await },
2184            move |reports| Ok(finish_fill_reports(&core, reports)),
2185        ))
2186    }
2187
2188    fn generate_position_status_reports_task(
2189        &self,
2190        cmd: &GeneratePositionStatusReports,
2191    ) -> Option<ExecutionReportTask<Vec<PositionStatusReport>>> {
2192        let client = self.report_client();
2193        let command = cmd.clone();
2194        Some(ExecutionReportTask::new(
2195            async move { client?.collect_position_status_reports(&command).await },
2196            Ok,
2197        ))
2198    }
2199
2200    async fn generate_order_status_report(
2201        &self,
2202        cmd: &GenerateOrderStatusReport,
2203    ) -> anyhow::Result<Option<OrderStatusReport>> {
2204        let report = self
2205            .report_client()?
2206            .collect_order_status_report(cmd, self.cached_report_oid(cmd))
2207            .await?;
2208        finish_order_status_report(
2209            report,
2210            cmd.client_order_id,
2211            &self.ws_dispatch_state,
2212            &self.emitter,
2213            self.clock.get_time_ns(),
2214        )
2215    }
2216
2217    async fn generate_order_status_reports(
2218        &self,
2219        cmd: &GenerateOrderStatusReports,
2220    ) -> anyhow::Result<Vec<OrderStatusReport>> {
2221        self.report_client()?
2222            .collect_order_status_reports(cmd)
2223            .await
2224    }
2225
2226    async fn generate_fill_reports(
2227        &self,
2228        cmd: GenerateFillReports,
2229    ) -> anyhow::Result<Vec<FillReport>> {
2230        let reports = self.report_client()?.collect_fill_reports(cmd).await?;
2231        Ok(finish_fill_reports(&self.core, reports))
2232    }
2233
2234    async fn generate_position_status_reports(
2235        &self,
2236        cmd: &GeneratePositionStatusReports,
2237    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2238        self.report_client()?
2239            .collect_position_status_reports(cmd)
2240            .await
2241    }
2242
2243    async fn generate_mass_status(
2244        &self,
2245        lookback_mins: Option<u64>,
2246    ) -> anyhow::Result<Option<ExecutionMassStatus>> {
2247        let ts_init = self.clock.get_time_ns();
2248        let account_address = self.get_account_address()?;
2249
2250        let lookback_start = lookback_mins
2251            .map(DurationNanos::try_from_mins)
2252            .transpose()?
2253            .map(|lookback| ts_init.saturating_sub(lookback));
2254
2255        // Fetch history before fills, so the fills cover every fill a history row implies
2256        let historical_orders = self
2257            .http_client
2258            .info_historical_orders(&account_address)
2259            .await
2260            .context("failed to fetch historical orders for mass status")?;
2261        let fills_response = self
2262            .http_client
2263            .info_user_fills(&account_address)
2264            .await
2265            .context("failed to fetch fills for mass status")?;
2266        let fills_capped = fills_response.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT;
2267        let dexes = self
2268            .http_client
2269            .reconciliation_dexes_from_activity(&historical_orders, &fills_response)
2270            .await
2271            .context("failed to determine reconciliation dexes")?;
2272
2273        // The venue bounds both history endpoints to their most recent entries;
2274        // a saturated response may be truncated, so coverage is not provable
2275        let history_capped =
2276            historical_orders.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT || fills_capped;
2277
2278        let history_start = if history_capped {
2279            fills_response
2280                .iter()
2281                .map(|fill| UnixNanos::from(fill.time * 1_000_000))
2282                .min()
2283        } else {
2284            None
2285        };
2286
2287        if history_capped {
2288            log::warn!(
2289                "Mass-status history response at venue cap ({HYPERLIQUID_RECENT_HISTORY_LIMIT}): \
2290                 marking reports incomplete"
2291            );
2292        }
2293
2294        let order_sweep = self
2295            .http_client
2296            .request_order_status_reports_for_dexes(&account_address, None, &dexes)
2297            .await
2298            .context("failed to generate order status reports")?;
2299        let fill_sweep = self
2300            .http_client
2301            .fill_reports_from_response(fills_response, None)
2302            .context("failed to generate fill reports")?;
2303        let position_sweep = self
2304            .http_client
2305            .request_position_status_reports_for_dexes(&account_address, None, &dexes)
2306            .await
2307            .context("failed to generate position status reports")?;
2308
2309        // Per-order fill totals from the whole fill history, before any lookback trim, and only
2310        // when that history is complete
2311        let fill_totals = (fill_sweep.complete && !fills_capped)
2312            .then(|| fill_totals_by_order(&fill_sweep.reports));
2313
2314        let mut order_reports = order_sweep.reports;
2315        let mut fill_reports = fill_sweep.reports;
2316        let position_reports = position_sweep.reports;
2317        let mut reports_complete = order_sweep.complete
2318            && fill_sweep.complete
2319            && position_sweep.complete
2320            && !history_capped;
2321
2322        // Apply lookback filter to fills only (positions are current state,
2323        // and open orders must always be included for correct reconciliation)
2324        if let Some(start) = lookback_start {
2325            fill_reports.retain(|r| r.ts_event >= start);
2326        }
2327
2328        if !fill_reports.is_empty() {
2329            let filled_order_ids: ahash::AHashSet<_> = fill_reports
2330                .iter()
2331                .map(|report| report.venue_order_id)
2332                .collect();
2333            let open_order_ids: ahash::AHashSet<_> = order_reports
2334                .iter()
2335                .map(|report| report.venue_order_id)
2336                .collect();
2337            let historical_sweep = self
2338                .http_client
2339                .historical_order_status_reports_from_response(historical_orders, None)
2340                .context("failed to generate historical order status reports")?;
2341            reports_complete &= historical_sweep.complete;
2342            let mut historical_reports = historical_sweep.reports;
2343            historical_reports.retain(|report| {
2344                filled_order_ids.contains(&report.venue_order_id)
2345                    && !open_order_ids.contains(&report.venue_order_id)
2346            });
2347
2348            if let Some(fill_totals) = &fill_totals {
2349                clamp_filled_reports_to_fills(&mut historical_reports, fill_totals);
2350            }
2351            order_reports.extend(historical_reports);
2352        }
2353
2354        let mut mass_status = ExecutionMassStatus::new(
2355            self.core.client_id,
2356            self.core.account_id,
2357            self.core.venue,
2358            ts_init,
2359            None,
2360        );
2361        mass_status.set_report_window(lookback_start.or(history_start), reports_complete);
2362        mass_status.add_order_reports(order_reports);
2363        mass_status.add_fill_reports(fill_reports);
2364        mass_status.add_position_reports(position_reports);
2365
2366        log::info!(
2367            "Generated mass status: {} orders, {} fills, {} positions",
2368            mass_status.order_reports().len(),
2369            mass_status.fill_reports().len(),
2370            mass_status.position_reports().len(),
2371        );
2372
2373        Ok(Some(mass_status))
2374    }
2375}
2376
2377struct HyperliquidReportClient {
2378    http_client: HyperliquidHttpClient,
2379    account_address: String,
2380}
2381
2382impl HyperliquidReportClient {
2383    async fn collect_order_status_report(
2384        &self,
2385        cmd: &GenerateOrderStatusReport,
2386        cached_oid: Option<u64>,
2387    ) -> anyhow::Result<CollectedOrderReport> {
2388        let account_address = &self.account_address;
2389
2390        if cmd.venue_order_id.is_none() && cmd.client_order_id.is_none() {
2391            log::warn!(
2392                "Cannot generate order status report without venue_order_id or client_order_id"
2393            );
2394            return Ok(CollectedOrderReport::NotFound);
2395        }
2396
2397        // Search open orders by cloid first when supplied. Hyperliquid modify
2398        // produces a new venue oid while preserving cloid, so a cached oid can
2399        // point at the canceled leg rather than the live replacement.
2400        let mut cloid_lookup_error = None;
2401
2402        if let Some(client_order_id) = &cmd.client_order_id {
2403            match self
2404                .http_client
2405                .request_order_status_report_by_client_order_id(account_address, client_order_id)
2406                .await
2407            {
2408                Ok(Some(report)) => {
2409                    log::debug!("Generated order status report for {client_order_id}");
2410                    return Ok(CollectedOrderReport::Cloid(report));
2411                }
2412                Ok(None) => {}
2413                Err(e) => {
2414                    log::warn!(
2415                        "Failed to generate order status report for {client_order_id}: {e}; \
2416                         falling back to oid lookup"
2417                    );
2418                    cloid_lookup_error = Some(anyhow::anyhow!(e));
2419                }
2420            }
2421        }
2422
2423        let oid = if let Some(venue_order_id) = cmd.venue_order_id {
2424            venue_order_id
2425                .as_str()
2426                .parse::<u64>()
2427                .context("failed to parse venue_order_id as oid")?
2428        } else if let Some(oid) = cached_oid {
2429            oid
2430        } else {
2431            if let Some(e) = cloid_lookup_error {
2432                return Err(
2433                    e.context("cloid lookup failed and no venue_order_id fallback available")
2434                );
2435            }
2436
2437            log::debug!("No order status report found for {:?}", cmd.client_order_id);
2438            return Ok(CollectedOrderReport::NotFound);
2439        };
2440
2441        let mut report = self
2442            .http_client
2443            .request_order_status_report(account_address, oid)
2444            .await
2445            .context("failed to generate order status report")?;
2446
2447        if let Some(report) = &mut report
2448            && let Some(client_order_id) = cmd.client_order_id
2449        {
2450            attach_known_client_order_id(report, client_order_id);
2451        }
2452
2453        if let Some(report) = &mut report {
2454            clamp_reduce_only_report_to_fills(&self.http_client, account_address, report).await;
2455        }
2456
2457        if report.is_some() {
2458            log::debug!("Generated order status report for oid {oid}");
2459        } else {
2460            log::debug!("No order status report found for oid {oid}");
2461        }
2462
2463        Ok(report.map_or(CollectedOrderReport::NotFound, CollectedOrderReport::Oid))
2464    }
2465
2466    async fn collect_order_status_reports(
2467        &self,
2468        cmd: &GenerateOrderStatusReports,
2469    ) -> anyhow::Result<Vec<OrderStatusReport>> {
2470        let account_address = &self.account_address;
2471
2472        let mut reports = self
2473            .http_client
2474            .request_order_status_reports(account_address, cmd.instrument_id)
2475            .await
2476            .context("failed to generate order status reports")?;
2477
2478        retain_order_status_reports(&mut reports, cmd);
2479
2480        log::debug!("Generated {} order status reports", reports.len());
2481        Ok(reports)
2482    }
2483
2484    async fn collect_fill_reports(
2485        &self,
2486        cmd: GenerateFillReports,
2487    ) -> anyhow::Result<Vec<FillReport>> {
2488        let account_address = &self.account_address;
2489
2490        let reports = self
2491            .http_client
2492            .request_fill_reports(account_address, cmd.instrument_id)
2493            .await
2494            .context("failed to generate fill reports")?;
2495
2496        let reports = if let (Some(start), Some(end)) = (cmd.start, cmd.end) {
2497            reports
2498                .into_iter()
2499                .filter(|r| r.ts_event >= start && r.ts_event <= end)
2500                .collect()
2501        } else if let Some(start) = cmd.start {
2502            reports
2503                .into_iter()
2504                .filter(|r| r.ts_event >= start)
2505                .collect()
2506        } else if let Some(end) = cmd.end {
2507            reports.into_iter().filter(|r| r.ts_event <= end).collect()
2508        } else {
2509            reports
2510        };
2511
2512        log::debug!("Generated {} fill reports", reports.len());
2513        Ok(reports)
2514    }
2515
2516    async fn collect_position_status_reports(
2517        &self,
2518        cmd: &GeneratePositionStatusReports,
2519    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2520        let account_address = &self.account_address;
2521
2522        let reports = self
2523            .http_client
2524            .request_position_status_reports(account_address, cmd.instrument_id)
2525            .await
2526            .context("failed to generate position status reports")?;
2527
2528        log::debug!("Generated {} position status reports", reports.len());
2529        Ok(reports)
2530    }
2531}
2532
2533fn finish_fill_reports(
2534    core: &ExecutionClientCore,
2535    mut reports: Vec<FillReport>,
2536) -> Vec<FillReport> {
2537    let cache = core.cache();
2538
2539    // Unmapped fills must still match retained position fills after their order is purged
2540    for report in &mut reports {
2541        report.client_order_id = cache.client_order_id(&report.venue_order_id).copied();
2542    }
2543
2544    reports
2545}
2546
2547enum CollectedOrderReport {
2548    Cloid(OrderStatusReport),
2549    Oid(OrderStatusReport),
2550    NotFound,
2551}
2552
2553fn finish_order_status_report(
2554    collected: CollectedOrderReport,
2555    client_order_id: Option<ClientOrderId>,
2556    dispatch_state: &WsDispatchState,
2557    emitter: &ExecutionEventEmitter,
2558    ts_init: UnixNanos,
2559) -> anyhow::Result<Option<OrderStatusReport>> {
2560    let report = match &collected {
2561        CollectedOrderReport::Cloid(report) | CollectedOrderReport::Oid(report) => report,
2562        CollectedOrderReport::NotFound => return Ok(None),
2563    };
2564
2565    if let Some(client_order_id) = client_order_id.or(report.client_order_id) {
2566        let current_oid = dispatch_state.cached_venue_order_id(&client_order_id);
2567        let older_leg = current_oid
2568            .is_some_and(|current_oid| venue_oid(report.venue_order_id) < venue_oid(current_oid));
2569
2570        let superseded = match report.order_status {
2571            // Promoted legs still carry fill evidence; reconciliation suppresses their cancellation.
2572            OrderStatus::Canceled => {
2573                is_inflight_modify_old_leg_cancel(dispatch_state, &client_order_id, report)
2574            }
2575            OrderStatus::Accepted | OrderStatus::Triggered => older_leg,
2576            _ => false,
2577        };
2578
2579        if superseded {
2580            log::debug!(
2581                "Suppressing {} for superseded leg on {client_order_id}",
2582                report.order_status
2583            );
2584            anyhow::bail!("order status report for superseded leg on {client_order_id}");
2585        }
2586    }
2587
2588    Ok(match collected {
2589        CollectedOrderReport::Cloid(report) => {
2590            promote_replacement_from_query(&report, dispatch_state, emitter, ts_init);
2591            Some(report)
2592        }
2593        CollectedOrderReport::Oid(report) => Some(report),
2594        CollectedOrderReport::NotFound => None,
2595    })
2596}
2597
2598// Mass status clamps a reduce-only `Filled` report to its fills from its own fill sweep. The
2599// single-order lookups have no fills, so fetch them under the same rules; a failed fetch, a
2600// history at the venue cap, or an incomplete history leaves the venue report as it is
2601async fn clamp_reduce_only_report_to_fills(
2602    http_client: &HyperliquidHttpClient,
2603    account_address: &str,
2604    report: &mut OrderStatusReport,
2605) {
2606    if report.order_status != OrderStatus::Filled || !report.reduce_only {
2607        return;
2608    }
2609
2610    let venue_order_id = report.venue_order_id;
2611    let fills_response = match http_client.info_user_fills(account_address).await {
2612        Ok(fills) => fills,
2613        Err(e) => {
2614            log::warn!(
2615                "Failed to fetch fills for reduce-only order {venue_order_id}, keeping venue quantity: {e}"
2616            );
2617            return;
2618        }
2619    };
2620
2621    if fills_response.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT {
2622        log::debug!(
2623            "Fill history at venue cap, keeping venue quantity for reduce-only order {venue_order_id}"
2624        );
2625        return;
2626    }
2627
2628    match http_client.fill_reports_from_response(fills_response, Some(report.instrument_id)) {
2629        Ok(fill_sweep) if fill_sweep.complete => {
2630            let fill_totals = fill_totals_by_order(&fill_sweep.reports);
2631            clamp_filled_reports_to_fills(std::slice::from_mut(report), &fill_totals);
2632        }
2633        Ok(_) => log::debug!(
2634            "Fill history incomplete, keeping venue quantity for reduce-only order {venue_order_id}"
2635        ),
2636        Err(e) => log::warn!(
2637            "Failed to parse fills for reduce-only order {venue_order_id}, keeping venue quantity: {e}"
2638        ),
2639    }
2640}
2641
2642fn fill_totals_by_order(fills: &[FillReport]) -> AHashMap<(InstrumentId, VenueOrderId), Decimal> {
2643    let mut totals: AHashMap<(InstrumentId, VenueOrderId), Decimal> = AHashMap::new();
2644    for fill in fills {
2645        *totals
2646            .entry((fill.instrument_id, fill.venue_order_id))
2647            .or_default() += fill.last_qty.as_decimal();
2648    }
2649    totals
2650}
2651
2652// The venue reports a reduce-only order `filled` with nothing remaining once it closes the
2653// position, even when the position was smaller than the order, so the row's filled quantity
2654// exceeds the order's fills and the core would try to infer the missing fill. Clamp such a
2655// `Filled` report to its fills. Other orders are left alone and logged, since a shortfall there
2656// is not a known venue shape.
2657fn clamp_filled_reports_to_fills(
2658    reports: &mut [OrderStatusReport],
2659    fill_totals: &AHashMap<(InstrumentId, VenueOrderId), Decimal>,
2660) {
2661    for report in reports {
2662        if report.order_status != OrderStatus::Filled {
2663            continue;
2664        }
2665
2666        let Some(&fill_total) = fill_totals.get(&(report.instrument_id, report.venue_order_id))
2667        else {
2668            continue;
2669        };
2670
2671        if fill_total.is_zero() || fill_total >= report.filled_qty.as_decimal() {
2672            continue;
2673        }
2674
2675        if !report.reduce_only {
2676            log::warn!(
2677                "Filled order {} reports {} filled but its fills total {fill_total}, leaving as is",
2678                report.venue_order_id,
2679                report.filled_qty,
2680            );
2681            continue;
2682        }
2683
2684        let Ok(filled_qty) = Quantity::from_decimal_dp(fill_total, report.filled_qty.precision)
2685        else {
2686            log::warn!(
2687                "Cannot clamp filled order {} to its fill total {fill_total}",
2688                report.venue_order_id,
2689            );
2690            continue;
2691        };
2692
2693        log::info!(
2694            "Clamping reduce-only order {} to its fills: venue quantity {}, filled {filled_qty}",
2695            report.venue_order_id,
2696            report.quantity,
2697        );
2698        report.quantity = filled_qty;
2699        report.filled_qty = filled_qty;
2700    }
2701}
2702
2703impl HyperliquidExecutionClient {
2704    async fn start_ws_stream(&self) -> anyhow::Result<()> {
2705        // Must match REST queries; mismatch silently drops fills on agent wallets
2706        let subscription_address = self.get_account_address()?;
2707
2708        let mut ws_client = self.ws_client.clone();
2709
2710        let instruments = self
2711            .http_client
2712            .request_instruments()
2713            .await
2714            .unwrap_or_default();
2715
2716        ws_client.merge_instruments(&instruments);
2717
2718        ws_client.connect().await?;
2719        if let Err(e) = ws_client
2720            .subscribe_order_updates(&subscription_address)
2721            .await
2722        {
2723            let _ = ws_client.disconnect().await;
2724            return Err(e);
2725        }
2726
2727        if let Err(e) = ws_client.subscribe_user_events(&subscription_address).await {
2728            let _ = ws_client.disconnect().await;
2729            return Err(e);
2730        }
2731        log::debug!("Subscribed to Hyperliquid execution updates for {subscription_address}");
2732
2733        let emitter = self.emitter.clone();
2734        let dispatch_state = self.ws_dispatch_state.clone();
2735        let staged_brackets = self.staged_brackets.clone();
2736        let http_client = self.http_client.clone();
2737        let builder = self.http_client.builder_attribution();
2738        let clock = self.clock;
2739        let session_spawner = self
2740            .session_tasks
2741            .spawner()
2742            .map_err(|e| anyhow::anyhow!("Hyperliquid session task admission is closed: {e}"))?;
2743
2744        self.session_tasks.spawn(async move {
2745            // Cloids for external / untracked orders that reach a terminal
2746            // state: we evict their mapping immediately so long-running
2747            // sessions do not leak. Tracked orders clear their own cloid
2748            // mapping from the dispatch `cleanup_terminal` path below.
2749            //
2750            // For a tracked order that hits a status-only `FILLED` marker
2751            // without an accompanying fill, we defer the cloid cleanup until
2752            // the matching `FillReport` arrives so partial fills do not lose
2753            // their `client_order_id` link. The bounded FIFO cache keeps
2754            // orphaned entries from growing unbounded.
2755            let mut pending_filled_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
2756
2757            // Venue time of the latest delivered report (the stream start before any), so a
2758            // stalled stream cannot push the recovery window past events lost after it.
2759            let mut ts_last_report = clock.get_time_ns();
2760            let mut recovery_window: Option<RecoveryWindow> = None;
2761            let mut reconnect_generation = 0;
2762            let mut recovered_reports = None;
2763            let (follow_up_tx, mut follow_up_rx) =
2764                tokio::sync::mpsc::unbounded_channel::<FollowUpHistory>();
2765
2766            loop {
2767                let event = next_execution_event(
2768                    &mut recovered_reports,
2769                    &mut recovery_window,
2770                    &mut follow_up_rx,
2771                    &mut ws_client,
2772                    &http_client,
2773                    &dispatch_state,
2774                )
2775                .await;
2776
2777                match event {
2778                    Some(msg) => match msg {
2779                        NautilusWsMessage::ExecutionReports(reports) => {
2780                            for report in reports {
2781                                ts_last_report = ts_last_report.max(ts_venue(&report));
2782
2783                                let staged_parent_fill = match &report {
2784                                    ExecutionReport::Fill(report) => report.client_order_id,
2785                                    ExecutionReport::Order(_) => None,
2786                                };
2787
2788                                let staged_parent_terminal = match &report {
2789                                    ExecutionReport::Order(report)
2790                                        if matches!(
2791                                            report.order_status,
2792                                            OrderStatus::Canceled
2793                                                | OrderStatus::Rejected
2794                                                | OrderStatus::Expired
2795                                        ) =>
2796                                    {
2797                                        report.client_order_id.map(|client_order_id| {
2798                                            (client_order_id, report.ts_last)
2799                                        })
2800                                    }
2801                                    _ => None,
2802                                };
2803
2804                                let active_child_terminal = match &report {
2805                                    ExecutionReport::Order(report)
2806                                        if matches!(
2807                                            report.order_status,
2808                                            OrderStatus::Filled
2809                                                | OrderStatus::Canceled
2810                                                | OrderStatus::Rejected
2811                                                | OrderStatus::Expired
2812                                        ) =>
2813                                    {
2814                                        report.client_order_id
2815                                    }
2816                                    ExecutionReport::Fill(report) => {
2817                                        report.client_order_id.filter(|client_order_id| {
2818                                            let Some(context) =
2819                                                dispatch_state.lookup_context(client_order_id)
2820                                            else {
2821                                                return false;
2822                                            };
2823                                            let previous = dispatch_state
2824                                                .previous_filled_qty(client_order_id)
2825                                                .unwrap_or_else(|| {
2826                                                    Quantity::zero(report.last_qty.precision)
2827                                                });
2828                                            previous + report.last_qty >= context.quantity
2829                                        })
2830                                    }
2831                                    _ => None,
2832                                };
2833
2834                                let active_child_fill = match &report {
2835                                    ExecutionReport::Fill(report) => {
2836                                        report.client_order_id.and_then(|client_order_id| {
2837                                            dispatch_state.lookup_context(&client_order_id).map(
2838                                                |context| {
2839                                                    (
2840                                                        client_order_id,
2841                                                        dispatch_state
2842                                                            .previous_filled_qty(&client_order_id)
2843                                                            .unwrap_or_else(|| {
2844                                                                Quantity::zero(
2845                                                                    report.last_qty.precision,
2846                                                                )
2847                                                            }),
2848                                                        context.quantity,
2849                                                    )
2850                                                },
2851                                            )
2852                                        })
2853                                    }
2854                                    ExecutionReport::Order(_) => None,
2855                                };
2856
2857                                let (terminal_skipped, corrective) = handle_execution_report(
2858                                    report,
2859                                    &dispatch_state,
2860                                    &emitter,
2861                                    &ws_client,
2862                                    &http_client,
2863                                    &mut pending_filled_cloids,
2864                                    clock.get_time_ns(),
2865                                );
2866
2867                                if let Some((cid, corrective)) = corrective {
2868                                    spawn_corrective_reduce(
2869                                        &ws_client,
2870                                        &http_client,
2871                                        &dispatch_state,
2872                                        cid,
2873                                        corrective,
2874                                        &session_spawner,
2875                                    );
2876                                }
2877
2878                                if let Some(parent_id) = staged_parent_fill
2879                                    && let Some(children) =
2880                                        staged_brackets.lock().activate(&parent_id)
2881                                {
2882                                    spawn_staged_children(
2883                                        children,
2884                                        &emitter,
2885                                        &ws_client,
2886                                        &http_client,
2887                                        dispatch_state.clone(),
2888                                        staged_brackets.clone(),
2889                                        builder.clone(),
2890                                        clock,
2891                                        &session_spawner,
2892                                    );
2893                                }
2894
2895                                if let Some((parent_id, ts_event)) =
2896                                    staged_parent_terminal.filter(|_| !terminal_skipped)
2897                                {
2898                                    let children =
2899                                        staged_brackets.lock().cancel_for_parent(&parent_id);
2900
2901                                    for child in children {
2902                                        emitter.emit_order_canceled(&child, None, ts_event);
2903                                    }
2904                                }
2905
2906                                if let Some((client_order_id, previous, quantity)) =
2907                                    active_child_fill
2908                                    && let Some(cumulative) =
2909                                        dispatch_state.previous_filled_qty(&client_order_id)
2910                                    && cumulative > previous
2911                                    && cumulative < quantity
2912                                {
2913                                    let sibling =
2914                                        staged_brackets.lock().active_sibling(&client_order_id);
2915
2916                                    if let Some(sibling) = sibling {
2917                                        spawn_active_sibling_resize(
2918                                            sibling,
2919                                            quantity - cumulative,
2920                                            &emitter,
2921                                            &ws_client,
2922                                            &http_client,
2923                                            &dispatch_state,
2924                                            &session_spawner,
2925                                        );
2926                                    }
2927                                }
2928
2929                                if let Some(client_order_id) =
2930                                    active_child_terminal.filter(|_| !terminal_skipped)
2931                                {
2932                                    let sibling = staged_brackets
2933                                        .lock()
2934                                        .take_active_sibling(&client_order_id);
2935
2936                                    if let Some(sibling) = sibling {
2937                                        spawn_active_sibling_cancel(
2938                                            sibling,
2939                                            &emitter,
2940                                            &ws_client,
2941                                            &http_client,
2942                                            &dispatch_state,
2943                                            &session_spawner,
2944                                        );
2945                                    }
2946                                }
2947                            }
2948                        }
2949                        NautilusWsMessage::Reconnected => {
2950                            log::info!("WebSocket reconnected");
2951
2952                            reconnect_generation += 1;
2953
2954                            let window = RecoveryWindow {
2955                                ts_start: recovery_window
2956                                    .map_or(ts_last_report, |window| window.ts_start),
2957                                generation: reconnect_generation,
2958                            };
2959
2960                            recovery_window = Some(window);
2961
2962                            recovered_reports = fetch_reconnect_reports(
2963                                &ws_client,
2964                                &http_client,
2965                                &dispatch_state,
2966                                &subscription_address,
2967                                window.ts_start,
2968                            )
2969                            .await
2970                            .inspect(|reports| {
2971                                log::info!(
2972                                    "Recovered {} execution reports missed while disconnected",
2973                                    reports.len(),
2974                                );
2975                            })
2976                            .inspect_err(|e| {
2977                                log::error!(
2978                                    "Failed to recover execution reports missed while disconnected: {e:#}"
2979                                );
2980                            })
2981                            .ok();
2982
2983                            spawn_follow_up_history_read(
2984                                &http_client,
2985                                &subscription_address,
2986                                window,
2987                                follow_up_tx.clone(),
2988                                &session_spawner,
2989                            );
2990                        }
2991                        NautilusWsMessage::Error(e) => {
2992                            log::warn!("WebSocket error: {e}");
2993                        }
2994                        NautilusWsMessage::Trades(_)
2995                        | NautilusWsMessage::Quote(_)
2996                        | NautilusWsMessage::Deltas(_)
2997                        | NautilusWsMessage::Depth(_)
2998                        | NautilusWsMessage::BookInvalid(_)
2999                        | NautilusWsMessage::Candle(_)
3000                        | NautilusWsMessage::MarkPrice(_)
3001                        | NautilusWsMessage::IndexPrice(_)
3002                        | NautilusWsMessage::FundingRate(_)
3003                        | NautilusWsMessage::CustomData(_) => {}
3004                    },
3005                    None => {
3006                        log::debug!("WebSocket next_event returned None, stream closed");
3007                        break;
3008                    }
3009                }
3010            }
3011        })?;
3012
3013        log::debug!("Hyperliquid WebSocket execution stream started");
3014        Ok(())
3015    }
3016}
3017
3018async fn refresh_scheduled_cancel(
3019    http_client: &HyperliquidHttpClient,
3020    clock: &AtomicTime,
3021    timeout: Duration,
3022    refresh_interval: Duration,
3023) -> anyhow::Result<()> {
3024    let timeout_ms = u64::try_from(timeout.as_millis())?;
3025    let deadline = clock
3026        .get_time_ms()
3027        .checked_add(timeout_ms)
3028        .context("scheduled cancellation deadline overflows milliseconds")?;
3029
3030    tokio::time::timeout(
3031        refresh_interval,
3032        http_client.schedule_cancel(Some(deadline)),
3033    )
3034    .await
3035    .context("scheduled cancellation request timed out")??;
3036    Ok(())
3037}
3038
3039async fn next_execution_event(
3040    recovered_reports: &mut Option<Vec<ExecutionReport>>,
3041    recovery_window: &mut Option<RecoveryWindow>,
3042    follow_up_rx: &mut tokio::sync::mpsc::UnboundedReceiver<FollowUpHistory>,
3043    ws_client: &mut HyperliquidWebSocketClient,
3044    http_client: &HyperliquidHttpClient,
3045    dispatch_state: &WsDispatchState,
3046) -> Option<NautilusWsMessage> {
3047    // Recovered reports take the live path ahead of the messages queued behind the
3048    // reconnect, so dispatch sees events in venue order.
3049    if let Some(reports) = recovered_reports.take() {
3050        return Some(NautilusWsMessage::ExecutionReports(reports));
3051    }
3052
3053    // Released terminal reports take the same path, so bracket siblings react to them.
3054    // Follow-up history is built here, against the dispatch state it replays into
3055    let mut event = tokio::select! {
3056        event = ws_client.next_event() => event,
3057        released = dispatch_state.recv_released_cancels() => {
3058            Some(NautilusWsMessage::ExecutionReports(
3059                released.into_iter().map(ExecutionReport::Order).collect(),
3060            ))
3061        }
3062        Some((orders, fills, window)) = follow_up_rx.recv() => {
3063            let reports = reconnect_reports_from_history(
3064                fills,
3065                orders,
3066                window.ts_start.saturating_sub(RECONNECT_RECOVERY_MARGIN),
3067                ws_client,
3068                http_client,
3069                dispatch_state,
3070            )
3071            .inspect(|reports| {
3072                // An older read cannot settle a gap reopened by a newer reconnect
3073                if *recovery_window == Some(window) {
3074                    *recovery_window = None;
3075                }
3076
3077                log::info!(
3078                    "Recovered {} execution reports in the follow-up history read",
3079                    reports.len(),
3080                );
3081            })
3082            .inspect_err(|e| log::error!("Failed the follow-up history read: {e:#}"))
3083            .unwrap_or_default();
3084            Some(NautilusWsMessage::ExecutionReports(reports))
3085        }
3086    };
3087
3088    // A recovered fill can also arrive on the resumed stream, and bracket sibling handling
3089    // counts a fill before dispatch drops its duplicate.
3090    if let Some(NautilusWsMessage::ExecutionReports(reports)) = &mut event {
3091        reports.retain(|report| match report {
3092            ExecutionReport::Fill(fill) => !dispatch_state.trade_emitted(&fill.trade_id),
3093            ExecutionReport::Order(_) => true,
3094        });
3095    }
3096
3097    event
3098}
3099
3100fn ts_venue(report: &ExecutionReport) -> UnixNanos {
3101    match report {
3102        ExecutionReport::Order(report) => report.ts_last,
3103        ExecutionReport::Fill(report) => report.ts_event,
3104    }
3105}
3106
3107async fn fetch_reconnect_reports(
3108    ws_client: &HyperliquidWebSocketClient,
3109    http_client: &HyperliquidHttpClient,
3110    dispatch_state: &WsDispatchState,
3111    account_address: &str,
3112    ts_last_report: UnixNanos,
3113) -> anyhow::Result<Vec<ExecutionReport>> {
3114    // History read before the venue resumes both streams could miss an event in between
3115    if let Err(e) = ws_client
3116        .wait_for_subscriptions_confirmed(RECONNECT_SUBSCRIPTION_TIMEOUT)
3117        .await
3118    {
3119        log::warn!("{e}; recovering missed execution reports regardless");
3120    }
3121
3122    let (orders, fills) = read_history(http_client, account_address).await?;
3123
3124    reconnect_reports_from_history(
3125        fills,
3126        orders,
3127        ts_last_report.saturating_sub(RECONNECT_RECOVERY_MARGIN),
3128        ws_client,
3129        http_client,
3130        dispatch_state,
3131    )
3132}
3133
3134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3135struct RecoveryWindow {
3136    ts_start: UnixNanos,
3137    generation: u64,
3138}
3139
3140// Raw history a follow-up read fetched, with the recovery window it can settle
3141type FollowUpHistory = (
3142    Vec<HyperliquidOrderStatusEntry>,
3143    HyperliquidFills,
3144    RecoveryWindow,
3145);
3146
3147// The stream loop builds and replays the reports, so a slow read never holds back stream updates
3148fn spawn_follow_up_history_read(
3149    http_client: &HyperliquidHttpClient,
3150    account_address: &str,
3151    window: RecoveryWindow,
3152    sender: tokio::sync::mpsc::UnboundedSender<FollowUpHistory>,
3153    task_spawner: &TaskSpawner,
3154) {
3155    let http_client = http_client.clone();
3156    let account_address = account_address.to_string();
3157
3158    if let Err(e) = task_spawner.spawn(async move {
3159        tokio::time::sleep(RECONNECT_FOLLOW_UP_DELAY).await;
3160
3161        match read_history(&http_client, &account_address).await {
3162            Ok((orders, fills)) => {
3163                let _ = sender.send((orders, fills, window));
3164            }
3165            Err(e) => log::error!("Failed the follow-up history read: {e:#}"),
3166        }
3167    }) {
3168        log::warn!("Skipping Hyperliquid follow-up history read after shutdown began: {e}");
3169    }
3170}
3171
3172// Orders first: a fill precedes its order's terminal status, so the later fill read holds
3173// every fill behind a terminal status in the order snapshot.
3174async fn read_history(
3175    http_client: &HyperliquidHttpClient,
3176    account_address: &str,
3177) -> anyhow::Result<(Vec<HyperliquidOrderStatusEntry>, HyperliquidFills)> {
3178    let orders = http_client
3179        .info_historical_orders(account_address)
3180        .await
3181        .context("failed to fetch historical orders")?;
3182    let fills = http_client
3183        .info_user_fills(account_address)
3184        .await
3185        .context("failed to fetch user fills")?;
3186
3187    Ok((orders, fills))
3188}
3189
3190fn reconnect_reports_from_history(
3191    mut fills: HyperliquidFills,
3192    mut orders: Vec<HyperliquidOrderStatusEntry>,
3193    ts_start: UnixNanos,
3194    ws_client: &HyperliquidWebSocketClient,
3195    http_client: &HyperliquidHttpClient,
3196    dispatch_state: &WsDispatchState,
3197) -> anyhow::Result<Vec<ExecutionReport>> {
3198    let start_ms = ts_start.as_millis();
3199    fills.retain(|fill| fill.time >= start_ms);
3200    orders.retain(|entry| entry.status_timestamp >= start_ms);
3201
3202    let fill_cloids: AHashMap<VenueOrderId, Ustr> = fills
3203        .iter()
3204        .filter_map(|fill| {
3205            let cloid = fill.cloid.as_deref()?;
3206            Some((VenueOrderId::new(fill.oid.to_string()), Ustr::from(cloid)))
3207        })
3208        .collect();
3209
3210    let mut reports = Vec::new();
3211
3212    for mut report in http_client.fill_reports_from_response(fills, None)?.reports {
3213        if dispatch_state.trade_emitted(&report.trade_id) {
3214            continue;
3215        }
3216
3217        report.client_order_id = fill_cloids
3218            .get(&report.venue_order_id)
3219            .and_then(|cloid| ws_client.get_cloid_mapping(cloid));
3220        reports.push(ExecutionReport::Fill(report));
3221    }
3222
3223    let order_reports = http_client
3224        .historical_order_status_reports_from_response(orders, None)?
3225        .reports
3226        .into_iter()
3227        .map(|mut report| {
3228            report.client_order_id = report
3229                .client_order_id
3230                .map(|cloid| ws_client.get_cloid_mapping(&cloid.inner()).unwrap_or(cloid));
3231            report
3232        })
3233        .collect();
3234
3235    // The recovered fills carry the fill state; dispatch takes nothing from the marker
3236    reports.extend(
3237        resolve_order_legs(order_reports, dispatch_state)
3238            .into_iter()
3239            .filter(|report| report.order_status != OrderStatus::Filled)
3240            .map(ExecutionReport::Order),
3241    );
3242
3243    // A leg's opening status precedes its fills, which precede its closing status. Legs placed in
3244    // one block share a timestamp, so within a millisecond each leg's events stay together in
3245    // placement order
3246    reports.sort_by_key(|report| match report {
3247        ExecutionReport::Order(report) if report.order_status.is_closed() => {
3248            (report.ts_last, venue_oid(report.venue_order_id), 2)
3249        }
3250        ExecutionReport::Order(report) => (report.ts_last, venue_oid(report.venue_order_id), 0),
3251        ExecutionReport::Fill(report) => (report.ts_event, venue_oid(report.venue_order_id), 1),
3252    });
3253
3254    Ok(reports)
3255}
3256
3257// History holds only the latest status of each venue order, so the legs of a modify read as already
3258// closed and a replacement the client never saw accepted shows no opening. Replays each tracked
3259// order's chain as the live stream would: every leg newer than the bound one opens in creation
3260// order ahead of its fills, so dispatch promotes through the chain one modify at a time, and only
3261// the newest leg's own status follows (an older leg's close is its cancel-replace).
3262fn resolve_order_legs(
3263    reports: Vec<OrderStatusReport>,
3264    dispatch_state: &WsDispatchState,
3265) -> Vec<OrderStatusReport> {
3266    let newest_legs = newest_resting_legs(&reports, dispatch_state);
3267    let mut resolved = Vec::with_capacity(reports.len());
3268
3269    for report in reports {
3270        let Some((client_order_id, newest)) = report
3271            .client_order_id
3272            .and_then(|id| newest_legs.get(&id).map(|newest| (id, *newest)))
3273        else {
3274            resolved.push(report);
3275            continue;
3276        };
3277
3278        if report.order_status == OrderStatus::Canceled {
3279            dispatch_state.record_closed_venue_order_id(report.venue_order_id);
3280        }
3281
3282        let bound = dispatch_state.cached_venue_order_id(&client_order_id);
3283        let unseen = bound.is_none_or(|bound| venue_oid(bound) < venue_oid(report.venue_order_id));
3284
3285        // An open row is its own opening, and a rejected row never rested, as when a modify
3286        // fails to place its replacement
3287        if unseen
3288            && !matches!(
3289                report.order_status,
3290                OrderStatus::Accepted | OrderStatus::Rejected
3291            )
3292        {
3293            resolved.push(opening_report(&report));
3294        }
3295
3296        if report.venue_order_id != newest {
3297            continue;
3298        }
3299
3300        resolved.push(report);
3301    }
3302
3303    resolved
3304}
3305
3306fn newest_resting_legs(
3307    reports: &[OrderStatusReport],
3308    dispatch_state: &WsDispatchState,
3309) -> AHashMap<ClientOrderId, VenueOrderId> {
3310    let mut newest_legs: AHashMap<ClientOrderId, VenueOrderId> = AHashMap::new();
3311
3312    for report in reports {
3313        let Some(client_order_id) = report.client_order_id else {
3314            continue;
3315        };
3316
3317        if report.order_status == OrderStatus::Rejected
3318            || dispatch_state.lookup_context(&client_order_id).is_none()
3319        {
3320            continue;
3321        }
3322
3323        let newest = newest_legs
3324            .entry(client_order_id)
3325            .or_insert(report.venue_order_id);
3326
3327        if venue_oid(report.venue_order_id) > venue_oid(*newest) {
3328            *newest = report.venue_order_id;
3329        }
3330    }
3331
3332    newest_legs
3333}
3334
3335fn opening_report(report: &OrderStatusReport) -> OrderStatusReport {
3336    let mut opening = report.clone();
3337    opening.order_status = OrderStatus::Accepted;
3338    opening.filled_qty = Quantity::zero(report.filled_qty.precision);
3339    opening.avg_px = None;
3340    opening.ts_last = report.ts_accepted;
3341    opening.report_id = UUID4::new();
3342    opening
3343}
3344
3345#[derive(Debug, Clone)]
3346struct StagedBracketChild {
3347    order: OrderAny,
3348    request: HyperliquidExchangePlaceOrderRequest,
3349}
3350
3351#[derive(Debug, Default)]
3352struct StagedBracketState {
3353    children_by_parent: AHashMap<ClientOrderId, Vec<StagedBracketChild>>,
3354    active_children: AHashMap<ClientOrderId, StagedBracketChild>,
3355    active_siblings: AHashMap<ClientOrderId, ClientOrderId>,
3356}
3357
3358impl StagedBracketState {
3359    fn stage(&mut self, parent_id: ClientOrderId, children: Vec<StagedBracketChild>) {
3360        self.children_by_parent.insert(parent_id, children);
3361    }
3362
3363    fn activate(&mut self, parent_id: &ClientOrderId) -> Option<Vec<StagedBracketChild>> {
3364        let children = self.children_by_parent.remove(parent_id)?;
3365        self.track_active(&children);
3366
3367        Some(children)
3368    }
3369
3370    fn restore_active(&mut self, children: &[StagedBracketChild]) {
3371        self.track_active(children);
3372    }
3373
3374    fn track_active(&mut self, children: &[StagedBracketChild]) {
3375        let child_ids = children
3376            .iter()
3377            .map(|child| child.order.client_order_id())
3378            .collect::<Vec<_>>();
3379
3380        for child in children {
3381            let child_id = child.order.client_order_id();
3382            if let Some(sibling_id) = child
3383                .order
3384                .linked_order_ids()
3385                .and_then(|ids| ids.iter().find(|id| child_ids.contains(id)))
3386            {
3387                self.active_siblings.insert(child_id, *sibling_id);
3388            }
3389            self.active_children.insert(child_id, child.clone());
3390        }
3391    }
3392
3393    fn contains_parent(&self, parent_id: &ClientOrderId) -> bool {
3394        self.children_by_parent.contains_key(parent_id)
3395    }
3396
3397    fn cancel_child(&mut self, child_id: &ClientOrderId) -> Option<OrderAny> {
3398        let parent_id = self
3399            .children_by_parent
3400            .iter()
3401            .find_map(|(parent_id, children)| {
3402                children
3403                    .iter()
3404                    .any(|child| child.order.client_order_id() == *child_id)
3405                    .then_some(*parent_id)
3406            })?;
3407        let children = self.children_by_parent.get_mut(&parent_id)?;
3408        let index = children
3409            .iter()
3410            .position(|child| child.order.client_order_id() == *child_id)?;
3411        let child = children.remove(index);
3412
3413        if children.is_empty() {
3414            self.children_by_parent.remove(&parent_id);
3415        }
3416
3417        Some(child.order)
3418    }
3419
3420    fn cancel_for_parent(&mut self, parent_id: &ClientOrderId) -> Vec<OrderAny> {
3421        self.children_by_parent
3422            .remove(parent_id)
3423            .map(|children| children.into_iter().map(|child| child.order).collect())
3424            .unwrap_or_default()
3425    }
3426
3427    fn take_active_sibling(
3428        &mut self,
3429        client_order_id: &ClientOrderId,
3430    ) -> Option<StagedBracketChild> {
3431        self.active_children.remove(client_order_id);
3432        let sibling_id = self.active_siblings.remove(client_order_id)?;
3433        self.active_siblings.remove(&sibling_id);
3434        self.active_children.remove(&sibling_id)
3435    }
3436
3437    fn active_sibling(&self, client_order_id: &ClientOrderId) -> Option<StagedBracketChild> {
3438        self.active_siblings
3439            .get(client_order_id)
3440            .and_then(|sibling_id| self.active_children.get(sibling_id))
3441            .cloned()
3442    }
3443}
3444
3445fn query_order_recovery_context(
3446    order: &OrderAny,
3447    venue_order_id: Option<VenueOrderId>,
3448    dispatch_state: &WsDispatchState,
3449) -> Option<OrderContext> {
3450    if order.order_type() != OrderType::Limit
3451        || order.is_quote_quantity()
3452        || order.is_reduce_only()
3453        || order.is_contingency()
3454        || order.linked_order_ids().is_some()
3455        || order.parent_order_id().is_some()
3456        || !order.is_open()
3457        || order.is_pending_update()
3458        || !order.filled_qty().is_zero()
3459        || !order.trade_ids().is_empty()
3460        || order.venue_order_ids().len() != 1
3461        || order.venue_order_id() != venue_order_id
3462        || order
3463            .events()
3464            .iter()
3465            .any(|event| matches!(event, OrderEventAny::Updated(_)))
3466    {
3467        return None;
3468    }
3469
3470    let client_order_id = order.client_order_id();
3471    let context = OrderContext::from(order);
3472    if dispatch_state.lookup_context(&client_order_id) != Some(context)
3473        || dispatch_state.cached_venue_order_id(&client_order_id) != venue_order_id
3474        || dispatch_state.has_pending_modify(&client_order_id)
3475    {
3476        return None;
3477    }
3478
3479    Some(context)
3480}
3481
3482async fn recover_query_order_fills(
3483    http_client: &HyperliquidHttpClient,
3484    account_address: &str,
3485    report: &OrderStatusReport,
3486    context: &OrderContext,
3487    dispatch_state: &WsDispatchState,
3488    emitter: &ExecutionEventEmitter,
3489) {
3490    let client_order_id = context.identity.client_order_id;
3491    let cloid = http_client
3492        .cached_client_order_id_cloid(&client_order_id)
3493        .unwrap_or_else(|| Cloid::from_client_order_id(client_order_id));
3494    let cloid_hex = cloid.to_hex();
3495
3496    if report.order_status != OrderStatus::Filled
3497        || report.reduce_only
3498        || report.order_type != OrderType::Limit
3499        || report.instrument_id != context.identity.instrument_id
3500        || report.order_side != Some(context.identity.order_side)
3501        || report.quantity != context.quantity
3502        || report.filled_qty != context.quantity
3503        || report
3504            .client_order_id
3505            .is_some_and(|id| id != client_order_id && id.as_str() != cloid_hex)
3506        || dispatch_state.cached_venue_order_id(&client_order_id) != Some(report.venue_order_id)
3507    {
3508        return;
3509    }
3510
3511    let fills =
3512        match collect_query_order_fills(http_client, account_address, report, context, cloid).await
3513        {
3514            Ok(fills) => fills,
3515            Err(e) => {
3516                log::warn!("Failed to recover fills for {client_order_id}: {e:#}");
3517                return;
3518            }
3519        };
3520
3521    // Admission does not own a leg across HTTP awaits; only unchanged bindings may recover
3522    if dispatch_state.lookup_context(&client_order_id) != Some(*context)
3523        || dispatch_state.cached_venue_order_id(&client_order_id) != Some(report.venue_order_id)
3524        || dispatch_state.has_pending_modify(&client_order_id)
3525    {
3526        log::warn!(
3527            "Discarding query fill recovery for {client_order_id}: order binding or terms changed"
3528        );
3529        return;
3530    }
3531
3532    for fill in fills {
3533        emitter.send_fill_report(fill);
3534    }
3535}
3536
3537async fn collect_query_order_fills(
3538    http_client: &HyperliquidHttpClient,
3539    account_address: &str,
3540    report: &OrderStatusReport,
3541    context: &OrderContext,
3542    cloid: Cloid,
3543) -> anyhow::Result<Vec<FillReport>> {
3544    if http_client.get_account_address()? != account_address {
3545        anyhow::bail!("fill history account does not match the execution account");
3546    }
3547
3548    let fills = http_client.info_user_fills(account_address).await?;
3549    let oid = report.venue_order_id.as_str().parse::<u64>()?;
3550
3551    if let Some(fill) = fills
3552        .iter()
3553        .find(|fill| fill.time.checked_mul(1_000_000).is_none())
3554    {
3555        anyhow::bail!("invalid fill timestamp for venue order {}", fill.oid);
3556    }
3557
3558    for fill in fills.iter().filter(|fill| fill.oid == oid) {
3559        let fill_cloid = fill
3560            .cloid
3561            .as_deref()
3562            .map(Cloid::from_hex)
3563            .transpose()
3564            .map_err(anyhow::Error::msg)?;
3565
3566        if fill_cloid.is_some_and(|value| value != cloid)
3567            || fill.sz <= Decimal::ZERO
3568            || fill.px <= Decimal::ZERO
3569        {
3570            anyhow::bail!("invalid fill CLOID, quantity, or price for venue order {oid}");
3571        }
3572    }
3573
3574    let mut sweep = http_client.fill_reports_from_response(fills.clone(), None)?;
3575    if !sweep.complete {
3576        anyhow::bail!("fill history could not be fully resolved or parsed");
3577    }
3578
3579    for (raw, parsed) in fills.iter().zip(&mut sweep.reports) {
3580        if raw.oid != oid {
3581            continue;
3582        }
3583
3584        if raw.sz != parsed.last_qty.as_decimal()
3585            || raw.px != parsed.last_px.as_decimal()
3586            || raw.fee != parsed.commission.as_decimal()
3587        {
3588            anyhow::bail!(
3589                "fill quantity, price, or commission loses precision for venue order {oid}"
3590            );
3591        }
3592
3593        parsed.client_order_id = Some(context.identity.client_order_id);
3594    }
3595
3596    validate_query_order_fills(sweep.reports, report, context)
3597}
3598
3599fn validate_query_order_fills(
3600    fills: Vec<FillReport>,
3601    report: &OrderStatusReport,
3602    context: &OrderContext,
3603) -> anyhow::Result<Vec<FillReport>> {
3604    let venue_order_id = report.venue_order_id;
3605    let client_order_id = context.identity.client_order_id;
3606    let mut unique: AHashMap<_, FillReport> = AHashMap::new();
3607    let mut filled_qty = Decimal::ZERO;
3608
3609    for mut fill in fills {
3610        if fill.venue_order_id != venue_order_id {
3611            continue;
3612        }
3613
3614        if fill.account_id != report.account_id
3615            || fill.instrument_id != report.instrument_id
3616            || fill.order_side != context.identity.order_side
3617            || fill.client_order_id.is_some_and(|id| id != client_order_id)
3618            || fill.last_qty.is_zero()
3619        {
3620            anyhow::bail!("fill identity or quantity mismatch for venue order {venue_order_id}");
3621        }
3622
3623        fill.client_order_id = Some(client_order_id);
3624        if let Some(previous) = unique.get(&fill.trade_id) {
3625            fill.report_id = previous.report_id;
3626            fill.ts_init = previous.ts_init;
3627            if fill != *previous {
3628                anyhow::bail!(
3629                    "conflicting duplicate trade {} for venue order {venue_order_id}",
3630                    fill.trade_id
3631                );
3632            }
3633
3634            continue;
3635        }
3636
3637        filled_qty += fill.last_qty.as_decimal();
3638        unique.insert(fill.trade_id, fill);
3639    }
3640
3641    if filled_qty != report.filled_qty.as_decimal() || filled_qty != context.quantity.as_decimal() {
3642        anyhow::bail!(
3643            "incomplete fills for venue order {venue_order_id}: expected {}, recovered {filled_qty}",
3644            context.quantity,
3645        );
3646    }
3647
3648    let mut fills: Vec<_> = unique.into_values().collect();
3649    fills.sort_by_key(|fill| (fill.ts_event, fill.trade_id));
3650    Ok(fills)
3651}
3652
3653fn attach_known_client_order_id(report: &mut OrderStatusReport, client_order_id: ClientOrderId) {
3654    if report.client_order_id.is_none() {
3655        report.client_order_id = Some(client_order_id);
3656    }
3657}
3658
3659// During a tracked cancel-replace the cached venue_order_id is still the old
3660// leg, so only its `Canceled` must be dropped (it would wrongly terminate the
3661// live order); a late `Filled` or any other status is forwarded for recovery.
3662fn is_inflight_modify_old_leg_cancel(
3663    dispatch_state: &WsDispatchState,
3664    client_order_id: &ClientOrderId,
3665    report: &OrderStatusReport,
3666) -> bool {
3667    report.order_status == OrderStatus::Canceled
3668        && dispatch_state.pending_modify_contains_old(client_order_id, report.venue_order_id)
3669}
3670
3671fn remove_generated_modify_cloid(
3672    http_client: &HyperliquidHttpClient,
3673    ws_client: &HyperliquidWebSocketClient,
3674    generated_modify_cloid: Option<(ClientOrderId, Cloid)>,
3675) {
3676    let Some((client_order_id, cloid)) = generated_modify_cloid else {
3677        return;
3678    };
3679
3680    if http_client.cached_client_order_id_cloid(&client_order_id) != Some(cloid) {
3681        return;
3682    }
3683
3684    let cloid_hex = Ustr::from(&cloid.to_hex());
3685    ws_client.remove_cloid_mapping(&cloid_hex);
3686    http_client.remove_client_order_id_cloid(&client_order_id);
3687}
3688
3689#[derive(Clone)]
3690struct CancelEntry {
3691    strategy_id: StrategyId,
3692    instrument_id: InstrumentId,
3693    client_order_id: ClientOrderId,
3694    venue_order_id: Option<VenueOrderId>,
3695    symbol: Ustr,
3696    fast: bool,
3697}
3698
3699struct CancelDispatch {
3700    cloid_requests: Vec<(HyperliquidExchangeCancelByCloidRequest, CancelEntry)>,
3701    oid_requests: Vec<(HyperliquidExchangeCancelOrderRequest, CancelEntry)>,
3702}
3703
3704impl CancelDispatch {
3705    fn new() -> Self {
3706        Self {
3707            cloid_requests: Vec::new(),
3708            oid_requests: Vec::new(),
3709        }
3710    }
3711
3712    fn is_empty(&self) -> bool {
3713        self.cloid_requests.is_empty() && self.oid_requests.is_empty()
3714    }
3715
3716    fn push(
3717        &mut self,
3718        entry: &CancelEntry,
3719        asset: u32,
3720        http_client: &HyperliquidHttpClient,
3721    ) -> Result<(), CommandFailure> {
3722        if let Some(cloid) = http_client.cached_client_order_id_cloid(&entry.client_order_id) {
3723            self.cloid_requests.push((
3724                HyperliquidExchangeCancelByCloidRequest { asset, cloid },
3725                entry.clone(),
3726            ));
3727        } else if let Some(venue_order_id) = entry.venue_order_id {
3728            match venue_order_id.as_str().parse::<u64>() {
3729                Ok(oid) => {
3730                    self.oid_requests.push((
3731                        HyperliquidExchangeCancelOrderRequest { asset, oid },
3732                        entry.clone(),
3733                    ));
3734                }
3735                Err(_) => {
3736                    return Err(CommandFailure::not_sent("Invalid venue order ID format"));
3737                }
3738            }
3739        } else {
3740            let cloid = http_client.get_or_generate_client_order_id_cloid(entry.client_order_id);
3741            self.cloid_requests.push((
3742                HyperliquidExchangeCancelByCloidRequest { asset, cloid },
3743                entry.clone(),
3744            ));
3745        }
3746        Ok(())
3747    }
3748}
3749
3750fn classify_post_failure(failure: PostRequestError) -> CommandFailure {
3751    let (error, outcome) = match failure {
3752        PostRequestError::BeforeDispatch(error) => {
3753            let outcome = CommandFailure::not_sent(error.to_string());
3754            (error, outcome)
3755        }
3756        PostRequestError::AfterDispatch(error) => {
3757            let outcome = CommandFailure::ambiguous(error.to_string());
3758            (error, outcome)
3759        }
3760        PostRequestError::Rejected { error, reason } => {
3761            (error, CommandFailure::venue_rejected(reason))
3762        }
3763    };
3764
3765    log::debug!("WebSocket command failed: {error}");
3766    outcome
3767}
3768
3769fn emit_modify_failure(
3770    emitter: &ExecutionEventEmitter,
3771    command: &ModifyOrder,
3772    venue_order_id: Option<VenueOrderId>,
3773    failure: CommandFailure,
3774    ts_event: UnixNanos,
3775) {
3776    match failure {
3777        CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
3778            emitter.emit_order_modify_rejected_event(
3779                command.strategy_id,
3780                command.instrument_id,
3781                command.client_order_id,
3782                venue_order_id,
3783                &reason,
3784                ts_event,
3785            );
3786        }
3787        CommandFailure::Ambiguous(reason) => {
3788            log::warn!(
3789                "Modify outcome unknown for {}: {reason}; awaiting reconciliation",
3790                command.client_order_id
3791            );
3792        }
3793    }
3794}
3795
3796fn emit_cancel_failure(
3797    emitter: &ExecutionEventEmitter,
3798    entry: &CancelEntry,
3799    failure: CommandFailure,
3800    ts_event: UnixNanos,
3801) {
3802    match failure {
3803        CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
3804            emitter.emit_order_cancel_rejected_event(
3805                entry.strategy_id,
3806                entry.instrument_id,
3807                entry.client_order_id,
3808                entry.venue_order_id,
3809                &reason,
3810                ts_event,
3811            );
3812        }
3813        CommandFailure::Ambiguous(reason) => {
3814            log::warn!(
3815                "Cancel outcome unknown for {}: {reason}; awaiting reconciliation",
3816                entry.client_order_id
3817            );
3818        }
3819    }
3820}
3821
3822async fn submit_cancel_dispatch(
3823    label: &str,
3824    dispatch: CancelDispatch,
3825    ws_client: &HyperliquidWebSocketClient,
3826    http_client: &HyperliquidHttpClient,
3827    emitter: &ExecutionEventEmitter,
3828    clock: &'static AtomicTime,
3829) {
3830    let CancelDispatch {
3831        cloid_requests,
3832        oid_requests,
3833    } = dispatch;
3834
3835    let (fast_cloid_requests, fast_cloid_entries, cloid_requests, cloid_entries) =
3836        split_fast_cancel_requests(cloid_requests);
3837
3838    if !fast_cloid_requests.is_empty() {
3839        let action = HyperliquidExchangeAction::CancelByCloid {
3840            cancels: fast_cloid_requests,
3841            fast: Some(true),
3842        };
3843        submit_cancel_action(
3844            label,
3845            action,
3846            &fast_cloid_entries,
3847            ws_client,
3848            http_client,
3849            emitter,
3850            clock,
3851        )
3852        .await;
3853    }
3854
3855    if !cloid_requests.is_empty() {
3856        let action = HyperliquidExchangeAction::CancelByCloid {
3857            cancels: cloid_requests,
3858            fast: None,
3859        };
3860        submit_cancel_action(
3861            label,
3862            action,
3863            &cloid_entries,
3864            ws_client,
3865            http_client,
3866            emitter,
3867            clock,
3868        )
3869        .await;
3870    }
3871
3872    let (fast_oid_requests, fast_oid_entries, oid_requests, oid_entries) =
3873        split_fast_cancel_requests(oid_requests);
3874
3875    if !fast_oid_requests.is_empty() {
3876        let action = HyperliquidExchangeAction::Cancel {
3877            cancels: fast_oid_requests,
3878            fast: Some(true),
3879        };
3880        submit_cancel_action(
3881            label,
3882            action,
3883            &fast_oid_entries,
3884            ws_client,
3885            http_client,
3886            emitter,
3887            clock,
3888        )
3889        .await;
3890    }
3891
3892    if !oid_requests.is_empty() {
3893        let action = HyperliquidExchangeAction::Cancel {
3894            cancels: oid_requests,
3895            fast: None,
3896        };
3897        submit_cancel_action(
3898            label,
3899            action,
3900            &oid_entries,
3901            ws_client,
3902            http_client,
3903            emitter,
3904            clock,
3905        )
3906        .await;
3907    }
3908}
3909
3910fn split_fast_cancel_requests<T>(
3911    requests: Vec<(T, CancelEntry)>,
3912) -> (Vec<T>, Vec<CancelEntry>, Vec<T>, Vec<CancelEntry>) {
3913    let mut fast_requests = Vec::new();
3914    let mut fast_entries = Vec::new();
3915    let mut requests_without_fast = Vec::new();
3916    let mut entries_without_fast = Vec::new();
3917
3918    for (request, entry) in requests {
3919        if entry.fast {
3920            fast_requests.push(request);
3921            fast_entries.push(entry);
3922        } else {
3923            requests_without_fast.push(request);
3924            entries_without_fast.push(entry);
3925        }
3926    }
3927
3928    (
3929        fast_requests,
3930        fast_entries,
3931        requests_without_fast,
3932        entries_without_fast,
3933    )
3934}
3935
3936async fn submit_cancel_action(
3937    label: &str,
3938    action: HyperliquidExchangeAction,
3939    sent_entries: &[CancelEntry],
3940    ws_client: &HyperliquidWebSocketClient,
3941    http_client: &HyperliquidHttpClient,
3942    emitter: &ExecutionEventEmitter,
3943    clock: &'static AtomicTime,
3944) {
3945    match ws_client
3946        .post_action_command(http_client, &action)
3947        .await
3948        .map_err(classify_post_failure)
3949    {
3950        Ok(response) => {
3951            let inner_errors = extract_inner_errors(&response);
3952            let ts = clock.get_time_ns();
3953
3954            if inner_errors.is_empty() {
3955                log::debug!("{label} submitted successfully: {response:?}");
3956            } else if let Some(reason) =
3957                cancel_status_count_mismatch_reason(label, sent_entries.len(), inner_errors.len())
3958            {
3959                log::warn!("{reason}");
3960            } else {
3961                for (entry, error) in sent_entries.iter().zip(inner_errors) {
3962                    if let Some(reason) = error {
3963                        emit_cancel_failure(
3964                            emitter,
3965                            entry,
3966                            CommandFailure::venue_rejected(reason),
3967                            ts,
3968                        );
3969                    }
3970                }
3971            }
3972        }
3973        Err(failure) => {
3974            for entry in sent_entries {
3975                emit_cancel_failure(emitter, entry, failure.clone(), clock.get_time_ns());
3976            }
3977        }
3978    }
3979}
3980
3981/// Registers an order's context in the dispatch state so its subsequent
3982/// WebSocket lifecycle can route through the typed-event path.
3983///
3984/// Quote-quantity orders submit a quote amount (e.g. 100 USD) but the venue
3985/// reports fills in base units. Comparing those two when deciding whether an
3986/// order is fully filled would leave the order stuck "open" forever, so they
3987/// flow through the untracked path and the engine reconciles them from
3988/// status reports instead.
3989fn register_order_context_into(state: &WsDispatchState, order: &OrderAny) {
3990    let context = OrderContext::from(order);
3991    if context.is_quote_quantity {
3992        return;
3993    }
3994
3995    state.register_context(context);
3996    state.mark_submission_pending(context.identity.client_order_id);
3997}
3998
3999fn order_normal_tpsl_submission(
4000    orders: Vec<OrderAny>,
4001    requests: Vec<HyperliquidExchangePlaceOrderRequest>,
4002    grouping: HyperliquidExchangeGrouping,
4003) -> (Vec<OrderAny>, Vec<HyperliquidExchangePlaceOrderRequest>) {
4004    if grouping != HyperliquidExchangeGrouping::NormalTpsl {
4005        return (orders, requests);
4006    }
4007
4008    let mut pairs: Vec<_> = orders.into_iter().zip(requests).collect();
4009    pairs.sort_by_key(|(order, request)| {
4010        if !order.is_reduce_only() {
4011            0
4012        } else if matches!(
4013            &request.kind,
4014            HyperliquidExchangeOrderKind::Trigger { trigger }
4015                if trigger.tpsl == HyperliquidExchangeTpSl::Sl
4016        ) {
4017            2
4018        } else {
4019            1
4020        }
4021    });
4022
4023    pairs.into_iter().unzip()
4024}
4025
4026/// Validates that an order is acceptable for submission to Hyperliquid.
4027///
4028/// Checks symbol format, order type support, and HIP-4-specific restrictions
4029/// (no reduce-only, no trigger order types on outcome side tokens).
4030///
4031/// # Errors
4032///
4033/// Returns an error if validation fails. The error describes the first failure encountered.
4034pub fn validate_order_for_hyperliquid(order: &OrderAny) -> anyhow::Result<()> {
4035    let instrument_id = order.instrument_id();
4036    let symbol = instrument_id.symbol.as_str();
4037    let product_type = HyperliquidProductType::from_symbol(symbol).map_err(|_| {
4038        anyhow::anyhow!(
4039            "Unsupported instrument symbol format for Hyperliquid: {symbol} \
4040             (expected -PERP, -SPOT, or HIP-4 outcome `{{N}}-{{YES|NO}}-OUTCOME`)"
4041        )
4042    })?;
4043
4044    match order.order_type() {
4045        OrderType::Market
4046        | OrderType::Limit
4047        | OrderType::StopMarket
4048        | OrderType::StopLimit
4049        | OrderType::MarketIfTouched
4050        | OrderType::LimitIfTouched => {}
4051        _ => anyhow::bail!(
4052            "Unsupported order type for Hyperliquid: {:?}",
4053            order.order_type()
4054        ),
4055    }
4056
4057    // HIP-4 outcomes are fully-collateralized side tokens with no margin,
4058    // funding, or trigger machinery. Reject features that don't apply.
4059    if product_type == HyperliquidProductType::Outcome {
4060        if order.is_reduce_only() {
4061            anyhow::bail!("Reduce-only is not supported for Hyperliquid HIP-4 outcomes: {symbol}");
4062        }
4063
4064        if !matches!(order.order_type(), OrderType::Market | OrderType::Limit) {
4065            anyhow::bail!(
4066                "Trigger order types are not supported for Hyperliquid HIP-4 outcomes: \
4067                 {symbol} (received {:?})",
4068                order.order_type()
4069            );
4070        }
4071    }
4072
4073    if matches!(
4074        order.order_type(),
4075        OrderType::StopMarket
4076            | OrderType::StopLimit
4077            | OrderType::MarketIfTouched
4078            | OrderType::LimitIfTouched
4079    ) && order.trigger_price().is_none()
4080    {
4081        anyhow::bail!(
4082            "Conditional orders require a trigger price for Hyperliquid: {:?}",
4083            order.order_type()
4084        );
4085    }
4086
4087    if matches!(
4088        order.order_type(),
4089        OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
4090    ) && order.price().is_none()
4091    {
4092        anyhow::bail!(
4093            "Limit orders require a limit price for Hyperliquid: {:?}",
4094            order.order_type()
4095        );
4096    }
4097
4098    Ok(())
4099}
4100
4101fn can_fast_cancel_order(order_type: Option<OrderType>) -> bool {
4102    matches!(order_type, Some(OrderType::Market | OrderType::Limit))
4103}
4104
4105fn cancel_status_count_mismatch_reason(
4106    label: &str,
4107    expected_count: usize,
4108    actual_count: usize,
4109) -> Option<String> {
4110    (actual_count != 0 && actual_count != expected_count).then(|| {
4111        format!(
4112            "{label} response status count mismatch: expected {expected_count}, received {actual_count}"
4113        )
4114    })
4115}
4116
4117#[expect(clippy::too_many_arguments)]
4118async fn post_order_batch(
4119    label: &str,
4120    orders: Vec<OrderAny>,
4121    requests: Vec<HyperliquidExchangePlaceOrderRequest>,
4122    grouping: HyperliquidExchangeGrouping,
4123    builder: Option<crate::http::models::HyperliquidExchangeBuilderFee>,
4124    emitter: &ExecutionEventEmitter,
4125    ws_client: &HyperliquidWebSocketClient,
4126    http_client: &HyperliquidHttpClient,
4127    dispatch_state: Arc<WsDispatchState>,
4128    staged_brackets: Arc<Mutex<StagedBracketState>>,
4129    clock: &'static AtomicTime,
4130    task_spawner: TaskSpawner,
4131) {
4132    let cloid_hexes: Vec<Ustr> = requests
4133        .iter()
4134        .map(|request| {
4135            Ustr::from(
4136                &request
4137                    .cloid
4138                    .expect("order conversion must set a CLOID")
4139                    .to_hex(),
4140            )
4141        })
4142        .collect();
4143    let action = HyperliquidExchangeAction::Order {
4144        orders: requests,
4145        grouping,
4146        builder,
4147    };
4148    let rejection_route = PostRejectionRoute::with_staged_brackets(
4149        emitter,
4150        ws_client,
4151        http_client,
4152        dispatch_state,
4153        staged_brackets,
4154        task_spawner,
4155    );
4156
4157    match ws_client
4158        .post_action_command(http_client, &action)
4159        .await
4160        .map_err(classify_post_failure)
4161    {
4162        Ok(response) => {
4163            let inner_errors = extract_inner_errors(&response);
4164            let ts = clock.get_time_ns();
4165
4166            if inner_errors.len() == orders.len() {
4167                for ((order, cloid_hex), error) in orders
4168                    .iter()
4169                    .zip(cloid_hexes.iter())
4170                    .zip(inner_errors.iter())
4171                {
4172                    if let Some(error_msg) = error {
4173                        log::debug!(
4174                            "Order {} rejected by exchange: {error_msg}",
4175                            order.client_order_id(),
4176                        );
4177                        rejection_route.emit_once(order, error_msg, ts, cloid_hex);
4178                    }
4179                }
4180            } else if orders.len() > 1
4181                && inner_errors.len() == 1
4182                && let Some(error_msg) = inner_errors[0].as_ref()
4183            {
4184                log::debug!(
4185                    "{label} rejected by deterministic whole-batch validation: {error_msg}",
4186                );
4187
4188                for (order, cloid_hex) in orders.iter().zip(cloid_hexes.iter()) {
4189                    rejection_route.emit_once(order, error_msg, ts, cloid_hex);
4190                }
4191            } else if !inner_errors.is_empty() {
4192                log::warn!(
4193                    "{label} returned {} statuses for {} orders; preserving unresolved identities \
4194                     for WebSocket or startup reconciliation",
4195                    inner_errors.len(),
4196                    orders.len(),
4197                );
4198            } else {
4199                log::debug!("{label} submitted successfully: {response:?}");
4200            }
4201        }
4202        Err(CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason)) => {
4203            let ts = clock.get_time_ns();
4204            for (order, cloid_hex) in orders.iter().zip(&cloid_hexes) {
4205                rejection_route.emit_once(order, &reason, ts, cloid_hex);
4206            }
4207        }
4208        Err(CommandFailure::Ambiguous(reason)) => {
4209            log::warn!("{label} outcome unknown: {reason}; awaiting reconciliation");
4210        }
4211    }
4212
4213    let ts = clock.get_time_ns();
4214    for (order, cloid_hex) in orders.iter().zip(cloid_hexes.iter()) {
4215        rejection_route.resolve_without_post_rejection(order, ts, cloid_hex);
4216    }
4217}
4218
4219#[expect(clippy::too_many_arguments)]
4220fn spawn_staged_children(
4221    children: Vec<StagedBracketChild>,
4222    emitter: &ExecutionEventEmitter,
4223    ws_client: &HyperliquidWebSocketClient,
4224    http_client: &HyperliquidHttpClient,
4225    dispatch_state: Arc<WsDispatchState>,
4226    staged_brackets: Arc<Mutex<StagedBracketState>>,
4227    builder: Option<crate::http::models::HyperliquidExchangeBuilderFee>,
4228    clock: &'static AtomicTime,
4229    task_spawner: &TaskSpawner,
4230) {
4231    let (orders, requests): (Vec<_>, Vec<_>) = children
4232        .into_iter()
4233        .map(|child| (child.order, child.request))
4234        .unzip();
4235
4236    let denied_orders = orders.clone();
4237    let task_emitter = emitter.clone();
4238    let ws_client = ws_client.clone();
4239    let http_client = http_client.clone();
4240    let child_spawner = task_spawner.clone();
4241
4242    if let Err(e) = task_spawner.spawn(async move {
4243        for (order, request) in orders.iter().zip(requests.iter()) {
4244            let cloid = request.cloid.expect("order conversion must set a CLOID");
4245            http_client.cache_client_order_id_cloid(order.client_order_id(), cloid);
4246            ws_client.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), order.client_order_id());
4247            register_order_context_into(&dispatch_state, order);
4248            task_emitter.emit_order_submitted(order);
4249        }
4250
4251        post_order_batch(
4252            "Bracket child batch",
4253            orders,
4254            requests,
4255            HyperliquidExchangeGrouping::Na,
4256            builder,
4257            &task_emitter,
4258            &ws_client,
4259            &http_client,
4260            dispatch_state,
4261            staged_brackets,
4262            clock,
4263            child_spawner,
4264        )
4265        .await;
4266    }) {
4267        log::warn!("Skipping Hyperliquid bracket child batch after shutdown began: {e}");
4268
4269        for order in &denied_orders {
4270            emitter.emit_order_denied(
4271                order,
4272                &OrderDeniedReason::SubmitFailed {
4273                    detail: TASK_SHUTDOWN_DENIAL_REASON.to_string(),
4274                }
4275                .to_string(),
4276            );
4277        }
4278    }
4279}
4280
4281fn spawn_active_sibling_cancel(
4282    sibling: StagedBracketChild,
4283    emitter: &ExecutionEventEmitter,
4284    ws_client: &HyperliquidWebSocketClient,
4285    http_client: &HyperliquidHttpClient,
4286    dispatch_state: &WsDispatchState,
4287    task_spawner: &TaskSpawner,
4288) {
4289    let client_order_id = sibling.order.client_order_id();
4290    let Some(cloid) = sibling.request.cloid else {
4291        log::error!("Cannot cancel OUO sibling {client_order_id}: missing CLOID");
4292        return;
4293    };
4294    let venue_order_id = dispatch_state.cached_venue_order_id(&client_order_id);
4295    let action = HyperliquidExchangeAction::CancelByCloid {
4296        cancels: vec![HyperliquidExchangeCancelByCloidRequest {
4297            asset: sibling.request.asset,
4298            cloid,
4299        }],
4300        fast: can_fast_cancel_order(Some(sibling.order.order_type())).then_some(true),
4301    };
4302    let emitter = emitter.clone();
4303    let ws_client = ws_client.clone();
4304    let http_client = http_client.clone();
4305
4306    if let Err(e) = task_spawner.spawn(async move {
4307        match ws_client.post_action_exec(&http_client, &action).await {
4308            Ok(response) if response.is_ok() => {
4309                if let Some(error) = extract_inner_error(&response) {
4310                    emitter.emit_order_cancel_rejected(
4311                        &sibling.order,
4312                        venue_order_id,
4313                        &error,
4314                        get_atomic_clock_realtime().get_time_ns(),
4315                    );
4316                }
4317            }
4318            Ok(response) => {
4319                log::warn!(
4320                    "OUO sibling cancel for {client_order_id} returned an ambiguous response; \
4321                     awaiting WebSocket or startup reconciliation: {}",
4322                    extract_error_message(&response),
4323                );
4324            }
4325            Err(e) => {
4326                log::warn!(
4327                    "OUO sibling cancel for {client_order_id} failed; awaiting WebSocket or \
4328                     startup reconciliation: {e}",
4329                );
4330            }
4331        }
4332    }) {
4333        log::warn!("Skipping Hyperliquid sibling cancellation after shutdown began: {e}");
4334    }
4335}
4336
4337fn spawn_active_sibling_resize(
4338    sibling: StagedBracketChild,
4339    target_total_qty: Quantity,
4340    emitter: &ExecutionEventEmitter,
4341    ws_client: &HyperliquidWebSocketClient,
4342    http_client: &HyperliquidHttpClient,
4343    dispatch_state: &Arc<WsDispatchState>,
4344    task_spawner: &TaskSpawner,
4345) {
4346    let client_order_id = sibling.order.client_order_id();
4347    let Some(old_venue_order_id) = dispatch_state.cached_venue_order_id(&client_order_id) else {
4348        log::warn!(
4349            "Cannot resize OUO sibling {client_order_id}: venue order ID not known; awaiting \
4350             WebSocket or startup reconciliation",
4351        );
4352        return;
4353    };
4354    let filled_qty = dispatch_state
4355        .previous_filled_qty(&client_order_id)
4356        .unwrap_or_else(|| Quantity::zero(target_total_qty.precision));
4357    let Some(order) = build_ouo_resize_request(&sibling, target_total_qty, filled_qty) else {
4358        spawn_active_sibling_cancel(
4359            sibling,
4360            emitter,
4361            ws_client,
4362            http_client,
4363            dispatch_state,
4364            task_spawner,
4365        );
4366        return;
4367    };
4368    let Some(cloid) = order.cloid else {
4369        log::error!("Cannot resize OUO sibling {client_order_id}: missing CLOID");
4370        return;
4371    };
4372
4373    let generation =
4374        dispatch_state.mark_pending_modify(client_order_id, old_venue_order_id, target_total_qty);
4375    dispatch_state.stash_modify_request(client_order_id, order.clone());
4376    let action = HyperliquidExchangeAction::Modify {
4377        modify: HyperliquidExchangeModifyOrderRequest {
4378            oid: HyperliquidExchangeModifyTarget::Cloid(cloid),
4379            order,
4380        },
4381    };
4382    let ws_client = ws_client.clone();
4383    let http_client = http_client.clone();
4384    let dispatch_state = dispatch_state.clone();
4385
4386    if let Err(e) = task_spawner.spawn(async move {
4387        match ws_client.post_action_exec(&http_client, &action).await {
4388            Ok(response) if response.is_ok() && extract_inner_error(&response).is_none() => {
4389                log::debug!("OUO sibling resize submitted for {client_order_id}");
4390            }
4391            Ok(response) => {
4392                dispatch_state.clear_modify_generation(&client_order_id, generation);
4393                log::warn!(
4394                    "OUO sibling resize for {client_order_id} rejected: {}",
4395                    extract_inner_error(&response)
4396                        .unwrap_or_else(|| extract_error_message(&response)),
4397                );
4398            }
4399            Err(e) if e.is_transport_error() => {
4400                log::warn!(
4401                    "OUO sibling resize transport failure for {client_order_id}: {e}; awaiting \
4402                     WebSocket or startup reconciliation",
4403                );
4404            }
4405            Err(e) => {
4406                dispatch_state.clear_modify_generation(&client_order_id, generation);
4407                log::warn!("OUO sibling resize failed for {client_order_id}: {e}");
4408            }
4409        }
4410    }) {
4411        log::warn!("Skipping Hyperliquid sibling resize after shutdown began: {e}");
4412    }
4413}
4414
4415fn build_ouo_resize_request(
4416    sibling: &StagedBracketChild,
4417    target_total_qty: Quantity,
4418    filled_qty: Quantity,
4419) -> Option<HyperliquidExchangePlaceOrderRequest> {
4420    if target_total_qty <= filled_qty {
4421        return None;
4422    }
4423
4424    let mut request = sibling.request.clone();
4425    request.size = (target_total_qty - filled_qty).as_decimal().normalize();
4426    Some(request)
4427}
4428
4429struct PostRejectionRoute {
4430    emitter: ExecutionEventEmitter,
4431    ws_client: HyperliquidWebSocketClient,
4432    http_client: HyperliquidHttpClient,
4433    dispatch_state: Arc<WsDispatchState>,
4434    staged_brackets: Arc<Mutex<StagedBracketState>>,
4435    task_spawner: TaskSpawner,
4436}
4437
4438impl PostRejectionRoute {
4439    fn new(
4440        emitter: &ExecutionEventEmitter,
4441        ws_client: &HyperliquidWebSocketClient,
4442        http_client: &HyperliquidHttpClient,
4443        dispatch_state: Arc<WsDispatchState>,
4444        task_spawner: TaskSpawner,
4445    ) -> Self {
4446        Self {
4447            emitter: emitter.clone(),
4448            ws_client: ws_client.clone(),
4449            http_client: http_client.clone(),
4450            dispatch_state,
4451            staged_brackets: Arc::new(Mutex::new(StagedBracketState::default())),
4452            task_spawner,
4453        }
4454    }
4455
4456    fn with_staged_brackets(
4457        emitter: &ExecutionEventEmitter,
4458        ws_client: &HyperliquidWebSocketClient,
4459        http_client: &HyperliquidHttpClient,
4460        dispatch_state: Arc<WsDispatchState>,
4461        staged_brackets: Arc<Mutex<StagedBracketState>>,
4462        task_spawner: TaskSpawner,
4463    ) -> Self {
4464        Self {
4465            emitter: emitter.clone(),
4466            ws_client: ws_client.clone(),
4467            http_client: http_client.clone(),
4468            dispatch_state,
4469            staged_brackets,
4470            task_spawner,
4471        }
4472    }
4473
4474    fn emit_once(
4475        &self,
4476        order: &OrderAny,
4477        reason: &str,
4478        ts_event: UnixNanos,
4479        cloid_hex: &Ustr,
4480    ) -> bool {
4481        let client_order_id = order.client_order_id();
4482        let rejection = self.dispatch_state.resolve_submission(&client_order_id);
4483        let has_fills = self
4484            .dispatch_state
4485            .previous_filled_qty(&client_order_id)
4486            .is_some_and(|qty| !qty.is_zero())
4487            || !order.filled_qty().is_zero();
4488
4489        if let Some(report) = rejection.filter(|report| !report.filled_qty.is_zero())
4490            && self
4491                .dispatch_state
4492                .lookup_context(&client_order_id)
4493                .is_some()
4494        {
4495            if dispatch_order_event(&report, &self.dispatch_state, &self.emitter, ts_event)
4496                == DispatchOutcome::Skip
4497            {
4498                return false;
4499            }
4500        } else if !self.dispatch_state.insert_filled(client_order_id) {
4501            log::debug!(
4502                "Skipping duplicate post rejection for terminal order {client_order_id}: {reason}",
4503            );
4504            self.ws_client.remove_cloid_mapping(cloid_hex);
4505            self.http_client
4506                .remove_client_order_id_cloid(&client_order_id);
4507            return false;
4508        } else if has_fills {
4509            self.emitter.emit_order_canceled(
4510                order,
4511                self.dispatch_state.cached_venue_order_id(&client_order_id),
4512                ts_event,
4513            );
4514        } else {
4515            if reason.contains(HYPERLIQUID_BUILDER_FEE_NOT_APPROVED) {
4516                log::warn!(
4517                    "Builder fee not approved: complete the one-time 0% builder approval \
4518                     (signed by the master wallet). See: {HYPERLIQUID_BUILDER_APPROVAL_DOCS_URL}",
4519                );
4520            }
4521
4522            let normalized_reason = reason.to_lowercase();
4523            let due_post_only = order.is_post_only()
4524                && (normalized_reason.contains(&HYPERLIQUID_POST_ONLY_WOULD_MATCH.to_lowercase())
4525                    || normalized_reason
4526                        .contains("post-only order would have immediately matched"));
4527            self.emitter
4528                .emit_order_rejected(order, reason, ts_event, due_post_only);
4529            let staged_children = self
4530                .staged_brackets
4531                .lock()
4532                .cancel_for_parent(&client_order_id);
4533
4534            for child in staged_children {
4535                self.emitter.emit_order_canceled(&child, None, ts_event);
4536            }
4537        }
4538
4539        let active_sibling = self
4540            .staged_brackets
4541            .lock()
4542            .take_active_sibling(&client_order_id);
4543
4544        if let Some(sibling) = active_sibling {
4545            spawn_active_sibling_cancel(
4546                sibling,
4547                &self.emitter,
4548                &self.ws_client,
4549                &self.http_client,
4550                &self.dispatch_state,
4551                &self.task_spawner,
4552            );
4553        }
4554
4555        self.dispatch_state.insert_terminal_cloid(*cloid_hex);
4556        self.dispatch_state.cleanup_terminal(&client_order_id);
4557        self.ws_client.remove_cloid_mapping(cloid_hex);
4558        self.http_client
4559            .remove_client_order_id_cloid(&client_order_id);
4560
4561        true
4562    }
4563
4564    fn resolve_without_post_rejection(
4565        &self,
4566        order: &OrderAny,
4567        ts_init: UnixNanos,
4568        cloid_hex: &Ustr,
4569    ) {
4570        let client_order_id = order.client_order_id();
4571        let Some(report) = self.dispatch_state.resolve_submission(&client_order_id) else {
4572            return;
4573        };
4574        let is_terminal = report.order_status.is_closed();
4575        let outcome = dispatch_order_event(&report, &self.dispatch_state, &self.emitter, ts_init);
4576
4577        if outcome == DispatchOutcome::External {
4578            self.emitter.send_order_status_report(report);
4579        }
4580
4581        if is_terminal && outcome != DispatchOutcome::Skip {
4582            if self.dispatch_state.filled_orders.contains(&client_order_id) {
4583                self.dispatch_state.insert_terminal_cloid(*cloid_hex);
4584            }
4585
4586            self.ws_client.remove_cloid_mapping(cloid_hex);
4587            self.http_client
4588                .remove_client_order_id_cloid(&client_order_id);
4589        }
4590    }
4591}
4592
4593/// Routes a single execution report through the two-tier dispatch.
4594///
4595/// For tracked orders this emits typed `OrderEventAny` events via the
4596/// dispatch module; external / untracked orders fall back to the raw report
4597/// so the engine can reconcile. Cloid-mapping cleanup is handled here so
4598/// long-running sessions do not leak mapping entries. Returns whether to suppress
4599/// bracket terminal handling, and any corrective reduce the dispatch queued.
4600fn handle_execution_report(
4601    report: ExecutionReport,
4602    dispatch_state: &WsDispatchState,
4603    emitter: &ExecutionEventEmitter,
4604    ws_client: &HyperliquidWebSocketClient,
4605    http_client: &HyperliquidHttpClient,
4606    pending_filled_cloids: &mut FifoCache<ClientOrderId, 10_000>,
4607    ts_init: UnixNanos,
4608) -> (bool, Option<(ClientOrderId, CorrectiveReduce)>) {
4609    match report {
4610        ExecutionReport::Order(order_report) => {
4611            let is_filled_marker = matches!(order_report.order_status, OrderStatus::Filled);
4612            let is_terminal = order_report.order_status.is_closed();
4613            let client_order_id = order_report.client_order_id;
4614
4615            let skip_terminal = match (order_report.order_status, order_report.filled_qty.is_zero())
4616            {
4617                (OrderStatus::Canceled, _) | (OrderStatus::Rejected, false) => true,
4618                (OrderStatus::Rejected, true) => {
4619                    !client_order_id.is_some_and(|id| dispatch_state.submission_pending(&id))
4620                }
4621                _ => false,
4622            };
4623
4624            let outcome = dispatch_order_event(&order_report, dispatch_state, emitter, ts_init);
4625
4626            if outcome == DispatchOutcome::External {
4627                emitter.send_order_status_report(order_report);
4628            }
4629
4630            // Cloid cleanup:
4631            //
4632            // * `Skip` (stale cancel leg of a cancel-replace, cancel-before-accept
4633            //   race, or replay after terminal): leave the mapping intact. The
4634            //   still-open replacement order depends on it for subsequent events,
4635            //   and a genuinely terminal replay had its mapping evicted earlier.
4636            // * `Tracked` + status-only FILLED marker: defer the eviction until
4637            //   the matching `FillReport` lands so the partial fill preceding it
4638            //   keeps its client-order-id link.
4639            // * `Tracked` non-marker terminal and `External` terminal: evict now
4640            //   so long-running sessions do not leak cloid mappings.
4641            if let Some(id) = client_order_id
4642                && is_terminal
4643            {
4644                match outcome {
4645                    DispatchOutcome::Skip => {}
4646                    DispatchOutcome::Tracked if is_filled_marker => {
4647                        pending_filled_cloids.add(id);
4648                    }
4649                    DispatchOutcome::Tracked | DispatchOutcome::External => {
4650                        remove_cloid_mapping_for_client_order_id(
4651                            ws_client,
4652                            http_client,
4653                            dispatch_state,
4654                            &id,
4655                        );
4656                    }
4657                }
4658            }
4659
4660            // Hand any queued corrective reduce to the loop to post; this
4661            // cache-free task cannot rebuild the order spec itself.
4662            let corrective = client_order_id.and_then(|id| {
4663                dispatch_state
4664                    .take_corrective(&id)
4665                    .map(|corrective| (id, corrective))
4666            });
4667
4668            (
4669                skip_terminal && outcome == DispatchOutcome::Skip,
4670                corrective,
4671            )
4672        }
4673        ExecutionReport::Fill(fill_report) => {
4674            let client_order_id = fill_report.client_order_id;
4675
4676            let outcome = dispatch_order_fill(&fill_report, dispatch_state, emitter, ts_init);
4677
4678            if outcome == DispatchOutcome::External {
4679                emitter.send_fill_report(fill_report);
4680            }
4681
4682            // Skip cleanup while a cancel-replace fill is buffered; the
4683            // replacement ACCEPTED still needs to resolve the cloid (GH-3972).
4684            if let Some(id) = client_order_id
4685                && pending_filled_cloids.contains(&id)
4686                && dispatch_state.buffered_fill_count(&id) == 0
4687            {
4688                pending_filled_cloids.remove(&id);
4689                remove_cloid_mapping_for_client_order_id(
4690                    ws_client,
4691                    http_client,
4692                    dispatch_state,
4693                    &id,
4694                );
4695            }
4696
4697            let corrective = client_order_id.and_then(|id| {
4698                dispatch_state
4699                    .take_corrective(&id)
4700                    .map(|corrective| (id, corrective))
4701            });
4702
4703            (false, corrective)
4704        }
4705    }
4706}
4707
4708/// Posts a corrective reduce queued by the cancel-replace promotion.
4709///
4710/// Runs on the runtime (not the WS receive loop) so the post does not block
4711/// event processing. Keeps the re-armed pending-modify marker only while the
4712/// reduce may still be live (a clean ack, or a transport failure the WS may
4713/// reconcile); clears it otherwise so a stale marker cannot hold a later real
4714/// `CANCELED(oid)` as a cancel-before-accept leg. Clears only the reduce's own
4715/// marker, so a modify of the order still in flight keeps its own.
4716fn spawn_corrective_reduce(
4717    ws_client: &HyperliquidWebSocketClient,
4718    http_client: &HyperliquidHttpClient,
4719    dispatch_state: &Arc<WsDispatchState>,
4720    client_order_id: ClientOrderId,
4721    corrective: CorrectiveReduce,
4722    task_spawner: &TaskSpawner,
4723) {
4724    let ws_client = ws_client.clone();
4725    let http_client = http_client.clone();
4726    let dispatch_state = dispatch_state.clone();
4727    let CorrectiveReduce {
4728        oid,
4729        generation,
4730        request,
4731    } = corrective;
4732
4733    if let Err(e) = task_spawner.spawn(async move {
4734        let action = HyperliquidExchangeAction::Modify {
4735            modify: HyperliquidExchangeModifyOrderRequest {
4736                oid: oid.into(),
4737                order: request,
4738            },
4739        };
4740
4741        let keep_marker = match ws_client.post_action_exec(&http_client, &action).await {
4742            Ok(resp) if resp.is_ok() && extract_inner_error(&resp).is_none() => {
4743                log::debug!("Corrective reduce acknowledged for {client_order_id} on oid {oid}");
4744                true
4745            }
4746            Ok(resp) => {
4747                let reason =
4748                    extract_inner_error(&resp).unwrap_or_else(|| extract_error_message(&resp));
4749                log::warn!(
4750                    "Corrective reduce rejected for {client_order_id} on oid {oid}: {reason}"
4751                );
4752                false
4753            }
4754            Err(e) if e.is_transport_error() => {
4755                log::warn!(
4756                    "Corrective reduce transport failure for {client_order_id} on oid {oid}: \
4757                     {e}; awaiting WS reconciliation",
4758                );
4759                true
4760            }
4761            Err(e) => {
4762                log::warn!("Corrective reduce failed for {client_order_id} on oid {oid}: {e}");
4763                false
4764            }
4765        };
4766
4767        if !keep_marker {
4768            dispatch_state.clear_modify_generation(&client_order_id, generation);
4769        }
4770    }) {
4771        log::warn!("Skipping Hyperliquid corrective reduce after shutdown began: {e}");
4772    }
4773}
4774
4775fn remove_cloid_mapping_for_client_order_id(
4776    ws_client: &HyperliquidWebSocketClient,
4777    http_client: &HyperliquidHttpClient,
4778    dispatch_state: &WsDispatchState,
4779    client_order_id: &ClientOrderId,
4780) {
4781    let terminal = dispatch_state.filled_orders.contains(client_order_id);
4782    let generated_cloid = Cloid::from_client_order_id(*client_order_id);
4783
4784    if let Some(cloid) = http_client.remove_client_order_id_cloid(client_order_id) {
4785        if terminal {
4786            dispatch_state.insert_terminal_cloid(Ustr::from(&cloid.to_hex()));
4787        }
4788
4789        ws_client.remove_cloid_mapping(&Ustr::from(&cloid.to_hex()));
4790        if cloid == generated_cloid {
4791            return;
4792        }
4793    }
4794
4795    if terminal {
4796        dispatch_state.insert_terminal_cloid(Ustr::from(&generated_cloid.to_hex()));
4797    }
4798
4799    ws_client.remove_cloid_mapping(&Ustr::from(&generated_cloid.to_hex()));
4800}
4801
4802use crate::common::parse::determine_order_list_grouping;
4803
4804#[cfg(test)]
4805mod tests {
4806    use std::{cell::RefCell, rc::Rc, sync::Arc};
4807
4808    use alloy::signers::local::PrivateKeySigner;
4809    use nautilus_common::{
4810        cache::Cache,
4811        clock::VirtualClock,
4812        messages::{ExecutionEvent, ExecutionReport as EngineExecutionReport},
4813    };
4814    use nautilus_core::{
4815        UUID4, UnixNanos, string::secret::SecretString, time::get_atomic_clock_realtime,
4816    };
4817    use nautilus_execution::engine::ExecutionEngine;
4818    use nautilus_live::{
4819        ExecutionEventEmitter,
4820        execution::{
4821            context::{OrderContext, OrderIdentity},
4822            failure::CommandFailure,
4823        },
4824        task::TaskGroup,
4825    };
4826    use nautilus_model::{
4827        accounts::{AccountAny, MarginAccount},
4828        enums::{
4829            AccountType, ContingencyType, LiquiditySide, OmsType, OrderSide, OrderStatus,
4830            OrderType, TimeInForce, TriggerType,
4831        },
4832        events::{AccountState, OrderEventAny, OrderPendingCancel, OrderPendingUpdate},
4833        identifiers::{
4834            AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TradeId, TraderId,
4835            VenueOrderId,
4836        },
4837        instruments::{CryptoPerpetual, InstrumentAny},
4838        orders::{Order, OrderAny, limit::LimitOrder, stop_market::StopMarketOrder},
4839        reports::{FillReport, OrderStatusReport},
4840        types::{AccountBalance, Currency, Money, Price, Quantity},
4841    };
4842    use nautilus_network::websocket::TransportBackend;
4843    use rstest::rstest;
4844    use rust_decimal::Decimal;
4845    use ustr::Ustr;
4846    use zeroize::Zeroizing;
4847
4848    use super::{
4849        CancelEntry, ExecutionClient, ExecutionClientCore, ExecutionReport, FifoCache,
4850        HyperliquidExecutionClient, HyperliquidExecutionClientConfig, HyperliquidHttpClient,
4851        HyperliquidWebSocketClient, PostRejectionRoute, StagedBracketChild, StagedBracketState,
4852        WsDispatchState, attach_known_client_order_id, build_ouo_resize_request,
4853        can_fast_cancel_order, classify_post_failure, determine_order_list_grouping,
4854        handle_execution_report, reconnect_reports_from_history, register_order_context_into,
4855        resolve_order_legs, split_fast_cancel_requests, ts_venue, validate_order_for_hyperliquid,
4856        validate_query_order_fills,
4857    };
4858    use crate::{
4859        common::{
4860            consts::{HYPERLIQUID_CLIENT_ID, HYPERLIQUID_VENUE},
4861            enums::HyperliquidEnvironment,
4862            parse::make_fill_trade_id,
4863            testing::load_test_data,
4864        },
4865        http::{
4866            models::{
4867                Cloid, HyperliquidExchangeAction, HyperliquidExchangeCancelOrderRequest,
4868                HyperliquidExchangeGrouping, HyperliquidExchangeLimitParams,
4869                HyperliquidExchangeOrderKind, HyperliquidExchangePlaceOrderRequest,
4870                HyperliquidExchangeTif, HyperliquidFill, HyperliquidOrderStatusEntry, PerpMeta,
4871            },
4872            parse::{create_instrument_from_def, parse_perp_instruments},
4873        },
4874    };
4875
4876    const TEST_INSTRUMENT_ID: &str = "BTC-USD-PERP.HYPERLIQUID";
4877
4878    fn test_emitter() -> (
4879        ExecutionEventEmitter,
4880        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
4881    ) {
4882        let clock = get_atomic_clock_realtime();
4883        let mut emitter = ExecutionEventEmitter::new(
4884            clock,
4885            TraderId::from("TESTER-001"),
4886            AccountId::from("HYPERLIQUID-001"),
4887            AccountType::Margin,
4888            None,
4889        );
4890        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
4891        emitter.set_sender(tx);
4892        (emitter, rx)
4893    }
4894
4895    fn drain_events(
4896        rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
4897    ) -> Vec<ExecutionEvent> {
4898        let mut out = Vec::new();
4899        while let Ok(e) = rx.try_recv() {
4900            out.push(e);
4901        }
4902        out
4903    }
4904
4905    fn make_ws_client() -> HyperliquidWebSocketClient {
4906        // `HyperliquidWebSocketClient::new` does not connect, so this is a
4907        // cheap unit-test shim that still exercises the real `cloid_cache`
4908        // mapping APIs used by `handle_execution_report`.
4909        HyperliquidWebSocketClient::new(
4910            Some("wss://test.invalid".to_string()),
4911            HyperliquidEnvironment::Testnet,
4912            None,
4913            TransportBackend::default(),
4914            None,
4915        )
4916    }
4917
4918    fn make_http_client() -> HyperliquidHttpClient {
4919        HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 1, None).unwrap()
4920    }
4921
4922    fn make_execution_client() -> HyperliquidExecutionClient {
4923        let wallet = PrivateKeySigner::random();
4924        let key = Zeroizing::new(format!("{:#x}", wallet.to_bytes()));
4925        let core = ExecutionClientCore::new(
4926            TraderId::from("TESTER-001"),
4927            *HYPERLIQUID_CLIENT_ID,
4928            *HYPERLIQUID_VENUE,
4929            OmsType::Netting,
4930            AccountId::from("HYPERLIQUID-001"),
4931            AccountType::Margin,
4932            None,
4933            Rc::new(RefCell::new(Cache::default())),
4934        );
4935        let config = HyperliquidExecutionClientConfig::builder()
4936            .private_key(SecretString::from(key.to_string()))
4937            .environment(HyperliquidEnvironment::Testnet)
4938            .build();
4939
4940        HyperliquidExecutionClient::new(core, config).unwrap()
4941    }
4942
4943    // Matches the order built by `limit_order_with_flags` so registration
4944    // tests can assert the stored context by equality.
4945    fn test_context(client_order_id: ClientOrderId) -> OrderContext {
4946        OrderContext {
4947            identity: OrderIdentity {
4948                client_order_id,
4949                strategy_id: StrategyId::from("S-001"),
4950                instrument_id: InstrumentId::from(TEST_INSTRUMENT_ID),
4951                order_side: OrderSide::Buy,
4952                order_type: OrderType::Limit,
4953            },
4954            quantity: Quantity::from("0.0001"),
4955            price: Some(Price::from("56730.0")),
4956            trigger_price: None,
4957            trigger_type: None,
4958            time_in_force: TimeInForce::Gtc,
4959            is_post_only: false,
4960            is_reduce_only: false,
4961            is_quote_quantity: false,
4962        }
4963    }
4964
4965    #[rstest]
4966    fn test_on_instrument_registers_asset_index_for_a_new_market() {
4967        // A market listed after the client bootstrapped arrives through the
4968        // message bus, and must become submittable without a restart.
4969        let mut client = make_execution_client();
4970        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
4971        let defs = parse_perp_instruments(&meta, 0).unwrap();
4972        let def = &defs[1];
4973        let instrument = create_instrument_from_def(def, UnixNanos::default()).unwrap();
4974
4975        assert_eq!(
4976            client.http_client.get_asset_index(def.symbol.as_str()),
4977            None
4978        );
4979
4980        client.on_instrument(instrument);
4981
4982        assert_eq!(
4983            client.http_client.get_asset_index(def.symbol.as_str()),
4984            Some(def.asset_index),
4985        );
4986    }
4987
4988    #[rstest]
4989    fn test_on_instrument_replaces_asset_index_and_precision() {
4990        let mut client = make_execution_client();
4991        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
4992        let mut defs = parse_perp_instruments(&meta, 0).unwrap();
4993        let def = &mut defs[0];
4994        let original = create_instrument_from_def(def, UnixNanos::from(1)).unwrap();
4995        client.on_instrument(original.clone());
4996        let cache = client.ws_client.instruments_cache();
4997        let previous = cache.load();
4998
4999        def.asset_index = 73;
5000        def.price_decimals = 3;
5001        def.tick_size = Decimal::new(1, 3);
5002        def.size_decimals = 4;
5003        def.lot_size = Decimal::new(1, 4);
5004        let refreshed = create_instrument_from_def(def, UnixNanos::from(2)).unwrap();
5005        client.on_instrument(refreshed.clone());
5006
5007        assert_eq!(
5008            client.http_client.get_asset_index(def.symbol.as_str()),
5009            Some(73)
5010        );
5011        assert_eq!(
5012            client.http_client.get_price_precision(def.symbol.as_str()),
5013            Some(3)
5014        );
5015        assert_eq!(
5016            serde_json::to_value(previous.get(&def.raw_symbol).unwrap()).unwrap(),
5017            serde_json::to_value(original).unwrap(),
5018        );
5019        assert_eq!(
5020            serde_json::to_value(cache.load().get(&def.raw_symbol).unwrap()).unwrap(),
5021            serde_json::to_value(refreshed).unwrap(),
5022        );
5023    }
5024
5025    #[rstest]
5026    fn oid_query_attaches_the_known_client_order_id() {
5027        let mut report = make_status_report(None, "55030848197", OrderStatus::Accepted);
5028        let client_order_id = ClientOrderId::new("O-ATTACH-001");
5029
5030        attach_known_client_order_id(&mut report, client_order_id);
5031
5032        assert_eq!(report.client_order_id, Some(client_order_id));
5033        assert_eq!(report.venue_order_id, VenueOrderId::new("55030848197"));
5034        assert_eq!(report.order_status, OrderStatus::Accepted);
5035    }
5036
5037    #[rstest]
5038    fn oid_query_keeps_the_api_reported_client_order_id() {
5039        let mut report = make_status_report(
5040            Some("0x72a3c2f2de33c2c74640ad7f8d11ed74"),
5041            "222222",
5042            OrderStatus::Canceled,
5043        );
5044        let client_order_id = ClientOrderId::new("O-20240101-000002");
5045
5046        attach_known_client_order_id(&mut report, client_order_id);
5047
5048        assert_eq!(
5049            report.client_order_id,
5050            Some(ClientOrderId::new("0x72a3c2f2de33c2c74640ad7f8d11ed74"))
5051        );
5052        assert_eq!(report.venue_order_id, VenueOrderId::new("222222"));
5053        assert_eq!(report.order_status, OrderStatus::Canceled);
5054    }
5055
5056    fn make_status_report(
5057        client_order_id: Option<&str>,
5058        venue_order_id: &str,
5059        status: OrderStatus,
5060    ) -> OrderStatusReport {
5061        make_status_report_with_quantity(
5062            client_order_id,
5063            venue_order_id,
5064            status,
5065            Quantity::from("0.0001"),
5066        )
5067    }
5068
5069    #[rstest]
5070    #[case::account(true)]
5071    #[case::client(false)]
5072    fn test_query_order_fill_validation_rejects_linkage(#[case] wrong_account: bool) {
5073        let context = test_context(ClientOrderId::from("O-QUERY"));
5074        let mut report = make_status_report(Some("O-QUERY"), "100", OrderStatus::Filled);
5075        report.filled_qty = context.quantity;
5076        let mut fill = make_fill_report(Some("O-QUERY"), "100", "TRADE-QUERY");
5077        if wrong_account {
5078            fill.account_id = AccountId::from("HYPERLIQUID-OTHER");
5079        } else {
5080            fill.client_order_id = Some(ClientOrderId::from("OTHER-ORDER"));
5081        }
5082
5083        assert_eq!(
5084            validate_query_order_fills(vec![fill], &report, &context)
5085                .unwrap_err()
5086                .to_string(),
5087            "fill identity or quantity mismatch for venue order 100"
5088        );
5089    }
5090
5091    fn make_status_report_with_quantity(
5092        client_order_id: Option<&str>,
5093        venue_order_id: &str,
5094        status: OrderStatus,
5095        quantity: Quantity,
5096    ) -> OrderStatusReport {
5097        OrderStatusReport::new(
5098            AccountId::from("HYPERLIQUID-001"),
5099            InstrumentId::from(TEST_INSTRUMENT_ID),
5100            client_order_id.map(ClientOrderId::new),
5101            VenueOrderId::new(venue_order_id),
5102            OrderSide::Buy.into(),
5103            OrderType::Limit,
5104            TimeInForce::Gtc,
5105            status,
5106            quantity,
5107            Quantity::from("0"),
5108            UnixNanos::default(),
5109            UnixNanos::default(),
5110            UnixNanos::default(),
5111            Some(UUID4::new()),
5112        )
5113        .with_price(Price::from("56730.0"))
5114    }
5115
5116    fn make_fill_report(
5117        client_order_id: Option<&str>,
5118        venue_order_id: &str,
5119        trade_id: &str,
5120    ) -> FillReport {
5121        make_fill_report_with_qty(
5122            client_order_id,
5123            venue_order_id,
5124            trade_id,
5125            Quantity::from("0.0001"),
5126        )
5127    }
5128
5129    fn make_fill_report_with_qty(
5130        client_order_id: Option<&str>,
5131        venue_order_id: &str,
5132        trade_id: &str,
5133        last_qty: Quantity,
5134    ) -> FillReport {
5135        FillReport::new(
5136            AccountId::from("HYPERLIQUID-001"),
5137            InstrumentId::from(TEST_INSTRUMENT_ID),
5138            VenueOrderId::new(venue_order_id),
5139            TradeId::new(trade_id),
5140            OrderSide::Buy,
5141            last_qty,
5142            Price::from("56730.0"),
5143            Money::new(0.0, Currency::USD()),
5144            LiquiditySide::Taker,
5145            client_order_id.map(ClientOrderId::new),
5146            None,
5147            UnixNanos::default(),
5148            UnixNanos::default(),
5149            Some(UUID4::new()),
5150        )
5151    }
5152
5153    fn cloid_for(id: &str) -> Ustr {
5154        let cloid = Cloid::from_client_order_id(ClientOrderId::from(id));
5155        Ustr::from(&cloid.to_hex())
5156    }
5157
5158    fn limit_order(
5159        id: &str,
5160        reduce_only: bool,
5161        contingency: Option<ContingencyType>,
5162        linked_ids: Option<Vec<&str>>,
5163        parent_id: Option<&str>,
5164    ) -> OrderAny {
5165        OrderAny::Limit(LimitOrder::new(
5166            TraderId::from("TESTER-001"),
5167            StrategyId::from("S-001"),
5168            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
5169            ClientOrderId::from(id),
5170            OrderSide::Buy,
5171            Quantity::from(1),
5172            Price::from("3000.00"),
5173            TimeInForce::Gtc,
5174            None,  // expire_time
5175            false, // post_only
5176            reduce_only,
5177            false, // quote_quantity
5178            None,  // display_qty
5179            None,  // emulation_trigger
5180            None,  // trigger_instrument_id
5181            contingency,
5182            None, // order_list_id
5183            linked_ids.map(|ids| ids.into_iter().map(ClientOrderId::from).collect()),
5184            parent_id.map(ClientOrderId::from),
5185            None, // exec_algorithm_id
5186            None, // exec_algorithm_params
5187            None, // exec_spawn_id
5188            None, // tags
5189            Default::default(),
5190            Default::default(),
5191        ))
5192    }
5193
5194    fn stop_order(
5195        id: &str,
5196        reduce_only: bool,
5197        contingency: Option<ContingencyType>,
5198        linked_ids: Option<Vec<&str>>,
5199        parent_id: Option<&str>,
5200    ) -> OrderAny {
5201        OrderAny::StopMarket(StopMarketOrder::new(
5202            TraderId::from("TESTER-001"),
5203            StrategyId::from("S-001"),
5204            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
5205            ClientOrderId::from(id),
5206            OrderSide::Sell,
5207            Quantity::from(1),
5208            Price::from("2800.00"),
5209            TriggerType::LastPrice,
5210            TimeInForce::Gtc,
5211            None, // expire_time
5212            reduce_only,
5213            false, // quote_quantity
5214            None,  // display_qty
5215            None,  // emulation_trigger
5216            None,  // trigger_instrument_id
5217            contingency,
5218            None, // order_list_id
5219            linked_ids.map(|ids| ids.into_iter().map(ClientOrderId::from).collect()),
5220            parent_id.map(ClientOrderId::from),
5221            None, // exec_algorithm_id
5222            None, // exec_algorithm_params
5223            None, // exec_spawn_id
5224            None, // tags
5225            Default::default(),
5226            Default::default(),
5227        ))
5228    }
5229
5230    fn staged_child(id: &str, sibling_id: &str) -> StagedBracketChild {
5231        StagedBracketChild {
5232            order: limit_order(
5233                id,
5234                true,
5235                Some(ContingencyType::Ouo),
5236                Some(vec![sibling_id]),
5237                Some("O-PARENT"),
5238            ),
5239            request: HyperliquidExchangePlaceOrderRequest {
5240                asset: 4,
5241                is_buy: false,
5242                price: Decimal::from(3_000),
5243                size: Decimal::ONE,
5244                reduce_only: true,
5245                kind: HyperliquidExchangeOrderKind::Limit {
5246                    limit: HyperliquidExchangeLimitParams {
5247                        tif: HyperliquidExchangeTif::Gtc,
5248                    },
5249                },
5250                cloid: Some(Cloid::from_client_order_id(ClientOrderId::from(id))),
5251            },
5252        }
5253    }
5254
5255    #[rstest]
5256    fn test_staged_bracket_activation_links_ouo_siblings_once() {
5257        let parent_id = ClientOrderId::from("O-PARENT");
5258        let first_id = ClientOrderId::from("O-CHILD-1");
5259        let second_id = ClientOrderId::from("O-CHILD-2");
5260        let mut state = StagedBracketState::default();
5261        state.stage(
5262            parent_id,
5263            vec![
5264                staged_child(first_id.as_str(), second_id.as_str()),
5265                staged_child(second_id.as_str(), first_id.as_str()),
5266            ],
5267        );
5268
5269        let activated = state.activate(&parent_id).expect("staged children");
5270        let sibling = state
5271            .take_active_sibling(&first_id)
5272            .expect("active OUO sibling");
5273
5274        assert_eq!(activated.len(), 2);
5275        assert_eq!(sibling.order.client_order_id(), second_id);
5276        assert!(state.activate(&parent_id).is_none());
5277        assert!(state.take_active_sibling(&second_id).is_none());
5278    }
5279
5280    #[rstest]
5281    fn test_restored_active_bracket_rebuilds_ouo_without_reactivation() {
5282        let parent_id = ClientOrderId::from("O-PARENT");
5283        let first_id = ClientOrderId::from("O-CHILD-1");
5284        let second_id = ClientOrderId::from("O-CHILD-2");
5285        let mut state = StagedBracketState::default();
5286        state.restore_active(&[
5287            staged_child(first_id.as_str(), second_id.as_str()),
5288            staged_child(second_id.as_str(), first_id.as_str()),
5289        ]);
5290
5291        let sibling = state
5292            .take_active_sibling(&first_id)
5293            .expect("restored OUO sibling");
5294
5295        assert!(state.activate(&parent_id).is_none());
5296        assert_eq!(sibling.order.client_order_id(), second_id);
5297        assert!(state.take_active_sibling(&second_id).is_none());
5298    }
5299
5300    #[rstest]
5301    fn test_staged_bracket_child_cancel_preserves_other_child_for_parent_fill() {
5302        let parent_id = ClientOrderId::from("O-PARENT");
5303        let first_id = ClientOrderId::from("O-CHILD-1");
5304        let second_id = ClientOrderId::from("O-CHILD-2");
5305        let mut state = StagedBracketState::default();
5306        state.stage(
5307            parent_id,
5308            vec![
5309                staged_child(first_id.as_str(), second_id.as_str()),
5310                staged_child(second_id.as_str(), first_id.as_str()),
5311            ],
5312        );
5313
5314        let canceled = state.cancel_child(&first_id).expect("staged child");
5315        let remaining = state.activate(&parent_id).expect("remaining child");
5316
5317        assert_eq!(canceled.client_order_id(), first_id);
5318        assert_eq!(remaining.len(), 1);
5319        assert_eq!(remaining[0].order.client_order_id(), second_id);
5320    }
5321
5322    #[rstest]
5323    fn test_staged_bracket_parent_cancel_returns_all_unsubmitted_children() {
5324        let parent_id = ClientOrderId::from("O-PARENT");
5325        let first_id = ClientOrderId::from("O-CHILD-1");
5326        let second_id = ClientOrderId::from("O-CHILD-2");
5327        let mut state = StagedBracketState::default();
5328        state.stage(
5329            parent_id,
5330            vec![
5331                staged_child(first_id.as_str(), second_id.as_str()),
5332                staged_child(second_id.as_str(), first_id.as_str()),
5333            ],
5334        );
5335
5336        let canceled = state.cancel_for_parent(&parent_id);
5337        let canceled_ids = canceled
5338            .iter()
5339            .map(Order::client_order_id)
5340            .collect::<Vec<_>>();
5341
5342        assert_eq!(canceled_ids, vec![first_id, second_id]);
5343        assert!(state.activate(&parent_id).is_none());
5344    }
5345
5346    #[rstest]
5347    fn test_build_ouo_resize_request_sends_sibling_leaves_quantity() {
5348        let sibling = staged_child("O-CHILD-2", "O-CHILD-1");
5349
5350        let request =
5351            build_ouo_resize_request(&sibling, Quantity::from("0.7"), Quantity::from("0.2"))
5352                .expect("resized request");
5353        let exhausted =
5354            build_ouo_resize_request(&sibling, Quantity::from("0.2"), Quantity::from("0.2"));
5355
5356        assert_eq!(request.size, Decimal::new(5, 1));
5357        assert_eq!(request.cloid, sibling.request.cloid);
5358        assert!(exhausted.is_none());
5359    }
5360
5361    #[rstest]
5362    #[case::independent_orders(
5363        vec![
5364            limit_order("O-001", false, None, None, None),
5365            limit_order("O-002", false, None, None, None),
5366        ],
5367        HyperliquidExchangeGrouping::Na,
5368    )]
5369    #[case::bracket_oto(
5370        vec![
5371            limit_order("O-001", false, Some(ContingencyType::Oto), Some(vec!["O-002", "O-003"]), None),
5372            limit_order("O-002", true, Some(ContingencyType::Oco), Some(vec!["O-003"]), Some("O-001")),
5373            stop_order("O-003", true, Some(ContingencyType::Oco), Some(vec!["O-002"]), Some("O-001")),
5374        ],
5375        HyperliquidExchangeGrouping::NormalTpsl,
5376    )]
5377    #[case::bracket_oto_with_factory_ouo_children(
5378        vec![
5379            limit_order("O-001", false, Some(ContingencyType::Oto), Some(vec!["O-002", "O-003"]), None),
5380            limit_order("O-002", true, Some(ContingencyType::Ouo), Some(vec!["O-003"]), Some("O-001")),
5381            stop_order("O-003", true, Some(ContingencyType::Ouo), Some(vec!["O-002"]), Some("O-001")),
5382        ],
5383        HyperliquidExchangeGrouping::NormalTpsl,
5384    )]
5385    #[case::oto_not_bracket_shaped(
5386        vec![
5387            limit_order("O-001", false, Some(ContingencyType::Oto), Some(vec!["O-002"]), None),
5388            limit_order("O-002", false, Some(ContingencyType::Oto), Some(vec!["O-001"]), None),
5389        ],
5390        HyperliquidExchangeGrouping::Na,
5391    )]
5392    #[case::oco_all_reduce_only(
5393        vec![
5394            limit_order("O-001", true, Some(ContingencyType::Oco), Some(vec!["O-002"]), None),
5395            stop_order("O-002", true, Some(ContingencyType::Oco), Some(vec!["O-001"]), None),
5396        ],
5397        HyperliquidExchangeGrouping::PositionTpsl,
5398    )]
5399    #[case::oco_not_all_reduce_only(
5400        vec![
5401            limit_order("O-001", false, Some(ContingencyType::Oco), Some(vec!["O-002"]), None),
5402            stop_order("O-002", true, Some(ContingencyType::Oco), Some(vec!["O-001"]), None),
5403        ],
5404        HyperliquidExchangeGrouping::Na,
5405    )]
5406    #[case::oto_with_non_oco_children(
5407        vec![
5408            limit_order("O-001", false, Some(ContingencyType::Oto), Some(vec!["O-002", "O-003"]), None),
5409            limit_order("O-002", true, None, None, None),
5410            stop_order("O-003", true, None, None, None),
5411        ],
5412        HyperliquidExchangeGrouping::Na,
5413    )]
5414    #[case::mixed_oco_and_plain_reduce_only(
5415        vec![
5416            limit_order("O-001", true, Some(ContingencyType::Oco), Some(vec!["O-002"]), None),
5417            stop_order("O-002", true, None, None, None),
5418        ],
5419        HyperliquidExchangeGrouping::Na,
5420    )]
5421    #[case::unlinked_oco_reduce_only(
5422        vec![
5423            limit_order("O-001", true, Some(ContingencyType::Oco), Some(vec!["O-099"]), None),
5424            stop_order("O-002", true, Some(ContingencyType::Oco), Some(vec!["O-098"]), None),
5425        ],
5426        HyperliquidExchangeGrouping::Na,
5427    )]
5428    #[case::single_order(
5429        vec![limit_order("O-001", false, None, None, None)],
5430        HyperliquidExchangeGrouping::Na,
5431    )]
5432    fn test_determine_order_list_grouping(
5433        #[case] orders: Vec<OrderAny>,
5434        #[case] expected: HyperliquidExchangeGrouping,
5435    ) {
5436        let result = determine_order_list_grouping(&orders);
5437        assert_eq!(result, expected);
5438    }
5439
5440    #[rstest]
5441    #[case::market(Some(OrderType::Market), true)]
5442    #[case::limit(Some(OrderType::Limit), true)]
5443    #[case::stop_market(Some(OrderType::StopMarket), false)]
5444    #[case::unknown(None, false)]
5445    fn test_can_fast_cancel_order_only_allows_plain_order_types(
5446        #[case] order_type: Option<OrderType>,
5447        #[case] expected: bool,
5448    ) {
5449        assert_eq!(can_fast_cancel_order(order_type), expected);
5450    }
5451
5452    #[rstest]
5453    fn test_split_fast_cancel_requests_preserves_request_entry_alignment() {
5454        let requests = vec![
5455            (10_u64, cancel_entry("O-FAST-1", true)),
5456            (20_u64, cancel_entry("O-NORMAL-1", false)),
5457            (30_u64, cancel_entry("O-FAST-2", true)),
5458            (40_u64, cancel_entry("O-NORMAL-2", false)),
5459        ];
5460
5461        let (fast_requests, fast_entries, normal_requests, normal_entries) =
5462            split_fast_cancel_requests(requests);
5463
5464        assert_eq!(fast_requests, vec![10, 30]);
5465        assert_eq!(
5466            client_order_ids(&fast_entries),
5467            vec![
5468                ClientOrderId::from("O-FAST-1"),
5469                ClientOrderId::from("O-FAST-2"),
5470            ]
5471        );
5472        assert!(fast_entries.iter().all(|entry| entry.fast));
5473        assert_eq!(normal_requests, vec![20, 40]);
5474        assert_eq!(
5475            client_order_ids(&normal_entries),
5476            vec![
5477                ClientOrderId::from("O-NORMAL-1"),
5478                ClientOrderId::from("O-NORMAL-2"),
5479            ]
5480        );
5481        assert!(normal_entries.iter().all(|entry| !entry.fast));
5482    }
5483
5484    fn cancel_entry(client_order_id: &str, fast: bool) -> CancelEntry {
5485        CancelEntry {
5486            strategy_id: StrategyId::from("S-001"),
5487            instrument_id: InstrumentId::from(TEST_INSTRUMENT_ID),
5488            client_order_id: ClientOrderId::from(client_order_id),
5489            venue_order_id: Some(VenueOrderId::new("123")),
5490            symbol: Ustr::from("BTC-USD-PERP"),
5491            fast,
5492        }
5493    }
5494
5495    fn client_order_ids(entries: &[CancelEntry]) -> Vec<ClientOrderId> {
5496        entries.iter().map(|entry| entry.client_order_id).collect()
5497    }
5498
5499    fn limit_order_with_flags(id: &str, quote_quantity: bool, post_only: bool) -> OrderAny {
5500        OrderAny::Limit(LimitOrder::new(
5501            TraderId::from("TESTER-001"),
5502            StrategyId::from("S-001"),
5503            InstrumentId::from(TEST_INSTRUMENT_ID),
5504            ClientOrderId::from(id),
5505            OrderSide::Buy,
5506            Quantity::from("0.0001"),
5507            Price::from("56730.0"),
5508            TimeInForce::Gtc,
5509            None,
5510            post_only,
5511            false,
5512            quote_quantity,
5513            None,
5514            None,
5515            None,
5516            None,
5517            None,
5518            None,
5519            None,
5520            None,
5521            None,
5522            None,
5523            None,
5524            Default::default(),
5525            Default::default(),
5526        ))
5527    }
5528
5529    #[rstest]
5530    fn test_register_order_context_registers_regular_order() {
5531        let state = WsDispatchState::new();
5532        let client_order_id = ClientOrderId::from("O-REG-001");
5533        let order = limit_order_with_flags("O-REG-001", false, false);
5534
5535        register_order_context_into(&state, &order);
5536
5537        assert_eq!(
5538            state.lookup_context(&client_order_id),
5539            Some(test_context(client_order_id)),
5540        );
5541    }
5542
5543    #[rstest]
5544    fn test_register_order_context_skips_quote_quantity_order() {
5545        let state = WsDispatchState::new();
5546        let order = limit_order_with_flags("O-QQ-001", true, false);
5547
5548        register_order_context_into(&state, &order);
5549
5550        // Quote-quantity orders flow through the untracked path so the engine
5551        // reconciles them from status reports; registering would make the
5552        // cumulative-fill comparison mismatch base-unit fills against the
5553        // quote-unit tracked quantity and leave the order stuck "open".
5554        assert!(
5555            state
5556                .lookup_context(&ClientOrderId::from("O-QQ-001"))
5557                .is_none()
5558        );
5559    }
5560
5561    #[rstest]
5562    #[case::canceled(OrderStatus::Canceled)]
5563    #[case::rejected(OrderStatus::Rejected)]
5564    fn test_handle_execution_report_skip_keeps_cloid_mapping(#[case] status: OrderStatus) {
5565        // Regression guard for GH-3827: when the dispatch returns Skip (e.g.
5566        // the stale cancel leg of a cancel-replace), the cloid mapping must
5567        // stay in place so the still-open replacement order can still be
5568        // resolved by subsequent events.
5569        let ws_client = make_ws_client();
5570        let (emitter, mut rx) = test_emitter();
5571        let state = WsDispatchState::new();
5572        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
5573
5574        let cid = ClientOrderId::from("O-HER-SKIP");
5575        state.register_context(test_context(cid));
5576        state.insert_accepted(cid);
5577        state.record_venue_order_id(cid, VenueOrderId::new("new-voi"));
5578
5579        ws_client.cache_cloid_mapping(cloid_for("O-HER-SKIP"), cid);
5580
5581        let report = make_status_report(Some("O-HER-SKIP"), "old-voi", status);
5582        let (terminal_skipped, corrective) = handle_execution_report(
5583            ExecutionReport::Order(report),
5584            &state,
5585            &emitter,
5586            &ws_client,
5587            &make_http_client(),
5588            &mut pending_cloids,
5589            UnixNanos::default(),
5590        );
5591
5592        assert!(terminal_skipped);
5593        assert!(corrective.is_none());
5594        assert!(drain_events(&mut rx).is_empty());
5595        assert_eq!(
5596            ws_client.get_cloid_mapping(&cloid_for("O-HER-SKIP")),
5597            Some(cid)
5598        );
5599        assert!(state.lookup_context(&cid).is_some());
5600    }
5601
5602    #[rstest]
5603    #[case::pending_rejection(OrderStatus::Rejected)]
5604    #[case::filled_replay(OrderStatus::Filled)]
5605    fn test_handle_execution_report_skipped_terminal_keeps_bracket_handling(
5606        #[case] status: OrderStatus,
5607    ) {
5608        let ws_client = make_ws_client();
5609        let (emitter, mut rx) = test_emitter();
5610        let state = WsDispatchState::new();
5611        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
5612        let cid = ClientOrderId::from("O-HER-BRACKET");
5613        state.register_context(test_context(cid));
5614        match status {
5615            OrderStatus::Rejected => state.mark_submission_pending(cid),
5616            OrderStatus::Filled => {
5617                state.insert_filled(cid);
5618                state.cleanup_terminal(&cid);
5619            }
5620            _ => unreachable!(),
5621        }
5622
5623        ws_client.cache_cloid_mapping(cloid_for("O-HER-BRACKET"), cid);
5624        let report = make_status_report(Some("O-HER-BRACKET"), "v-bracket", status);
5625        let (terminal_skipped, corrective) = handle_execution_report(
5626            ExecutionReport::Order(report),
5627            &state,
5628            &emitter,
5629            &ws_client,
5630            &make_http_client(),
5631            &mut pending_cloids,
5632            UnixNanos::default(),
5633        );
5634
5635        assert!(!terminal_skipped);
5636        assert!(corrective.is_none());
5637        assert!(drain_events(&mut rx).is_empty());
5638        assert_eq!(
5639            ws_client.get_cloid_mapping(&cloid_for("O-HER-BRACKET")),
5640            Some(cid)
5641        );
5642    }
5643
5644    #[rstest]
5645    #[case::adapter_issued(None, 1)]
5646    #[case::adapter_issued_chain(None, 2)]
5647    #[case::strategy_modify(Some(OrderStatus::PendingUpdate), 1)]
5648    #[case::strategy_cancel(Some(OrderStatus::PendingCancel), 1)]
5649    fn test_handle_execution_report_closed_replacement(
5650        #[case] pending_status: Option<OrderStatus>,
5651        #[case] modifies: usize,
5652        #[values(false, true)] recovery: bool,
5653        #[values(false, true)] replacement_first: bool,
5654        #[values("0", "0.00004")] filled: &str,
5655    ) {
5656        let ws_client = make_ws_client();
5657        let mut http_client = make_http_client();
5658        http_client.set_account_id(AccountId::from("HYPERLIQUID-001"));
5659        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
5660        let defs = parse_perp_instruments(&meta, 0).unwrap();
5661        http_client
5662            .cache_instrument(&create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap());
5663        let (emitter, mut rx) = test_emitter();
5664        let state = WsDispatchState::new();
5665        let mut pending_cloids = FifoCache::new();
5666        let cid = ClientOrderId::new("O-CLOSED-REPLACEMENT");
5667        let original = VenueOrderId::new("100");
5668        let replacement = VenueOrderId::new((100 + modifies).to_string());
5669        let cloid = Cloid::from_client_order_id(cid);
5670        let mut order = limit_order_with_flags(cid.as_str(), false, false);
5671        state.register_context(OrderContext::from(&order));
5672        ws_client.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), cid);
5673        http_client.cache_client_order_id_cloid(cid, cloid);
5674        super::dispatch_order_event(
5675            &make_status_report(Some(cid.as_str()), "100", OrderStatus::Accepted),
5676            &state,
5677            &emitter,
5678            UnixNanos::default(),
5679        );
5680        let filled_qty = Quantity::from(filled);
5681        if filled_qty > Quantity::zero(filled_qty.precision) {
5682            super::dispatch_order_fill(
5683                &make_fill_report_with_qty(Some(cid.as_str()), "100", "T-OLD", filled_qty),
5684                &state,
5685                &emitter,
5686                UnixNanos::default(),
5687            );
5688        }
5689
5690        for event in drain_events(&mut rx) {
5691            let ExecutionEvent::Order(event) = event else {
5692                panic!("expected order event, received {event:?}");
5693            };
5694
5695            order.apply(event).unwrap();
5696        }
5697
5698        let pending = match pending_status {
5699            Some(OrderStatus::PendingUpdate) => {
5700                Some(OrderEventAny::PendingUpdate(OrderPendingUpdate::new(
5701                    order.trader_id(),
5702                    order.strategy_id(),
5703                    order.instrument_id(),
5704                    cid,
5705                    order.account_id(),
5706                    UUID4::new(),
5707                    UnixNanos::default(),
5708                    UnixNanos::default(),
5709                    false,
5710                    Some(original),
5711                )))
5712            }
5713            Some(OrderStatus::PendingCancel) => {
5714                Some(OrderEventAny::PendingCancel(OrderPendingCancel::new(
5715                    order.trader_id(),
5716                    order.strategy_id(),
5717                    order.instrument_id(),
5718                    cid,
5719                    order.account_id(),
5720                    UUID4::new(),
5721                    UnixNanos::default(),
5722                    UnixNanos::default(),
5723                    false,
5724                    Some(original),
5725                )))
5726            }
5727            None => None,
5728            _ => unreachable!(),
5729        };
5730
5731        if let Some(event) = pending {
5732            order.apply(event).unwrap();
5733        }
5734
5735        let targets = [Quantity::from("0.0002"), Quantity::from("0.0003")];
5736        let target = targets[modifies - 1];
5737        for quantity in targets.into_iter().take(modifies) {
5738            state.mark_pending_modify(cid, original, quantity);
5739            let mut request = staged_child(cid.as_str(), "O-SIBLING").request;
5740            request.size = quantity.as_decimal();
5741            state.stash_modify_request(cid, request);
5742        }
5743
5744        let mut old_cancel = make_status_report(Some(cid.as_str()), "100", OrderStatus::Canceled);
5745        old_cancel.ts_accepted = UnixNanos::from(1_000_000);
5746        old_cancel.ts_last = UnixNanos::from(2_000_000);
5747        let mut new_cancel = make_status_report(Some(cid.as_str()), "101", OrderStatus::Canceled);
5748        new_cancel.price = Some(Price::from("56740.0"));
5749        new_cancel.ts_accepted = UnixNanos::from(2_000_000);
5750        new_cancel.ts_last = UnixNanos::from(3_000_000);
5751        let mut final_cancel = new_cancel.clone();
5752        final_cancel.venue_order_id = replacement;
5753        final_cancel.ts_accepted = UnixNanos::from((1 + modifies as u64) * 1_000_000);
5754        final_cancel.ts_last = UnixNanos::from((2 + modifies as u64) * 1_000_000);
5755        let mut reports: Vec<_> = [old_cancel.clone(), new_cancel, final_cancel.clone()]
5756            .into_iter()
5757            .take(modifies + 1)
5758            .collect();
5759
5760        if replacement_first {
5761            reports.rotate_left(1);
5762        }
5763
5764        let reports = if recovery {
5765            let entries = reports
5766                .iter()
5767                .map(|report| {
5768                    let mut entry = btc_order_entry(
5769                        report.venue_order_id.as_str().parse().unwrap(),
5770                        "canceled",
5771                        report.ts_last.as_millis(),
5772                        &cloid.to_hex(),
5773                    );
5774                    entry.order.timestamp = report.ts_accepted.as_millis();
5775                    entry.order.limit_px = report.price.unwrap().as_decimal();
5776                    entry.order.sz = Decimal::new(1, 4);
5777                    entry.order.orig_sz = Decimal::new(1, 4);
5778                    entry
5779                })
5780                .collect();
5781
5782            reconnect_reports_from_history(
5783                vec![],
5784                entries,
5785                UnixNanos::default(),
5786                &ws_client,
5787                &http_client,
5788                &state,
5789            )
5790            .unwrap()
5791        } else {
5792            reports.into_iter().map(ExecutionReport::Order).collect()
5793        };
5794
5795        let mut terminal_skipped = Vec::new();
5796
5797        for report in reports {
5798            let (skipped, corrective) = handle_execution_report(
5799                report,
5800                &state,
5801                &emitter,
5802                &ws_client,
5803                &http_client,
5804                &mut pending_cloids,
5805                UnixNanos::default(),
5806            );
5807            terminal_skipped.push(skipped);
5808            assert!(corrective.is_none());
5809        }
5810
5811        let events = drain_events(&mut rx);
5812        assert_eq!(events.len(), modifies + 1);
5813        assert!(
5814            events[..modifies]
5815                .iter()
5816                .all(|event| matches!(event, ExecutionEvent::Order(OrderEventAny::Updated(_)),))
5817        );
5818        assert!(matches!(
5819            events[modifies],
5820            ExecutionEvent::Order(OrderEventAny::Canceled(_))
5821        ));
5822
5823        for event in events {
5824            let ExecutionEvent::Order(event) = event else {
5825                panic!("expected order event, received {event:?}");
5826            };
5827
5828            order.apply(event).unwrap();
5829        }
5830
5831        let mut expected_skipped = vec![!recovery; modifies + 1];
5832        expected_skipped[modifies] = false;
5833        if replacement_first && !recovery {
5834            expected_skipped.rotate_left(1);
5835        }
5836
5837        assert_eq!(terminal_skipped, expected_skipped);
5838        assert_eq!(order.status(), OrderStatus::Canceled);
5839        assert_eq!(order.venue_order_id(), Some(replacement));
5840        assert_eq!(order.quantity(), target);
5841        assert_eq!(order.filled_qty(), filled_qty);
5842        assert_eq!(order.leaves_qty(), target - filled_qty);
5843        assert_eq!(order.price(), Some(Price::from("56740.0")));
5844        assert_eq!(order.account_id(), Some(AccountId::from("HYPERLIQUID-001")));
5845        assert_eq!(state.pending_modify(&cid), None);
5846        assert_eq!(state.lookup_context(&cid), None);
5847        assert_eq!(
5848            ws_client.get_cloid_mapping(&Ustr::from(&cloid.to_hex())),
5849            None
5850        );
5851        assert_eq!(http_client.cached_client_order_id_cloid(&cid), None);
5852
5853        for report in [final_cancel.clone(), old_cancel, final_cancel] {
5854            let (skipped, corrective) = handle_execution_report(
5855                ExecutionReport::Order(report),
5856                &state,
5857                &emitter,
5858                &ws_client,
5859                &http_client,
5860                &mut pending_cloids,
5861                UnixNanos::default(),
5862            );
5863            assert!(skipped);
5864            assert!(corrective.is_none());
5865        }
5866
5867        assert!(drain_events(&mut rx).is_empty());
5868    }
5869
5870    #[rstest]
5871    fn test_recovery_canceled_replacement_preserves_fill() {
5872        let ws_client = make_ws_client();
5873        let http_client = make_http_client();
5874        let (emitter, mut rx) = test_emitter();
5875        let state = WsDispatchState::new();
5876        let mut pending_cloids = FifoCache::new();
5877        let cid = ClientOrderId::new("O-CLOSED-FILL");
5878        let mut order = limit_order_with_flags(cid.as_str(), false, false);
5879        state.register_context(OrderContext::from(&order));
5880        super::dispatch_order_event(
5881            &make_status_report(Some(cid.as_str()), "100", OrderStatus::Accepted),
5882            &state,
5883            &emitter,
5884            UnixNanos::default(),
5885        );
5886        super::dispatch_order_fill(
5887            &make_fill_report_with_qty(
5888                Some(cid.as_str()),
5889                "100",
5890                "T-ORIGINAL",
5891                Quantity::from("0.00002"),
5892            ),
5893            &state,
5894            &emitter,
5895            UnixNanos::default(),
5896        );
5897        let target = Quantity::from("0.0002");
5898        state.mark_pending_modify(cid, VenueOrderId::new("100"), target);
5899        let mut request = staged_child(cid.as_str(), "O-SIBLING").request;
5900        request.size = target.as_decimal();
5901        state.stash_modify_request(cid, request);
5902        let mut reports = resolve_order_legs(
5903            vec![
5904                make_status_report(Some(cid.as_str()), "100", OrderStatus::Canceled),
5905                make_status_report(Some(cid.as_str()), "101", OrderStatus::Canceled),
5906            ],
5907            &state,
5908        )
5909        .into_iter()
5910        .map(ExecutionReport::Order)
5911        .collect::<Vec<_>>();
5912        reports.insert(
5913            1,
5914            ExecutionReport::Fill(make_fill_report_with_qty(
5915                Some(cid.as_str()),
5916                "101",
5917                "T-REPLACEMENT",
5918                Quantity::from("0.00004"),
5919            )),
5920        );
5921
5922        for report in reports {
5923            let (skipped, corrective) = handle_execution_report(
5924                report,
5925                &state,
5926                &emitter,
5927                &ws_client,
5928                &http_client,
5929                &mut pending_cloids,
5930                UnixNanos::default(),
5931            );
5932            assert!(!skipped);
5933            assert!(corrective.is_none());
5934        }
5935
5936        let events = drain_events(&mut rx);
5937        assert!(matches!(
5938            events.as_slice(),
5939            [
5940                ExecutionEvent::Order(OrderEventAny::Accepted(_)),
5941                ExecutionEvent::Order(OrderEventAny::Filled(_)),
5942                ExecutionEvent::Order(OrderEventAny::Updated(_)),
5943                ExecutionEvent::Order(OrderEventAny::Filled(_)),
5944                ExecutionEvent::Order(OrderEventAny::Canceled(_)),
5945            ]
5946        ));
5947
5948        for event in events {
5949            let ExecutionEvent::Order(event) = event else {
5950                panic!("expected order event, received {event:?}");
5951            };
5952
5953            order.apply(event).unwrap();
5954        }
5955
5956        assert_eq!(order.status(), OrderStatus::Canceled);
5957        assert_eq!(order.venue_order_id(), Some(VenueOrderId::new("101")));
5958        assert_eq!(order.quantity(), target);
5959        assert_eq!(order.filled_qty(), Quantity::from("0.00006"));
5960        assert_eq!(order.leaves_qty(), Quantity::from("0.00014"));
5961        assert_eq!(state.pending_modify(&cid), None);
5962    }
5963
5964    #[rstest]
5965    fn test_canceled_replacement_preserves_late_fill() {
5966        let ws_client = make_ws_client();
5967        let http_client = make_http_client();
5968        let (emitter, mut rx) = test_emitter();
5969        let state = WsDispatchState::new();
5970        let mut pending_cloids = FifoCache::new();
5971        let cid = ClientOrderId::new("O-CLOSED-LATE-FILL");
5972        let mut order = limit_order_with_flags(cid.as_str(), false, false);
5973        state.register_context(OrderContext::from(&order));
5974        super::dispatch_order_event(
5975            &make_status_report(Some(cid.as_str()), "100", OrderStatus::Accepted),
5976            &state,
5977            &emitter,
5978            UnixNanos::default(),
5979        );
5980        let old_fill = make_fill_report_with_qty(
5981            Some(cid.as_str()),
5982            "100",
5983            "T-OLD-LATE",
5984            Quantity::from("0.00002"),
5985        );
5986        super::dispatch_order_fill(&old_fill, &state, &emitter, UnixNanos::default());
5987        let target = Quantity::from("0.0002");
5988        state.mark_pending_modify(cid, VenueOrderId::new("100"), target);
5989
5990        for oid in ["100", "101"] {
5991            let (_, corrective) = handle_execution_report(
5992                ExecutionReport::Order(make_status_report(
5993                    Some(cid.as_str()),
5994                    oid,
5995                    OrderStatus::Canceled,
5996                )),
5997                &state,
5998                &emitter,
5999                &ws_client,
6000                &http_client,
6001                &mut pending_cloids,
6002                UnixNanos::default(),
6003            );
6004            assert!(corrective.is_none());
6005        }
6006
6007        for event in drain_events(&mut rx) {
6008            let ExecutionEvent::Order(event) = event else {
6009                panic!("expected order event");
6010            };
6011
6012            order.apply(event).unwrap();
6013        }
6014
6015        let late_fill = make_fill_report_with_qty(
6016            Some(cid.as_str()),
6017            "101",
6018            "T-NEW-LATE",
6019            Quantity::from("0.00004"),
6020        );
6021
6022        for fill in [old_fill, late_fill.clone(), late_fill.clone()] {
6023            handle_execution_report(
6024                ExecutionReport::Fill(fill),
6025                &state,
6026                &emitter,
6027                &ws_client,
6028                &http_client,
6029                &mut pending_cloids,
6030                UnixNanos::default(),
6031            );
6032        }
6033
6034        let events = drain_events(&mut rx);
6035
6036        let [ExecutionEvent::Report(EngineExecutionReport::Fill(report))] = events.as_slice()
6037        else {
6038            panic!("expected one late fill report, received {events:?}");
6039        };
6040
6041        assert_eq!(report.as_ref(), &late_fill);
6042        assert_eq!(order.status(), OrderStatus::Canceled);
6043        assert_eq!(order.venue_order_id(), Some(VenueOrderId::new("101")));
6044        assert_eq!(order.quantity(), target);
6045        assert_eq!(order.filled_qty(), Quantity::from("0.00002"));
6046    }
6047
6048    #[rstest]
6049    fn test_canceled_stop_replacement_applies_to_order(
6050        #[values(false, true)] recovery: bool,
6051        #[values(false, true)] strategy_modify: bool,
6052    ) {
6053        let ws_client = make_ws_client();
6054        let http_client = make_http_client();
6055        let (emitter, mut rx) = test_emitter();
6056        let state = WsDispatchState::new();
6057        let mut pending_cloids = FifoCache::new();
6058        let cid = ClientOrderId::new("O-CLOSED-STOP");
6059        let mut order = stop_order(
6060            cid.as_str(),
6061            true,
6062            Some(ContingencyType::Ouo),
6063            Some(vec!["O-TAKE-PROFIT"]),
6064            Some("O-PARENT"),
6065        );
6066        state.register_context(OrderContext::from(&order));
6067        super::dispatch_order_event(
6068            &make_status_report(Some(cid.as_str()), "100", OrderStatus::Accepted),
6069            &state,
6070            &emitter,
6071            UnixNanos::default(),
6072        );
6073
6074        for event in drain_events(&mut rx) {
6075            let ExecutionEvent::Order(event) = event else {
6076                panic!("expected order event");
6077            };
6078
6079            order.apply(event).unwrap();
6080        }
6081
6082        if strategy_modify {
6083            order
6084                .apply(OrderEventAny::PendingUpdate(OrderPendingUpdate::new(
6085                    order.trader_id(),
6086                    order.strategy_id(),
6087                    order.instrument_id(),
6088                    cid,
6089                    order.account_id(),
6090                    UUID4::new(),
6091                    UnixNanos::default(),
6092                    UnixNanos::default(),
6093                    false,
6094                    Some(VenueOrderId::new("100")),
6095                )))
6096                .unwrap();
6097        }
6098
6099        let target = Quantity::from("0.8");
6100        state.mark_pending_modify(cid, VenueOrderId::new("100"), target);
6101        let mut reports: Vec<_> = ["100", "101"]
6102            .into_iter()
6103            .map(|oid| {
6104                let mut report = make_status_report(Some(cid.as_str()), oid, OrderStatus::Canceled);
6105                report.instrument_id = order.instrument_id();
6106                report.order_type = OrderType::StopMarket;
6107                report.trigger_price = Some(Price::from("2790.00"));
6108                report
6109            })
6110            .collect();
6111
6112        if recovery {
6113            reports = resolve_order_legs(reports, &state);
6114        }
6115
6116        for report in reports {
6117            let (_, corrective) = handle_execution_report(
6118                ExecutionReport::Order(report),
6119                &state,
6120                &emitter,
6121                &ws_client,
6122                &http_client,
6123                &mut pending_cloids,
6124                UnixNanos::default(),
6125            );
6126            assert!(corrective.is_none());
6127        }
6128
6129        let events = drain_events(&mut rx);
6130        assert!(matches!(
6131            events.as_slice(),
6132            [
6133                ExecutionEvent::Order(OrderEventAny::Updated(_)),
6134                ExecutionEvent::Order(OrderEventAny::Canceled(_))
6135            ]
6136        ));
6137
6138        for event in events {
6139            let ExecutionEvent::Order(event) = event else {
6140                panic!("expected order event");
6141            };
6142
6143            order.apply(event).unwrap();
6144        }
6145
6146        assert_eq!(order.status(), OrderStatus::Canceled);
6147        assert_eq!(order.venue_order_id(), Some(VenueOrderId::new("101")));
6148        assert_eq!(order.quantity(), target);
6149        assert_eq!(order.price(), None);
6150        assert_eq!(order.trigger_price(), Some(Price::from("2790.00")));
6151        assert_eq!(order.filled_qty().as_decimal(), Decimal::ZERO);
6152        assert_eq!(order.leaves_qty(), target);
6153    }
6154
6155    #[rstest]
6156    #[case::fill_before_status(2, None, "0.00005")]
6157    #[case::status_before_fill(0, None, "0.00005")]
6158    #[case::status_between_fills(1, None, "0.00005")]
6159    #[case::dispatch_fill_evidence(2, None, "0")]
6160    #[case::post_before_fill(0, Some(true), "0.00005")]
6161    #[case::post_after_fill(2, Some(true), "0")]
6162    #[case::post_success_before_fill(0, Some(false), "0.00005")]
6163    #[case::post_success_after_fill(2, Some(false), "0.00005")]
6164    #[tokio::test]
6165    async fn test_partial_fill_rejection_preserves_order_and_position(
6166        #[case] fills_before_status: usize,
6167        #[case] post_rejection: Option<bool>,
6168        #[case] report_filled_qty: &str,
6169    ) {
6170        let ws_client = make_ws_client();
6171        let http_client = make_http_client();
6172        let (emitter, mut rx) = test_emitter();
6173        let state = Arc::new(WsDispatchState::new());
6174        let mut pending_cloids = FifoCache::new();
6175        let cid = ClientOrderId::new("O-PARTIAL-REJECTION");
6176        let order = limit_order_with_flags(cid.as_str(), false, false);
6177        let context = OrderContext::from(&order);
6178        state.register_context(context);
6179        if post_rejection.is_some() {
6180            state.mark_submission_pending(cid);
6181        }
6182
6183        ws_client.cache_cloid_mapping(cloid_for(cid.as_str()), cid);
6184
6185        let instrument = InstrumentAny::CryptoPerpetual(
6186            CryptoPerpetual::builder()
6187                .instrument_id(context.identity.instrument_id)
6188                .raw_symbol("BTC".into())
6189                .base_currency(Currency::BTC())
6190                .quote_currency(Currency::USD())
6191                .settlement_currency(Currency::USD())
6192                .is_inverse(false)
6193                .price_precision(1)
6194                .size_precision(5)
6195                .price_increment(Price::from("0.1"))
6196                .size_increment(Quantity::from("0.00001"))
6197                .ts_event(UnixNanos::default())
6198                .ts_init(UnixNanos::default())
6199                .build()
6200                .unwrap(),
6201        );
6202        let cache = Rc::new(RefCell::new(Cache::default()));
6203        cache.borrow_mut().add_instrument(instrument).unwrap();
6204        cache
6205            .borrow_mut()
6206            .add_order(order.clone(), None, None, true)
6207            .unwrap();
6208
6209        let account_state = AccountState::new(
6210            AccountId::from("HYPERLIQUID-001"),
6211            AccountType::Margin,
6212            vec![AccountBalance::new(
6213                Money::from("10000 USD"),
6214                Money::from("0 USD"),
6215                Money::from("10000 USD"),
6216            )],
6217            vec![],
6218            true,
6219            UUID4::new(),
6220            UnixNanos::default(),
6221            UnixNanos::default(),
6222            Some(Currency::USD()),
6223        );
6224        let account = AccountAny::Margin(MarginAccount::new(account_state, true));
6225        cache.borrow_mut().add_account(account).unwrap();
6226        let clock = Rc::new(RefCell::new(VirtualClock::new()));
6227        let mut engine = ExecutionEngine::new(clock, cache.clone(), None);
6228        let mut fills = Vec::new();
6229
6230        for (trade_id, quantity, price, commission) in [
6231            ("T-PARTIAL-1", "0.00002", "56730.0", "0.17 USD"),
6232            ("T-PARTIAL-2", "0.00003", "56731.0", "0.29 USD"),
6233            ("T-PARTIAL-LATE", "0.00001", "56732.0", "0.11 USD"),
6234        ] {
6235            let mut fill = make_fill_report_with_qty(
6236                Some(cid.as_str()),
6237                "100",
6238                trade_id,
6239                Quantity::from(quantity),
6240            );
6241            fill.last_px = Price::from(price);
6242            fill.commission = Money::from(commission);
6243            fills.push(fill);
6244        }
6245
6246        let mut rejection = make_status_report(Some(cid.as_str()), "100", OrderStatus::Rejected);
6247        rejection.filled_qty = Quantity::from(report_filled_qty);
6248
6249        let mut reports: Vec<_> = fills[..fills_before_status]
6250            .iter()
6251            .cloned()
6252            .map(ExecutionReport::Fill)
6253            .collect();
6254        reports.push(ExecutionReport::Order(rejection.clone()));
6255        reports.push(ExecutionReport::Order(rejection.clone()));
6256        for report in reports {
6257            handle_execution_report(
6258                report,
6259                &state,
6260                &emitter,
6261                &ws_client,
6262                &http_client,
6263                &mut pending_cloids,
6264                UnixNanos::default(),
6265            );
6266        }
6267
6268        if let Some(rejected) = post_rejection {
6269            let tasks = TaskGroup::new();
6270
6271            let route = PostRejectionRoute::new(
6272                &emitter,
6273                &ws_client,
6274                &http_client,
6275                state.clone(),
6276                tasks.spawner().unwrap(),
6277            );
6278
6279            if rejected {
6280                let closed = route.emit_once(
6281                    &order,
6282                    "Order rejected by exchange",
6283                    UnixNanos::default(),
6284                    &cloid_for(cid.as_str()),
6285                );
6286                assert_eq!(closed, fills_before_status == 2);
6287            } else {
6288                route.resolve_without_post_rejection(
6289                    &order,
6290                    UnixNanos::default(),
6291                    &cloid_for(cid.as_str()),
6292                );
6293            }
6294        }
6295
6296        for fill in fills[fills_before_status..2]
6297            .iter()
6298            .flat_map(|fill| [fill, fill])
6299        {
6300            handle_execution_report(
6301                ExecutionReport::Fill(fill.clone()),
6302                &state,
6303                &emitter,
6304                &ws_client,
6305                &http_client,
6306                &mut pending_cloids,
6307                UnixNanos::default(),
6308            );
6309        }
6310
6311        if fills_before_status < 2 {
6312            let released = tokio::time::timeout(
6313                std::time::Duration::from_secs(1),
6314                state.recv_released_cancels(),
6315            )
6316            .await
6317            .unwrap();
6318            assert_eq!(released.len(), 1);
6319
6320            for report in released {
6321                assert_eq!(report.order_status, OrderStatus::Canceled);
6322                assert_eq!(report.filled_qty, Quantity::from("0.00005"));
6323                handle_execution_report(
6324                    ExecutionReport::Order(report),
6325                    &state,
6326                    &emitter,
6327                    &ws_client,
6328                    &http_client,
6329                    &mut pending_cloids,
6330                    UnixNanos::default(),
6331                );
6332            }
6333        }
6334
6335        let mut replay = rejection;
6336        replay.client_order_id = Some(ClientOrderId::new(cloid_for(cid.as_str()).as_str()));
6337        let (skipped, _) = handle_execution_report(
6338            ExecutionReport::Order(replay),
6339            &state,
6340            &emitter,
6341            &ws_client,
6342            &http_client,
6343            &mut pending_cloids,
6344            UnixNanos::default(),
6345        );
6346        assert!(skipped);
6347        let mut late = fills[2].clone();
6348        late.client_order_id = None;
6349
6350        for _ in 0..2 {
6351            handle_execution_report(
6352                ExecutionReport::Fill(late.clone()),
6353                &state,
6354                &emitter,
6355                &ws_client,
6356                &http_client,
6357                &mut pending_cloids,
6358                UnixNanos::default(),
6359            );
6360        }
6361
6362        let events = drain_events(&mut rx);
6363        let mut terminal_count = 0;
6364
6365        for event in events {
6366            match event {
6367                ExecutionEvent::Order(event) => {
6368                    assert!(!matches!(event, OrderEventAny::Rejected(_)));
6369                    terminal_count += usize::from(matches!(event, OrderEventAny::Canceled(_)));
6370                    engine.process(&event);
6371                }
6372                ExecutionEvent::Report(report) => engine.reconcile_execution_report(&report),
6373                other => panic!("unexpected event: {other:?}"),
6374            }
6375        }
6376
6377        let cache = cache.borrow();
6378        let order = cache.order(&cid).unwrap();
6379        let positions = cache.positions(None, None, None, None, None);
6380        assert_eq!(terminal_count, 1);
6381        assert_eq!(order.status(), OrderStatus::Canceled);
6382        assert_eq!(order.venue_order_id(), Some(VenueOrderId::new("100")));
6383        assert_eq!(order.quantity(), Quantity::from("0.0001"));
6384        assert_eq!(order.filled_qty(), Quantity::from("0.00006"));
6385        assert_eq!(order.leaves_qty(), Quantity::from("0.00004"));
6386        assert_eq!(
6387            order.trade_ids(),
6388            fills.iter().map(|fill| &fill.trade_id).collect::<Vec<_>>()
6389        );
6390        assert_eq!(
6391            order.commissions().values().copied().collect::<Vec<_>>(),
6392            vec![Money::from("0.57 USD")]
6393        );
6394        assert_eq!(positions.len(), 1);
6395        assert_fills_preserved(&order, &context, &fills, positions[0].id);
6396        assert_eq!(positions[0].quantity, Quantity::from("0.00006"));
6397        assert_eq!(positions[0].commissions(), vec![Money::from("0.57 USD")]);
6398        assert_eq!(
6399            positions[0].trade_ids(),
6400            fills.iter().map(|fill| fill.trade_id).collect::<Vec<_>>()
6401        );
6402        assert!(state.lookup_context(&cid).is_none());
6403        assert_eq!(state.previous_filled_qty(&cid), None);
6404        assert_eq!(ws_client.get_cloid_mapping(&cloid_for(cid.as_str())), None);
6405    }
6406
6407    fn assert_fills_preserved(
6408        order: &OrderAny,
6409        context: &OrderContext,
6410        reports: &[FillReport],
6411        position_id: PositionId,
6412    ) {
6413        let applied_fills: Vec<_> = order
6414            .events()
6415            .into_iter()
6416            .filter_map(|event| match event {
6417                OrderEventAny::Filled(fill) => Some(fill),
6418                _ => None,
6419            })
6420            .collect();
6421
6422        assert_eq!(applied_fills.len(), reports.len());
6423
6424        for (applied, reported) in applied_fills.iter().zip(reports) {
6425            assert_eq!(applied.client_order_id, context.identity.client_order_id);
6426            assert_eq!(applied.strategy_id, context.identity.strategy_id);
6427            assert_eq!(applied.instrument_id, reported.instrument_id);
6428            assert_eq!(applied.venue_order_id, reported.venue_order_id);
6429            assert_eq!(applied.account_id, reported.account_id);
6430            assert_eq!(applied.trade_id, reported.trade_id);
6431            assert_eq!(applied.order_side, reported.order_side);
6432            assert_eq!(applied.order_type, context.identity.order_type);
6433            assert_eq!(applied.last_qty, reported.last_qty);
6434            assert_eq!(applied.last_px, reported.last_px);
6435            assert_eq!(applied.commission, Some(reported.commission));
6436            assert_eq!(applied.liquidity_side, reported.liquidity_side);
6437            assert_eq!(applied.position_id, Some(position_id));
6438            assert_eq!(applied.ts_event, reported.ts_event);
6439        }
6440    }
6441
6442    #[rstest]
6443    #[case::weaker_duplicate("0.00005", "0", true)]
6444    #[case::stronger_duplicate("0", "0.00005", false)]
6445    fn test_submission_rejection_retains_reported_fills(
6446        #[case] first_qty: &str,
6447        #[case] second_qty: &str,
6448        #[case] retain_first: bool,
6449    ) {
6450        let (emitter, mut rx) = test_emitter();
6451        let state = WsDispatchState::new();
6452        let cid = ClientOrderId::new("O-PENDING-REJECTION");
6453        state.register_context(test_context(cid));
6454        state.mark_submission_pending(cid);
6455        let mut first = make_status_report(Some(cid.as_str()), "100", OrderStatus::Rejected);
6456        first.filled_qty = Quantity::from(first_qty);
6457        let mut second = first.clone();
6458        second.filled_qty = Quantity::from(second_qty);
6459        for report in [&first, &second] {
6460            assert_eq!(
6461                super::dispatch_order_event(report, &state, &emitter, UnixNanos::default()),
6462                super::DispatchOutcome::Skip
6463            );
6464        }
6465
6466        let resolved = state.resolve_submission(&cid);
6467
6468        let expected = if retain_first { first } else { second };
6469        assert_eq!(resolved, Some(expected));
6470        assert!(drain_events(&mut rx).is_empty());
6471    }
6472
6473    #[rstest]
6474    fn test_partial_rejection_keeps_stronger_fill_evidence() {
6475        let (emitter, mut rx) = test_emitter();
6476        let state = WsDispatchState::new();
6477        let cid = ClientOrderId::new("O-PARTIAL-REPLAY");
6478        state.register_context(test_context(cid));
6479        let mut rejection = make_status_report(Some(cid.as_str()), "100", OrderStatus::Rejected);
6480        rejection.filled_qty = Quantity::from("0.00005");
6481        super::dispatch_order_event(&rejection, &state, &emitter, UnixNanos::default());
6482        super::dispatch_order_fill(
6483            &make_fill_report_with_qty(
6484                Some(cid.as_str()),
6485                "100",
6486                "T-FIRST",
6487                Quantity::from("0.00002"),
6488            ),
6489            &state,
6490            &emitter,
6491            UnixNanos::default(),
6492        );
6493        rejection.filled_qty = Quantity::from("0.00001");
6494        let outcome =
6495            super::dispatch_order_event(&rejection, &state, &emitter, UnixNanos::default());
6496        let events = drain_events(&mut rx);
6497        assert_eq!(outcome, super::DispatchOutcome::Skip);
6498        assert_eq!(state.lookup_context(&cid), Some(test_context(cid)));
6499        assert!(matches!(
6500            events.as_slice(),
6501            [
6502                ExecutionEvent::Order(OrderEventAny::Accepted(_)),
6503                ExecutionEvent::Order(OrderEventAny::Filled(_))
6504            ]
6505        ));
6506        assert!(futures_util::FutureExt::now_or_never(state.recv_released_cancels()).is_none());
6507    }
6508
6509    #[rstest]
6510    fn test_full_fill_after_rejection_finishes_filled() {
6511        let (emitter, mut rx) = test_emitter();
6512        let state = WsDispatchState::new();
6513        let cid = ClientOrderId::new("O-FULL-REJECTION");
6514        state.register_context(test_context(cid));
6515        let mut rejection = make_status_report(Some(cid.as_str()), "100", OrderStatus::Rejected);
6516        rejection.filled_qty = Quantity::from("0.0001");
6517        super::dispatch_order_event(&rejection, &state, &emitter, UnixNanos::default());
6518        super::dispatch_order_fill(
6519            &make_fill_report(Some(cid.as_str()), "100", "T-FULL"),
6520            &state,
6521            &emitter,
6522            UnixNanos::default(),
6523        );
6524        let replay =
6525            super::dispatch_order_event(&rejection, &state, &emitter, UnixNanos::default());
6526        let events = drain_events(&mut rx);
6527
6528        let [
6529            ExecutionEvent::Order(OrderEventAny::Accepted(_)),
6530            ExecutionEvent::Order(OrderEventAny::Filled(fill)),
6531        ] = events.as_slice()
6532        else {
6533            panic!("expected acceptance and full fill: {events:?}")
6534        };
6535
6536        assert_eq!(fill.last_qty, Quantity::from("0.0001"));
6537        assert_eq!(fill.trade_id, TradeId::new("T-FULL"));
6538        assert_eq!(replay, super::DispatchOutcome::Skip);
6539        assert_eq!(state.lookup_context(&cid), None);
6540        assert!(futures_util::FutureExt::now_or_never(state.recv_released_cancels()).is_none());
6541    }
6542
6543    #[rstest]
6544    fn test_partial_rejection_waits_for_its_venue_leg_fills() {
6545        let (emitter, mut rx) = test_emitter();
6546        let state = WsDispatchState::new();
6547        let cid = ClientOrderId::new("O-PARTIAL-LEG");
6548        state.register_context(test_context(cid));
6549        super::dispatch_order_fill(
6550            &make_fill_report_with_qty(
6551                Some(cid.as_str()),
6552                "100",
6553                "T-OLD",
6554                Quantity::from("0.00004"),
6555            ),
6556            &state,
6557            &emitter,
6558            UnixNanos::default(),
6559        );
6560        state.record_venue_order_id(cid, VenueOrderId::new("101"));
6561        let mut rejection = make_status_report(Some(cid.as_str()), "101", OrderStatus::Rejected);
6562        rejection.filled_qty = Quantity::from("0.00002");
6563        let outcome =
6564            super::dispatch_order_event(&rejection, &state, &emitter, UnixNanos::default());
6565        super::dispatch_order_fill(
6566            &make_fill_report_with_qty(
6567                Some(cid.as_str()),
6568                "100",
6569                "T-OLD-LATE",
6570                Quantity::from("0.00001"),
6571            ),
6572            &state,
6573            &emitter,
6574            UnixNanos::default(),
6575        );
6576        let events = drain_events(&mut rx);
6577        assert_eq!(outcome, super::DispatchOutcome::Skip);
6578        assert_eq!(state.lookup_context(&cid), Some(test_context(cid)));
6579        assert!(events.iter().all(|event| !matches!(
6580            event,
6581            ExecutionEvent::Order(OrderEventAny::Canceled(_) | OrderEventAny::Rejected(_))
6582        )));
6583        assert_eq!(
6584            state.previous_filled_qty(&cid),
6585            Some(Quantity::from("0.00005"))
6586        );
6587        let pending = state.recv_released_cancels();
6588        assert!(futures_util::FutureExt::now_or_never(pending).is_none());
6589    }
6590
6591    #[rstest]
6592    #[case::status_before_fill(false, true)]
6593    #[case::fill_before_status(true, true)]
6594    #[case::dispatch_fill_evidence(true, false)]
6595    #[tokio::test]
6596    async fn test_partial_rejection_preserves_staged_children_until_fill(
6597        #[case] fill_before_post: bool,
6598        #[case] report_has_fills: bool,
6599    ) {
6600        let ws_client = make_ws_client();
6601        let http_client = make_http_client();
6602        let (emitter, mut rx) = test_emitter();
6603        let state = Arc::new(WsDispatchState::new());
6604        let cid = ClientOrderId::new("O-PARTIAL-PARENT");
6605        let order = limit_order_with_flags(cid.as_str(), false, false);
6606        state.register_context(OrderContext::from(&order));
6607        state.mark_submission_pending(cid);
6608        let mut rejection = make_status_report(Some(cid.as_str()), "100", OrderStatus::Rejected);
6609        if report_has_fills {
6610            rejection.filled_qty = Quantity::from("0.00002");
6611        }
6612
6613        let mut pending_cloids = FifoCache::new();
6614
6615        let (skipped, _) = if report_has_fills {
6616            handle_execution_report(
6617                ExecutionReport::Order(rejection),
6618                &state,
6619                &emitter,
6620                &ws_client,
6621                &http_client,
6622                &mut pending_cloids,
6623                UnixNanos::default(),
6624            )
6625        } else {
6626            (false, None)
6627        };
6628
6629        let brackets = Arc::new(parking_lot::Mutex::new(StagedBracketState::default()));
6630        brackets.lock().stage(
6631            cid,
6632            vec![
6633                staged_child("O-TP-PARTIAL", "O-SL-PARTIAL"),
6634                staged_child("O-SL-PARTIAL", "O-TP-PARTIAL"),
6635            ],
6636        );
6637        let tasks = TaskGroup::new();
6638        let route = PostRejectionRoute::with_staged_brackets(
6639            &emitter,
6640            &ws_client,
6641            &http_client,
6642            state.clone(),
6643            brackets.clone(),
6644            tasks.spawner().unwrap(),
6645        );
6646        let fill = make_fill_report_with_qty(
6647            Some(cid.as_str()),
6648            "100",
6649            "T-PARENT",
6650            Quantity::from("0.00002"),
6651        );
6652
6653        if fill_before_post {
6654            super::dispatch_order_fill(&fill, &state, &emitter, UnixNanos::default());
6655        }
6656
6657        let closed = route.emit_once(
6658            &order,
6659            "Order rejected by exchange",
6660            UnixNanos::default(),
6661            &cloid_for(cid.as_str()),
6662        );
6663        assert_eq!(skipped, report_has_fills);
6664        assert_eq!(closed, fill_before_post);
6665
6666        if !fill_before_post {
6667            assert!(drain_events(&mut rx).is_empty());
6668            super::dispatch_order_fill(&fill, &state, &emitter, UnixNanos::default());
6669        }
6670
6671        let children = brackets.lock().activate(&cid).unwrap();
6672
6673        let released = if fill_before_post {
6674            Vec::new()
6675        } else {
6676            tokio::time::timeout(
6677                std::time::Duration::from_secs(1),
6678                state.recv_released_cancels(),
6679            )
6680            .await
6681            .unwrap()
6682        };
6683
6684        assert_eq!(
6685            children
6686                .iter()
6687                .map(|child| child.order.client_order_id())
6688                .collect::<Vec<_>>(),
6689            vec![
6690                ClientOrderId::new("O-TP-PARTIAL"),
6691                ClientOrderId::new("O-SL-PARTIAL")
6692            ]
6693        );
6694        assert_eq!(released.len(), usize::from(!fill_before_post));
6695
6696        for report in released {
6697            assert_eq!(report.order_status, OrderStatus::Canceled);
6698            assert_eq!(report.venue_order_id, VenueOrderId::new("100"));
6699        }
6700    }
6701
6702    #[rstest]
6703    #[case::canceled(OrderStatus::Canceled)]
6704    #[case::rejected(OrderStatus::Rejected)]
6705    fn test_handle_execution_report_tracked_terminal_evicts_cloid(#[case] status: OrderStatus) {
6706        let ws_client = make_ws_client();
6707        let (emitter, mut rx) = test_emitter();
6708        let state = WsDispatchState::new();
6709        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
6710
6711        let cid = ClientOrderId::from("O-HER-CANCEL");
6712        state.register_context(test_context(cid));
6713        state.insert_accepted(cid);
6714        state.record_venue_order_id(cid, VenueOrderId::new("v-cancel"));
6715
6716        ws_client.cache_cloid_mapping(cloid_for("O-HER-CANCEL"), cid);
6717
6718        let report = make_status_report(Some("O-HER-CANCEL"), "v-cancel", status);
6719        let (terminal_skipped, corrective) = handle_execution_report(
6720            ExecutionReport::Order(report),
6721            &state,
6722            &emitter,
6723            &ws_client,
6724            &make_http_client(),
6725            &mut pending_cloids,
6726            UnixNanos::default(),
6727        );
6728
6729        let events = drain_events(&mut rx);
6730        assert!(!terminal_skipped);
6731        assert!(corrective.is_none());
6732        assert_eq!(events.len(), 1);
6733
6734        match status {
6735            OrderStatus::Canceled => assert!(matches!(
6736                events[0],
6737                ExecutionEvent::Order(OrderEventAny::Canceled(_))
6738            )),
6739            OrderStatus::Rejected => assert!(matches!(
6740                events[0],
6741                ExecutionEvent::Order(OrderEventAny::Rejected(_))
6742            )),
6743            _ => unreachable!(),
6744        }
6745        assert_eq!(
6746            ws_client.get_cloid_mapping(&cloid_for("O-HER-CANCEL")),
6747            None
6748        );
6749        assert!(state.filled_orders.contains(&cid));
6750        assert!(state.lookup_context(&cid).is_none());
6751        assert_eq!(state.cached_venue_order_id(&cid), None);
6752    }
6753
6754    #[rstest]
6755    fn test_post_rejection_preserves_exact_reason_when_ws_rejection_arrives_first() {
6756        let ws_client = make_ws_client();
6757        let (emitter, mut rx) = test_emitter();
6758        let state = Arc::new(WsDispatchState::new());
6759        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
6760
6761        let cid = ClientOrderId::from("O-HER-WS-REJ");
6762        state.register_context(test_context(cid));
6763        state.mark_submission_pending(cid);
6764        ws_client.cache_cloid_mapping(cloid_for("O-HER-WS-REJ"), cid);
6765
6766        let report = make_status_report(Some("O-HER-WS-REJ"), "v-rej", OrderStatus::Rejected);
6767        handle_execution_report(
6768            ExecutionReport::Order(report),
6769            &state,
6770            &emitter,
6771            &ws_client,
6772            &make_http_client(),
6773            &mut pending_cloids,
6774            UnixNanos::default(),
6775        );
6776
6777        assert!(drain_events(&mut rx).is_empty());
6778        assert_eq!(
6779            ws_client.get_cloid_mapping(&cloid_for("O-HER-WS-REJ")),
6780            Some(cid),
6781        );
6782
6783        let order = limit_order_with_flags("O-HER-WS-REJ", false, true);
6784        let http_client = make_http_client();
6785        let tasks = TaskGroup::new();
6786        let rejection_route = PostRejectionRoute::new(
6787            &emitter,
6788            &ws_client,
6789            &http_client,
6790            state.clone(),
6791            tasks.spawner().unwrap(),
6792        );
6793        let emitted = rejection_route.emit_once(
6794            &order,
6795            "Post only order would have immediately matched, bbo was 56729.0.",
6796            UnixNanos::default(),
6797            &cloid_for("O-HER-WS-REJ"),
6798        );
6799
6800        let events = drain_events(&mut rx);
6801        let ExecutionEvent::Order(OrderEventAny::Rejected(rejected)) = &events[0] else {
6802            panic!("expected OrderRejected, received {:?}", events[0]);
6803        };
6804        assert!(emitted);
6805        assert_eq!(events.len(), 1);
6806        assert_eq!(
6807            rejected.reason,
6808            "Post only order would have immediately matched, bbo was 56729.0.",
6809        );
6810        assert!(rejected.due_post_only);
6811    }
6812
6813    #[rstest]
6814    fn test_post_rejection_suppresses_late_raw_cloid_reject() {
6815        let ws_client = make_ws_client();
6816        let (emitter, mut rx) = test_emitter();
6817        let state = Arc::new(WsDispatchState::new());
6818        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
6819
6820        let cid = ClientOrderId::from("O-HER-POST-REJ");
6821        let cloid = cloid_for("O-HER-POST-REJ");
6822        let order = limit_order_with_flags("O-HER-POST-REJ", false, true);
6823        state.register_context(test_context(cid));
6824        ws_client.cache_cloid_mapping(cloid, cid);
6825
6826        let http_client = make_http_client();
6827        let tasks = TaskGroup::new();
6828        let rejection_route = PostRejectionRoute::new(
6829            &emitter,
6830            &ws_client,
6831            &http_client,
6832            state.clone(),
6833            tasks.spawner().unwrap(),
6834        );
6835        let emitted = rejection_route.emit_once(
6836            &order,
6837            "Post only order would have immediately matched",
6838            UnixNanos::default(),
6839            &cloid,
6840        );
6841
6842        let events = drain_events(&mut rx);
6843        assert!(emitted);
6844        assert_eq!(events.len(), 1);
6845        let ExecutionEvent::Order(OrderEventAny::Rejected(rejected)) = &events[0] else {
6846            panic!("expected OrderRejected, received {:?}", events[0]);
6847        };
6848        assert_eq!(
6849            rejected.reason,
6850            "Post only order would have immediately matched",
6851        );
6852        assert!(rejected.due_post_only);
6853        assert_eq!(ws_client.get_cloid_mapping(&cloid), None);
6854        assert!(state.filled_orders.contains(&cid));
6855        assert!(state.terminal_cloid_seen(&cloid));
6856
6857        let late_reject = make_status_report(Some(cloid.as_str()), "v-rej", OrderStatus::Rejected);
6858        handle_execution_report(
6859            ExecutionReport::Order(late_reject),
6860            &state,
6861            &emitter,
6862            &ws_client,
6863            &make_http_client(),
6864            &mut pending_cloids,
6865            UnixNanos::default(),
6866        );
6867
6868        assert!(drain_events(&mut rx).is_empty());
6869    }
6870
6871    #[rstest]
6872    fn test_handle_execution_report_filled_marker_then_fill_evicts_on_fill() {
6873        // The status-only FILLED marker defers the cloid eviction to the
6874        // pending cache; the matching FillReport emits OrderFilled and then
6875        // evicts the cloid mapping as part of the deferred-cleanup path.
6876        let ws_client = make_ws_client();
6877        let (emitter, mut rx) = test_emitter();
6878        let state = WsDispatchState::new();
6879        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
6880
6881        let cid = ClientOrderId::from("O-HER-FILL");
6882        state.register_context(test_context(cid));
6883        state.insert_accepted(cid);
6884        state.record_venue_order_id(cid, VenueOrderId::new("v-fill"));
6885
6886        ws_client.cache_cloid_mapping(cloid_for("O-HER-FILL"), cid);
6887
6888        let status_marker = make_status_report(Some("O-HER-FILL"), "v-fill", OrderStatus::Filled);
6889        handle_execution_report(
6890            ExecutionReport::Order(status_marker),
6891            &state,
6892            &emitter,
6893            &ws_client,
6894            &make_http_client(),
6895            &mut pending_cloids,
6896            UnixNanos::default(),
6897        );
6898
6899        assert!(drain_events(&mut rx).is_empty());
6900        assert_eq!(
6901            ws_client.get_cloid_mapping(&cloid_for("O-HER-FILL")),
6902            Some(cid)
6903        );
6904
6905        let fill = make_fill_report(Some("O-HER-FILL"), "v-fill", "trade-fill");
6906        handle_execution_report(
6907            ExecutionReport::Fill(fill),
6908            &state,
6909            &emitter,
6910            &ws_client,
6911            &make_http_client(),
6912            &mut pending_cloids,
6913            UnixNanos::default(),
6914        );
6915
6916        let events = drain_events(&mut rx);
6917        assert_eq!(events.len(), 1);
6918        assert!(matches!(
6919            events[0],
6920            ExecutionEvent::Order(OrderEventAny::Filled(_))
6921        ));
6922        assert_eq!(ws_client.get_cloid_mapping(&cloid_for("O-HER-FILL")), None);
6923    }
6924
6925    /// GH-4270: when a status-only `FILLED` marker arrives before the
6926    /// replacement fill and the `ACCEPTED(new_voi)` is dropped, the fill itself
6927    /// promotes the binding (OrderUpdated then OrderFilled) and, being terminal
6928    /// and no longer buffered, completes the deferred cloid eviction.
6929    #[rstest]
6930    fn test_handle_execution_report_fill_under_filled_marker_promotes_and_evicts_cloid() {
6931        let ws_client = make_ws_client();
6932        let (emitter, mut rx) = test_emitter();
6933        let state = WsDispatchState::new();
6934        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
6935
6936        let cid = ClientOrderId::from("O-HER-BUF");
6937        state.register_context(test_context(cid));
6938        state.insert_accepted(cid);
6939        state.record_venue_order_id(cid, VenueOrderId::new("old-voi"));
6940        state.mark_pending_modify(
6941            cid,
6942            VenueOrderId::new("old-voi"),
6943            test_context(cid).quantity,
6944        );
6945
6946        ws_client.cache_cloid_mapping(cloid_for("O-HER-BUF"), cid);
6947
6948        let status_marker = make_status_report(Some("O-HER-BUF"), "new-voi", OrderStatus::Filled);
6949        handle_execution_report(
6950            ExecutionReport::Order(status_marker),
6951            &state,
6952            &emitter,
6953            &ws_client,
6954            &make_http_client(),
6955            &mut pending_cloids,
6956            UnixNanos::default(),
6957        );
6958        assert!(pending_cloids.contains(&cid));
6959        assert_eq!(
6960            ws_client.get_cloid_mapping(&cloid_for("O-HER-BUF")),
6961            Some(cid)
6962        );
6963
6964        let fill = make_fill_report(Some("O-HER-BUF"), "new-voi", "trade-buf");
6965        handle_execution_report(
6966            ExecutionReport::Fill(fill),
6967            &state,
6968            &emitter,
6969            &ws_client,
6970            &make_http_client(),
6971            &mut pending_cloids,
6972            UnixNanos::default(),
6973        );
6974
6975        let events = drain_events(&mut rx);
6976        assert_eq!(events.len(), 2);
6977        assert!(matches!(
6978            events[0],
6979            ExecutionEvent::Order(OrderEventAny::Updated(_))
6980        ));
6981        assert!(matches!(
6982            events[1],
6983            ExecutionEvent::Order(OrderEventAny::Filled(_))
6984        ));
6985        assert_eq!(state.buffered_fill_count(&cid), 0);
6986        assert!(
6987            !pending_cloids.contains(&cid),
6988            "deferred cleanup must complete once the promoting fill lands",
6989        );
6990        assert_eq!(
6991            ws_client.get_cloid_mapping(&cloid_for("O-HER-BUF")),
6992            None,
6993            "cloid mapping must be evicted after the terminal fill",
6994        );
6995    }
6996
6997    /// After a partial fill, the cancel-replace `OrderUpdated` must carry
6998    /// the user's absolute total, not the venue's remaining-only view.
6999    #[rstest]
7000    fn test_cancel_replace_emits_target_total_quantity() {
7001        let ws_client = make_ws_client();
7002        let (emitter, mut rx) = test_emitter();
7003        let state = WsDispatchState::new();
7004        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
7005
7006        let cid = ClientOrderId::from("O-HER-CR-QTY");
7007        let target_total = Quantity::from("0.00020");
7008        let venue_remaining = Quantity::from("0.00015");
7009
7010        let mut context = test_context(cid);
7011        context.quantity = target_total;
7012        state.register_context(context);
7013        state.insert_accepted(cid);
7014        state.record_venue_order_id(cid, VenueOrderId::new("old-voi"));
7015        state.mark_pending_modify(cid, VenueOrderId::new("old-voi"), target_total);
7016
7017        ws_client.cache_cloid_mapping(cloid_for("O-HER-CR-QTY"), cid);
7018
7019        let accepted = make_status_report_with_quantity(
7020            Some("O-HER-CR-QTY"),
7021            "new-voi",
7022            OrderStatus::Accepted,
7023            venue_remaining,
7024        );
7025        handle_execution_report(
7026            ExecutionReport::Order(accepted),
7027            &state,
7028            &emitter,
7029            &ws_client,
7030            &make_http_client(),
7031            &mut pending_cloids,
7032            UnixNanos::default(),
7033        );
7034
7035        let events = drain_events(&mut rx);
7036        assert_eq!(events.len(), 1);
7037        match &events[0] {
7038            ExecutionEvent::Order(OrderEventAny::Updated(updated)) => {
7039                assert_eq!(
7040                    updated.quantity, target_total,
7041                    "OrderUpdated must carry the engine's absolute total quantity",
7042                );
7043                assert_eq!(updated.venue_order_id, Some(VenueOrderId::new("new-voi")));
7044            }
7045            other => panic!("expected OrderUpdated, found {other:?}"),
7046        }
7047
7048        // Terminal-fill detection uses the context's absolute target quantity
7049        let context = state
7050            .lookup_context(&cid)
7051            .expect("context should still be tracked");
7052        assert_eq!(context.quantity, target_total);
7053
7054        assert!(state.pending_modify(&cid).is_none());
7055        assert!(state.pending_modify_target_qty(&cid).is_none());
7056        assert_eq!(
7057            state.cached_venue_order_id(&cid),
7058            Some(VenueOrderId::new("new-voi")),
7059        );
7060    }
7061
7062    /// Without a target-qty marker (e.g. reconcile-driven modifies), the
7063    /// promotion falls back to `report.quantity`.
7064    #[rstest]
7065    fn test_cancel_replace_without_marker_falls_back_to_report_quantity() {
7066        let ws_client = make_ws_client();
7067        let (emitter, mut rx) = test_emitter();
7068        let state = WsDispatchState::new();
7069        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
7070
7071        let cid = ClientOrderId::from("O-HER-CR-EXT");
7072        state.register_context(test_context(cid));
7073        state.insert_accepted(cid);
7074        state.record_venue_order_id(cid, VenueOrderId::new("old-voi"));
7075
7076        ws_client.cache_cloid_mapping(cloid_for("O-HER-CR-EXT"), cid);
7077
7078        let report_qty = Quantity::from("0.0005");
7079        let accepted = make_status_report_with_quantity(
7080            Some("O-HER-CR-EXT"),
7081            "new-voi",
7082            OrderStatus::Accepted,
7083            report_qty,
7084        );
7085        handle_execution_report(
7086            ExecutionReport::Order(accepted),
7087            &state,
7088            &emitter,
7089            &ws_client,
7090            &make_http_client(),
7091            &mut pending_cloids,
7092            UnixNanos::default(),
7093        );
7094
7095        let events = drain_events(&mut rx);
7096        assert_eq!(events.len(), 1);
7097        match &events[0] {
7098            ExecutionEvent::Order(OrderEventAny::Updated(updated)) => {
7099                assert_eq!(updated.quantity, report_qty);
7100            }
7101            other => panic!("expected OrderUpdated, found {other:?}"),
7102        }
7103    }
7104
7105    fn limit_request(size: Decimal) -> HyperliquidExchangePlaceOrderRequest {
7106        HyperliquidExchangePlaceOrderRequest {
7107            asset: 0,
7108            is_buy: true,
7109            price: "88.949".parse::<Decimal>().unwrap(),
7110            size,
7111            reduce_only: false,
7112            kind: HyperliquidExchangeOrderKind::Limit {
7113                limit: HyperliquidExchangeLimitParams {
7114                    tif: HyperliquidExchangeTif::Gtc,
7115                },
7116            },
7117            cloid: None,
7118        }
7119    }
7120
7121    /// A partial fill landing mid-modify leaves the cancel-replace replacement
7122    /// oversized (sized at the full target). The promotion must queue a
7123    /// corrective reduce to `target - filled` and re-arm the marker.
7124    #[rstest]
7125    fn test_cancel_replace_queues_corrective_reduce_on_in_flight_fill() {
7126        let ws_client = make_ws_client();
7127        let (emitter, mut rx) = test_emitter();
7128        let state = WsDispatchState::new();
7129        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
7130
7131        let cid = ClientOrderId::from("O-HER-4154");
7132        let target_total = Quantity::from("1.000");
7133        let old_voi = "445117664938";
7134        let new_voi = "445117686214";
7135
7136        let mut context = test_context(cid);
7137        context.quantity = target_total;
7138        state.register_context(context);
7139        state.insert_accepted(cid);
7140        state.record_venue_order_id(cid, VenueOrderId::new(old_voi));
7141
7142        state.mark_pending_modify(cid, VenueOrderId::new(old_voi), target_total);
7143        state.stash_modify_request(cid, limit_request(Decimal::from(1)));
7144
7145        state.record_filled_qty(cid, Quantity::from("0.165"));
7146
7147        let accepted = make_status_report_with_quantity(
7148            Some("O-HER-4154"),
7149            new_voi,
7150            OrderStatus::Accepted,
7151            Quantity::from("0.835"),
7152        );
7153        let (_, corrective) = handle_execution_report(
7154            ExecutionReport::Order(accepted),
7155            &state,
7156            &emitter,
7157            &ws_client,
7158            &make_http_client(),
7159            &mut pending_cloids,
7160            UnixNanos::default(),
7161        );
7162
7163        let events = drain_events(&mut rx);
7164        assert_eq!(events.len(), 1);
7165        match &events[0] {
7166            ExecutionEvent::Order(OrderEventAny::Updated(updated)) => {
7167                assert_eq!(updated.quantity, target_total);
7168                assert_eq!(updated.venue_order_id, Some(VenueOrderId::new(new_voi)));
7169            }
7170            other => panic!("expected OrderUpdated, found {other:?}"),
7171        }
7172
7173        let (corr_cid, queued) =
7174            corrective.expect("oversized replacement must queue a corrective reduce");
7175        assert_eq!(corr_cid, cid);
7176        assert_eq!(queued.oid, 445_117_686_214);
7177        assert_eq!(queued.request.size, "0.835".parse::<Decimal>().unwrap());
7178        // Marker re-armed on the new voi so the corrective's own cancel leg
7179        // is suppressed and a further in-flight fill chains another reduce.
7180        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new(new_voi)));
7181        assert_eq!(state.pending_modify_target_qty(&cid), Some(target_total));
7182
7183        // The queued generation is the reduce's own marker
7184        state.clear_modify_generation(&cid, queued.generation);
7185        assert!(!state.has_pending_modify(&cid));
7186    }
7187
7188    /// GH-4270: when the replacement ACCEPTED is dropped, a fill on the new leg
7189    /// promotes the binding and (parity with the ACCEPTED path) queues a corrective
7190    /// reduce when an earlier old-leg fill left the replacement oversized.
7191    #[rstest]
7192    fn test_cancel_replace_fill_promotion_queues_corrective_reduce() {
7193        let ws_client = make_ws_client();
7194        let (emitter, mut rx) = test_emitter();
7195        let state = WsDispatchState::new();
7196        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
7197
7198        let cid = ClientOrderId::from("O-HER-FILL-CORR");
7199        let target_total = Quantity::from("1.000");
7200        let old_voi = "445117664938";
7201        let new_voi = "445117686214";
7202
7203        let mut context = test_context(cid);
7204        context.quantity = target_total;
7205        state.register_context(context);
7206        state.insert_accepted(cid);
7207        state.record_venue_order_id(cid, VenueOrderId::new(old_voi));
7208        state.mark_pending_modify(cid, VenueOrderId::new(old_voi), target_total);
7209        state.stash_modify_request(cid, limit_request(Decimal::from(1)));
7210        state.record_filled_qty(cid, Quantity::from("0.165"));
7211
7212        let fill = make_fill_report_with_qty(
7213            Some("O-HER-FILL-CORR"),
7214            new_voi,
7215            "T-FILL-CORR",
7216            Quantity::from("0.100"),
7217        );
7218        let (_, corrective) = handle_execution_report(
7219            ExecutionReport::Fill(fill),
7220            &state,
7221            &emitter,
7222            &ws_client,
7223            &make_http_client(),
7224            &mut pending_cloids,
7225            UnixNanos::default(),
7226        );
7227
7228        let events = drain_events(&mut rx);
7229        assert_eq!(events.len(), 2);
7230        assert!(matches!(
7231            events[0],
7232            ExecutionEvent::Order(OrderEventAny::Updated(_))
7233        ));
7234        assert!(matches!(
7235            events[1],
7236            ExecutionEvent::Order(OrderEventAny::Filled(_))
7237        ));
7238
7239        let (corr_cid, queued) =
7240            corrective.expect("oversized replacement must queue a corrective reduce");
7241        assert_eq!(corr_cid, cid);
7242        assert_eq!(queued.oid, 445_117_686_214);
7243        assert_eq!(queued.request.size, "0.735".parse::<Decimal>().unwrap());
7244        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new(new_voi)));
7245    }
7246
7247    /// Without an in-flight fill the replacement is correctly sized, so the
7248    /// promotion must not queue a corrective reduce and must clear the marker.
7249    #[rstest]
7250    fn test_cancel_replace_no_corrective_without_in_flight_fill() {
7251        let ws_client = make_ws_client();
7252        let (emitter, mut rx) = test_emitter();
7253        let state = WsDispatchState::new();
7254        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
7255
7256        let cid = ClientOrderId::from("O-HER-4154-NOFILL");
7257        let target_total = Quantity::from("1.000");
7258
7259        let mut context = test_context(cid);
7260        context.quantity = target_total;
7261        state.register_context(context);
7262        state.insert_accepted(cid);
7263        state.record_venue_order_id(cid, VenueOrderId::new("445117664938"));
7264        state.mark_pending_modify(cid, VenueOrderId::new("445117664938"), target_total);
7265        state.stash_modify_request(cid, limit_request(Decimal::from(1)));
7266
7267        let accepted = make_status_report_with_quantity(
7268            Some("O-HER-4154-NOFILL"),
7269            "445117686214",
7270            OrderStatus::Accepted,
7271            target_total,
7272        );
7273        let (_, corrective) = handle_execution_report(
7274            ExecutionReport::Order(accepted),
7275            &state,
7276            &emitter,
7277            &ws_client,
7278            &make_http_client(),
7279            &mut pending_cloids,
7280            UnixNanos::default(),
7281        );
7282
7283        let _ = drain_events(&mut rx);
7284        assert!(corrective.is_none());
7285        assert!(state.pending_modify(&cid).is_none());
7286        assert!(state.take_corrective(&cid).is_none());
7287        assert!(state.modify_request(&cid).is_none());
7288    }
7289
7290    /// A fill buffered during the in-flight cancel-replace is drained before
7291    /// the corrective is computed, so the corrective must size from the
7292    /// post-drain cumulative. A pre-drain read would see 0 filled and skip it.
7293    #[rstest]
7294    fn test_cancel_replace_corrective_uses_post_drain_buffered_fill() {
7295        let ws_client = make_ws_client();
7296        let (emitter, mut rx) = test_emitter();
7297        let state = WsDispatchState::new();
7298        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
7299
7300        let cid = ClientOrderId::from("O-HER-4154-BUF");
7301        let target_total = Quantity::from("1.000");
7302        let new_voi = "445117686214";
7303
7304        let mut context = test_context(cid);
7305        context.quantity = target_total;
7306        state.register_context(context);
7307        state.insert_accepted(cid);
7308        state.record_venue_order_id(cid, VenueOrderId::new("445117664938"));
7309        state.mark_pending_modify(cid, VenueOrderId::new("445117664938"), target_total);
7310        state.stash_modify_request(cid, limit_request(Decimal::from(1)));
7311
7312        let buffered = make_fill_report_with_qty(
7313            Some("O-HER-4154-BUF"),
7314            new_voi,
7315            "trade-buf-4154",
7316            Quantity::from("0.165"),
7317        );
7318        state.buffer_fill(cid, buffered);
7319
7320        let accepted = make_status_report_with_quantity(
7321            Some("O-HER-4154-BUF"),
7322            new_voi,
7323            OrderStatus::Accepted,
7324            Quantity::from("0.835"),
7325        );
7326        let (_, corrective) = handle_execution_report(
7327            ExecutionReport::Order(accepted),
7328            &state,
7329            &emitter,
7330            &ws_client,
7331            &make_http_client(),
7332            &mut pending_cloids,
7333            UnixNanos::default(),
7334        );
7335
7336        let _ = drain_events(&mut rx);
7337        let (_, queued) =
7338            corrective.expect("buffered fill drained before compute must still queue a corrective");
7339        assert_eq!(queued.request.size, "0.835".parse::<Decimal>().unwrap());
7340    }
7341
7342    /// When an in-flight fill brings cumulative filled to exactly the target,
7343    /// the remaining is zero, so no corrective (a reduce-to-zero is not valid)
7344    /// must be queued and the marker is cleared.
7345    #[rstest]
7346    fn test_cancel_replace_no_corrective_when_filled_equals_target() {
7347        let ws_client = make_ws_client();
7348        let (emitter, mut rx) = test_emitter();
7349        let state = WsDispatchState::new();
7350        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
7351
7352        let cid = ClientOrderId::from("O-HER-4154-EXACT");
7353        let target_total = Quantity::from("1.000");
7354
7355        let mut context = test_context(cid);
7356        context.quantity = target_total;
7357        state.register_context(context);
7358        state.insert_accepted(cid);
7359        state.record_venue_order_id(cid, VenueOrderId::new("445117664938"));
7360        state.mark_pending_modify(cid, VenueOrderId::new("445117664938"), target_total);
7361        state.stash_modify_request(cid, limit_request(Decimal::from(1)));
7362        state.record_filled_qty(cid, target_total);
7363
7364        let accepted = make_status_report_with_quantity(
7365            Some("O-HER-4154-EXACT"),
7366            "445117686214",
7367            OrderStatus::Accepted,
7368            target_total,
7369        );
7370        let (_, corrective) = handle_execution_report(
7371            ExecutionReport::Order(accepted),
7372            &state,
7373            &emitter,
7374            &ws_client,
7375            &make_http_client(),
7376            &mut pending_cloids,
7377            UnixNanos::default(),
7378        );
7379
7380        let _ = drain_events(&mut rx);
7381        assert!(corrective.is_none());
7382        assert!(state.pending_modify(&cid).is_none());
7383    }
7384
7385    /// A further in-flight fill during the corrective's own modify chains
7386    /// another reduce: the second promotion sizes from the new cumulative.
7387    #[rstest]
7388    fn test_cancel_replace_chains_second_corrective_reduce() {
7389        let ws_client = make_ws_client();
7390        let (emitter, mut rx) = test_emitter();
7391        let state = WsDispatchState::new();
7392        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
7393
7394        let cid = ClientOrderId::from("O-HER-4154-CHAIN");
7395        let target_total = Quantity::from("1.000");
7396        let voi3 = "445117699999";
7397
7398        // State after the first corrective: marker re-armed on the prior
7399        // replacement, stashed request reduced to 0.835, 0.165 already filled.
7400        let mut context = test_context(cid);
7401        context.quantity = target_total;
7402        state.register_context(context);
7403        state.insert_accepted(cid);
7404        state.record_venue_order_id(cid, VenueOrderId::new("445117686214"));
7405        state.mark_pending_modify(cid, VenueOrderId::new("445117686214"), target_total);
7406        state.stash_modify_request(cid, limit_request("0.835".parse::<Decimal>().unwrap()));
7407        state.record_filled_qty(cid, Quantity::from("0.465"));
7408
7409        let accepted = make_status_report_with_quantity(
7410            Some("O-HER-4154-CHAIN"),
7411            voi3,
7412            OrderStatus::Accepted,
7413            Quantity::from("0.535"),
7414        );
7415        let (_, corrective) = handle_execution_report(
7416            ExecutionReport::Order(accepted),
7417            &state,
7418            &emitter,
7419            &ws_client,
7420            &make_http_client(),
7421            &mut pending_cloids,
7422            UnixNanos::default(),
7423        );
7424
7425        let _ = drain_events(&mut rx);
7426        let (_, queued) =
7427            corrective.expect("a further in-flight fill must chain another corrective");
7428        assert_eq!(queued.oid, 445_117_699_999);
7429        assert_eq!(queued.request.size, "0.535".parse::<Decimal>().unwrap());
7430        assert_eq!(state.pending_modify(&cid), Some(VenueOrderId::new(voi3)));
7431    }
7432
7433    #[rstest]
7434    fn test_handle_execution_report_external_terminal_evicts_cloid() {
7435        // External (untracked) terminal reports forward to the engine via
7436        // send_order_status_report and immediately evict the cloid mapping
7437        // so the client does not leak mappings for orders it does not own.
7438        let ws_client = make_ws_client();
7439        let (emitter, mut rx) = test_emitter();
7440        let state = WsDispatchState::new();
7441        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
7442
7443        let cid = ClientOrderId::from("O-HER-EXT");
7444        ws_client.cache_cloid_mapping(cloid_for("O-HER-EXT"), cid);
7445
7446        let report = make_status_report(Some("O-HER-EXT"), "v-ext", OrderStatus::Canceled);
7447        handle_execution_report(
7448            ExecutionReport::Order(report),
7449            &state,
7450            &emitter,
7451            &ws_client,
7452            &make_http_client(),
7453            &mut pending_cloids,
7454            UnixNanos::default(),
7455        );
7456
7457        let events = drain_events(&mut rx);
7458        assert_eq!(events.len(), 1);
7459        assert!(
7460            matches!(events[0], ExecutionEvent::Report(_)),
7461            "external terminal report should forward to the engine as a report",
7462        );
7463        assert_eq!(ws_client.get_cloid_mapping(&cloid_for("O-HER-EXT")), None);
7464    }
7465
7466    #[rstest]
7467    fn test_handle_execution_report_open_status_preserves_cloid() {
7468        let ws_client = make_ws_client();
7469        let (emitter, _rx) = test_emitter();
7470        let state = WsDispatchState::new();
7471        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
7472
7473        let cid = ClientOrderId::from("O-HER-OPEN");
7474        state.register_context(test_context(cid));
7475        ws_client.cache_cloid_mapping(cloid_for("O-HER-OPEN"), cid);
7476
7477        let report = make_status_report(Some("O-HER-OPEN"), "v-open", OrderStatus::Accepted);
7478        handle_execution_report(
7479            ExecutionReport::Order(report),
7480            &state,
7481            &emitter,
7482            &ws_client,
7483            &make_http_client(),
7484            &mut pending_cloids,
7485            UnixNanos::default(),
7486        );
7487
7488        assert_eq!(
7489            ws_client.get_cloid_mapping(&cloid_for("O-HER-OPEN")),
7490            Some(cid)
7491        );
7492    }
7493
7494    #[rstest]
7495    fn test_handle_execution_report_tracked_accepted_emits_typed_event() {
7496        // A tracked open ACCEPTED must flow through the typed-event path,
7497        // NOT the raw report fallback. Catches a mutation that swaps the
7498        // branch polarity inside `handle_execution_report`.
7499        let ws_client = make_ws_client();
7500        let (emitter, mut rx) = test_emitter();
7501        let state = WsDispatchState::new();
7502        let mut pending_cloids: FifoCache<ClientOrderId, 10_000> = FifoCache::new();
7503
7504        let cid = ClientOrderId::from("O-HER-ACC");
7505        state.register_context(test_context(cid));
7506        ws_client.cache_cloid_mapping(cloid_for("O-HER-ACC"), cid);
7507
7508        let report = make_status_report(Some("O-HER-ACC"), "v-acc", OrderStatus::Accepted);
7509        handle_execution_report(
7510            ExecutionReport::Order(report),
7511            &state,
7512            &emitter,
7513            &ws_client,
7514            &make_http_client(),
7515            &mut pending_cloids,
7516            UnixNanos::default(),
7517        );
7518
7519        let events = drain_events(&mut rx);
7520        assert_eq!(events.len(), 1);
7521        assert!(
7522            matches!(events[0], ExecutionEvent::Order(OrderEventAny::Accepted(_))),
7523            "tracked accepted should route through the typed-event path",
7524        );
7525        assert_eq!(
7526            ws_client.get_cloid_mapping(&cloid_for("O-HER-ACC")),
7527            Some(cid)
7528        );
7529    }
7530
7531    fn outcome_limit_order(id: &str, reduce_only: bool) -> OrderAny {
7532        outcome_limit_order_full(id, reduce_only, false, TimeInForce::Gtc)
7533    }
7534
7535    fn outcome_limit_order_full(
7536        id: &str,
7537        reduce_only: bool,
7538        post_only: bool,
7539        time_in_force: TimeInForce,
7540    ) -> OrderAny {
7541        OrderAny::Limit(LimitOrder::new(
7542            TraderId::from("TESTER-001"),
7543            StrategyId::from("S-001"),
7544            InstrumentId::from("1-YES-OUTCOME.HYPERLIQUID"),
7545            ClientOrderId::from(id),
7546            OrderSide::Buy,
7547            Quantity::from("1"),
7548            Price::from("0.5000"),
7549            time_in_force,
7550            None,
7551            post_only,
7552            reduce_only,
7553            false,
7554            None,
7555            None,
7556            None,
7557            None,
7558            None,
7559            None,
7560            None,
7561            None,
7562            None,
7563            None,
7564            None,
7565            Default::default(),
7566            Default::default(),
7567        ))
7568    }
7569
7570    fn outcome_stop_order(id: &str) -> OrderAny {
7571        OrderAny::StopMarket(StopMarketOrder::new(
7572            TraderId::from("TESTER-001"),
7573            StrategyId::from("S-001"),
7574            InstrumentId::from("1-YES-OUTCOME.HYPERLIQUID"),
7575            ClientOrderId::from(id),
7576            OrderSide::Sell,
7577            Quantity::from("1"),
7578            Price::from("0.4000"),
7579            TriggerType::LastPrice,
7580            TimeInForce::Gtc,
7581            None,
7582            false,
7583            false,
7584            None,
7585            None,
7586            None,
7587            None,
7588            None,
7589            None,
7590            None,
7591            None,
7592            None,
7593            None,
7594            None,
7595            Default::default(),
7596            Default::default(),
7597        ))
7598    }
7599
7600    fn perp_with_unsupported_symbol(id: &str) -> OrderAny {
7601        OrderAny::Limit(LimitOrder::new(
7602            TraderId::from("TESTER-001"),
7603            StrategyId::from("S-001"),
7604            InstrumentId::from("BTC-USD-FOO.HYPERLIQUID"),
7605            ClientOrderId::from(id),
7606            OrderSide::Buy,
7607            Quantity::from("1"),
7608            Price::from("100.0"),
7609            TimeInForce::Gtc,
7610            None,
7611            false,
7612            false,
7613            false,
7614            None,
7615            None,
7616            None,
7617            None,
7618            None,
7619            None,
7620            None,
7621            None,
7622            None,
7623            None,
7624            None,
7625            Default::default(),
7626            Default::default(),
7627        ))
7628    }
7629
7630    #[rstest]
7631    fn test_validate_accepts_perp_limit_order() {
7632        let order = limit_order("O-VAL-PERP", false, None, None, None);
7633        validate_order_for_hyperliquid(&order).unwrap();
7634    }
7635
7636    #[rstest]
7637    #[case::gtc_post_only(true, TimeInForce::Gtc)]
7638    #[case::gtc_taker(false, TimeInForce::Gtc)]
7639    #[case::ioc_post_only(true, TimeInForce::Ioc)]
7640    #[case::ioc_taker(false, TimeInForce::Ioc)]
7641    fn test_validate_accepts_outcome_limit_order(
7642        #[case] post_only: bool,
7643        #[case] time_in_force: TimeInForce,
7644    ) {
7645        let order = outcome_limit_order_full(
7646            "O-VAL-OUTCOME",
7647            /* reduce_only */ false,
7648            post_only,
7649            time_in_force,
7650        );
7651        validate_order_for_hyperliquid(&order).unwrap();
7652    }
7653
7654    #[rstest]
7655    fn test_validate_rejects_outcome_reduce_only() {
7656        let order = outcome_limit_order("O-VAL-RO", true);
7657        let err = validate_order_for_hyperliquid(&order).unwrap_err();
7658        assert!(
7659            err.to_string().contains("Reduce-only is not supported"),
7660            "unexpected error: {err}",
7661        );
7662    }
7663
7664    #[rstest]
7665    fn test_validate_rejects_outcome_trigger_order() {
7666        let order = outcome_stop_order("O-VAL-TRIG");
7667        let err = validate_order_for_hyperliquid(&order).unwrap_err();
7668        assert!(
7669            err.to_string()
7670                .contains("Trigger order types are not supported"),
7671            "unexpected error: {err}",
7672        );
7673    }
7674
7675    #[rstest]
7676    fn test_validate_rejects_unsupported_symbol_suffix() {
7677        let order = perp_with_unsupported_symbol("O-VAL-BAD");
7678        let err = validate_order_for_hyperliquid(&order).unwrap_err();
7679        assert!(
7680            err.to_string()
7681                .contains("Unsupported instrument symbol format"),
7682            "unexpected error: {err}",
7683        );
7684    }
7685
7686    #[rstest]
7687    #[tokio::test]
7688    async fn test_post_command_deadline_before_enqueue_is_not_sent() {
7689        let wallet = PrivateKeySigner::random();
7690        let key = Zeroizing::new(format!("{:#x}", wallet.to_bytes()));
7691        let signer = HyperliquidHttpClient::from_credentials(
7692            &key,
7693            None,
7694            HyperliquidEnvironment::Testnet,
7695            10,
7696            None,
7697        )
7698        .unwrap();
7699        let mut client = HyperliquidWebSocketClient::new(
7700            None,
7701            HyperliquidEnvironment::Testnet,
7702            None,
7703            TransportBackend::default(),
7704            None,
7705        );
7706        client.set_post_timeout(std::time::Duration::ZERO);
7707        let action = HyperliquidExchangeAction::Cancel {
7708            cancels: Vec::new(),
7709            fast: None,
7710        };
7711        let failure = client
7712            .post_action_command(&signer, &action)
7713            .await
7714            .map_err(classify_post_failure)
7715            .unwrap_err();
7716        assert_eq!(failure, CommandFailure::not_sent("timeout"));
7717    }
7718
7719    #[rstest]
7720    #[tokio::test]
7721    #[ignore = "live testnet rejection proof with disposable unfunded signer"]
7722    async fn live_testnet_unfunded_command_rejections() {
7723        let wallet = PrivateKeySigner::random();
7724        let key = Zeroizing::new(format!("{:#x}", wallet.to_bytes()));
7725        let signer = HyperliquidHttpClient::from_credentials(
7726            &key,
7727            None,
7728            HyperliquidEnvironment::Testnet,
7729            10,
7730            None,
7731        )
7732        .unwrap();
7733        let mut client = HyperliquidWebSocketClient::new(
7734            None,
7735            HyperliquidEnvironment::Testnet,
7736            None,
7737            TransportBackend::default(),
7738            None,
7739        );
7740        client.set_post_timeout(std::time::Duration::from_secs(10));
7741        client.connect().await.unwrap();
7742        let actions = [
7743            HyperliquidExchangeAction::Order {
7744                orders: Vec::new(),
7745                grouping: HyperliquidExchangeGrouping::Na,
7746                builder: None,
7747            },
7748            HyperliquidExchangeAction::Cancel {
7749                cancels: vec![HyperliquidExchangeCancelOrderRequest { asset: 0, oid: 0 }],
7750                fast: None,
7751            },
7752        ];
7753        let result = tokio::time::timeout(std::time::Duration::from_secs(30), async {
7754            let mut outcomes = Vec::new();
7755            for action in &actions {
7756                outcomes.push(
7757                    client
7758                        .post_action_command(&signer, action)
7759                        .await
7760                        .map_err(classify_post_failure),
7761                );
7762            }
7763            outcomes
7764        })
7765        .await;
7766        client.disconnect().await.unwrap();
7767        for (index, outcome) in result.unwrap().into_iter().enumerate() {
7768            let failure = outcome.unwrap_err();
7769            let CommandFailure::VenueRejected(reason) = &failure else {
7770                panic!("Expected authoritative venue rejection, found {failure:?}");
7771            };
7772            let address = format!("{:#x}", wallet.address());
7773            assert_eq!(
7774                reason.to_lowercase(),
7775                format!("user or api wallet {address} does not exist."),
7776            );
7777            eprintln!(
7778                "Live action {index}: {}",
7779                reason
7780                    .to_lowercase()
7781                    .replace(&address, "<disposable-signer>")
7782            );
7783
7784            if index == 1 {
7785                let (emitter, mut rx) = test_emitter();
7786                let entry = cancel_entry("O-LIVE-UNFUNDED", false);
7787                let ts = get_atomic_clock_realtime().get_time_ns();
7788                super::emit_cancel_failure(&emitter, &entry, failure.clone(), ts);
7789                let ExecutionEvent::Order(OrderEventAny::CancelRejected(event)) =
7790                    rx.try_recv().unwrap()
7791                else {
7792                    panic!("Expected cancel rejection");
7793                };
7794                assert_eq!(event.reason.as_str(), reason);
7795                assert_eq!(event.trader_id, TraderId::from("TESTER-001"));
7796                assert_eq!(event.strategy_id, entry.strategy_id);
7797                assert_eq!(event.instrument_id, entry.instrument_id);
7798                assert_eq!(event.client_order_id, entry.client_order_id);
7799                assert_eq!(event.venue_order_id, entry.venue_order_id);
7800                assert_eq!(event.account_id, Some(AccountId::from("HYPERLIQUID-001")));
7801                assert_eq!(event.ts_event, ts);
7802                assert!(!event.reconciliation);
7803                assert_eq!(event.causation_id, None);
7804                assert!(rx.try_recv().is_err());
7805            }
7806        }
7807    }
7808
7809    #[rstest]
7810    fn test_reconnect_reports_from_history_returns_missed_events_oldest_first() {
7811        let mut http_client = make_http_client();
7812        http_client.set_account_id(AccountId::from("HYPERLIQUID-001"));
7813        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
7814        let defs = parse_perp_instruments(&meta, 0).unwrap();
7815        http_client
7816            .cache_instrument(&create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap());
7817        let ws_client = make_ws_client();
7818        let dispatch_state = WsDispatchState::new();
7819        let filled_id = ClientOrderId::new("O-RECOVER-001");
7820        let accepted_id = ClientOrderId::new("O-RECOVER-002");
7821        let filled_cloid = Cloid::from_client_order_id(filled_id).to_hex();
7822        let accepted_cloid = Cloid::from_client_order_id(accepted_id).to_hex();
7823        let external_cloid = "0x0000000000000000000000000000000a";
7824        ws_client.cache_cloid_mapping(Ustr::from(&filled_cloid), filled_id);
7825        ws_client.cache_cloid_mapping(Ustr::from(&accepted_cloid), accepted_id);
7826        let start_ms = 1_700_000_000_000;
7827        dispatch_state.check_and_insert_trade(btc_fill_trade_id(1001, "0xa3", start_ms + 10));
7828
7829        let fills = vec![
7830            btc_user_fill(1001, "0xa1", start_ms - 1, &filled_cloid),
7831            btc_user_fill(1001, "0xa2", start_ms + 20, &filled_cloid),
7832            btc_user_fill(1001, "0xa3", start_ms + 10, &filled_cloid),
7833            btc_user_fill(2002, "0xb1", start_ms, external_cloid),
7834        ];
7835        let mut filled_order = btc_order_entry(4004, "filled", start_ms + 30, &filled_cloid);
7836        filled_order.order.sz = Decimal::ZERO;
7837
7838        let orders = vec![
7839            btc_order_entry(1001, "canceled", start_ms + 20, &filled_cloid),
7840            btc_order_entry(3003, "open", start_ms, &accepted_cloid),
7841            filled_order,
7842            btc_order_entry(5005, "canceled", start_ms + 40, external_cloid),
7843            btc_order_entry(6006, "canceled", start_ms - 1, &accepted_cloid),
7844            btc_order_entry(7010, "canceled", start_ms + 50, external_cloid),
7845            btc_order_entry(7009, "canceled", start_ms + 50, external_cloid),
7846            btc_order_entry(7008, "canceled", start_ms + 50, external_cloid),
7847            btc_order_entry(7007, "canceled", start_ms + 50, external_cloid),
7848        ];
7849
7850        let reports = reconnect_reports_from_history(
7851            fills,
7852            orders,
7853            UnixNanos::from(start_ms * 1_000_000),
7854            &ws_client,
7855            &http_client,
7856            &dispatch_state,
7857        )
7858        .unwrap();
7859
7860        let summary: Vec<_> = reports
7861            .iter()
7862            .map(|report| match report {
7863                ExecutionReport::Order(report) => (
7864                    report.ts_last.as_millis(),
7865                    report.venue_order_id,
7866                    report.client_order_id,
7867                    Some(report.order_status),
7868                    None,
7869                ),
7870                ExecutionReport::Fill(report) => (
7871                    report.ts_event.as_millis(),
7872                    report.venue_order_id,
7873                    report.client_order_id,
7874                    None,
7875                    Some(report.trade_id),
7876                ),
7877            })
7878            .collect();
7879
7880        assert_eq!(
7881            summary,
7882            vec![
7883                (
7884                    start_ms,
7885                    VenueOrderId::new("2002"),
7886                    None,
7887                    None,
7888                    Some(btc_fill_trade_id(2002, "0xb1", start_ms)),
7889                ),
7890                (
7891                    start_ms,
7892                    VenueOrderId::new("3003"),
7893                    Some(accepted_id),
7894                    Some(OrderStatus::Accepted),
7895                    None,
7896                ),
7897                (
7898                    start_ms + 20,
7899                    VenueOrderId::new("1001"),
7900                    Some(filled_id),
7901                    None,
7902                    Some(btc_fill_trade_id(1001, "0xa2", start_ms + 20)),
7903                ),
7904                (
7905                    start_ms + 20,
7906                    VenueOrderId::new("1001"),
7907                    Some(filled_id),
7908                    Some(OrderStatus::Canceled),
7909                    None,
7910                ),
7911                (
7912                    start_ms + 40,
7913                    VenueOrderId::new("5005"),
7914                    Some(ClientOrderId::new(external_cloid)),
7915                    Some(OrderStatus::Canceled),
7916                    None,
7917                ),
7918                (
7919                    start_ms + 50,
7920                    VenueOrderId::new("7007"),
7921                    Some(ClientOrderId::new(external_cloid)),
7922                    Some(OrderStatus::Canceled),
7923                    None,
7924                ),
7925                (
7926                    start_ms + 50,
7927                    VenueOrderId::new("7008"),
7928                    Some(ClientOrderId::new(external_cloid)),
7929                    Some(OrderStatus::Canceled),
7930                    None,
7931                ),
7932                (
7933                    start_ms + 50,
7934                    VenueOrderId::new("7009"),
7935                    Some(ClientOrderId::new(external_cloid)),
7936                    Some(OrderStatus::Canceled),
7937                    None,
7938                ),
7939                (
7940                    start_ms + 50,
7941                    VenueOrderId::new("7010"),
7942                    Some(ClientOrderId::new(external_cloid)),
7943                    Some(OrderStatus::Canceled),
7944                    None,
7945                ),
7946            ],
7947        );
7948    }
7949
7950    #[rstest]
7951    fn test_ts_venue_reads_venue_time_not_receipt_time() {
7952        let mut order = make_status_report(None, "1001", OrderStatus::Accepted);
7953        order.ts_last = UnixNanos::from(1_000);
7954        order.ts_init = UnixNanos::from(9_000);
7955        let mut fill = make_fill_report(None, "1001", "T-1");
7956        fill.ts_event = UnixNanos::from(2_000);
7957        fill.ts_init = UnixNanos::from(9_000);
7958
7959        assert_eq!(
7960            ts_venue(&ExecutionReport::Order(order)),
7961            UnixNanos::from(1_000)
7962        );
7963        assert_eq!(
7964            ts_venue(&ExecutionReport::Fill(fill)),
7965            UnixNanos::from(2_000)
7966        );
7967    }
7968
7969    #[rstest]
7970    #[case::external_modify_chain_filled(
7971        true, Some("100"), vec![],
7972        vec![("100", OrderStatus::Canceled, 1, 2), ("101", OrderStatus::Canceled, 2, 3), ("102", OrderStatus::Filled, 3, 4)],
7973        vec![("101", OrderStatus::Accepted, 2, "0.0001", "0"), ("102", OrderStatus::Accepted, 3, "0.0001", "0"), ("102", OrderStatus::Filled, 4, "0.0001", "0.0001")],
7974        None,
7975    )]
7976    #[case::closed_bound_leg_keeps_pending_modify(
7977        true, Some("100"), vec![("100", "0.0002")],
7978        vec![("100", OrderStatus::Canceled, 1, 2)],
7979        vec![("100", OrderStatus::Canceled, 2, "0.0001", "0")],
7980        Some(("100", "0.0002")),
7981    )]
7982    #[case::failed_replacement_is_no_leg(
7983        true, Some("100"), vec![("100", "0.0002")],
7984        vec![("100", OrderStatus::Canceled, 1, 2), ("101", OrderStatus::Rejected, 2, 2)],
7985        vec![("100", OrderStatus::Canceled, 2, "0.0001", "0")],
7986        Some(("100", "0.0002")),
7987    )]
7988    #[case::open_unseen_replacement_is_its_own_opening(
7989        true, Some("100"), vec![("100", "0.0002")],
7990        vec![("100", OrderStatus::Canceled, 1, 2), ("101", OrderStatus::Accepted, 2, 2)],
7991        vec![("101", OrderStatus::Accepted, 2, "0.0001", "0")],
7992        Some(("100", "0.0002")),
7993    )]
7994    #[case::closed_unseen_replacement_opens_before_its_close(
7995        true, Some("100"), vec![("100", "0.0002")],
7996        vec![("100", OrderStatus::Canceled, 1, 2), ("101", OrderStatus::Canceled, 2, 3)],
7997        vec![("101", OrderStatus::Accepted, 2, "0.0001", "0"), ("101", OrderStatus::Canceled, 3, "0.0001", "0")],
7998        Some(("100", "0.0002")),
7999    )]
8000    #[case::each_unseen_leg_opens_in_turn(
8001        true, Some("100"), vec![("100", "0.0002"), ("100", "0.0003")],
8002        vec![("100", OrderStatus::Canceled, 1, 2), ("101", OrderStatus::Canceled, 2, 3), ("102", OrderStatus::Canceled, 3, 4)],
8003        vec![("101", OrderStatus::Accepted, 2, "0.0001", "0"), ("102", OrderStatus::Accepted, 3, "0.0001", "0"), ("102", OrderStatus::Canceled, 4, "0.0001", "0")],
8004        Some(("100", "0.0002")),
8005    )]
8006    #[case::triggered_unseen_replacement_opens_first(
8007        true, Some("100"), vec![],
8008        vec![("100", OrderStatus::Canceled, 1, 2), ("101", OrderStatus::Triggered, 2, 3)],
8009        vec![("101", OrderStatus::Accepted, 2, "0.0001", "0"), ("101", OrderStatus::Triggered, 3, "0.0001", "0")],
8010        None,
8011    )]
8012    #[case::binding_newer_than_history(
8013        true, Some("102"), vec![],
8014        vec![("100", OrderStatus::Canceled, 1, 2), ("101", OrderStatus::Canceled, 2, 3)],
8015        vec![("101", OrderStatus::Canceled, 3, "0.0001", "0")],
8016        None,
8017    )]
8018    #[case::untracked_order_passes_through(
8019        false, None, vec![],
8020        vec![("100", OrderStatus::Canceled, 1, 2), ("101", OrderStatus::Accepted, 2, 2)],
8021        vec![("100", OrderStatus::Canceled, 2, "0.0001", "0"), ("101", OrderStatus::Accepted, 2, "0.0001", "0")],
8022        None,
8023    )]
8024    fn test_resolve_order_legs(
8025        #[case] tracked: bool,
8026        #[case] bound_venue_order_id: Option<&str>,
8027        #[case] pending_modifies: Vec<(&str, &str)>,
8028        #[case] legs: Vec<(&str, OrderStatus, u64, u64)>,
8029        #[case] expected: Vec<(&str, OrderStatus, u64, &str, &str)>,
8030        #[case] front_modify_after: Option<(&str, &str)>,
8031    ) {
8032        let state = WsDispatchState::new();
8033        let client_order_id = ClientOrderId::new("O-LEGS");
8034
8035        if tracked {
8036            state.register_context(test_context(client_order_id));
8037        }
8038
8039        if let Some(voi) = bound_venue_order_id {
8040            state.record_venue_order_id(client_order_id, VenueOrderId::new(voi));
8041        }
8042
8043        for (old_leg, target) in pending_modifies {
8044            state.mark_pending_modify(
8045                client_order_id,
8046                VenueOrderId::new(old_leg),
8047                Quantity::from(target),
8048            );
8049        }
8050
8051        let reports = legs
8052            .into_iter()
8053            .map(|(voi, status, ts_accepted, ts_last)| {
8054                let mut report = make_status_report(Some("O-LEGS"), voi, status);
8055                report.ts_accepted = UnixNanos::from(ts_accepted);
8056                report.ts_last = UnixNanos::from(ts_last);
8057
8058                if status == OrderStatus::Filled {
8059                    report.filled_qty = report.quantity;
8060                }
8061
8062                report
8063            })
8064            .collect();
8065
8066        let resolved: Vec<(String, OrderStatus, u64, String, String)> =
8067            resolve_order_legs(reports, &state)
8068                .iter()
8069                .map(|report| {
8070                    (
8071                        report.venue_order_id.to_string(),
8072                        report.order_status,
8073                        report.ts_last.as_u64(),
8074                        report.quantity.as_decimal().normalize().to_string(),
8075                        report.filled_qty.as_decimal().normalize().to_string(),
8076                    )
8077                })
8078                .collect();
8079
8080        let expected: Vec<(String, OrderStatus, u64, String, String)> = expected
8081            .into_iter()
8082            .map(|(voi, status, ts_last, quantity, filled_qty)| {
8083                (
8084                    voi.to_string(),
8085                    status,
8086                    ts_last,
8087                    quantity.to_string(),
8088                    filled_qty.to_string(),
8089                )
8090            })
8091            .collect();
8092
8093        let front_modify = state
8094            .pending_modify(&client_order_id)
8095            .zip(state.pending_modify_target_qty(&client_order_id))
8096            .map(|(old_leg, target)| {
8097                (
8098                    old_leg.to_string(),
8099                    target.as_decimal().normalize().to_string(),
8100                )
8101            });
8102
8103        let front_modify_after =
8104            front_modify_after.map(|(old_leg, target)| (old_leg.to_string(), target.to_string()));
8105
8106        assert_eq!(resolved, expected);
8107        assert_eq!(front_modify, front_modify_after);
8108    }
8109
8110    fn btc_user_fill(oid: u64, hash: &str, time: u64, cloid: &str) -> HyperliquidFill {
8111        serde_json::from_value(serde_json::json!({
8112            "coin": "BTC",
8113            "px": "100000.0",
8114            "sz": "0.001",
8115            "side": "B",
8116            "time": time,
8117            "startPosition": "0.0",
8118            "dir": "Open Long",
8119            "closedPnl": "0.0",
8120            "hash": hash,
8121            "oid": oid,
8122            "crossed": false,
8123            "fee": "0.04",
8124            "tid": 1,
8125            "feeToken": "USDC",
8126            "cloid": cloid,
8127        }))
8128        .unwrap()
8129    }
8130
8131    fn btc_order_entry(
8132        oid: u64,
8133        status: &str,
8134        status_timestamp: u64,
8135        cloid: &str,
8136    ) -> HyperliquidOrderStatusEntry {
8137        serde_json::from_value(serde_json::json!({
8138            "order": {
8139                "coin": "BTC",
8140                "side": "B",
8141                "limitPx": "100000.0",
8142                "sz": "0.002",
8143                "oid": oid,
8144                "timestamp": status_timestamp,
8145                "origSz": "0.002",
8146                "reduceOnly": false,
8147                "orderType": "Limit",
8148                "tif": "Gtc",
8149                "cloid": cloid,
8150            },
8151            "status": status,
8152            "statusTimestamp": status_timestamp,
8153        }))
8154        .unwrap()
8155    }
8156
8157    fn btc_fill_trade_id(oid: u64, hash: &str, time: u64) -> TradeId {
8158        make_fill_trade_id(
8159            hash,
8160            oid,
8161            rust_decimal_macros::dec!(100000.0),
8162            rust_decimal_macros::dec!(0.001),
8163            time,
8164            rust_decimal_macros::dec!(0.0),
8165        )
8166    }
8167}