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