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