1use std::{
24 collections::HashMap,
25 num::NonZeroU32,
26 sync::{Arc, LazyLock},
27 time::Duration,
28};
29
30use ahash::AHashMap;
31use anyhow::Context;
32use nautilus_common::cache::InstrumentLookupError;
33use nautilus_core::{
34 AtomicMap, UUID4, UnixNanos,
35 consts::NAUTILUS_USER_AGENT,
36 datetime::datetime_to_unix_nanos,
37 time::{AtomicTime, get_atomic_clock_realtime},
38};
39use nautilus_model::{
40 data::{Bar, BarType},
41 enums::{
42 AccountType, BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce,
43 TriggerType,
44 },
45 events::AccountState,
46 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
47 instruments::{CurrencyPair, Instrument, InstrumentAny},
48 orders::{Order, OrderAny},
49 reports::{FillReport, OrderStatusReport, PositionStatusReport},
50 types::{AccountBalance, Currency, Price, Quantity},
51};
52use nautilus_network::{
53 http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
54 ratelimiter::quota::Quota,
55};
56use parking_lot::Mutex;
57use rust_decimal::Decimal;
58use serde_json::Value;
59use ustr::Ustr;
60
61use crate::{
62 account::resolve_execution_account_address,
63 common::{
64 consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url},
65 credential::{Secrets, VaultAddress, credential_env_vars},
66 enums::{
67 HyperliquidBarInterval, HyperliquidEnvironment,
68 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
69 },
70 parse::{
71 bar_type_to_interval, cache_alias_for_symbol, clamp_price_to_precision,
72 derive_limit_from_trigger, determine_order_list_grouping, extract_inner_error,
73 normalize_price, order_to_hyperliquid_request_with_asset_and_cloid,
74 parse_combined_account_balances_and_margins, parse_spot_account_balances,
75 parse_trigger_order_type, round_to_sig_figs, time_in_force_to_hyperliquid_tif,
76 },
77 },
78 data::candle_to_bar,
79 data_types::HyperliquidPublicTrade,
80 http::{
81 error::{Error, Result},
82 models::{
83 ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeAction,
84 HyperliquidExchangeBuilderFee, HyperliquidExchangeCancelByCloidRequest,
85 HyperliquidExchangeCancelOrderRequest, HyperliquidExchangeGrouping,
86 HyperliquidExchangeLimitParams, HyperliquidExchangeMergeOutcomeParams,
87 HyperliquidExchangeMergeQuestionParams, HyperliquidExchangeModifyOrderRequest,
88 HyperliquidExchangeModifyTarget, HyperliquidExchangeNegateOutcomeParams,
89 HyperliquidExchangeOrderKind, HyperliquidExchangeOrderResponseData,
90 HyperliquidExchangeOrderStatus, HyperliquidExchangePlaceOrderRequest,
91 HyperliquidExchangeRequest, HyperliquidExchangeResponse,
92 HyperliquidExchangeSplitOutcomeParams, HyperliquidExchangeTif, HyperliquidExchangeTpSl,
93 HyperliquidExchangeTriggerParams, HyperliquidExchangeUserOutcomeOp, HyperliquidFills,
94 HyperliquidFundingHistoryEntry, HyperliquidL2Book, HyperliquidMeta,
95 HyperliquidOrderStatus, HyperliquidOrderStatusEntry, HyperliquidRecentTrade,
96 OutcomeMeta, PerpDex, PerpMeta, PerpMetaAndCtxs, RESPONSE_STATUS_OK,
97 SpotClearinghouseState, SpotMeta, SpotMetaAndCtxs,
98 },
99 parse::{
100 HyperliquidInstrumentDef, filter_recent_public_trades, instruments_from_defs_owned,
101 parse_fill_report, parse_order_status_report_from_basic, parse_outcome_instruments,
102 parse_perp_instruments_with_settlement, parse_position_status_report,
103 parse_recent_public_trade, parse_spot_instruments, parse_spot_position_status_report,
104 resolve_perp_settlement_currency,
105 },
106 query::{ExchangeAction, InfoRequest},
107 rate_limits::{
108 RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
109 exec_action_weight, info_base_weight, info_extra_weight,
110 },
111 },
112 signing::{
113 HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
114 },
115 websocket::messages::WsBasicOrderData,
116};
117
118fn deduplicate_historical_order_reports(reports: Vec<OrderStatusReport>) -> Vec<OrderStatusReport> {
119 let mut best_by_venue_order_id = AHashMap::new();
120
121 for candidate in reports {
122 let Some(current) = best_by_venue_order_id.remove(&candidate.venue_order_id) else {
123 best_by_venue_order_id.insert(candidate.venue_order_id, candidate);
124 continue;
125 };
126 let (mut best, other) = if historical_report_is_more_advanced(&candidate, ¤t) {
127 (candidate, current)
128 } else {
129 (current, candidate)
130 };
131
132 if matches!(
133 best.order_type,
134 OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
135 ) {
136 best.price = best.price.or(other.price);
137 }
138 best.trigger_price = best.trigger_price.or(other.trigger_price);
139 best_by_venue_order_id.insert(best.venue_order_id, best);
140 }
141
142 best_by_venue_order_id.into_values().collect()
143}
144
145fn historical_report_is_more_advanced(
146 candidate: &OrderStatusReport,
147 current: &OrderStatusReport,
148) -> bool {
149 candidate.filled_qty > current.filled_qty
150 || (candidate.filled_qty == current.filled_qty
151 && (historical_status_priority(candidate.order_status)
152 > historical_status_priority(current.order_status)
153 || (candidate.order_status == current.order_status
154 && candidate.ts_last > current.ts_last)))
155}
156
157const fn historical_status_priority(status: OrderStatus) -> u8 {
158 match status {
159 OrderStatus::Initialized | OrderStatus::Submitted | OrderStatus::Emulated => 0,
160 OrderStatus::Released | OrderStatus::Denied => 1,
161 OrderStatus::Accepted | OrderStatus::PendingUpdate | OrderStatus::PendingCancel => 2,
162 OrderStatus::Triggered => 3,
163 OrderStatus::PartiallyFilled => 4,
164 OrderStatus::Canceled | OrderStatus::Expired | OrderStatus::Rejected => 5,
165 OrderStatus::Filled | OrderStatus::Voided => 6,
166 }
167}
168
169pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
171 LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
172
173const HYPERLIQUID_RECENT_HISTORY_LIMIT: usize = 2_000;
174const VAULT_TOKEN_PREFIX: &str = "vntls:";
175
176#[derive(Debug, Clone)]
181#[cfg_attr(
182 feature = "python",
183 pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
184)]
185pub struct HyperliquidRawHttpClient {
186 client: HttpClient,
187 environment: HyperliquidEnvironment,
188 base_info: String,
189 base_exchange: String,
190 signer: Option<HyperliquidEip712Signer>,
191 nonce_manager: Option<Arc<NonceManager>>,
192 vault_address: Option<VaultAddress>,
193 rest_limiter: Arc<WeightedLimiter>,
194 rate_limit_backoff_base: Duration,
195 rate_limit_backoff_cap: Duration,
196 rate_limit_max_attempts_info: u32,
197}
198
199impl HyperliquidRawHttpClient {
200 pub fn new(
206 environment: HyperliquidEnvironment,
207 timeout_secs: u64,
208 proxy_url: Option<String>,
209 ) -> std::result::Result<Self, HttpClientError> {
210 Ok(Self {
211 client: HttpClient::builder()
212 .headers(Self::default_headers())
213 .default_quota(*HYPERLIQUID_REST_QUOTA)
214 .timeout_secs(timeout_secs)
215 .maybe_proxy_url(proxy_url)
216 .build()?,
217 environment,
218 base_info: info_url(environment).to_string(),
219 base_exchange: exchange_url(environment).to_string(),
220 signer: None,
221 nonce_manager: None,
222 vault_address: None,
223 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
224 rate_limit_backoff_base: Duration::from_millis(125),
225 rate_limit_backoff_cap: Duration::from_secs(5),
226 rate_limit_max_attempts_info: 3,
227 })
228 }
229
230 pub fn with_credentials(
237 secrets: &Secrets,
238 timeout_secs: u64,
239 proxy_url: Option<String>,
240 ) -> std::result::Result<Self, HttpClientError> {
241 let signer = HyperliquidEip712Signer::new(&secrets.private_key)
242 .map_err(|e| HttpClientError::from(e.to_string()))?;
243 let nonce_manager = Arc::new(NonceManager::new());
244
245 Ok(Self {
246 client: HttpClient::builder()
247 .headers(Self::default_headers())
248 .default_quota(*HYPERLIQUID_REST_QUOTA)
249 .timeout_secs(timeout_secs)
250 .maybe_proxy_url(proxy_url)
251 .build()?,
252 environment: secrets.environment,
253 base_info: info_url(secrets.environment).to_string(),
254 base_exchange: exchange_url(secrets.environment).to_string(),
255 signer: Some(signer),
256 nonce_manager: Some(nonce_manager),
257 vault_address: secrets.vault_address,
258 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
259 rate_limit_backoff_base: Duration::from_millis(125),
260 rate_limit_backoff_cap: Duration::from_secs(5),
261 rate_limit_max_attempts_info: 3,
262 })
263 }
264
265 pub fn set_base_info_url(&mut self, url: String) {
267 self.base_info = url;
268 }
269
270 pub fn set_base_exchange_url(&mut self, url: String) {
272 self.base_exchange = url;
273 }
274
275 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
281 let secrets = Secrets::from_env(environment)
282 .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
283 Self::with_credentials(&secrets, 60, None)
284 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
285 }
286
287 pub fn from_credentials(
293 private_key: &str,
294 vault_address: Option<&str>,
295 environment: HyperliquidEnvironment,
296 timeout_secs: u64,
297 proxy_url: Option<String>,
298 ) -> Result<Self> {
299 let secrets = Secrets::from_private_key(private_key, vault_address, environment)
300 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
301 Self::with_credentials(&secrets, timeout_secs, proxy_url)
302 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
303 }
304
305 #[must_use]
307 pub fn with_rate_limits(mut self) -> Self {
308 self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
309 self.rate_limit_backoff_base = Duration::from_millis(125);
310 self.rate_limit_backoff_cap = Duration::from_secs(5);
311 self.rate_limit_max_attempts_info = 3;
312 self
313 }
314
315 #[must_use]
317 pub fn environment(&self) -> HyperliquidEnvironment {
318 self.environment
319 }
320
321 #[must_use]
323 pub fn is_testnet(&self) -> bool {
324 self.environment == HyperliquidEnvironment::Testnet
325 }
326
327 pub fn get_user_address(&self) -> Result<String> {
333 self.signer
334 .as_ref()
335 .ok_or_else(|| Error::auth("No signer configured"))?
336 .address()
337 }
338
339 #[must_use]
341 pub fn has_vault_address(&self) -> bool {
342 self.vault_address.is_some()
343 }
344
345 pub fn get_account_address(&self) -> Result<String> {
352 if let Some(vault) = &self.vault_address {
353 Ok(vault.to_hex())
354 } else {
355 self.get_user_address()
356 }
357 }
358
359 fn default_headers() -> HashMap<String, String> {
360 HashMap::from([
361 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
362 ("Content-Type".to_string(), "application/json".to_string()),
363 ])
364 }
365
366 fn signer_id(&self) -> SignerId {
367 SignerId("hyperliquid:default".into())
368 }
369
370 fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
371 let retry_after = headers.get("retry-after")?;
372 retry_after.parse::<u64>().ok().map(|s| s * 1000) }
374
375 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
377 let request = InfoRequest::meta();
378 let response = self.send_info_request(&request).await?;
379 serde_json::from_value(response).map_err(Error::Serde)
380 }
381
382 pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
384 let request = InfoRequest::spot_meta();
385 let response = self.send_info_request(&request).await?;
386 serde_json::from_value(response).map_err(Error::Serde)
387 }
388
389 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
391 let request = InfoRequest::meta_and_asset_ctxs();
392 let response = self.send_info_request(&request).await?;
393 serde_json::from_value(response).map_err(Error::Serde)
394 }
395
396 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
398 let request = InfoRequest::spot_meta_and_asset_ctxs();
399 let response = self.send_info_request(&request).await?;
400 serde_json::from_value(response).map_err(Error::Serde)
401 }
402
403 pub async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
405 let request = InfoRequest::outcome_meta();
406 let response = self.send_info_request(&request).await?;
407 serde_json::from_value(response).map_err(Error::Serde)
408 }
409
410 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
411 let request = InfoRequest::meta();
412 let response = self.send_info_request(&request).await?;
413 serde_json::from_value(response).map_err(Error::Serde)
414 }
415
416 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
418 let request = InfoRequest::all_perp_metas();
419 let response = self.send_info_request(&request).await?;
420 serde_json::from_value(response).map_err(Error::Serde)
421 }
422
423 pub(crate) async fn load_perp_dexs(&self) -> Result<Vec<Option<PerpDex>>> {
425 let request = InfoRequest::perp_dexs();
426 let response = self.send_info_request(&request).await?;
427 serde_json::from_value(response).map_err(Error::Serde)
428 }
429
430 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
432 let request = InfoRequest::l2_book(coin);
433 let response = self.send_info_request(&request).await?;
434 serde_json::from_value(response).map_err(Error::Serde)
435 }
436
437 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
442 let request = InfoRequest::recent_trades(coin);
443 let response = self.send_info_request(&request).await?;
444 serde_json::from_value(response).map_err(Error::Serde)
445 }
446
447 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
449 let request = InfoRequest::user_fills(user);
450 let response = self.send_info_request(&request).await?;
451 serde_json::from_value(response).map_err(Error::Serde)
452 }
453
454 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
456 let request = InfoRequest::order_status(user, oid);
457 let response = self.send_info_request(&request).await?;
458 serde_json::from_value(response).map_err(Error::Serde)
459 }
460
461 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
463 let request = InfoRequest::open_orders(user);
464 self.send_info_request(&request).await
465 }
466
467 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
469 self.info_frontend_open_orders_for_dex(user, None).await
470 }
471
472 async fn info_frontend_open_orders_for_dex(
473 &self,
474 user: &str,
475 dex: Option<&str>,
476 ) -> Result<Value> {
477 let request = InfoRequest::frontend_open_orders_for_dex(user, dex);
478 self.send_info_request(&request).await
479 }
480
481 pub async fn info_historical_orders(
483 &self,
484 user: &str,
485 ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
486 let request = InfoRequest::historical_orders(user);
487 let response = self.send_info_request(&request).await?;
488 serde_json::from_value(response).map_err(Error::Serde)
489 }
490
491 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
493 self.info_clearinghouse_state_for_dex(user, None).await
494 }
495
496 async fn info_clearinghouse_state_for_dex(
497 &self,
498 user: &str,
499 dex: Option<&str>,
500 ) -> Result<Value> {
501 let request = InfoRequest::clearinghouse_state_for_dex(user, dex);
502 self.send_info_request(&request).await
503 }
504
505 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
507 let request = InfoRequest::spot_clearinghouse_state(user);
508 self.send_info_request(&request).await
509 }
510
511 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
513 let request = InfoRequest::user_fees(user);
514 self.send_info_request(&request).await
515 }
516
517 pub async fn info_candle_snapshot(
519 &self,
520 coin: &str,
521 interval: HyperliquidBarInterval,
522 start_time: u64,
523 end_time: u64,
524 ) -> Result<HyperliquidCandleSnapshot> {
525 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
526 let response = self.send_info_request(&request).await?;
527
528 log::trace!(
529 "Candle snapshot raw response (len={}): {:?}",
530 response.as_array().map_or(0, |a| a.len()),
531 response
532 );
533
534 serde_json::from_value(response).map_err(Error::Serde)
535 }
536
537 pub async fn info_funding_history(
542 &self,
543 coin: &str,
544 start_time: u64,
545 end_time: Option<u64>,
546 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
547 let request = InfoRequest::funding_history(coin, start_time, end_time);
548 let response = self.send_info_request(&request).await?;
549 serde_json::from_value(response).map_err(Error::Serde)
550 }
551
552 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
554 self.send_info_request(request).await
555 }
556
557 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
558 let base_w = info_base_weight(request);
559 self.rest_limiter.acquire(base_w).await;
560
561 let mut attempt = 0u32;
562
563 loop {
564 let response = self.http_roundtrip_info(request).await?;
565
566 if response.status.is_success() {
567 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
569 let extra = info_extra_weight(request, &val);
570 if extra > 0 {
571 self.rest_limiter.debit_extra(extra).await;
572 log::debug!(
573 "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
574 );
575 }
576 return Ok(val);
577 }
578
579 if response.status.as_u16() == 429 {
581 if attempt >= self.rate_limit_max_attempts_info {
582 let ra = self.parse_retry_after_simple(&response.headers);
583 return Err(Error::rate_limit("info", base_w, ra));
584 }
585 let delay = self
586 .parse_retry_after_simple(&response.headers)
587 .map_or_else(
588 || {
589 backoff_full_jitter(
590 attempt,
591 self.rate_limit_backoff_base,
592 self.rate_limit_backoff_cap,
593 )
594 },
595 Duration::from_millis,
596 );
597 log::warn!(
598 "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
599 delay.as_millis()
600 );
601 attempt += 1;
602 tokio::time::sleep(delay).await;
603 self.rest_limiter.acquire(1).await;
605 continue;
606 }
607
608 if (response.status.is_server_error() || response.status.as_u16() == 408)
610 && attempt < self.rate_limit_max_attempts_info
611 {
612 let delay = backoff_full_jitter(
613 attempt,
614 self.rate_limit_backoff_base,
615 self.rate_limit_backoff_cap,
616 );
617 log::warn!(
618 "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
619 response.status.as_u16(),
620 delay.as_millis()
621 );
622 attempt += 1;
623 tokio::time::sleep(delay).await;
624 continue;
625 }
626
627 let error_body = String::from_utf8_lossy(&response.body);
629 return Err(Error::http(
630 response.status.as_u16(),
631 error_body.to_string(),
632 ));
633 }
634 }
635
636 async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
637 let url = &self.base_info;
638 let body = serde_json::to_value(request).map_err(Error::Serde)?;
639 let body_bytes = serde_json::to_string(&body)
640 .map_err(Error::Serde)?
641 .into_bytes();
642
643 self.client
644 .request(
645 Method::POST,
646 url.clone(),
647 None,
648 None,
649 Some(body_bytes),
650 None,
651 None,
652 )
653 .await
654 .map_err(Error::from_http_client)
655 }
656
657 pub async fn post_action(
659 &self,
660 action: &ExchangeAction,
661 ) -> Result<HyperliquidExchangeResponse> {
662 let w = exchange_weight(action);
663 self.rest_limiter.acquire(w).await;
664
665 let signer = self
666 .signer
667 .as_ref()
668 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
669
670 let nonce_manager = self
671 .nonce_manager
672 .as_ref()
673 .ok_or_else(|| Error::auth("nonce manager missing"))?;
674
675 let signer_id = self.signer_id();
676 let time_nonce = nonce_manager.next(signer_id)?;
677
678 let action_bytes = rmp_serde::to_vec_named(action)
680 .context("serialize action with MessagePack")
681 .map_err(|e| Error::bad_request(e.to_string()))?;
682
683 let sign_request = SignRequest {
684 action: None,
685 action_bytes: Some(action_bytes),
686 time_nonce,
687 action_type: HyperliquidActionType::L1,
688 is_testnet: self.is_testnet(),
689 vault_address: self.vault_address,
690 expires_after: None,
691 };
692
693 let sig = signer.sign(&sign_request)?.signature;
694
695 let nonce_u64 = time_nonce.as_millis() as u64;
696
697 let request = if let Some(vault) = self.vault_address {
698 HyperliquidExchangeRequest::with_vault(
699 action.clone(),
700 nonce_u64,
701 sig,
702 vault.to_string(),
703 )
704 } else {
705 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
706 };
707
708 let response = self.http_roundtrip_exchange(&request).await?;
709
710 if response.status.is_success() {
711 let parsed_response: HyperliquidExchangeResponse =
712 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
713
714 match &parsed_response {
716 HyperliquidExchangeResponse::Status {
717 status,
718 response: response_data,
719 } if status == "err" => {
720 let error_msg = response_data
721 .as_str()
722 .map_or_else(|| response_data.to_string(), |s| s.to_string());
723 log::error!("Hyperliquid API returned error: {error_msg}");
724 Err(Error::bad_request(format!("API error: {error_msg}")))
725 }
726 HyperliquidExchangeResponse::Error { error } => {
727 log::error!("Hyperliquid API returned error: {error}");
728 Err(Error::bad_request(format!("API error: {error}")))
729 }
730 _ => Ok(parsed_response),
731 }
732 } else if response.status.as_u16() == 429 {
733 let ra = self.parse_retry_after_simple(&response.headers);
734 Err(Error::rate_limit("exchange", w, ra))
735 } else {
736 let error_body = String::from_utf8_lossy(&response.body);
737 log::error!(
738 "Exchange API error (status {}): {}",
739 response.status.as_u16(),
740 error_body
741 );
742 Err(Error::http(
743 response.status.as_u16(),
744 error_body.to_string(),
745 ))
746 }
747 }
748
749 pub fn sign_action_exec_request(
751 &self,
752 action: &HyperliquidExchangeAction,
753 expires_after: Option<u64>,
754 ) -> Result<HyperliquidExchangeRequest<HyperliquidExchangeAction>> {
755 let signer = self
756 .signer
757 .as_ref()
758 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
759
760 let nonce_manager = self
761 .nonce_manager
762 .as_ref()
763 .ok_or_else(|| Error::auth("nonce manager missing"))?;
764
765 let signer_id = self.signer_id();
766 let time_nonce = nonce_manager.next(signer_id)?;
767 let action_bytes = rmp_serde::to_vec_named(action)
771 .context("serialize action with MessagePack")
772 .map_err(|e| Error::bad_request(e.to_string()))?;
773
774 let sig = signer
775 .sign(&SignRequest {
776 action: None,
777 action_bytes: Some(action_bytes),
778 time_nonce,
779 action_type: HyperliquidActionType::L1,
780 is_testnet: self.is_testnet(),
781 vault_address: self.vault_address,
782 expires_after,
783 })?
784 .signature;
785
786 let mut request = if let Some(vault) = self.vault_address {
787 HyperliquidExchangeRequest::with_vault(
788 action.clone(),
789 time_nonce.as_millis() as u64,
790 sig,
791 vault.to_string(),
792 )
793 } else {
794 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
795 };
796 request.expires_after = expires_after;
797 Ok(request)
798 }
799
800 pub async fn post_action_exec(
805 &self,
806 action: &HyperliquidExchangeAction,
807 ) -> Result<HyperliquidExchangeResponse> {
808 let w = exec_action_weight(action);
809 self.rest_limiter.acquire(w).await;
810
811 let request = self.sign_action_exec_request(action, None)?;
812
813 let response = self.http_roundtrip_exchange(&request).await?;
814
815 if response.status.is_success() {
816 let parsed_response: HyperliquidExchangeResponse =
817 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
818
819 match &parsed_response {
821 HyperliquidExchangeResponse::Status {
822 status,
823 response: response_data,
824 } if status == "err" => {
825 let error_msg = response_data
826 .as_str()
827 .map_or_else(|| response_data.to_string(), |s| s.to_string());
828 log::error!("Hyperliquid API returned error: {error_msg}");
829 Err(Error::bad_request(format!("API error: {error_msg}")))
830 }
831 HyperliquidExchangeResponse::Error { error } => {
832 log::error!("Hyperliquid API returned error: {error}");
833 Err(Error::bad_request(format!("API error: {error}")))
834 }
835 _ => Ok(parsed_response),
836 }
837 } else if response.status.as_u16() == 429 {
838 let ra = self.parse_retry_after_simple(&response.headers);
839 Err(Error::rate_limit("exchange", w, ra))
840 } else {
841 let error_body = String::from_utf8_lossy(&response.body);
842 Err(Error::http(
843 response.status.as_u16(),
844 error_body.to_string(),
845 ))
846 }
847 }
848
849 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
851 self.rest_limiter.snapshot().await
852 }
853 async fn http_roundtrip_exchange<T>(
854 &self,
855 request: &HyperliquidExchangeRequest<T>,
856 ) -> Result<HttpResponse>
857 where
858 T: serde::Serialize,
859 {
860 let url = &self.base_exchange;
861 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
862 let body_bytes = body.into_bytes();
863
864 let response = self
865 .client
866 .request(
867 Method::POST,
868 url.clone(),
869 None,
870 None,
871 Some(body_bytes),
872 None,
873 None,
874 )
875 .await
876 .map_err(Error::from_http_client)?;
877
878 Ok(response)
879 }
880}
881
882#[derive(Debug, Clone)]
888#[cfg_attr(
889 feature = "python",
890 pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
891)]
892#[cfg_attr(
893 feature = "python",
894 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
895)]
896pub struct HyperliquidHttpClient {
897 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
898 clock: &'static AtomicTime,
899 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
900 instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
901 asset_indices: Arc<AtomicMap<Ustr, u32>>,
903 spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
905 client_order_id_cloids: Arc<Mutex<AHashMap<ClientOrderId, Cloid>>>,
906 account_id: Option<AccountId>,
907 account_address: Option<String>,
911 normalize_prices: bool,
912 market_order_slippage_bps: u32,
913 include_builder_attribution: bool,
914}
915
916impl Default for HyperliquidHttpClient {
917 fn default() -> Self {
918 Self::new(HyperliquidEnvironment::Mainnet, 60, None)
919 .expect("Failed to create default Hyperliquid HTTP client")
920 }
921}
922
923impl HyperliquidHttpClient {
924 pub fn new(
930 environment: HyperliquidEnvironment,
931 timeout_secs: u64,
932 proxy_url: Option<String>,
933 ) -> std::result::Result<Self, HttpClientError> {
934 let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
935 Ok(Self::from_raw(raw_client))
936 }
937
938 pub fn with_secrets(
944 secrets: &Secrets,
945 timeout_secs: u64,
946 proxy_url: Option<String>,
947 ) -> std::result::Result<Self, HttpClientError> {
948 let raw_client =
949 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
950 Ok(Self::from_raw(raw_client))
951 }
952
953 fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
954 Self {
955 inner: Arc::new(raw_client),
956 clock: get_atomic_clock_realtime(),
957 instruments: Arc::new(AtomicMap::new()),
958 instruments_by_coin: Arc::new(AtomicMap::new()),
959 asset_indices: Arc::new(AtomicMap::new()),
960 spot_fill_coins: Arc::new(AtomicMap::new()),
961 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
962 account_id: None,
963 account_address: None,
964 normalize_prices: true,
965 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
966 include_builder_attribution: true,
967 }
968 }
969
970 #[must_use]
972 pub fn get_or_generate_client_order_id_cloid(&self, client_order_id: ClientOrderId) -> Cloid {
973 let mut cloids = self.client_order_id_cloids.lock();
974 *cloids
975 .entry(client_order_id)
976 .or_insert_with(|| Cloid::from_client_order_id(client_order_id))
977 }
978
979 pub fn cache_client_order_id_cloid(&self, client_order_id: ClientOrderId, cloid: Cloid) {
981 self.client_order_id_cloids
982 .lock()
983 .entry(client_order_id)
984 .or_insert(cloid);
985 }
986
987 #[must_use]
989 pub fn cached_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
990 self.client_order_id_cloids
991 .lock()
992 .get(client_order_id)
993 .copied()
994 }
995
996 #[must_use]
999 pub(crate) fn unique_cached_client_order_id_cloid(
1000 &self,
1001 client_order_id: &ClientOrderId,
1002 ) -> Option<Cloid> {
1003 let cloids = self.client_order_id_cloids.lock();
1004 let cloid = cloids.get(client_order_id).copied()?;
1005 let mapping_count = cloids
1006 .values()
1007 .filter(|cached_cloid| **cached_cloid == cloid)
1008 .count();
1009
1010 (mapping_count == 1).then_some(cloid)
1011 }
1012
1013 pub fn remove_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
1015 self.client_order_id_cloids.lock().remove(client_order_id)
1016 }
1017
1018 pub fn set_base_info_url(&mut self, url: String) {
1024 Arc::get_mut(&mut self.inner)
1025 .expect("cannot override URL: Arc has multiple references")
1026 .set_base_info_url(url);
1027 }
1028
1029 pub fn set_base_exchange_url(&mut self, url: String) {
1035 Arc::get_mut(&mut self.inner)
1036 .expect("cannot override URL: Arc has multiple references")
1037 .set_base_exchange_url(url);
1038 }
1039
1040 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
1046 let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
1047 Ok(Self {
1048 inner: Arc::new(raw_client),
1049 clock: get_atomic_clock_realtime(),
1050 instruments: Arc::new(AtomicMap::new()),
1051 instruments_by_coin: Arc::new(AtomicMap::new()),
1052 asset_indices: Arc::new(AtomicMap::new()),
1053 spot_fill_coins: Arc::new(AtomicMap::new()),
1054 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1055 account_id: None,
1056 account_address: None,
1057 normalize_prices: true,
1058 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1059 include_builder_attribution: true,
1060 })
1061 }
1062
1063 pub fn with_credentials(
1076 private_key: Option<String>,
1077 vault_address: Option<String>,
1078 account_address: Option<&str>,
1079 environment: HyperliquidEnvironment,
1080 timeout_secs: u64,
1081 proxy_url: Option<String>,
1082 ) -> Result<Self> {
1083 let (pk_env_var, vault_env_var) = credential_env_vars(environment);
1084
1085 let resolved_account_address = resolve_execution_account_address(
1086 private_key.as_deref(),
1087 vault_address.as_deref(),
1088 account_address,
1089 environment,
1090 )?;
1091
1092 let resolved_pk = private_key.or_else(|| std::env::var(pk_env_var).ok());
1094
1095 let resolved_vault = vault_address.or_else(|| std::env::var(vault_env_var).ok());
1097
1098 Self::from_resolved_credentials(
1099 resolved_pk,
1100 resolved_vault.as_deref(),
1101 resolved_account_address,
1102 environment,
1103 timeout_secs,
1104 proxy_url,
1105 )
1106 }
1107
1108 fn from_resolved_credentials(
1109 private_key: Option<String>,
1110 vault_address: Option<&str>,
1111 account_address: Option<String>,
1112 environment: HyperliquidEnvironment,
1113 timeout_secs: u64,
1114 proxy_url: Option<String>,
1115 ) -> Result<Self> {
1116 match private_key {
1117 Some(pk) => {
1118 let raw_client = HyperliquidRawHttpClient::from_credentials(
1119 &pk,
1120 vault_address,
1121 environment,
1122 timeout_secs,
1123 proxy_url,
1124 )?;
1125 Ok(Self {
1126 inner: Arc::new(raw_client),
1127 clock: get_atomic_clock_realtime(),
1128 instruments: Arc::new(AtomicMap::new()),
1129 instruments_by_coin: Arc::new(AtomicMap::new()),
1130 asset_indices: Arc::new(AtomicMap::new()),
1131 spot_fill_coins: Arc::new(AtomicMap::new()),
1132 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1133 account_id: None,
1134 account_address,
1135 normalize_prices: true,
1136 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1137 include_builder_attribution: true,
1138 })
1139 }
1140 None => {
1141 let mut client = Self::new(environment, timeout_secs, proxy_url)
1143 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))?;
1144 client.set_account_address(account_address);
1145 Ok(client)
1146 }
1147 }
1148 }
1149
1150 pub fn from_credentials(
1156 private_key: &str,
1157 vault_address: Option<&str>,
1158 environment: HyperliquidEnvironment,
1159 timeout_secs: u64,
1160 proxy_url: Option<String>,
1161 ) -> Result<Self> {
1162 let raw_client = HyperliquidRawHttpClient::from_credentials(
1163 private_key,
1164 vault_address,
1165 environment,
1166 timeout_secs,
1167 proxy_url,
1168 )?;
1169 Ok(Self {
1170 inner: Arc::new(raw_client),
1171 clock: get_atomic_clock_realtime(),
1172 instruments: Arc::new(AtomicMap::new()),
1173 instruments_by_coin: Arc::new(AtomicMap::new()),
1174 asset_indices: Arc::new(AtomicMap::new()),
1175 spot_fill_coins: Arc::new(AtomicMap::new()),
1176 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1177 account_id: None,
1178 account_address: None,
1179 normalize_prices: true,
1180 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1181 include_builder_attribution: true,
1182 })
1183 }
1184
1185 #[must_use]
1187 pub fn is_testnet(&self) -> bool {
1188 self.inner.is_testnet()
1189 }
1190
1191 #[must_use]
1193 pub fn normalize_prices(&self) -> bool {
1194 self.normalize_prices
1195 }
1196
1197 pub fn set_normalize_prices(&mut self, value: bool) {
1199 self.normalize_prices = value;
1200 }
1201
1202 #[must_use]
1204 pub fn market_order_slippage_bps(&self) -> u32 {
1205 self.market_order_slippage_bps
1206 }
1207
1208 pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1210 self.market_order_slippage_bps = value;
1211 }
1212
1213 #[must_use]
1215 pub fn include_builder_attribution(&self) -> bool {
1216 self.include_builder_attribution
1217 }
1218
1219 pub fn set_include_builder_attribution(&mut self, value: bool) {
1221 self.include_builder_attribution = value;
1222 }
1223
1224 pub fn get_user_address(&self) -> Result<String> {
1230 self.inner.get_user_address()
1231 }
1232
1233 #[must_use]
1235 pub fn has_vault_address(&self) -> bool {
1236 self.inner.has_vault_address()
1237 }
1238
1239 #[must_use]
1244 pub fn builder_attribution(&self) -> Option<HyperliquidExchangeBuilderFee> {
1245 if !self.include_builder_attribution || self.has_vault_address() || self.is_testnet() {
1246 None
1247 } else {
1248 Some(HyperliquidExchangeBuilderFee {
1249 address: NAUTILUS_BUILDER_ADDRESS.to_string(),
1250 fee_tenths_bp: 0,
1251 })
1252 }
1253 }
1254
1255 pub fn get_account_address(&self) -> Result<String> {
1263 if let Some(addr) = &self.account_address {
1264 return Ok(addr.clone());
1265 }
1266 self.inner.get_account_address()
1267 }
1268
1269 pub fn set_account_address(&mut self, address: Option<String>) {
1271 self.account_address = address;
1272 }
1273
1274 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1279 let full_symbol = instrument.symbol().inner();
1280 let coin = instrument.raw_symbol().inner();
1281
1282 self.instruments.rcu(|m| {
1283 m.insert(full_symbol, instrument.clone());
1284 m.insert(coin, instrument.clone());
1286 });
1287
1288 if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
1290 self.instruments_by_coin.rcu(|m| {
1291 m.insert((coin, product_type), instrument.clone());
1292
1293 if let Some(alias_ustr) = cache_alias_for_symbol(full_symbol.as_str())
1318 .map(|alias| Ustr::from(alias.as_str()))
1319 {
1320 let key = (alias_ustr, product_type);
1321 if alias_ustr != coin && !m.contains_key(&key) {
1322 m.insert(key, instrument.clone());
1323 }
1324 }
1325 });
1326 } else {
1327 log::warn!("Unable to determine product type for symbol: {full_symbol}");
1328 }
1329 }
1330
1331 fn get_or_create_instrument(
1332 &self,
1333 coin: &Ustr,
1334 product_type: Option<HyperliquidProductType>,
1335 ) -> Option<InstrumentAny> {
1336 if let Some(pt) = product_type
1337 && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1338 {
1339 return Some(instrument.clone());
1340 }
1341
1342 if product_type.is_none() {
1346 let guard = self.instruments_by_coin.load();
1347
1348 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Outcome)) {
1349 return Some(instrument.clone());
1350 }
1351
1352 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1353 return Some(instrument.clone());
1354 }
1355
1356 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1357 return Some(instrument.clone());
1358 }
1359 }
1360
1361 if coin.as_str().starts_with('@')
1363 && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1364 {
1365 if let Some(instrument) = self.instruments.load().get(symbol) {
1368 return Some(instrument.clone());
1369 }
1370 }
1371
1372 if coin.as_str().starts_with(VAULT_TOKEN_PREFIX) {
1374 log::debug!("Creating synthetic instrument for vault token: {coin}");
1375
1376 let ts_event = self.clock.get_time_ns();
1377
1378 let symbol_str = format!("{coin}-USDC-SPOT");
1380 let symbol = Symbol::new(&symbol_str);
1381 let venue = *HYPERLIQUID_VENUE;
1382 let instrument_id = InstrumentId::new(symbol, venue);
1383
1384 let base_currency = Currency::new(
1386 coin.as_str(),
1387 8, 0, coin.as_str(),
1390 CurrencyType::Crypto,
1391 );
1392
1393 let quote_currency = Currency::new(
1394 "USDC",
1395 6, 0,
1397 "USDC",
1398 CurrencyType::Crypto,
1399 );
1400
1401 let price_increment = Price::from("0.00000001");
1402 let size_increment = Quantity::from("0.00000001");
1403
1404 let instrument = InstrumentAny::CurrencyPair(
1405 CurrencyPair::builder()
1406 .instrument_id(instrument_id)
1407 .raw_symbol(symbol)
1408 .base_currency(base_currency)
1409 .quote_currency(quote_currency)
1410 .price_precision(8)
1411 .size_precision(8)
1412 .price_increment(price_increment)
1413 .size_increment(size_increment)
1414 .ts_event(ts_event)
1415 .ts_init(ts_event)
1416 .build()
1417 .unwrap(),
1418 );
1419
1420 self.cache_instrument(&instrument);
1421
1422 Some(instrument)
1423 } else {
1424 log::warn!("Instrument not found in cache: {coin}");
1426 None
1427 }
1428 }
1429
1430 pub fn set_account_id(&mut self, account_id: AccountId) {
1434 self.account_id = Some(account_id);
1435 }
1436
1437 pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1439 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1440 let spot_meta = match self.inner.get_spot_meta().await {
1441 Ok(spot_meta) => Some(spot_meta),
1442 Err(e) => {
1443 log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1444 None
1445 }
1446 };
1447
1448 match self.inner.load_all_perp_metas().await {
1450 Ok(all_metas) => {
1451 for (dex_index, meta) in all_metas.iter().enumerate() {
1452 let base = perp_dex_asset_index_base(dex_index);
1453 let settlement_currency = match resolve_perp_settlement_currency(
1454 meta,
1455 spot_meta.as_ref(),
1456 ) {
1457 Ok(settlement_currency) => settlement_currency,
1458 Err(e) => {
1459 return Err(Error::decode(format!(
1460 "failed to resolve perp settlement currency for dex {dex_index}: {e}",
1461 )));
1462 }
1463 };
1464
1465 let perp_defs = parse_perp_instruments_with_settlement(
1466 meta,
1467 base,
1468 settlement_currency.as_str(),
1469 );
1470 log::debug!(
1471 "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1472 perp_defs.len(),
1473 );
1474 defs.extend(perp_defs);
1475 }
1476 }
1477 Err(e) => {
1478 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1479
1480 match self.inner.load_perp_meta().await {
1481 Ok(perp_meta) => {
1482 match resolve_perp_settlement_currency(&perp_meta, spot_meta.as_ref()) {
1483 Ok(settlement_currency) => {
1484 let perp_defs = parse_perp_instruments_with_settlement(
1485 &perp_meta,
1486 0,
1487 settlement_currency.as_str(),
1488 );
1489 log::debug!(
1490 "Loaded Hyperliquid perp defs via fallback: count={}",
1491 perp_defs.len(),
1492 );
1493 defs.extend(perp_defs);
1494 }
1495 Err(e) => {
1496 return Err(Error::decode(format!(
1497 "failed to resolve fallback perp settlement currency: {e}",
1498 )));
1499 }
1500 }
1501 }
1502 Err(e) => {
1503 log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1504 }
1505 }
1506 }
1507 }
1508
1509 if let Some(spot_meta) = spot_meta.as_ref() {
1510 match parse_spot_instruments(spot_meta) {
1511 Ok(spot_defs) => {
1512 log::debug!(
1513 "Loaded Hyperliquid spot definitions: count={}",
1514 spot_defs.len(),
1515 );
1516 defs.extend(spot_defs);
1517 }
1518 Err(e) => {
1519 log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1520 }
1521 }
1522 }
1523
1524 match self.inner.get_outcome_meta().await {
1528 Ok(outcome_meta) => match parse_outcome_instruments(&outcome_meta) {
1529 Ok(outcome_defs) => {
1530 log::debug!(
1531 "Loaded Hyperliquid outcome definitions: count={}",
1532 outcome_defs.len(),
1533 );
1534 defs.extend(outcome_defs);
1535 }
1536 Err(e) => {
1537 log::warn!("Failed to parse Hyperliquid outcome instruments: {e}");
1538 }
1539 },
1540 Err(e) => {
1541 log::debug!("Skipping Hyperliquid outcome metadata: {e}");
1542 }
1543 }
1544
1545 let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1552 let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1553 for def in defs {
1554 if seen_symbols.insert(def.symbol) {
1555 deduped.push(def);
1556 } else {
1557 log::warn!(
1558 "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1559 def.symbol,
1560 def.raw_symbol,
1561 );
1562 }
1563 }
1564 let defs = deduped;
1565
1566 self.asset_indices.rcu(|m| {
1568 for def in &defs {
1569 m.insert(def.symbol, def.asset_index);
1570 }
1571 });
1572 log::debug!(
1573 "Populated asset indices map (count={})",
1574 self.asset_indices.len()
1575 );
1576
1577 Ok(defs)
1578 }
1579
1580 pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1582 let ts_init = self.clock.get_time_ns();
1583 instruments_from_defs_owned(defs, ts_init)
1584 }
1585
1586 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1588 let defs = self.request_instrument_defs().await?;
1589 Ok(self.convert_defs(defs))
1590 }
1591
1592 pub async fn build_all_dex_asset_ctxs_instrument_ids(
1597 &self,
1598 ) -> Result<AHashMap<String, Vec<Option<InstrumentId>>>> {
1599 let all_metas = match self.inner.load_all_perp_metas().await {
1600 Ok(all_metas) => all_metas,
1601 Err(e) => {
1602 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1603 vec![self.inner.load_perp_meta().await?]
1604 }
1605 };
1606
1607 let perp_dexs = match self.inner.load_perp_dexs().await {
1608 Ok(dexs) => Some(dexs),
1609 Err(e) => {
1610 log::warn!("Failed to load perpDexs, inferring dex names from metadata: {e}");
1611 None
1612 }
1613 };
1614
1615 let raw_symbol_to_id =
1616 self.instruments
1617 .load()
1618 .values()
1619 .fold(AHashMap::new(), |mut acc, instrument| {
1620 acc.insert(instrument.raw_symbol().to_string(), instrument.id());
1621 acc
1622 });
1623
1624 let mut mapping = AHashMap::new();
1625
1626 for (dex_index, meta) in all_metas.iter().enumerate() {
1627 let dex_name = resolve_perp_dex_name(dex_index, meta, perp_dexs.as_deref());
1628 let mut instrument_ids = Vec::with_capacity(meta.universe.len());
1629
1630 for asset in &meta.universe {
1631 if let Some(instrument_id) = raw_symbol_to_id.get(&asset.name) {
1632 instrument_ids.push(Some(*instrument_id));
1633 } else {
1634 log::warn!(
1635 "Missing cached Hyperliquid instrument for dex='{}' raw_symbol='{}'",
1636 dex_name,
1637 asset.name
1638 );
1639 instrument_ids.push(None);
1640 }
1641 }
1642
1643 mapping.insert(dex_name, instrument_ids);
1644 }
1645
1646 Ok(mapping)
1647 }
1648
1649 pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1657 self.get_asset_index_for_symbol(Ustr::from(symbol))
1658 }
1659
1660 pub(crate) fn get_asset_index_for_symbol(&self, symbol: Ustr) -> Option<u32> {
1664 self.asset_indices.load().get(&symbol).copied()
1665 }
1666
1667 pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1669 self.get_price_precision_for_symbol(Ustr::from(symbol))
1670 }
1671
1672 pub(crate) fn get_price_precision_for_symbol(&self, symbol: Ustr) -> Option<u8> {
1674 self.instruments
1675 .load()
1676 .get(&symbol)
1677 .map(|inst| inst.price_precision())
1678 }
1679
1680 #[must_use]
1688 pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1689 const SPOT_INDEX_OFFSET: u32 = 10_000;
1690 const BUILDER_PERP_OFFSET: u32 = 100_000;
1691
1692 let guard = self.asset_indices.load();
1693
1694 let mut mapping = AHashMap::new();
1695
1696 for (symbol, &asset_index) in guard.iter() {
1697 if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1699 let pair_index = asset_index - SPOT_INDEX_OFFSET;
1700 let fill_coin = Ustr::from(&format!("@{pair_index}"));
1701 mapping.insert(fill_coin, *symbol);
1702 }
1703 }
1704
1705 self.spot_fill_coins.store(mapping.clone());
1707
1708 mapping
1709 }
1710
1711 #[allow(dead_code)]
1713 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1714 self.inner.load_perp_meta().await
1715 }
1716
1717 #[allow(dead_code)]
1719 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1720 self.inner.load_all_perp_metas().await
1721 }
1722
1723 #[allow(dead_code)]
1725 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1726 self.inner.get_spot_meta().await
1727 }
1728
1729 pub(crate) async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
1731 self.inner.get_outcome_meta().await
1732 }
1733
1734 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1736 self.inner.info_l2_book(coin).await
1737 }
1738
1739 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
1741 self.inner.info_recent_trades(coin).await
1742 }
1743
1744 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1746 self.inner.info_user_fills(user).await
1747 }
1748
1749 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1751 self.inner.info_order_status(user, oid).await
1752 }
1753
1754 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1756 self.inner.info_open_orders(user).await
1757 }
1758
1759 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1761 self.inner.info_frontend_open_orders(user).await
1762 }
1763
1764 async fn info_frontend_open_orders_for_dex(
1765 &self,
1766 user: &str,
1767 dex: Option<&str>,
1768 ) -> Result<Value> {
1769 self.inner
1770 .info_frontend_open_orders_for_dex(user, dex)
1771 .await
1772 }
1773
1774 pub async fn info_historical_orders(
1776 &self,
1777 user: &str,
1778 ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
1779 self.inner.info_historical_orders(user).await
1780 }
1781
1782 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1784 self.inner.info_clearinghouse_state(user).await
1785 }
1786
1787 async fn info_clearinghouse_state_for_dex(
1788 &self,
1789 user: &str,
1790 dex: Option<&str>,
1791 ) -> Result<Value> {
1792 self.inner.info_clearinghouse_state_for_dex(user, dex).await
1793 }
1794
1795 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
1797 self.inner.info_spot_clearinghouse_state(user).await
1798 }
1799
1800 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
1802 self.inner.info_user_fees(user).await
1803 }
1804
1805 pub async fn info_candle_snapshot(
1807 &self,
1808 coin: &str,
1809 interval: HyperliquidBarInterval,
1810 start_time: u64,
1811 end_time: u64,
1812 ) -> Result<HyperliquidCandleSnapshot> {
1813 self.inner
1814 .info_candle_snapshot(coin, interval, start_time, end_time)
1815 .await
1816 }
1817
1818 pub async fn info_funding_history(
1820 &self,
1821 coin: &str,
1822 start_time: u64,
1823 end_time: Option<u64>,
1824 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
1825 self.inner
1826 .info_funding_history(coin, start_time, end_time)
1827 .await
1828 }
1829
1830 pub async fn post_action(
1832 &self,
1833 action: &ExchangeAction,
1834 ) -> Result<HyperliquidExchangeResponse> {
1835 self.inner.post_action(action).await
1836 }
1837
1838 pub async fn post_action_exec(
1840 &self,
1841 action: &HyperliquidExchangeAction,
1842 ) -> Result<HyperliquidExchangeResponse> {
1843 self.inner.post_action_exec(action).await
1844 }
1845
1846 pub fn sign_action_exec_request(
1848 &self,
1849 action: &HyperliquidExchangeAction,
1850 expires_after: Option<u64>,
1851 ) -> Result<HyperliquidExchangeRequest<HyperliquidExchangeAction>> {
1852 self.inner.sign_action_exec_request(action, expires_after)
1853 }
1854
1855 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1857 self.inner.info_meta().await
1858 }
1859
1860 pub async fn cancel_order(
1870 &self,
1871 instrument_id: InstrumentId,
1872 client_order_id: Option<ClientOrderId>,
1873 venue_order_id: Option<VenueOrderId>,
1874 ) -> Result<()> {
1875 let symbol = instrument_id.symbol.inner();
1877 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1878 Error::bad_request(format!(
1879 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1880 ))
1881 })?;
1882
1883 let action = if let Some(client_order_id) = client_order_id {
1884 if let Some(cloid) = self.cached_client_order_id_cloid(&client_order_id) {
1885 HyperliquidExchangeAction::CancelByCloid {
1886 cancels: vec![HyperliquidExchangeCancelByCloidRequest {
1887 asset: asset_id,
1888 cloid,
1889 }],
1890 fast: None,
1891 }
1892 } else if let Some(oid) = venue_order_id {
1893 let oid_u64 = oid
1894 .as_str()
1895 .parse::<u64>()
1896 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1897 HyperliquidExchangeAction::Cancel {
1898 cancels: vec![HyperliquidExchangeCancelOrderRequest {
1899 asset: asset_id,
1900 oid: oid_u64,
1901 }],
1902 fast: None,
1903 }
1904 } else {
1905 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
1906 HyperliquidExchangeAction::CancelByCloid {
1907 cancels: vec![HyperliquidExchangeCancelByCloidRequest {
1908 asset: asset_id,
1909 cloid,
1910 }],
1911 fast: None,
1912 }
1913 }
1914 } else if let Some(oid) = venue_order_id {
1915 let oid_u64 = oid
1916 .as_str()
1917 .parse::<u64>()
1918 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1919 HyperliquidExchangeAction::Cancel {
1920 cancels: vec![HyperliquidExchangeCancelOrderRequest {
1921 asset: asset_id,
1922 oid: oid_u64,
1923 }],
1924 fast: None,
1925 }
1926 } else {
1927 return Err(Error::bad_request(
1928 "Either client_order_id or venue_order_id must be provided",
1929 ));
1930 };
1931
1932 let response = self.inner.post_action_exec(&action).await?;
1934
1935 match response {
1937 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
1938 HyperliquidExchangeResponse::Status {
1939 status,
1940 response: error_data,
1941 } => Err(Error::bad_request(format!(
1942 "Cancel order failed: status={status}, error={error_data}"
1943 ))),
1944 HyperliquidExchangeResponse::Error { error } => {
1945 Err(Error::bad_request(format!("Cancel order error: {error}")))
1946 }
1947 }
1948 }
1949
1950 #[expect(clippy::too_many_arguments)]
1960 pub async fn modify_order(
1961 &self,
1962 instrument_id: InstrumentId,
1963 venue_order_id: Option<VenueOrderId>,
1964 order_side: OrderSide,
1965 order_type: OrderType,
1966 price: Price,
1967 quantity: Quantity,
1968 trigger_price: Option<Price>,
1969 reduce_only: bool,
1970 post_only: bool,
1971 time_in_force: TimeInForce,
1972 client_order_id: Option<ClientOrderId>,
1973 ) -> Result<()> {
1974 let symbol = instrument_id.symbol.inner();
1975 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1976 Error::bad_request(format!(
1977 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1978 ))
1979 })?;
1980
1981 let oid = match client_order_id
1982 .as_ref()
1983 .and_then(|id| self.unique_cached_client_order_id_cloid(id))
1984 {
1985 Some(cloid) => HyperliquidExchangeModifyTarget::Cloid(cloid),
1986 None => {
1987 let Some(venue_order_id) = venue_order_id.as_ref() else {
1988 return Err(Error::bad_request(
1989 "venue_order_id or unique cached CLOID is required for modify",
1990 ));
1991 };
1992 HyperliquidExchangeModifyTarget::from_venue_order_id(venue_order_id)
1993 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?
1994 }
1995 };
1996
1997 let is_buy = matches!(order_side, OrderSide::Buy);
1998 let decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
1999
2000 let normalized_price = if self.normalize_prices {
2001 normalize_price(price.as_decimal(), decimals).normalize()
2002 } else {
2003 price.as_decimal().normalize()
2004 };
2005
2006 let size = quantity.as_decimal().normalize();
2007
2008 let kind = match order_type {
2009 OrderType::Market => HyperliquidExchangeOrderKind::Limit {
2010 limit: HyperliquidExchangeLimitParams {
2011 tif: HyperliquidExchangeTif::Ioc,
2012 },
2013 },
2014 OrderType::Limit => {
2015 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
2016 .map_err(|e| Error::bad_request(format!("{e}")))?;
2017 HyperliquidExchangeOrderKind::Limit {
2018 limit: HyperliquidExchangeLimitParams { tif },
2019 }
2020 }
2021 OrderType::StopMarket
2022 | OrderType::StopLimit
2023 | OrderType::MarketIfTouched
2024 | OrderType::LimitIfTouched => {
2025 if let Some(trig_px) = trigger_price {
2026 let trigger_price_decimal = if self.normalize_prices {
2027 normalize_price(trig_px.as_decimal(), decimals).normalize()
2028 } else {
2029 trig_px.as_decimal().normalize()
2030 };
2031 let tpsl = match order_type {
2032 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
2033 _ => HyperliquidExchangeTpSl::Tp,
2034 };
2035 let is_market = matches!(
2036 order_type,
2037 OrderType::StopMarket | OrderType::MarketIfTouched
2038 );
2039 HyperliquidExchangeOrderKind::Trigger {
2040 trigger: HyperliquidExchangeTriggerParams {
2041 is_market,
2042 trigger_px: trigger_price_decimal,
2043 tpsl,
2044 },
2045 }
2046 } else {
2047 return Err(Error::bad_request("Trigger orders require a trigger price"));
2048 }
2049 }
2050 _ => {
2051 return Err(Error::bad_request(format!(
2052 "Order type {order_type:?} not supported for modify"
2053 )));
2054 }
2055 };
2056 let cloid = client_order_id.map(|id| self.get_or_generate_client_order_id_cloid(id));
2057
2058 let order = HyperliquidExchangePlaceOrderRequest {
2059 asset: asset_id,
2060 is_buy,
2061 price: normalized_price,
2062 size,
2063 reduce_only,
2064 kind,
2065 cloid,
2066 };
2067
2068 let action = HyperliquidExchangeAction::Modify {
2069 modify: HyperliquidExchangeModifyOrderRequest { oid, order },
2070 };
2071
2072 let response = self.inner.post_action_exec(&action).await?;
2073
2074 match response {
2075 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
2076 if let Some(inner_error) = extract_inner_error(&response) {
2077 Err(Error::bad_request(format!(
2078 "Modify order rejected: {inner_error}",
2079 )))
2080 } else {
2081 Ok(())
2082 }
2083 }
2084 HyperliquidExchangeResponse::Status {
2085 status,
2086 response: error_data,
2087 } => Err(Error::bad_request(format!(
2088 "Modify order failed: status={status}, error={error_data}"
2089 ))),
2090 HyperliquidExchangeResponse::Error { error } => {
2091 Err(Error::bad_request(format!("Modify order error: {error}")))
2092 }
2093 }
2094 }
2095
2096 pub async fn submit_split_outcome(
2110 &self,
2111 outcome: u32,
2112 amount: Decimal,
2113 ) -> Result<HyperliquidExchangeResponse> {
2114 let action = HyperliquidExchangeAction::UserOutcome {
2115 op: HyperliquidExchangeUserOutcomeOp::SplitOutcome(
2116 HyperliquidExchangeSplitOutcomeParams { outcome, amount },
2117 ),
2118 };
2119 self.inner.post_action_exec(&action).await
2120 }
2121
2122 pub async fn submit_merge_outcome(
2133 &self,
2134 outcome: u32,
2135 amount: Option<Decimal>,
2136 ) -> Result<HyperliquidExchangeResponse> {
2137 let action = HyperliquidExchangeAction::UserOutcome {
2138 op: HyperliquidExchangeUserOutcomeOp::MergeOutcome(
2139 HyperliquidExchangeMergeOutcomeParams { outcome, amount },
2140 ),
2141 };
2142 self.inner.post_action_exec(&action).await
2143 }
2144
2145 pub async fn submit_merge_question(
2155 &self,
2156 question: u32,
2157 amount: Option<Decimal>,
2158 ) -> Result<HyperliquidExchangeResponse> {
2159 let action = HyperliquidExchangeAction::UserOutcome {
2160 op: HyperliquidExchangeUserOutcomeOp::MergeQuestion(
2161 HyperliquidExchangeMergeQuestionParams { question, amount },
2162 ),
2163 };
2164 self.inner.post_action_exec(&action).await
2165 }
2166
2167 pub async fn submit_negate_outcome(
2177 &self,
2178 question: u32,
2179 outcome: u32,
2180 amount: Decimal,
2181 ) -> Result<HyperliquidExchangeResponse> {
2182 let action = HyperliquidExchangeAction::UserOutcome {
2183 op: HyperliquidExchangeUserOutcomeOp::NegateOutcome(
2184 HyperliquidExchangeNegateOutcomeParams {
2185 question,
2186 outcome,
2187 amount,
2188 },
2189 ),
2190 };
2191 self.inner.post_action_exec(&action).await
2192 }
2193
2194 pub async fn request_order_status_reports(
2207 &self,
2208 user: &str,
2209 instrument_id: Option<InstrumentId>,
2210 ) -> Result<Vec<OrderStatusReport>> {
2211 let dexes = self.reconciliation_dexes(instrument_id);
2212 self.request_order_status_reports_for_dexes(user, instrument_id, &dexes)
2213 .await
2214 }
2215
2216 pub(crate) async fn request_order_status_reports_for_dexes(
2217 &self,
2218 user: &str,
2219 instrument_id: Option<InstrumentId>,
2220 dexes: &[Option<Ustr>],
2221 ) -> Result<Vec<OrderStatusReport>> {
2222 let account_id = self
2223 .account_id
2224 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2225 let mut reports = Vec::new();
2226 let ts_init = self.clock.get_time_ns();
2227
2228 for dex in dexes {
2229 let response = self
2230 .info_frontend_open_orders_for_dex(user, dex.as_deref())
2231 .await?;
2232 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
2233 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
2234
2235 for order_value in orders {
2236 let order: WsBasicOrderData = match serde_json::from_value(order_value) {
2237 Ok(order) => order,
2238 Err(e) => {
2239 log::warn!("Failed to parse order: {e}");
2240 continue;
2241 }
2242 };
2243
2244 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2245 Some(instrument) => instrument,
2246 None => continue,
2247 };
2248
2249 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2250 continue;
2251 }
2252
2253 match parse_order_status_report_from_basic(
2254 &order,
2255 &HyperliquidOrderStatusEnum::Open,
2256 &instrument,
2257 account_id,
2258 ts_init,
2259 ) {
2260 Ok(report) => reports.push(report),
2261 Err(e) => log::error!("Failed to parse order status report: {e}"),
2262 }
2263 }
2264 }
2265
2266 Ok(reports)
2267 }
2268
2269 pub async fn request_historical_order_status_reports(
2275 &self,
2276 user: &str,
2277 instrument_id: Option<InstrumentId>,
2278 ) -> Result<Vec<OrderStatusReport>> {
2279 let entries = self.info_historical_orders(user).await?;
2280 self.historical_order_status_reports_from_response(entries, instrument_id)
2281 }
2282
2283 pub(crate) fn historical_order_status_reports_from_response(
2284 &self,
2285 entries: Vec<HyperliquidOrderStatusEntry>,
2286 instrument_id: Option<InstrumentId>,
2287 ) -> Result<Vec<OrderStatusReport>> {
2288 let account_id = self
2289 .account_id
2290 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2291 let mut reports = Vec::new();
2292 let ts_init = self.clock.get_time_ns();
2293
2294 for entry in entries {
2295 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2296 Some(instrument) => instrument,
2297 None => continue,
2298 };
2299
2300 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2301 continue;
2302 }
2303
2304 let order_type = entry.order.order_type.as_deref().unwrap_or_default();
2305 let tpsl = if order_type.starts_with("Take Profit") {
2306 Some(crate::common::enums::HyperliquidTpSl::Tp)
2307 } else if order_type.starts_with("Stop") {
2308 Some(crate::common::enums::HyperliquidTpSl::Sl)
2309 } else {
2310 None
2311 };
2312 let is_market = entry
2313 .order
2314 .order_type
2315 .as_deref()
2316 .is_some_and(|label| label.ends_with("Market"));
2317 let historical_order_type = match tpsl.as_ref() {
2318 Some(tpsl) => parse_trigger_order_type(is_market, tpsl),
2319 None if is_market => OrderType::Market,
2320 None => OrderType::Limit,
2321 };
2322 let order = WsBasicOrderData {
2323 coin: entry.order.coin,
2324 side: entry.order.side,
2325 limit_px: entry.order.limit_px,
2326 sz: entry.order.sz,
2327 oid: entry.order.oid,
2328 timestamp: entry.order.timestamp,
2329 orig_sz: entry.order.orig_sz,
2330 cloid: entry.order.cloid,
2331 tif: entry.order.tif,
2332 reduce_only: entry.order.reduce_only,
2333 trigger_px: entry
2334 .order
2335 .trigger_px
2336 .filter(|price| *price != Decimal::ZERO),
2337 is_market: tpsl.is_some().then_some(is_market),
2338 tpsl,
2339 trigger_activated: None,
2340 trailing_stop: None,
2341 };
2342
2343 match parse_order_status_report_from_basic(
2344 &order,
2345 &entry.status,
2346 &instrument,
2347 account_id,
2348 ts_init,
2349 ) {
2350 Ok(mut report) => {
2351 report.order_type = historical_order_type;
2352 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2353 reports.push(report);
2354 }
2355 Err(e) => log::error!("Failed to parse historical order status report: {e}"),
2356 }
2357 }
2358
2359 Ok(deduplicate_historical_order_reports(reports))
2360 }
2361
2362 pub async fn request_order_status_report(
2372 &self,
2373 user: &str,
2374 oid: u64,
2375 ) -> Result<Option<OrderStatusReport>> {
2376 let account_id = self
2377 .account_id
2378 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2379
2380 let ts_init = self.clock.get_time_ns();
2381
2382 let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
2387 Ok(response) => match serde_json::from_value(response) {
2388 Ok(v) => v,
2389 Err(e) => {
2390 log::warn!("Failed to parse frontend open orders response: {e}");
2391 Vec::new()
2392 }
2393 },
2394 Err(e) => {
2395 log::warn!(
2396 "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
2397 );
2398 Vec::new()
2399 }
2400 };
2401
2402 if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
2403 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2404 Some(inst) => inst,
2405 None => return Ok(None),
2406 };
2407
2408 let status = if order.trigger_activated == Some(true) {
2409 HyperliquidOrderStatusEnum::Triggered
2410 } else {
2411 HyperliquidOrderStatusEnum::Open
2412 };
2413
2414 return match parse_order_status_report_from_basic(
2415 &order,
2416 &status,
2417 &instrument,
2418 account_id,
2419 ts_init,
2420 ) {
2421 Ok(report) => Ok(Some(report)),
2422 Err(e) => {
2423 log::error!("Failed to parse order status report for oid {oid}: {e}");
2424 Ok(None)
2425 }
2426 };
2427 }
2428
2429 let response = self.info_order_status(user, oid).await?;
2431 let entry = match response.into_order() {
2432 Some(e) => e,
2433 None => return Ok(None),
2434 };
2435
2436 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2437 Some(inst) => inst,
2438 None => return Ok(None),
2439 };
2440
2441 let basic = WsBasicOrderData {
2446 coin: entry.order.coin,
2447 side: entry.order.side,
2448 limit_px: entry.order.limit_px,
2449 sz: entry.order.sz,
2450 oid: entry.order.oid,
2451 timestamp: entry.order.timestamp,
2452 orig_sz: entry.order.orig_sz,
2453 cloid: entry.order.cloid,
2454 tif: None,
2455 reduce_only: None,
2456 trigger_px: None,
2457 is_market: None,
2458 tpsl: None,
2459 trigger_activated: None,
2460 trailing_stop: None,
2461 };
2462
2463 match parse_order_status_report_from_basic(
2464 &basic,
2465 &entry.status,
2466 &instrument,
2467 account_id,
2468 ts_init,
2469 ) {
2470 Ok(mut report) => {
2471 if entry.status_timestamp > 0 {
2474 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2475 }
2476 Ok(Some(report))
2477 }
2478 Err(e) => {
2479 log::error!("Failed to parse order status report for oid {oid}: {e}");
2480 Ok(None)
2481 }
2482 }
2483 }
2484
2485 pub async fn request_order_status_report_by_client_order_id(
2494 &self,
2495 user: &str,
2496 client_order_id: &ClientOrderId,
2497 ) -> Result<Option<OrderStatusReport>> {
2498 let account_id = self
2499 .account_id
2500 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2501
2502 let ts_init = self.clock.get_time_ns();
2503
2504 let cached_cloid_hex = self
2505 .cached_client_order_id_cloid(client_order_id)
2506 .map(|cloid| cloid.to_hex());
2507 let cloid = Cloid::from_client_order_id(*client_order_id);
2508 let cloid_hex = cloid.to_hex();
2509
2510 let response = self.info_frontend_open_orders(user).await?;
2511 let orders: Vec<WsBasicOrderData> = match serde_json::from_value(response) {
2512 Ok(v) => v,
2513 Err(e) => {
2514 log::warn!("Failed to parse frontend open orders response: {e}");
2515 return Ok(None);
2516 }
2517 };
2518
2519 let order = match orders.into_iter().find(|o| {
2520 o.cloid
2521 .as_ref()
2522 .is_some_and(|c| cached_cloid_hex.as_ref() == Some(c) || c == &cloid_hex)
2523 }) {
2524 Some(o) => o,
2525 None => return Ok(None),
2526 };
2527
2528 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2529 Some(inst) => inst,
2530 None => return Ok(None),
2531 };
2532
2533 let status = if order.trigger_activated == Some(true) {
2534 HyperliquidOrderStatusEnum::Triggered
2535 } else {
2536 HyperliquidOrderStatusEnum::Open
2537 };
2538
2539 match parse_order_status_report_from_basic(
2540 &order,
2541 &status,
2542 &instrument,
2543 account_id,
2544 ts_init,
2545 ) {
2546 Ok(mut report) => {
2547 report.client_order_id = Some(*client_order_id);
2548 Ok(Some(report))
2549 }
2550 Err(e) => {
2551 log::error!("Failed to parse order status report for cloid {cloid_hex}: {e}");
2552 Ok(None)
2553 }
2554 }
2555 }
2556
2557 pub async fn request_fill_reports(
2571 &self,
2572 user: &str,
2573 instrument_id: Option<InstrumentId>,
2574 ) -> Result<Vec<FillReport>> {
2575 let fills_response = self.info_user_fills(user).await?;
2576 self.fill_reports_from_response(fills_response, instrument_id)
2577 }
2578
2579 pub(crate) fn fill_reports_from_response(
2580 &self,
2581 fills_response: HyperliquidFills,
2582 instrument_id: Option<InstrumentId>,
2583 ) -> Result<Vec<FillReport>> {
2584 let account_id = self
2585 .account_id
2586 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2587
2588 let mut reports = Vec::new();
2589 let ts_init = self.clock.get_time_ns();
2590
2591 for fill in fills_response {
2592 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
2594 Some(inst) => inst,
2595 None => continue, };
2597
2598 if let Some(filter_id) = instrument_id
2600 && instrument.id() != filter_id
2601 {
2602 continue;
2603 }
2604
2605 match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2607 Ok(report) => reports.push(report),
2608 Err(e) => log::error!("Failed to parse fill report: {e}"),
2609 }
2610 }
2611
2612 Ok(reports)
2613 }
2614
2615 pub async fn request_position_status_reports(
2639 &self,
2640 user: &str,
2641 instrument_id: Option<InstrumentId>,
2642 ) -> Result<Vec<PositionStatusReport>> {
2643 let dexes = self.reconciliation_dexes(instrument_id);
2644 self.request_position_status_reports_for_dexes(user, instrument_id, &dexes)
2645 .await
2646 }
2647
2648 pub(crate) async fn request_position_status_reports_for_dexes(
2649 &self,
2650 user: &str,
2651 instrument_id: Option<InstrumentId>,
2652 dexes: &[Option<Ustr>],
2653 ) -> Result<Vec<PositionStatusReport>> {
2654 let account_id = self
2655 .account_id
2656 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2657
2658 let filter_product = instrument_id
2659 .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
2660
2661 let fetch_perp = !matches!(
2662 filter_product,
2663 Some(HyperliquidProductType::Spot | HyperliquidProductType::Outcome)
2664 );
2665 let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
2666
2667 let mut reports = Vec::new();
2668 let ts_init = self.clock.get_time_ns();
2669
2670 if !fetch_perp {
2671 let spot_reports = self
2672 .request_spot_position_status_reports(user, instrument_id)
2673 .await?;
2674 reports.extend(spot_reports);
2675 return Ok(reports);
2676 }
2677
2678 for dex in dexes {
2679 let state_response = self
2680 .info_clearinghouse_state_for_dex(user, dex.as_deref())
2681 .await?;
2682 let asset_positions: Vec<serde_json::Value> = state_response
2683 .get("assetPositions")
2684 .and_then(|value| value.as_array())
2685 .ok_or_else(|| {
2686 Error::bad_request("assetPositions not found in clearinghouse state")
2687 })?
2688 .clone();
2689
2690 for position_value in asset_positions {
2691 let coin = position_value
2692 .get("position")
2693 .and_then(|position| position.get("coin"))
2694 .and_then(|coin| coin.as_str())
2695 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
2696
2697 let instrument = match self.get_or_create_instrument(&Ustr::from(coin), None) {
2698 Some(instrument) => instrument,
2699 None => continue,
2700 };
2701
2702 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2703 continue;
2704 }
2705
2706 match parse_position_status_report(
2707 &position_value,
2708 &instrument,
2709 account_id,
2710 ts_init,
2711 ) {
2712 Ok(report) => reports.push(report),
2713 Err(e) => log::error!("Failed to parse position status report: {e}"),
2714 }
2715 }
2716 }
2717
2718 if fetch_spot {
2721 let spot_reports = self
2722 .request_spot_position_status_reports(user, instrument_id)
2723 .await?;
2724 reports.extend(spot_reports);
2725 }
2726
2727 Ok(reports)
2728 }
2729
2730 pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
2744 let account_id = self
2745 .account_id
2746 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2747 let state_response = self.info_clearinghouse_state(user).await?;
2748 let ts_init = self.clock.get_time_ns();
2749
2750 log::trace!("Clearinghouse state response: {state_response}");
2751
2752 let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
2753 .map_err(|e| {
2754 log::error!("Failed to parse clearinghouse state: {e}");
2755 log::debug!("Raw response: {state_response}");
2756 Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
2757 })?;
2758
2759 let spot_response = self.info_spot_clearinghouse_state(user).await?;
2762 let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
2763 .map_err(|e| {
2764 log::error!("Failed to parse spot clearinghouse state: {e}");
2765 log::debug!("Raw spot response: {spot_response}");
2766 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2767 })?;
2768
2769 let (balances, margins) =
2770 parse_combined_account_balances_and_margins(&perp_state, &spot_state)
2771 .map_err(|e| Error::decode(e.to_string()))?;
2772
2773 Ok(AccountState::new(
2774 account_id,
2775 AccountType::Margin,
2776 balances,
2777 margins,
2778 true, UUID4::new(),
2780 ts_init,
2781 ts_init,
2782 None,
2783 ))
2784 }
2785
2786 pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
2797 let response = self.info_spot_clearinghouse_state(user).await?;
2798
2799 log::trace!("Spot clearinghouse state response: {response}");
2800
2801 let state: SpotClearinghouseState =
2802 serde_json::from_value(response.clone()).map_err(|e| {
2803 log::error!("Failed to parse spot clearinghouse state: {e}");
2804 log::debug!("Raw response: {response}");
2805 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2806 })?;
2807
2808 parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
2809 }
2810
2811 pub async fn request_spot_position_status_reports(
2826 &self,
2827 user: &str,
2828 instrument_id: Option<InstrumentId>,
2829 ) -> Result<Vec<PositionStatusReport>> {
2830 let account_id = self
2831 .account_id
2832 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2833 let response = self.info_spot_clearinghouse_state(user).await?;
2834
2835 let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
2836 log::error!("Failed to parse spot clearinghouse state: {e}");
2837 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2838 })?;
2839
2840 let ts_init = self.clock.get_time_ns();
2841 let mut reports = Vec::with_capacity(state.balances.len());
2842
2843 for balance in &state.balances {
2844 if balance.total.is_zero() {
2845 continue;
2846 }
2847
2848 if balance.coin.as_str() == "USDC" {
2853 continue;
2854 }
2855
2856 let product_type = match HyperliquidProductType::from_symbol(balance.coin.as_str()) {
2857 Ok(HyperliquidProductType::Outcome) => HyperliquidProductType::Outcome,
2858 _ => HyperliquidProductType::Spot,
2859 };
2860
2861 let instrument = match self.get_or_create_instrument(&balance.coin, Some(product_type))
2862 {
2863 Some(inst) => inst,
2864 None => continue,
2865 };
2866
2867 if let Some(filter_id) = instrument_id
2868 && instrument.id() != filter_id
2869 {
2870 continue;
2871 }
2872
2873 match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
2874 Ok(report) => reports.push(report),
2875 Err(e) => log::error!(
2876 "Failed to parse spot position status report for {}: {e}",
2877 balance.coin,
2878 ),
2879 }
2880 }
2881
2882 Ok(reports)
2883 }
2884
2885 pub async fn request_bars(
2902 &self,
2903 bar_type: BarType,
2904 start: Option<jiff::Timestamp>,
2905 end: Option<jiff::Timestamp>,
2906 limit: Option<u32>,
2907 ) -> Result<Vec<Bar>> {
2908 let instrument_id = bar_type.instrument_id();
2909 let symbol = instrument_id.symbol;
2910
2911 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
2912
2913 let alias = cache_alias_for_symbol(symbol.as_str())
2917 .map(|alias| Ustr::from(alias.as_str()))
2918 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
2919
2920 let instrument = self
2921 .get_or_create_instrument(&alias, product_type)
2922 .ok_or_else(|| {
2923 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
2924 })?;
2925
2926 let coin = instrument.raw_symbol().inner();
2931
2932 let price_precision = instrument.price_precision();
2933 let size_precision = instrument.size_precision();
2934
2935 let interval =
2936 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
2937
2938 let now = jiff::Timestamp::now();
2940 let end_time = end.unwrap_or(now).as_millisecond() as u64;
2941 let start_time = if let Some(start) = start {
2942 start.as_millisecond() as u64
2943 } else {
2944 let spec = bar_type.spec();
2946 let step_ms = match spec.aggregation {
2947 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
2948 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
2949 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
2950 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
2951 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
2952 _ => 60_000,
2953 };
2954 end_time.saturating_sub(1000 * step_ms)
2955 };
2956
2957 let candles = self
2958 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
2959 .await?;
2960
2961 let now_ms = now.as_millisecond() as u64;
2963
2964 let mut bars: Vec<Bar> = candles
2965 .iter()
2966 .filter(|candle| candle.end_timestamp < now_ms)
2967 .enumerate()
2968 .filter_map(|(i, candle)| {
2969 candle_to_bar(candle, bar_type, price_precision, size_precision)
2970 .map_err(|e| {
2971 log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
2972 e
2973 })
2974 .ok()
2975 })
2976 .collect();
2977
2978 if let Some(limit) = limit
2980 && limit > 0
2981 && bars.len() > limit as usize
2982 {
2983 bars.truncate(limit as usize);
2984 }
2985
2986 log::debug!(
2987 "Received {} bars for {} (filtered {} incomplete)",
2988 bars.len(),
2989 bar_type,
2990 candles.len() - bars.len()
2991 );
2992 Ok(bars)
2993 }
2994
2995 pub async fn request_public_trades(
3004 &self,
3005 instrument_id: InstrumentId,
3006 start: Option<jiff::Timestamp>,
3007 end: Option<jiff::Timestamp>,
3008 limit: Option<usize>,
3009 ) -> Result<Vec<HyperliquidPublicTrade>> {
3010 let symbol = instrument_id.symbol;
3011 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
3012 let alias = cache_alias_for_symbol(symbol.as_str())
3013 .map(|alias| Ustr::from(alias.as_str()))
3014 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
3015 let instrument = self
3016 .get_or_create_instrument(&alias, product_type)
3017 .ok_or_else(|| {
3018 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3019 })?;
3020
3021 let raw_trades = match self
3022 .info_recent_trades(instrument.raw_symbol().as_ref())
3023 .await
3024 {
3025 Ok(trades) => trades,
3026 Err(e) if e.is_unprocessable_entity() => {
3027 log::warn!(
3028 "Recent public trades endpoint unavailable for {instrument_id} \
3029 (requires the Hyperliquid indexer); returning empty response"
3030 );
3031 Vec::new()
3032 }
3033 Err(e) => return Err(e),
3034 };
3035
3036 let mut trades: Vec<HyperliquidPublicTrade> = raw_trades
3037 .iter()
3038 .filter_map(|raw| match parse_recent_public_trade(raw, &instrument) {
3039 Ok(trade) => Some(trade),
3040 Err(e) => {
3041 log::warn!("Skipping recent public trade for {instrument_id}: {e}");
3042 None
3043 }
3044 })
3045 .collect();
3046 trades.sort_by_key(|trade| trade.ts_event);
3047
3048 Ok(filter_recent_public_trades(
3049 trades,
3050 datetime_to_unix_nanos(start),
3051 datetime_to_unix_nanos(end),
3052 limit.filter(|limit| *limit > 0),
3053 instrument_id,
3054 ))
3055 }
3056
3057 #[expect(clippy::too_many_arguments)]
3064 pub async fn submit_order(
3065 &self,
3066 instrument_id: InstrumentId,
3067 client_order_id: ClientOrderId,
3068 order_side: OrderSide,
3069 order_type: OrderType,
3070 quantity: Quantity,
3071 time_in_force: TimeInForce,
3072 price: Option<Price>,
3073 trigger_price: Option<Price>,
3074 post_only: bool,
3075 reduce_only: bool,
3076 ) -> Result<OrderStatusReport> {
3077 let symbol = instrument_id.symbol.inner();
3078 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3079 Error::bad_request(format!(
3080 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3081 ))
3082 })?;
3083
3084 let is_buy = matches!(order_side, OrderSide::Buy);
3085 let price_precision = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
3086
3087 let price_decimal = match price {
3088 Some(px) if self.normalize_prices => {
3089 normalize_price(px.as_decimal(), price_precision).normalize()
3090 }
3091 Some(px) => px.as_decimal().normalize(),
3092 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
3093 None if matches!(
3094 order_type,
3095 OrderType::StopMarket | OrderType::MarketIfTouched
3096 ) =>
3097 {
3098 match trigger_price {
3099 Some(tp) => {
3100 let derived = derive_limit_from_trigger(
3101 tp.as_decimal().normalize(),
3102 is_buy,
3103 self.market_order_slippage_bps,
3104 );
3105 let sig_rounded = round_to_sig_figs(derived, 5);
3106 clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
3107 }
3108 None => Decimal::ZERO,
3109 }
3110 }
3111 None => return Err(Error::bad_request("Limit orders require a price")),
3112 };
3113
3114 let size_decimal = quantity.as_decimal().normalize();
3115
3116 let kind = match order_type {
3117 OrderType::Market => HyperliquidExchangeOrderKind::Limit {
3118 limit: HyperliquidExchangeLimitParams {
3119 tif: HyperliquidExchangeTif::Ioc,
3120 },
3121 },
3122 OrderType::Limit => {
3123 let tif = if post_only {
3124 HyperliquidExchangeTif::Alo
3125 } else {
3126 match time_in_force {
3127 TimeInForce::Gtc => HyperliquidExchangeTif::Gtc,
3128 TimeInForce::Ioc => HyperliquidExchangeTif::Ioc,
3129 TimeInForce::Fok
3130 | TimeInForce::Day
3131 | TimeInForce::Gtd
3132 | TimeInForce::AtTheOpen
3133 | TimeInForce::AtTheClose => {
3134 return Err(Error::bad_request(format!(
3135 "Time in force {time_in_force:?} not supported"
3136 )));
3137 }
3138 }
3139 };
3140 HyperliquidExchangeOrderKind::Limit {
3141 limit: HyperliquidExchangeLimitParams { tif },
3142 }
3143 }
3144 OrderType::StopMarket
3145 | OrderType::StopLimit
3146 | OrderType::MarketIfTouched
3147 | OrderType::LimitIfTouched => {
3148 if let Some(trig_px) = trigger_price {
3149 let trigger_price_decimal = if self.normalize_prices {
3150 normalize_price(trig_px.as_decimal(), price_precision).normalize()
3151 } else {
3152 trig_px.as_decimal().normalize()
3153 };
3154
3155 let tpsl = match order_type {
3159 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
3160 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
3161 HyperliquidExchangeTpSl::Tp
3162 }
3163 _ => unreachable!(),
3164 };
3165
3166 let is_market = matches!(
3167 order_type,
3168 OrderType::StopMarket | OrderType::MarketIfTouched
3169 );
3170
3171 HyperliquidExchangeOrderKind::Trigger {
3172 trigger: HyperliquidExchangeTriggerParams {
3173 is_market,
3174 trigger_px: trigger_price_decimal,
3175 tpsl,
3176 },
3177 }
3178 } else {
3179 return Err(Error::bad_request("Trigger orders require a trigger price"));
3180 }
3181 }
3182 _ => {
3183 return Err(Error::bad_request(format!(
3184 "Order type {order_type:?} not supported"
3185 )));
3186 }
3187 };
3188
3189 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
3190 let hyperliquid_order = HyperliquidExchangePlaceOrderRequest {
3191 asset,
3192 is_buy,
3193 price: price_decimal,
3194 size: size_decimal,
3195 reduce_only,
3196 kind,
3197 cloid: Some(cloid),
3198 };
3199
3200 let builder = self.builder_attribution();
3201
3202 let action = HyperliquidExchangeAction::Order {
3203 orders: vec![hyperliquid_order],
3204 grouping: HyperliquidExchangeGrouping::Na,
3205 builder,
3206 };
3207
3208 let response = self.inner.post_action_exec(&action).await?;
3209
3210 self.build_submit_order_report(
3213 instrument_id,
3214 client_order_id,
3215 order_side,
3216 order_type,
3217 quantity,
3218 time_in_force,
3219 price,
3220 trigger_price,
3221 response,
3222 )?
3223 .ok_or_else(|| {
3224 Error::bad_request(
3225 "Single-order submission returned no actionable status (deferred trigger child)",
3226 )
3227 })
3228 }
3229
3230 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
3234 self.submit_order(
3235 order.instrument_id(),
3236 order.client_order_id(),
3237 order.order_side(),
3238 order.order_type(),
3239 order.quantity(),
3240 order.time_in_force(),
3241 order.price(),
3242 order.trigger_price(),
3243 order.is_post_only(),
3244 order.is_reduce_only(),
3245 )
3246 .await
3247 }
3248
3249 #[expect(clippy::too_many_arguments)]
3250 fn create_order_status_report(
3251 &self,
3252 instrument_id: InstrumentId,
3253 client_order_id: Option<ClientOrderId>,
3254 venue_order_id: VenueOrderId,
3255 order_side: OrderSide,
3256 order_type: OrderType,
3257 quantity: Quantity,
3258 time_in_force: TimeInForce,
3259 price: Option<Price>,
3260 trigger_price: Option<Price>,
3261 order_status: OrderStatus,
3262 filled_qty: Quantity,
3263 _instrument: &InstrumentAny,
3264 account_id: AccountId,
3265 ts_init: UnixNanos,
3266 ) -> OrderStatusReport {
3267 let ts_accepted = self.clock.get_time_ns();
3268 let ts_last = ts_accepted;
3269 let report_id = UUID4::new();
3270
3271 let mut report = OrderStatusReport::new(
3272 account_id,
3273 instrument_id,
3274 client_order_id,
3275 venue_order_id,
3276 order_side.into(),
3277 order_type,
3278 time_in_force,
3279 order_status,
3280 quantity,
3281 filled_qty,
3282 ts_accepted,
3283 ts_last,
3284 ts_init,
3285 Some(report_id),
3286 );
3287
3288 if let Some(px) = price {
3289 report = report.with_price(px);
3290 }
3291
3292 if let Some(trig_px) = trigger_price {
3293 report = report
3294 .with_trigger_price(trig_px)
3295 .with_trigger_type(TriggerType::Default);
3296 }
3297
3298 report
3299 }
3300
3301 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
3308 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
3310 let mut client_order_ids = Vec::with_capacity(orders.len());
3311
3312 for order in orders {
3313 let instrument_id = order.instrument_id();
3314 let symbol = instrument_id.symbol.inner();
3315 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3316 Error::bad_request(format!(
3317 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3318 ))
3319 })?;
3320 let price_decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
3321 let request = order_to_hyperliquid_request_with_asset_and_cloid(
3322 order,
3323 asset,
3324 price_decimals,
3325 self.normalize_prices,
3326 self.market_order_slippage_bps,
3327 None,
3328 )
3329 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
3330 client_order_ids.push(order.client_order_id());
3331 hyperliquid_orders.push(request);
3332 }
3333
3334 for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
3335 request.cloid = Some(self.get_or_generate_client_order_id_cloid(client_order_id));
3336 }
3337
3338 let builder = self.builder_attribution();
3339
3340 let grouping =
3341 determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
3342
3343 let action = HyperliquidExchangeAction::Order {
3344 orders: hyperliquid_orders,
3345 grouping,
3346 builder,
3347 };
3348
3349 let response = self.inner.post_action_exec(&action).await?;
3351
3352 self.build_submit_orders_reports(orders, grouping, response)
3353 }
3354
3355 #[expect(clippy::too_many_arguments)]
3371 pub fn build_submit_order_report(
3372 &self,
3373 instrument_id: InstrumentId,
3374 client_order_id: ClientOrderId,
3375 order_side: OrderSide,
3376 order_type: OrderType,
3377 quantity: Quantity,
3378 time_in_force: TimeInForce,
3379 price: Option<Price>,
3380 trigger_price: Option<Price>,
3381 response: HyperliquidExchangeResponse,
3382 ) -> Result<Option<OrderStatusReport>> {
3383 let order_response = parse_order_response(response)?;
3384
3385 let Some(order_status) = order_response.statuses.first() else {
3386 return Ok(None);
3387 };
3388
3389 let account_id = self
3390 .account_id
3391 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3392 let ts_init = self.clock.get_time_ns();
3393
3394 self.build_status_report(
3395 instrument_id,
3396 client_order_id,
3397 order_side,
3398 order_type,
3399 quantity,
3400 time_in_force,
3401 price,
3402 trigger_price,
3403 order_status,
3404 account_id,
3405 ts_init,
3406 )
3407 }
3408
3409 pub fn build_submit_orders_reports(
3424 &self,
3425 orders: &[&OrderAny],
3426 grouping: HyperliquidExchangeGrouping,
3427 response: HyperliquidExchangeResponse,
3428 ) -> Result<Vec<OrderStatusReport>> {
3429 let order_response = parse_order_response(response)?;
3430
3431 let account_id = self
3432 .account_id
3433 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3434 let ts_init = self.clock.get_time_ns();
3435
3436 if grouping == HyperliquidExchangeGrouping::Na
3440 && order_response.statuses.len() != orders.len()
3441 {
3442 return Err(Error::bad_request(format!(
3443 "Mismatch between submitted orders ({}) and response statuses ({})",
3444 orders.len(),
3445 order_response.statuses.len()
3446 )));
3447 }
3448
3449 let mut reports = Vec::with_capacity(order_response.statuses.len());
3452 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
3453 if let Some(report) = self.build_status_report(
3454 order.instrument_id(),
3455 order.client_order_id(),
3456 order.order_side(),
3457 order.order_type(),
3458 order.quantity(),
3459 order.time_in_force(),
3460 order.price(),
3461 order.trigger_price(),
3462 order_status,
3463 account_id,
3464 ts_init,
3465 )? {
3466 reports.push(report);
3467 }
3468 }
3469
3470 Ok(reports)
3471 }
3472
3473 #[expect(clippy::too_many_arguments)]
3481 fn build_status_report(
3482 &self,
3483 instrument_id: InstrumentId,
3484 client_order_id: ClientOrderId,
3485 order_side: OrderSide,
3486 order_type: OrderType,
3487 quantity: Quantity,
3488 time_in_force: TimeInForce,
3489 price: Option<Price>,
3490 trigger_price: Option<Price>,
3491 order_status: &HyperliquidExchangeOrderStatus,
3492 account_id: AccountId,
3493 ts_init: UnixNanos,
3494 ) -> Result<Option<OrderStatusReport>> {
3495 if matches!(order_status, HyperliquidExchangeOrderStatus::Tag(_)) {
3496 return Ok(None);
3497 }
3498
3499 let symbol = instrument_id.symbol.as_str();
3500 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
3501
3502 let asset = cache_alias_for_symbol(symbol).unwrap_or_else(|| symbol.to_string());
3505 let instrument = self
3506 .get_or_create_instrument(&Ustr::from(asset.as_str()), product_type)
3507 .ok_or_else(|| {
3508 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3509 })?;
3510
3511 let report = match order_status {
3512 HyperliquidExchangeOrderStatus::Resting { resting } => self.create_order_status_report(
3513 instrument_id,
3514 Some(client_order_id),
3515 VenueOrderId::new(resting.oid.to_string()),
3516 order_side,
3517 order_type,
3518 quantity,
3519 time_in_force,
3520 price,
3521 trigger_price,
3522 OrderStatus::Accepted,
3523 Quantity::zero(instrument.size_precision()),
3524 &instrument,
3525 account_id,
3526 ts_init,
3527 ),
3528 HyperliquidExchangeOrderStatus::Filled { filled } => {
3529 let filled_qty =
3530 Quantity::from_decimal_dp(filled.total_sz, instrument.size_precision())
3531 .map_err(|e| {
3532 Error::bad_request(format!(
3533 "Invalid filled size {}: {e}",
3534 filled.total_sz
3535 ))
3536 })?;
3537 self.create_order_status_report(
3538 instrument_id,
3539 Some(client_order_id),
3540 VenueOrderId::new(filled.oid.to_string()),
3541 order_side,
3542 order_type,
3543 quantity,
3544 time_in_force,
3545 price,
3546 trigger_price,
3547 OrderStatus::Filled,
3548 filled_qty,
3549 &instrument,
3550 account_id,
3551 ts_init,
3552 )
3553 }
3554 HyperliquidExchangeOrderStatus::Error { error } => {
3555 return Err(Error::bad_request(format!(
3556 "Order {client_order_id} rejected: {error}"
3557 )));
3558 }
3559 HyperliquidExchangeOrderStatus::Tag(_) => unreachable!("handled above"),
3560 };
3561
3562 Ok(Some(report))
3563 }
3564
3565 fn reconciliation_dexes(&self, instrument_id: Option<InstrumentId>) -> Vec<Option<Ustr>> {
3566 if let Some(instrument_id) = instrument_id {
3567 return vec![perp_dex_from_symbol(instrument_id.symbol.as_str())];
3568 }
3569
3570 let cached = self.instruments.load();
3571 reconciliation_dexes_from_builders(
3572 cached
3573 .keys()
3574 .filter_map(|symbol| perp_dex_from_symbol(symbol.as_str())),
3575 )
3576 }
3577
3578 pub(crate) async fn reconciliation_dexes_from_activity(
3579 &self,
3580 historical_orders: &[HyperliquidOrderStatusEntry],
3581 fills: &HyperliquidFills,
3582 ) -> Result<Vec<Option<Ustr>>> {
3583 if historical_orders.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT
3584 || fills.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT
3585 {
3586 let builder_dexes = self
3587 .inner
3588 .load_perp_dexs()
3589 .await?
3590 .into_iter()
3591 .flatten()
3592 .filter(|dex| !dex.name.is_empty())
3593 .map(|dex| Ustr::from(dex.name.as_str()));
3594 return Ok(reconciliation_dexes_from_builders(builder_dexes));
3595 }
3596
3597 let builder_dexes = historical_orders
3598 .iter()
3599 .map(|entry| entry.order.coin.as_str())
3600 .chain(fills.iter().map(|fill| fill.coin.as_str()))
3601 .filter_map(perp_dex_from_activity_coin);
3602
3603 Ok(reconciliation_dexes_from_builders(builder_dexes))
3604 }
3605}
3606
3607fn reconciliation_dexes_from_builders(
3608 builder_dexes: impl IntoIterator<Item = Ustr>,
3609) -> Vec<Option<Ustr>> {
3610 let mut builder_dexes = builder_dexes.into_iter().collect::<Vec<_>>();
3611 builder_dexes.sort_unstable_by(|a, b| a.as_str().cmp(b.as_str()));
3612 builder_dexes.dedup();
3613
3614 let mut dexes = Vec::with_capacity(builder_dexes.len() + 1);
3615 dexes.push(None);
3616 dexes.extend(builder_dexes.into_iter().map(Some));
3617 dexes
3618}
3619
3620fn perp_dex_from_activity_coin(coin: &str) -> Option<Ustr> {
3621 if coin.starts_with(VAULT_TOKEN_PREFIX) {
3622 return None;
3623 }
3624
3625 let (dex, _) = coin.split_once(':')?;
3626 (!dex.is_empty()).then(|| Ustr::from(dex))
3627}
3628
3629fn perp_dex_from_symbol(symbol: &str) -> Option<Ustr> {
3630 symbol
3631 .strip_suffix("-PERP")?
3632 .split_once(':')
3633 .map(|(dex, _)| Ustr::from(dex))
3634}
3635
3636fn parse_order_response(
3641 response: HyperliquidExchangeResponse,
3642) -> Result<HyperliquidExchangeOrderResponseData> {
3643 let response_data = match response {
3644 HyperliquidExchangeResponse::Status {
3645 status,
3646 response: response_data,
3647 } if status == RESPONSE_STATUS_OK => response_data,
3648 HyperliquidExchangeResponse::Error { error } => {
3649 return Err(Error::bad_request(format!(
3650 "Order submission failed: {error}"
3651 )));
3652 }
3653 _ => return Err(Error::bad_request("Unexpected response format")),
3654 };
3655
3656 let data_value = if let Some(data) = response_data.get("data") {
3657 data.clone()
3658 } else {
3659 response_data
3660 };
3661
3662 serde_json::from_value(data_value)
3663 .map_err(|e| Error::bad_request(format!("Failed to parse order response: {e}")))
3664}
3665
3666fn resolve_perp_dex_name(
3667 dex_index: usize,
3668 meta: &PerpMeta,
3669 perp_dexs: Option<&[Option<PerpDex>]>,
3670) -> String {
3671 if dex_index == 0 {
3672 return String::new();
3673 }
3674
3675 if let Some(dex_name) = perp_dexs
3676 .and_then(|dexs| dexs.get(dex_index))
3677 .and_then(|dex| dex.as_ref())
3678 .map(|dex| dex.name.clone())
3679 {
3680 return dex_name;
3681 }
3682
3683 meta.universe
3684 .iter()
3685 .find_map(|asset| asset.name.split_once(':').map(|(dex, _)| dex.to_string()))
3686 .unwrap_or_default()
3687}
3688
3689fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
3694 if dex_index == 0 {
3695 0
3696 } else {
3697 100_000 + dex_index as u32 * 10_000
3698 }
3699}
3700
3701#[cfg(test)]
3702mod tests {
3703 use std::{net::SocketAddr, sync::Arc};
3704
3705 use axum::{
3706 Router,
3707 extract::State,
3708 http::StatusCode,
3709 response::{IntoResponse, Json, Response},
3710 routing::post,
3711 };
3712 use nautilus_core::time::get_atomic_clock_realtime;
3713 use nautilus_model::{
3714 currencies::CURRENCY_MAP,
3715 enums::{CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce},
3716 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol},
3717 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
3718 types::{Currency, Price, Quantity},
3719 };
3720 use rstest::rstest;
3721 use rust_decimal_macros::dec;
3722 use serde_json::{Value, json};
3723 use ustr::Ustr;
3724
3725 use super::{HyperliquidHttpClient, resolve_perp_dex_name};
3726 use crate::{
3727 common::{
3728 consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS},
3729 enums::{HyperliquidEnvironment, HyperliquidProductType},
3730 },
3731 http::{
3732 models::{Cloid, HyperliquidExchangeResponse, PerpAsset, PerpDex, PerpMeta},
3733 query::InfoRequest,
3734 },
3735 };
3736
3737 const TEST_PRIVATE_KEY: &str =
3738 "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
3739
3740 fn perp_meta_with_assets(names: &[&str]) -> PerpMeta {
3741 PerpMeta {
3742 universe: names
3743 .iter()
3744 .map(|name| PerpAsset {
3745 name: (*name).to_string(),
3746 ..Default::default()
3747 })
3748 .collect(),
3749 margin_tables: Vec::new(),
3750 collateral_token: None,
3751 }
3752 }
3753
3754 #[rstest]
3755 fn resolve_perp_dex_name_uses_empty_string_for_default_dex() {
3756 let meta = perp_meta_with_assets(&["BTC", "ETH"]);
3757 assert_eq!(resolve_perp_dex_name(0, &meta, None), "");
3758 }
3759
3760 #[rstest]
3761 fn resolve_perp_dex_name_prefers_perp_dexs_entry() {
3762 let meta = perp_meta_with_assets(&["xyz:TSLA"]);
3763 let perp_dexs = vec![
3764 None,
3765 Some(PerpDex {
3766 name: "xyz".to_string(),
3767 }),
3768 ];
3769 assert_eq!(resolve_perp_dex_name(1, &meta, Some(&perp_dexs)), "xyz");
3770 }
3771
3772 #[rstest]
3773 fn resolve_perp_dex_name_infers_from_asset_name_when_perp_dexs_missing() {
3774 let meta = perp_meta_with_assets(&["abc:TSLA", "abc:NVDA"]);
3775 assert_eq!(resolve_perp_dex_name(1, &meta, None), "abc");
3776 }
3777
3778 #[rstest]
3779 fn test_build_submit_order_report_elides_waiting_for_fill_tag() {
3780 let mut client =
3784 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3785 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
3786
3787 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
3788 "status": "ok",
3789 "response": {
3790 "type": "order",
3791 "data": {
3792 "statuses": ["waitingForFill"]
3793 }
3794 }
3795 }))
3796 .unwrap();
3797
3798 let result = client
3799 .build_submit_order_report(
3800 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
3801 ClientOrderId::from("O-WAITING-CHILD"),
3802 OrderSide::Buy,
3803 OrderType::StopMarket,
3804 Quantity::from("100"),
3805 TimeInForce::Gtc,
3806 None,
3807 Some(Price::from("0.16136")),
3808 response,
3809 )
3810 .unwrap();
3811
3812 assert!(
3813 result.is_none(),
3814 "Tag status must elide so the order stays SUBMITTED, was {result:?}"
3815 );
3816 }
3817
3818 #[rstest]
3819 fn test_build_submit_order_report_filled_uses_total_sz_decimal() {
3820 let mut client =
3824 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3825 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
3826
3827 let base = Currency::new("ARB", 8, 0, "ARB", CurrencyType::Crypto);
3828 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
3829 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3830 let clock = get_atomic_clock_realtime();
3831 let ts = clock.get_time_ns();
3832 let perp = InstrumentAny::CryptoPerpetual(
3833 CryptoPerpetual::builder()
3834 .instrument_id(InstrumentId::new(
3835 Symbol::new("ARB-USD-PERP"),
3836 *HYPERLIQUID_VENUE,
3837 ))
3838 .raw_symbol(Symbol::new("ARB"))
3839 .base_currency(base)
3840 .quote_currency(usd)
3841 .settlement_currency(usdc)
3842 .is_inverse(false)
3843 .price_precision(5)
3844 .size_precision(2)
3845 .price_increment(Price::from("0.00001"))
3846 .size_increment(Quantity::from("0.01"))
3847 .ts_event(ts)
3848 .ts_init(ts)
3849 .build()
3850 .unwrap(),
3851 );
3852 client.cache_instrument(&perp);
3853
3854 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
3855 "status": "ok",
3856 "response": {
3857 "type": "order",
3858 "data": {
3859 "statuses": [{
3860 "filled": {"totalSz": "0.5", "avgPx": "1.2345", "oid": 778899}
3861 }]
3862 }
3863 }
3864 }))
3865 .unwrap();
3866
3867 let report = client
3868 .build_submit_order_report(
3869 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
3870 ClientOrderId::from("O-FILLED-001"),
3871 OrderSide::Buy,
3872 OrderType::Market,
3873 Quantity::from("0.5"),
3874 TimeInForce::Ioc,
3875 None,
3876 None,
3877 response,
3878 )
3879 .unwrap()
3880 .expect("filled status must produce a report");
3881
3882 assert_eq!(report.order_status, OrderStatus::Filled);
3883 assert_eq!(report.venue_order_id.as_str(), "778899");
3884 assert_eq!(report.filled_qty.as_decimal(), dec!(0.5));
3885 }
3886
3887 #[derive(Clone, Default)]
3888 struct OutcomeMetaServerState {
3889 last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
3890 }
3891
3892 async fn handle_outcome_meta_info(
3893 State(state): State<OutcomeMetaServerState>,
3894 body: axum::body::Bytes,
3895 ) -> Response {
3896 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
3897 return (
3898 StatusCode::BAD_REQUEST,
3899 Json(json!({"error": "Invalid JSON body"})),
3900 )
3901 .into_response();
3902 };
3903
3904 *state.last_request_body.lock().await = Some(request_body.clone());
3905
3906 if request_body.get("type").and_then(|value| value.as_str()) != Some("outcomeMeta") {
3907 return (
3908 StatusCode::BAD_REQUEST,
3909 Json(json!({"error": "Expected outcomeMeta request"})),
3910 )
3911 .into_response();
3912 }
3913
3914 Json(json!({
3915 "outcomes": [
3916 {
3917 "outcome": 123,
3918 "name": "Recurring",
3919 "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m",
3920 "sideSpecs": [
3921 {"name": "Yes"},
3922 {"name": "No"}
3923 ]
3924 }
3925 ]
3926 }))
3927 .into_response()
3928 }
3929
3930 async fn start_outcome_meta_server(state: OutcomeMetaServerState) -> SocketAddr {
3931 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3932 let addr = listener.local_addr().unwrap();
3933 let router = Router::new()
3934 .route("/info", post(handle_outcome_meta_info))
3935 .with_state(state);
3936
3937 tokio::spawn(async move {
3938 axum::serve(listener, router).await.unwrap();
3939 });
3940
3941 addr
3942 }
3943
3944 async fn handle_unresolved_collateral_info(body: axum::body::Bytes) -> Response {
3945 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
3946 return (
3947 StatusCode::BAD_REQUEST,
3948 Json(json!({"error": "Invalid JSON body"})),
3949 )
3950 .into_response();
3951 };
3952
3953 match request_body.get("type").and_then(|value| value.as_str()) {
3954 Some("spotMeta") => (
3955 StatusCode::INTERNAL_SERVER_ERROR,
3956 Json(json!({"error": "spot metadata unavailable"})),
3957 )
3958 .into_response(),
3959 Some("allPerpMetas") => Json(json!([
3960 {
3961 "collateralToken": 360,
3962 "marginTables": [],
3963 "universe": [
3964 {
3965 "maxLeverage": 20,
3966 "name": "km:US500",
3967 "szDecimals": 3
3968 }
3969 ]
3970 }
3971 ]))
3972 .into_response(),
3973 _ => Json(json!({"universe": [], "marginTables": []})).into_response(),
3974 }
3975 }
3976
3977 async fn start_unresolved_collateral_server() -> SocketAddr {
3978 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3979 let addr = listener.local_addr().unwrap();
3980 let router = Router::new().route("/info", post(handle_unresolved_collateral_info));
3981
3982 tokio::spawn(async move {
3983 axum::serve(listener, router).await.unwrap();
3984 });
3985
3986 addr
3987 }
3988
3989 #[rstest]
3990 fn stable_json_roundtrips() {
3991 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
3992 let s = serde_json::to_string(&v).unwrap();
3993 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
3995 assert_eq!(parsed["type"], "l2Book");
3996 assert_eq!(parsed["coin"], "BTC");
3997 assert_eq!(parsed, v);
3998 }
3999
4000 #[rstest]
4001 fn info_pretty_shape() {
4002 let r = InfoRequest::l2_book("BTC");
4003 let val = serde_json::to_value(&r).unwrap();
4004 let pretty = serde_json::to_string_pretty(&val).unwrap();
4005 assert!(pretty.contains("\"type\": \"l2Book\""));
4006 assert!(pretty.contains("\"coin\": \"BTC\""));
4007 }
4008
4009 #[rstest]
4010 fn test_client_order_id_cloid_cache_is_stable_and_first_write_wins() {
4011 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4012 let client_order_id = ClientOrderId::new("O-CLOID-CACHE");
4013 let other_client_order_id = ClientOrderId::new("O-CLOID-CACHE-OTHER");
4014 let duplicate_client_order_id = ClientOrderId::new("O-CLOID-CACHE-DUPLICATE");
4015 let explicit_cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
4016
4017 let first = client.get_or_generate_client_order_id_cloid(client_order_id);
4018 let second = client.get_or_generate_client_order_id_cloid(client_order_id);
4019 client.cache_client_order_id_cloid(client_order_id, explicit_cloid);
4020 client.cache_client_order_id_cloid(other_client_order_id, explicit_cloid);
4021 client.cache_client_order_id_cloid(duplicate_client_order_id, explicit_cloid);
4022
4023 assert_eq!(first, Cloid::from_client_order_id(client_order_id));
4024 assert_eq!(first, second);
4025 assert_eq!(
4026 client.cached_client_order_id_cloid(&client_order_id),
4027 Some(first),
4028 "cache insert must not overwrite an existing generated CLOID",
4029 );
4030 assert_eq!(
4031 client.unique_cached_client_order_id_cloid(&client_order_id),
4032 Some(first),
4033 );
4034 assert_eq!(
4035 client.cached_client_order_id_cloid(&other_client_order_id),
4036 Some(explicit_cloid),
4037 );
4038 assert_eq!(
4039 client.unique_cached_client_order_id_cloid(&other_client_order_id),
4040 None,
4041 "duplicate CLOID mappings are not safe modify targets",
4042 );
4043 assert_eq!(
4044 client.remove_client_order_id_cloid(&client_order_id),
4045 Some(first),
4046 );
4047 assert_eq!(client.cached_client_order_id_cloid(&client_order_id), None);
4048 }
4049
4050 #[rstest]
4051 fn test_builder_attribution_defaults_to_mainnet_builder() {
4052 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4053 let builder = client
4054 .builder_attribution()
4055 .expect("mainnet client should include builder attribution by default");
4056
4057 assert!(client.include_builder_attribution());
4058 assert_eq!(builder.address, NAUTILUS_BUILDER_ADDRESS);
4059 assert_eq!(builder.fee_tenths_bp, 0);
4060 }
4061
4062 #[rstest]
4063 fn test_builder_attribution_disabled_returns_none() {
4064 let mut client =
4065 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4066 client.set_include_builder_attribution(false);
4067
4068 assert!(!client.include_builder_attribution());
4069 assert!(client.builder_attribution().is_none());
4070 }
4071
4072 #[rstest]
4073 fn test_builder_attribution_omitted_on_testnet() {
4074 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
4075
4076 assert!(client.include_builder_attribution());
4077 assert!(client.builder_attribution().is_none());
4078 }
4079
4080 #[rstest]
4081 #[tokio::test]
4082 async fn test_production_client_get_outcome_meta_uses_outcome_meta_request() {
4083 let state = OutcomeMetaServerState::default();
4084 let addr = start_outcome_meta_server(state.clone()).await;
4085 let mut client =
4086 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4087 client.set_base_info_url(format!("http://{addr}/info"));
4088
4089 let meta = client.get_outcome_meta().await.unwrap();
4090 let request_body = state.last_request_body.lock().await.clone().unwrap();
4091
4092 assert_eq!(request_body, json!({"type": "outcomeMeta"}));
4093 assert_eq!(meta.outcomes.len(), 1);
4094 assert_eq!(meta.outcomes[0].outcome, 123);
4095 assert_eq!(meta.outcomes[0].name, "Recurring");
4096 assert_eq!(meta.outcomes[0].side_specs.len(), 2);
4097 assert_eq!(meta.outcomes[0].side_specs[0].name, "Yes");
4098 assert_eq!(meta.outcomes[0].side_specs[1].name, "No");
4099 }
4100
4101 #[rstest]
4102 #[tokio::test]
4103 async fn test_request_instrument_defs_errors_when_non_usdc_collateral_unresolved() {
4104 let addr = start_unresolved_collateral_server().await;
4105 let mut client =
4106 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4107 client.set_base_info_url(format!("http://{addr}/info"));
4108
4109 let err = client.request_instrument_defs().await.unwrap_err();
4110
4111 assert_eq!(
4112 err.to_string(),
4113 "decode error: failed to resolve perp settlement currency for dex 0: \
4114 Spot metadata required to resolve perp collateral token 360",
4115 );
4116 }
4117
4118 #[rstest]
4119 fn test_with_credentials_preserves_explicit_account_address() {
4120 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4121 let client = HyperliquidHttpClient::with_credentials(
4122 Some(TEST_PRIVATE_KEY.to_string()),
4123 None,
4124 Some(account_address),
4125 HyperliquidEnvironment::Mainnet,
4126 60,
4127 None,
4128 )
4129 .unwrap();
4130
4131 assert_eq!(client.get_account_address().unwrap(), account_address);
4132 }
4133
4134 #[rstest]
4135 fn test_from_resolved_credentials_preserves_account_address_without_private_key() {
4136 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4137 let client = HyperliquidHttpClient::from_resolved_credentials(
4138 None,
4139 None,
4140 Some(account_address.to_string()),
4141 HyperliquidEnvironment::Mainnet,
4142 60,
4143 None,
4144 )
4145 .unwrap();
4146
4147 assert_eq!(client.get_account_address().unwrap(), account_address);
4148 }
4149
4150 #[rstest]
4151 fn test_cache_instrument_by_raw_symbol() {
4152 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4153
4154 let base_code = "vntls:vCURSOR";
4156 let quote_code = "USDC";
4157
4158 {
4160 let mut currency_map = CURRENCY_MAP.lock();
4161 if !currency_map.contains_key(base_code) {
4162 currency_map.insert(
4163 base_code.to_string(),
4164 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
4165 );
4166 }
4167 }
4168
4169 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
4170 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
4171
4172 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
4174 let venue = *HYPERLIQUID_VENUE;
4175 let instrument_id = InstrumentId::new(symbol, venue);
4176
4177 let raw_symbol = Symbol::new(base_code);
4179
4180 let clock = get_atomic_clock_realtime();
4181 let ts = clock.get_time_ns();
4182
4183 let instrument = InstrumentAny::CurrencyPair(
4184 CurrencyPair::builder()
4185 .instrument_id(instrument_id)
4186 .raw_symbol(raw_symbol)
4187 .base_currency(base_currency)
4188 .quote_currency(quote_currency)
4189 .price_precision(8)
4190 .size_precision(8)
4191 .price_increment(Price::from("0.00000001"))
4192 .size_increment(Quantity::from("0.00000001"))
4193 .ts_event(ts)
4194 .ts_init(ts)
4195 .build()
4196 .unwrap(),
4197 );
4198
4199 client.cache_instrument(&instrument);
4201
4202 let instruments = client.instruments.load();
4204 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
4205 assert!(
4206 by_full_symbol.is_some(),
4207 "Instrument should be accessible by full symbol"
4208 );
4209 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
4210
4211 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
4213 assert!(
4214 by_raw_symbol.is_some(),
4215 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
4216 );
4217 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
4218 drop(instruments);
4219
4220 let instruments_by_coin = client.instruments_by_coin.load();
4222 let by_coin =
4223 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
4224 assert!(
4225 by_coin.is_some(),
4226 "Instrument should be accessible by coin and product type"
4227 );
4228 assert_eq!(by_coin.unwrap().id(), instrument.id());
4229 drop(instruments_by_coin);
4230
4231 let retrieved_with_type = client.get_or_create_instrument(
4233 &Ustr::from("vntls:vCURSOR"),
4234 Some(HyperliquidProductType::Spot),
4235 );
4236 assert!(retrieved_with_type.is_some());
4237 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
4238
4239 let retrieved_without_type =
4241 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
4242 assert!(retrieved_without_type.is_some());
4243 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
4244 }
4245
4246 #[rstest]
4247 fn test_get_or_create_instrument_outcome_fallback_no_product_type() {
4248 use nautilus_core::time::get_atomic_clock_realtime;
4254 use nautilus_model::{
4255 enums::AssetClass,
4256 identifiers::{InstrumentId, Symbol},
4257 instruments::{BinaryOption, InstrumentAny},
4258 types::{Currency, Price, Quantity},
4259 };
4260
4261 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4262 let coin = "#500";
4263 let token = "+500";
4264
4265 let usdh = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
4266 let symbol = Symbol::new(token);
4267 let raw_symbol = Symbol::new(coin);
4268 let venue = *HYPERLIQUID_VENUE;
4269 let instrument_id = InstrumentId::new(symbol, venue);
4270
4271 let clock = get_atomic_clock_realtime();
4272 let ts = clock.get_time_ns();
4273
4274 let binary = InstrumentAny::BinaryOption(
4275 BinaryOption::builder()
4276 .instrument_id(instrument_id)
4277 .raw_symbol(raw_symbol)
4278 .asset_class(AssetClass::Alternative)
4279 .currency(usdh)
4280 .activation_ns(Default::default())
4281 .expiration_ns(Default::default())
4282 .price_precision(4)
4283 .size_precision(2)
4284 .price_increment(Price::from("0.0001"))
4285 .size_increment(Quantity::from("0.01"))
4286 .ts_event(ts)
4287 .ts_init(ts)
4288 .build()
4289 .unwrap(),
4290 );
4291
4292 client.cache_instrument(&binary);
4293
4294 let with_type = client
4295 .get_or_create_instrument(&Ustr::from(coin), Some(HyperliquidProductType::Outcome));
4296 assert!(with_type.is_some());
4297 assert_eq!(with_type.unwrap().id(), instrument_id);
4298
4299 let no_type = client.get_or_create_instrument(&Ustr::from(coin), None);
4300 assert!(
4301 no_type.is_some(),
4302 "Outcome coin must resolve through the no-product fallback",
4303 );
4304 assert_eq!(no_type.unwrap().id(), instrument_id);
4305
4306 let missing = client.get_or_create_instrument(&Ustr::from("#9999"), None);
4307 assert!(missing.is_none());
4308 }
4309
4310 #[rstest]
4311 fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
4312 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4317
4318 let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
4319 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4320 let clock = get_atomic_clock_realtime();
4321 let ts = clock.get_time_ns();
4322
4323 let canonical = InstrumentAny::CurrencyPair(
4324 CurrencyPair::builder()
4325 .instrument_id(InstrumentId::new(
4326 Symbol::new("HYPE-USDC-SPOT"),
4327 *HYPERLIQUID_VENUE,
4328 ))
4329 .raw_symbol(Symbol::new("@107"))
4330 .base_currency(hype)
4331 .quote_currency(usdc)
4332 .price_precision(5)
4333 .size_precision(2)
4334 .price_increment(Price::from("0.00001"))
4335 .size_increment(Quantity::from("0.01"))
4336 .ts_event(ts)
4337 .ts_init(ts)
4338 .build()
4339 .unwrap(),
4340 );
4341
4342 let non_canonical = InstrumentAny::CurrencyPair(
4343 CurrencyPair::builder()
4344 .instrument_id(InstrumentId::new(
4345 Symbol::new("HYPE-USDC-SPOT"),
4346 *HYPERLIQUID_VENUE,
4347 ))
4348 .raw_symbol(Symbol::new("@999"))
4349 .base_currency(hype)
4350 .quote_currency(usdc)
4351 .price_precision(5)
4352 .size_precision(2)
4353 .price_increment(Price::from("0.00001"))
4354 .size_increment(Quantity::from("0.01"))
4355 .ts_event(ts)
4356 .ts_init(ts)
4357 .build()
4358 .unwrap(),
4359 );
4360
4361 client.cache_instrument(&canonical);
4362 client.cache_instrument(&non_canonical);
4363
4364 let instruments_by_coin = client.instruments_by_coin.load();
4365 let by_base = instruments_by_coin
4366 .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
4367 .expect("base alias must resolve");
4368 assert_eq!(
4369 by_base.raw_symbol().inner().as_str(),
4370 "@107",
4371 "base alias must point to the canonical pair, not the one cached later",
4372 );
4373 }
4374
4375 #[rstest]
4376 fn test_cache_instrument_perp_aliases_sanitized_base() {
4377 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4383
4384 let base_currency = Currency::new(
4385 "dex:STREAMABCD****",
4386 8,
4387 0,
4388 "dex:STREAMABCD****",
4389 CurrencyType::Crypto,
4390 );
4391 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4392 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4393 let clock = get_atomic_clock_realtime();
4394 let ts = clock.get_time_ns();
4395
4396 let hip3 = InstrumentAny::CryptoPerpetual(
4397 CryptoPerpetual::builder()
4398 .instrument_id(InstrumentId::new(
4399 Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
4400 *HYPERLIQUID_VENUE,
4401 ))
4402 .raw_symbol(Symbol::new("dex:STREAMABCD****"))
4403 .base_currency(base_currency)
4404 .quote_currency(usd)
4405 .settlement_currency(usdc)
4406 .is_inverse(false)
4407 .price_precision(6)
4408 .size_precision(3)
4409 .price_increment(Price::from("0.000001"))
4410 .size_increment(Quantity::from("0.001"))
4411 .ts_event(ts)
4412 .ts_init(ts)
4413 .build()
4414 .unwrap(),
4415 );
4416
4417 client.cache_instrument(&hip3);
4418
4419 let instruments_by_coin = client.instruments_by_coin.load();
4420 let by_raw = instruments_by_coin
4421 .get(&(
4422 Ustr::from("dex:STREAMABCD****"),
4423 HyperliquidProductType::Perp,
4424 ))
4425 .expect("venue coin lookup must resolve");
4426 assert_eq!(by_raw.id(), hip3.id());
4427
4428 let by_sanitized = instruments_by_coin
4429 .get(&(
4430 Ustr::from("dex:STREAMABCDxxxx"),
4431 HyperliquidProductType::Perp,
4432 ))
4433 .expect("sanitized base lookup must resolve");
4434 assert_eq!(by_sanitized.id(), hip3.id());
4435 drop(instruments_by_coin);
4436
4437 let resolved = client
4439 .get_or_create_instrument(
4440 &Ustr::from("dex:STREAMABCDxxxx"),
4441 Some(HyperliquidProductType::Perp),
4442 )
4443 .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
4444 assert_eq!(resolved.id(), hip3.id());
4445 }
4446}