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