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