1use std::{
24 collections::HashMap,
25 num::NonZeroU32,
26 sync::{Arc, LazyLock, Mutex},
27 time::Duration,
28};
29
30use ahash::AHashMap;
31use anyhow::Context;
32use nautilus_common::cache::InstrumentLookupError;
33use nautilus_core::{
34 AtomicMap, MUTEX_POISONED, 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 rust_decimal::Decimal;
57use serde_json::Value;
58use ustr::Ustr;
59
60use crate::{
61 account::resolve_execution_account_address,
62 common::{
63 consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url},
64 credential::{Secrets, VaultAddress, credential_env_vars},
65 enums::{
66 HyperliquidBarInterval, HyperliquidEnvironment,
67 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
68 },
69 parse::{
70 bar_type_to_interval, cache_alias_for_symbol, clamp_price_to_precision,
71 derive_limit_from_trigger, determine_order_list_grouping, extract_inner_error,
72 normalize_price, order_to_hyperliquid_request_with_asset_and_cloid,
73 parse_combined_account_balances_and_margins, parse_spot_account_balances,
74 parse_trigger_order_type, round_to_sig_figs, time_in_force_to_hyperliquid_tif,
75 },
76 },
77 data::candle_to_bar,
78 data_types::HyperliquidPublicTrade,
79 http::{
80 error::{Error, Result},
81 models::{
82 ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeRequest,
83 HyperliquidExchangeResponse, HyperliquidExecAction, HyperliquidExecBuilderFee,
84 HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
85 HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecMergeOutcomeParams,
86 HyperliquidExecMergeQuestionParams, HyperliquidExecModifyOrderRequest,
87 HyperliquidExecModifyTarget, HyperliquidExecNegateOutcomeParams,
88 HyperliquidExecOrderKind, HyperliquidExecOrderResponseData, HyperliquidExecOrderStatus,
89 HyperliquidExecPlaceOrderRequest, HyperliquidExecSplitOutcomeParams,
90 HyperliquidExecTif, HyperliquidExecTpSl, HyperliquidExecTriggerParams,
91 HyperliquidExecUserOutcomeOp, HyperliquidFills, HyperliquidFundingHistoryEntry,
92 HyperliquidL2Book, HyperliquidMeta, HyperliquidOrderStatus,
93 HyperliquidOrderStatusEntry, HyperliquidRecentTrade, OutcomeMeta, PerpDex, PerpMeta,
94 PerpMetaAndCtxs, RESPONSE_STATUS_OK, SpotClearinghouseState, SpotMeta, SpotMetaAndCtxs,
95 },
96 parse::{
97 HyperliquidInstrumentDef, filter_recent_public_trades, instruments_from_defs_owned,
98 parse_fill_report, parse_order_status_report_from_basic, parse_outcome_instruments,
99 parse_perp_instruments_with_settlement, parse_position_status_report,
100 parse_recent_public_trade, parse_spot_instruments, parse_spot_position_status_report,
101 resolve_perp_settlement_currency,
102 },
103 query::{ExchangeAction, InfoRequest},
104 rate_limits::{
105 RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
106 exec_action_weight, info_base_weight, info_extra_weight,
107 },
108 },
109 signing::{
110 HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
111 },
112 websocket::messages::WsBasicOrderData,
113};
114
115fn deduplicate_historical_order_reports(reports: Vec<OrderStatusReport>) -> Vec<OrderStatusReport> {
116 let mut best_by_venue_order_id = AHashMap::new();
117
118 for candidate in reports {
119 let Some(current) = best_by_venue_order_id.remove(&candidate.venue_order_id) else {
120 best_by_venue_order_id.insert(candidate.venue_order_id, candidate);
121 continue;
122 };
123 let (mut best, other) = if historical_report_is_more_advanced(&candidate, ¤t) {
124 (candidate, current)
125 } else {
126 (current, candidate)
127 };
128
129 if matches!(
130 best.order_type,
131 OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
132 ) {
133 best.price = best.price.or(other.price);
134 }
135 best.trigger_price = best.trigger_price.or(other.trigger_price);
136 best_by_venue_order_id.insert(best.venue_order_id, best);
137 }
138
139 best_by_venue_order_id.into_values().collect()
140}
141
142fn historical_report_is_more_advanced(
143 candidate: &OrderStatusReport,
144 current: &OrderStatusReport,
145) -> bool {
146 candidate.filled_qty > current.filled_qty
147 || (candidate.filled_qty == current.filled_qty
148 && (historical_status_priority(candidate.order_status)
149 > historical_status_priority(current.order_status)
150 || (candidate.order_status == current.order_status
151 && candidate.ts_last > current.ts_last)))
152}
153
154const fn historical_status_priority(status: OrderStatus) -> u8 {
155 match status {
156 OrderStatus::Initialized | OrderStatus::Submitted | OrderStatus::Emulated => 0,
157 OrderStatus::Released | OrderStatus::Denied => 1,
158 OrderStatus::Accepted | OrderStatus::PendingUpdate | OrderStatus::PendingCancel => 2,
159 OrderStatus::Triggered => 3,
160 OrderStatus::PartiallyFilled => 4,
161 OrderStatus::Canceled | OrderStatus::Expired | OrderStatus::Rejected => 5,
162 OrderStatus::Filled | OrderStatus::Voided => 6,
163 }
164}
165
166pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
168 LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
169
170#[derive(Debug, Clone)]
175#[cfg_attr(
176 feature = "python",
177 pyo3::pyclass(
178 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
179 from_py_object
180 )
181)]
182pub struct HyperliquidRawHttpClient {
183 client: HttpClient,
184 environment: HyperliquidEnvironment,
185 base_info: String,
186 base_exchange: String,
187 signer: Option<HyperliquidEip712Signer>,
188 nonce_manager: Option<Arc<NonceManager>>,
189 vault_address: Option<VaultAddress>,
190 rest_limiter: Arc<WeightedLimiter>,
191 rate_limit_backoff_base: Duration,
192 rate_limit_backoff_cap: Duration,
193 rate_limit_max_attempts_info: u32,
194}
195
196impl HyperliquidRawHttpClient {
197 pub fn new(
203 environment: HyperliquidEnvironment,
204 timeout_secs: u64,
205 proxy_url: Option<String>,
206 ) -> std::result::Result<Self, HttpClientError> {
207 Ok(Self {
208 client: HttpClient::new(
209 Self::default_headers(),
210 vec![],
211 vec![],
212 Some(*HYPERLIQUID_REST_QUOTA),
213 Some(timeout_secs),
214 proxy_url,
215 )?,
216 environment,
217 base_info: info_url(environment).to_string(),
218 base_exchange: exchange_url(environment).to_string(),
219 signer: None,
220 nonce_manager: None,
221 vault_address: None,
222 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
223 rate_limit_backoff_base: Duration::from_millis(125),
224 rate_limit_backoff_cap: Duration::from_secs(5),
225 rate_limit_max_attempts_info: 3,
226 })
227 }
228
229 pub fn with_credentials(
236 secrets: &Secrets,
237 timeout_secs: u64,
238 proxy_url: Option<String>,
239 ) -> std::result::Result<Self, HttpClientError> {
240 let signer = HyperliquidEip712Signer::new(&secrets.private_key)
241 .map_err(|e| HttpClientError::from(e.to_string()))?;
242 let nonce_manager = Arc::new(NonceManager::new());
243
244 Ok(Self {
245 client: HttpClient::new(
246 Self::default_headers(),
247 vec![],
248 vec![],
249 Some(*HYPERLIQUID_REST_QUOTA),
250 Some(timeout_secs),
251 proxy_url,
252 )?,
253 environment: secrets.environment,
254 base_info: info_url(secrets.environment).to_string(),
255 base_exchange: exchange_url(secrets.environment).to_string(),
256 signer: Some(signer),
257 nonce_manager: Some(nonce_manager),
258 vault_address: secrets.vault_address,
259 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
260 rate_limit_backoff_base: Duration::from_millis(125),
261 rate_limit_backoff_cap: Duration::from_secs(5),
262 rate_limit_max_attempts_info: 3,
263 })
264 }
265
266 pub fn set_base_info_url(&mut self, url: String) {
268 self.base_info = url;
269 }
270
271 pub fn set_base_exchange_url(&mut self, url: String) {
273 self.base_exchange = url;
274 }
275
276 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
282 let secrets = Secrets::from_env(environment)
283 .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
284 Self::with_credentials(&secrets, 60, None)
285 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
286 }
287
288 pub fn from_credentials(
294 private_key: &str,
295 vault_address: Option<&str>,
296 environment: HyperliquidEnvironment,
297 timeout_secs: u64,
298 proxy_url: Option<String>,
299 ) -> Result<Self> {
300 let secrets = Secrets::from_private_key(private_key, vault_address, environment)
301 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
302 Self::with_credentials(&secrets, timeout_secs, proxy_url)
303 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
304 }
305
306 #[must_use]
308 pub fn with_rate_limits(mut self) -> Self {
309 self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
310 self.rate_limit_backoff_base = Duration::from_millis(125);
311 self.rate_limit_backoff_cap = Duration::from_secs(5);
312 self.rate_limit_max_attempts_info = 3;
313 self
314 }
315
316 #[must_use]
318 pub fn environment(&self) -> HyperliquidEnvironment {
319 self.environment
320 }
321
322 #[must_use]
324 pub fn is_testnet(&self) -> bool {
325 self.environment == HyperliquidEnvironment::Testnet
326 }
327
328 pub fn get_user_address(&self) -> Result<String> {
334 self.signer
335 .as_ref()
336 .ok_or_else(|| Error::auth("No signer configured"))?
337 .address()
338 }
339
340 #[must_use]
342 pub fn has_vault_address(&self) -> bool {
343 self.vault_address.is_some()
344 }
345
346 pub fn get_account_address(&self) -> Result<String> {
353 if let Some(vault) = &self.vault_address {
354 Ok(vault.to_hex())
355 } else {
356 self.get_user_address()
357 }
358 }
359
360 fn default_headers() -> HashMap<String, String> {
361 HashMap::from([
362 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
363 ("Content-Type".to_string(), "application/json".to_string()),
364 ])
365 }
366
367 fn signer_id(&self) -> SignerId {
368 SignerId("hyperliquid:default".into())
369 }
370
371 fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
372 let retry_after = headers.get("retry-after")?;
373 retry_after.parse::<u64>().ok().map(|s| s * 1000) }
375
376 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
378 let request = InfoRequest::meta();
379 let response = self.send_info_request(&request).await?;
380 serde_json::from_value(response).map_err(Error::Serde)
381 }
382
383 pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
385 let request = InfoRequest::spot_meta();
386 let response = self.send_info_request(&request).await?;
387 serde_json::from_value(response).map_err(Error::Serde)
388 }
389
390 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
392 let request = InfoRequest::meta_and_asset_ctxs();
393 let response = self.send_info_request(&request).await?;
394 serde_json::from_value(response).map_err(Error::Serde)
395 }
396
397 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
399 let request = InfoRequest::spot_meta_and_asset_ctxs();
400 let response = self.send_info_request(&request).await?;
401 serde_json::from_value(response).map_err(Error::Serde)
402 }
403
404 pub async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
406 let request = InfoRequest::outcome_meta();
407 let response = self.send_info_request(&request).await?;
408 serde_json::from_value(response).map_err(Error::Serde)
409 }
410
411 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
412 let request = InfoRequest::meta();
413 let response = self.send_info_request(&request).await?;
414 serde_json::from_value(response).map_err(Error::Serde)
415 }
416
417 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
419 let request = InfoRequest::all_perp_metas();
420 let response = self.send_info_request(&request).await?;
421 serde_json::from_value(response).map_err(Error::Serde)
422 }
423
424 pub(crate) async fn load_perp_dexs(&self) -> Result<Vec<Option<PerpDex>>> {
426 let request = InfoRequest::perp_dexs();
427 let response = self.send_info_request(&request).await?;
428 serde_json::from_value(response).map_err(Error::Serde)
429 }
430
431 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
433 let request = InfoRequest::l2_book(coin);
434 let response = self.send_info_request(&request).await?;
435 serde_json::from_value(response).map_err(Error::Serde)
436 }
437
438 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
443 let request = InfoRequest::recent_trades(coin);
444 let response = self.send_info_request(&request).await?;
445 serde_json::from_value(response).map_err(Error::Serde)
446 }
447
448 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
450 let request = InfoRequest::user_fills(user);
451 let response = self.send_info_request(&request).await?;
452 serde_json::from_value(response).map_err(Error::Serde)
453 }
454
455 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
457 let request = InfoRequest::order_status(user, oid);
458 let response = self.send_info_request(&request).await?;
459 serde_json::from_value(response).map_err(Error::Serde)
460 }
461
462 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
464 let request = InfoRequest::open_orders(user);
465 self.send_info_request(&request).await
466 }
467
468 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
470 let request = InfoRequest::frontend_open_orders(user);
471 self.send_info_request(&request).await
472 }
473
474 pub async fn info_historical_orders(
476 &self,
477 user: &str,
478 ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
479 let request = InfoRequest::historical_orders(user);
480 let response = self.send_info_request(&request).await?;
481 serde_json::from_value(response).map_err(Error::Serde)
482 }
483
484 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
486 let request = InfoRequest::clearinghouse_state(user);
487 self.send_info_request(&request).await
488 }
489
490 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
492 let request = InfoRequest::spot_clearinghouse_state(user);
493 self.send_info_request(&request).await
494 }
495
496 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
498 let request = InfoRequest::user_fees(user);
499 self.send_info_request(&request).await
500 }
501
502 pub async fn info_candle_snapshot(
504 &self,
505 coin: &str,
506 interval: HyperliquidBarInterval,
507 start_time: u64,
508 end_time: u64,
509 ) -> Result<HyperliquidCandleSnapshot> {
510 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
511 let response = self.send_info_request(&request).await?;
512
513 log::trace!(
514 "Candle snapshot raw response (len={}): {:?}",
515 response.as_array().map_or(0, |a| a.len()),
516 response
517 );
518
519 serde_json::from_value(response).map_err(Error::Serde)
520 }
521
522 pub async fn info_funding_history(
527 &self,
528 coin: &str,
529 start_time: u64,
530 end_time: Option<u64>,
531 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
532 let request = InfoRequest::funding_history(coin, start_time, end_time);
533 let response = self.send_info_request(&request).await?;
534 serde_json::from_value(response).map_err(Error::Serde)
535 }
536
537 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
539 self.send_info_request(request).await
540 }
541
542 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
543 let base_w = info_base_weight(request);
544 self.rest_limiter.acquire(base_w).await;
545
546 let mut attempt = 0u32;
547
548 loop {
549 let response = self.http_roundtrip_info(request).await?;
550
551 if response.status.is_success() {
552 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
554 let extra = info_extra_weight(request, &val);
555 if extra > 0 {
556 self.rest_limiter.debit_extra(extra).await;
557 log::debug!(
558 "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
559 );
560 }
561 return Ok(val);
562 }
563
564 if response.status.as_u16() == 429 {
566 if attempt >= self.rate_limit_max_attempts_info {
567 let ra = self.parse_retry_after_simple(&response.headers);
568 return Err(Error::rate_limit("info", base_w, ra));
569 }
570 let delay = self
571 .parse_retry_after_simple(&response.headers)
572 .map_or_else(
573 || {
574 backoff_full_jitter(
575 attempt,
576 self.rate_limit_backoff_base,
577 self.rate_limit_backoff_cap,
578 )
579 },
580 Duration::from_millis,
581 );
582 log::warn!(
583 "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
584 delay.as_millis()
585 );
586 attempt += 1;
587 tokio::time::sleep(delay).await;
588 self.rest_limiter.acquire(1).await;
590 continue;
591 }
592
593 if (response.status.is_server_error() || response.status.as_u16() == 408)
595 && attempt < self.rate_limit_max_attempts_info
596 {
597 let delay = backoff_full_jitter(
598 attempt,
599 self.rate_limit_backoff_base,
600 self.rate_limit_backoff_cap,
601 );
602 log::warn!(
603 "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
604 response.status.as_u16(),
605 delay.as_millis()
606 );
607 attempt += 1;
608 tokio::time::sleep(delay).await;
609 continue;
610 }
611
612 let error_body = String::from_utf8_lossy(&response.body);
614 return Err(Error::http(
615 response.status.as_u16(),
616 error_body.to_string(),
617 ));
618 }
619 }
620
621 async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
622 let url = &self.base_info;
623 let body = serde_json::to_value(request).map_err(Error::Serde)?;
624 let body_bytes = serde_json::to_string(&body)
625 .map_err(Error::Serde)?
626 .into_bytes();
627
628 self.client
629 .request(
630 Method::POST,
631 url.clone(),
632 None,
633 None,
634 Some(body_bytes),
635 None,
636 None,
637 )
638 .await
639 .map_err(Error::from_http_client)
640 }
641
642 pub async fn post_action(
644 &self,
645 action: &ExchangeAction,
646 ) -> Result<HyperliquidExchangeResponse> {
647 let w = exchange_weight(action);
648 self.rest_limiter.acquire(w).await;
649
650 let signer = self
651 .signer
652 .as_ref()
653 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
654
655 let nonce_manager = self
656 .nonce_manager
657 .as_ref()
658 .ok_or_else(|| Error::auth("nonce manager missing"))?;
659
660 let signer_id = self.signer_id();
661 let time_nonce = nonce_manager.next(signer_id)?;
662
663 let action_bytes = rmp_serde::to_vec_named(action)
665 .context("serialize action with MessagePack")
666 .map_err(|e| Error::bad_request(e.to_string()))?;
667
668 let sign_request = SignRequest {
669 action: None,
670 action_bytes: Some(action_bytes),
671 time_nonce,
672 action_type: HyperliquidActionType::L1,
673 is_testnet: self.is_testnet(),
674 vault_address: self.vault_address,
675 expires_after: None,
676 };
677
678 let sig = signer.sign(&sign_request)?.signature;
679
680 let nonce_u64 = time_nonce.as_millis() as u64;
681
682 let request = if let Some(vault) = self.vault_address {
683 HyperliquidExchangeRequest::with_vault(
684 action.clone(),
685 nonce_u64,
686 sig,
687 vault.to_string(),
688 )
689 } else {
690 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
691 };
692
693 let response = self.http_roundtrip_exchange(&request).await?;
694
695 if response.status.is_success() {
696 let parsed_response: HyperliquidExchangeResponse =
697 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
698
699 match &parsed_response {
701 HyperliquidExchangeResponse::Status {
702 status,
703 response: response_data,
704 } if status == "err" => {
705 let error_msg = response_data
706 .as_str()
707 .map_or_else(|| response_data.to_string(), |s| s.to_string());
708 log::error!("Hyperliquid API returned error: {error_msg}");
709 Err(Error::bad_request(format!("API error: {error_msg}")))
710 }
711 HyperliquidExchangeResponse::Error { error } => {
712 log::error!("Hyperliquid API returned error: {error}");
713 Err(Error::bad_request(format!("API error: {error}")))
714 }
715 _ => Ok(parsed_response),
716 }
717 } else if response.status.as_u16() == 429 {
718 let ra = self.parse_retry_after_simple(&response.headers);
719 Err(Error::rate_limit("exchange", w, ra))
720 } else {
721 let error_body = String::from_utf8_lossy(&response.body);
722 log::error!(
723 "Exchange API error (status {}): {}",
724 response.status.as_u16(),
725 error_body
726 );
727 Err(Error::http(
728 response.status.as_u16(),
729 error_body.to_string(),
730 ))
731 }
732 }
733
734 pub fn sign_action_exec_request(
736 &self,
737 action: &HyperliquidExecAction,
738 expires_after: Option<u64>,
739 ) -> Result<HyperliquidExchangeRequest<HyperliquidExecAction>> {
740 let signer = self
741 .signer
742 .as_ref()
743 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
744
745 let nonce_manager = self
746 .nonce_manager
747 .as_ref()
748 .ok_or_else(|| Error::auth("nonce manager missing"))?;
749
750 let signer_id = self.signer_id();
751 let time_nonce = nonce_manager.next(signer_id)?;
752 let action_bytes = rmp_serde::to_vec_named(action)
756 .context("serialize action with MessagePack")
757 .map_err(|e| Error::bad_request(e.to_string()))?;
758
759 let sig = signer
760 .sign(&SignRequest {
761 action: None,
762 action_bytes: Some(action_bytes),
763 time_nonce,
764 action_type: HyperliquidActionType::L1,
765 is_testnet: self.is_testnet(),
766 vault_address: self.vault_address,
767 expires_after,
768 })?
769 .signature;
770
771 let mut request = if let Some(vault) = self.vault_address {
772 HyperliquidExchangeRequest::with_vault(
773 action.clone(),
774 time_nonce.as_millis() as u64,
775 sig,
776 vault.to_string(),
777 )
778 } else {
779 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
780 };
781 request.expires_after = expires_after;
782 Ok(request)
783 }
784
785 pub async fn post_action_exec(
790 &self,
791 action: &HyperliquidExecAction,
792 ) -> Result<HyperliquidExchangeResponse> {
793 let w = exec_action_weight(action);
794 self.rest_limiter.acquire(w).await;
795
796 let request = self.sign_action_exec_request(action, None)?;
797
798 let response = self.http_roundtrip_exchange(&request).await?;
799
800 if response.status.is_success() {
801 let parsed_response: HyperliquidExchangeResponse =
802 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
803
804 match &parsed_response {
806 HyperliquidExchangeResponse::Status {
807 status,
808 response: response_data,
809 } if status == "err" => {
810 let error_msg = response_data
811 .as_str()
812 .map_or_else(|| response_data.to_string(), |s| s.to_string());
813 log::error!("Hyperliquid API returned error: {error_msg}");
814 Err(Error::bad_request(format!("API error: {error_msg}")))
815 }
816 HyperliquidExchangeResponse::Error { error } => {
817 log::error!("Hyperliquid API returned error: {error}");
818 Err(Error::bad_request(format!("API error: {error}")))
819 }
820 _ => Ok(parsed_response),
821 }
822 } else if response.status.as_u16() == 429 {
823 let ra = self.parse_retry_after_simple(&response.headers);
824 Err(Error::rate_limit("exchange", w, ra))
825 } else {
826 let error_body = String::from_utf8_lossy(&response.body);
827 Err(Error::http(
828 response.status.as_u16(),
829 error_body.to_string(),
830 ))
831 }
832 }
833
834 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
837 self.rest_limiter.snapshot().await
838 }
839 async fn http_roundtrip_exchange<T>(
840 &self,
841 request: &HyperliquidExchangeRequest<T>,
842 ) -> Result<HttpResponse>
843 where
844 T: serde::Serialize,
845 {
846 let url = &self.base_exchange;
847 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
848 let body_bytes = body.into_bytes();
849
850 let response = self
851 .client
852 .request(
853 Method::POST,
854 url.clone(),
855 None,
856 None,
857 Some(body_bytes),
858 None,
859 None,
860 )
861 .await
862 .map_err(Error::from_http_client)?;
863
864 Ok(response)
865 }
866}
867
868#[derive(Debug, Clone)]
874#[cfg_attr(
875 feature = "python",
876 pyo3::pyclass(
877 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
878 from_py_object
879 )
880)]
881#[cfg_attr(
882 feature = "python",
883 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
884)]
885pub struct HyperliquidHttpClient {
886 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
887 clock: &'static AtomicTime,
888 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
889 instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
890 asset_indices: Arc<AtomicMap<Ustr, u32>>,
892 spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
894 client_order_id_cloids: Arc<Mutex<AHashMap<ClientOrderId, Cloid>>>,
895 account_id: Option<AccountId>,
896 account_address: Option<String>,
900 normalize_prices: bool,
901 market_order_slippage_bps: u32,
902 include_builder_attribution: bool,
903}
904
905impl Default for HyperliquidHttpClient {
906 fn default() -> Self {
907 Self::new(HyperliquidEnvironment::Mainnet, 60, None)
908 .expect("Failed to create default Hyperliquid HTTP client")
909 }
910}
911
912impl HyperliquidHttpClient {
913 pub fn new(
919 environment: HyperliquidEnvironment,
920 timeout_secs: u64,
921 proxy_url: Option<String>,
922 ) -> std::result::Result<Self, HttpClientError> {
923 let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
924 Ok(Self::from_raw(raw_client))
925 }
926
927 pub fn with_secrets(
933 secrets: &Secrets,
934 timeout_secs: u64,
935 proxy_url: Option<String>,
936 ) -> std::result::Result<Self, HttpClientError> {
937 let raw_client =
938 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
939 Ok(Self::from_raw(raw_client))
940 }
941
942 fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
943 Self {
944 inner: Arc::new(raw_client),
945 clock: get_atomic_clock_realtime(),
946 instruments: Arc::new(AtomicMap::new()),
947 instruments_by_coin: Arc::new(AtomicMap::new()),
948 asset_indices: Arc::new(AtomicMap::new()),
949 spot_fill_coins: Arc::new(AtomicMap::new()),
950 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
951 account_id: None,
952 account_address: None,
953 normalize_prices: true,
954 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
955 include_builder_attribution: true,
956 }
957 }
958
959 #[allow(
961 clippy::missing_panics_doc,
962 reason = "cloid cache mutex poisoning is not expected"
963 )]
964 #[must_use]
965 pub fn get_or_generate_client_order_id_cloid(&self, client_order_id: ClientOrderId) -> Cloid {
966 let mut cloids = self.client_order_id_cloids.lock().expect(MUTEX_POISONED);
967 *cloids
968 .entry(client_order_id)
969 .or_insert_with(|| Cloid::from_client_order_id(client_order_id))
970 }
971
972 #[allow(
974 clippy::missing_panics_doc,
975 reason = "cloid cache mutex poisoning is not expected"
976 )]
977 pub fn cache_client_order_id_cloid(&self, client_order_id: ClientOrderId, cloid: Cloid) {
978 self.client_order_id_cloids
979 .lock()
980 .expect(MUTEX_POISONED)
981 .entry(client_order_id)
982 .or_insert(cloid);
983 }
984
985 #[allow(
987 clippy::missing_panics_doc,
988 reason = "cloid cache mutex poisoning is not expected"
989 )]
990 #[must_use]
991 pub fn cached_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
992 self.client_order_id_cloids
993 .lock()
994 .expect(MUTEX_POISONED)
995 .get(client_order_id)
996 .copied()
997 }
998
999 #[allow(
1002 clippy::missing_panics_doc,
1003 reason = "cloid cache mutex poisoning is not expected"
1004 )]
1005 #[must_use]
1006 pub(crate) fn unique_cached_client_order_id_cloid(
1007 &self,
1008 client_order_id: &ClientOrderId,
1009 ) -> Option<Cloid> {
1010 let cloids = self.client_order_id_cloids.lock().expect(MUTEX_POISONED);
1011 let cloid = cloids.get(client_order_id).copied()?;
1012 let mapping_count = cloids
1013 .values()
1014 .filter(|cached_cloid| **cached_cloid == cloid)
1015 .count();
1016
1017 (mapping_count == 1).then_some(cloid)
1018 }
1019
1020 #[allow(
1022 clippy::missing_panics_doc,
1023 reason = "cloid cache mutex poisoning is not expected"
1024 )]
1025 pub fn remove_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
1026 self.client_order_id_cloids
1027 .lock()
1028 .expect(MUTEX_POISONED)
1029 .remove(client_order_id)
1030 }
1031
1032 pub fn set_base_info_url(&mut self, url: String) {
1038 Arc::get_mut(&mut self.inner)
1039 .expect("cannot override URL: Arc has multiple references")
1040 .set_base_info_url(url);
1041 }
1042
1043 pub fn set_base_exchange_url(&mut self, url: String) {
1049 Arc::get_mut(&mut self.inner)
1050 .expect("cannot override URL: Arc has multiple references")
1051 .set_base_exchange_url(url);
1052 }
1053
1054 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
1060 let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
1061 Ok(Self {
1062 inner: Arc::new(raw_client),
1063 clock: get_atomic_clock_realtime(),
1064 instruments: Arc::new(AtomicMap::new()),
1065 instruments_by_coin: Arc::new(AtomicMap::new()),
1066 asset_indices: Arc::new(AtomicMap::new()),
1067 spot_fill_coins: Arc::new(AtomicMap::new()),
1068 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1069 account_id: None,
1070 account_address: None,
1071 normalize_prices: true,
1072 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1073 include_builder_attribution: true,
1074 })
1075 }
1076
1077 pub fn with_credentials(
1090 private_key: Option<String>,
1091 vault_address: Option<String>,
1092 account_address: Option<&str>,
1093 environment: HyperliquidEnvironment,
1094 timeout_secs: u64,
1095 proxy_url: Option<String>,
1096 ) -> Result<Self> {
1097 let (pk_env_var, vault_env_var) = credential_env_vars(environment);
1098
1099 let resolved_account_address = resolve_execution_account_address(
1100 private_key.as_deref(),
1101 vault_address.as_deref(),
1102 account_address,
1103 environment,
1104 )?;
1105
1106 let resolved_pk = private_key.or_else(|| std::env::var(pk_env_var).ok());
1108
1109 let resolved_vault = vault_address.or_else(|| std::env::var(vault_env_var).ok());
1111
1112 Self::from_resolved_credentials(
1113 resolved_pk,
1114 resolved_vault.as_deref(),
1115 resolved_account_address,
1116 environment,
1117 timeout_secs,
1118 proxy_url,
1119 )
1120 }
1121
1122 fn from_resolved_credentials(
1123 private_key: Option<String>,
1124 vault_address: Option<&str>,
1125 account_address: Option<String>,
1126 environment: HyperliquidEnvironment,
1127 timeout_secs: u64,
1128 proxy_url: Option<String>,
1129 ) -> Result<Self> {
1130 match private_key {
1131 Some(pk) => {
1132 let raw_client = HyperliquidRawHttpClient::from_credentials(
1133 &pk,
1134 vault_address,
1135 environment,
1136 timeout_secs,
1137 proxy_url,
1138 )?;
1139 Ok(Self {
1140 inner: Arc::new(raw_client),
1141 clock: get_atomic_clock_realtime(),
1142 instruments: Arc::new(AtomicMap::new()),
1143 instruments_by_coin: Arc::new(AtomicMap::new()),
1144 asset_indices: Arc::new(AtomicMap::new()),
1145 spot_fill_coins: Arc::new(AtomicMap::new()),
1146 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1147 account_id: None,
1148 account_address,
1149 normalize_prices: true,
1150 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1151 include_builder_attribution: true,
1152 })
1153 }
1154 None => {
1155 let mut client = Self::new(environment, timeout_secs, proxy_url)
1157 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))?;
1158 client.set_account_address(account_address);
1159 Ok(client)
1160 }
1161 }
1162 }
1163
1164 pub fn from_credentials(
1170 private_key: &str,
1171 vault_address: Option<&str>,
1172 environment: HyperliquidEnvironment,
1173 timeout_secs: u64,
1174 proxy_url: Option<String>,
1175 ) -> Result<Self> {
1176 let raw_client = HyperliquidRawHttpClient::from_credentials(
1177 private_key,
1178 vault_address,
1179 environment,
1180 timeout_secs,
1181 proxy_url,
1182 )?;
1183 Ok(Self {
1184 inner: Arc::new(raw_client),
1185 clock: get_atomic_clock_realtime(),
1186 instruments: Arc::new(AtomicMap::new()),
1187 instruments_by_coin: Arc::new(AtomicMap::new()),
1188 asset_indices: Arc::new(AtomicMap::new()),
1189 spot_fill_coins: Arc::new(AtomicMap::new()),
1190 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1191 account_id: None,
1192 account_address: None,
1193 normalize_prices: true,
1194 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1195 include_builder_attribution: true,
1196 })
1197 }
1198
1199 #[must_use]
1201 pub fn is_testnet(&self) -> bool {
1202 self.inner.is_testnet()
1203 }
1204
1205 #[must_use]
1207 pub fn normalize_prices(&self) -> bool {
1208 self.normalize_prices
1209 }
1210
1211 pub fn set_normalize_prices(&mut self, value: bool) {
1213 self.normalize_prices = value;
1214 }
1215
1216 #[must_use]
1218 pub fn market_order_slippage_bps(&self) -> u32 {
1219 self.market_order_slippage_bps
1220 }
1221
1222 pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1224 self.market_order_slippage_bps = value;
1225 }
1226
1227 #[must_use]
1229 pub fn include_builder_attribution(&self) -> bool {
1230 self.include_builder_attribution
1231 }
1232
1233 pub fn set_include_builder_attribution(&mut self, value: bool) {
1235 self.include_builder_attribution = value;
1236 }
1237
1238 pub fn get_user_address(&self) -> Result<String> {
1244 self.inner.get_user_address()
1245 }
1246
1247 #[must_use]
1249 pub fn has_vault_address(&self) -> bool {
1250 self.inner.has_vault_address()
1251 }
1252
1253 #[must_use]
1258 pub fn builder_attribution(&self) -> Option<HyperliquidExecBuilderFee> {
1259 if !self.include_builder_attribution || self.has_vault_address() || self.is_testnet() {
1260 None
1261 } else {
1262 Some(HyperliquidExecBuilderFee {
1263 address: NAUTILUS_BUILDER_ADDRESS.to_string(),
1264 fee_tenths_bp: 0,
1265 })
1266 }
1267 }
1268
1269 pub fn get_account_address(&self) -> Result<String> {
1277 if let Some(addr) = &self.account_address {
1278 return Ok(addr.clone());
1279 }
1280 self.inner.get_account_address()
1281 }
1282
1283 pub fn set_account_address(&mut self, address: Option<String>) {
1285 self.account_address = address;
1286 }
1287
1288 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1293 let full_symbol = instrument.symbol().inner();
1294 let coin = instrument.raw_symbol().inner();
1295
1296 self.instruments.rcu(|m| {
1297 m.insert(full_symbol, instrument.clone());
1298 m.insert(coin, instrument.clone());
1300 });
1301
1302 if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
1304 self.instruments_by_coin.rcu(|m| {
1305 m.insert((coin, product_type), instrument.clone());
1306
1307 if let Some(alias_ustr) = cache_alias_for_symbol(full_symbol.as_str())
1332 .map(|alias| Ustr::from(alias.as_str()))
1333 {
1334 let key = (alias_ustr, product_type);
1335 if alias_ustr != coin && !m.contains_key(&key) {
1336 m.insert(key, instrument.clone());
1337 }
1338 }
1339 });
1340 } else {
1341 log::warn!("Unable to determine product type for symbol: {full_symbol}");
1342 }
1343 }
1344
1345 fn get_or_create_instrument(
1346 &self,
1347 coin: &Ustr,
1348 product_type: Option<HyperliquidProductType>,
1349 ) -> Option<InstrumentAny> {
1350 if let Some(pt) = product_type
1351 && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1352 {
1353 return Some(instrument.clone());
1354 }
1355
1356 if product_type.is_none() {
1360 let guard = self.instruments_by_coin.load();
1361
1362 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Outcome)) {
1363 return Some(instrument.clone());
1364 }
1365
1366 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1367 return Some(instrument.clone());
1368 }
1369
1370 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1371 return Some(instrument.clone());
1372 }
1373 }
1374
1375 if coin.as_str().starts_with('@')
1377 && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1378 {
1379 if let Some(instrument) = self.instruments.load().get(symbol) {
1382 return Some(instrument.clone());
1383 }
1384 }
1385
1386 if coin.as_str().starts_with("vntls:") {
1388 log::debug!("Creating synthetic instrument for vault token: {coin}");
1389
1390 let ts_event = self.clock.get_time_ns();
1391
1392 let symbol_str = format!("{coin}-USDC-SPOT");
1394 let symbol = Symbol::new(&symbol_str);
1395 let venue = *HYPERLIQUID_VENUE;
1396 let instrument_id = InstrumentId::new(symbol, venue);
1397
1398 let base_currency = Currency::new(
1400 coin.as_str(),
1401 8, 0, coin.as_str(),
1404 CurrencyType::Crypto,
1405 );
1406
1407 let quote_currency = Currency::new(
1408 "USDC",
1409 6, 0,
1411 "USDC",
1412 CurrencyType::Crypto,
1413 );
1414
1415 let price_increment = Price::from("0.00000001");
1416 let size_increment = Quantity::from("0.00000001");
1417
1418 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1419 instrument_id,
1420 symbol,
1421 base_currency,
1422 quote_currency,
1423 8, 8, price_increment,
1426 size_increment,
1427 None, None, None, None, None, None, None, None, None, None, None, None, None, None, ts_event,
1442 ts_event,
1443 ));
1444
1445 self.cache_instrument(&instrument);
1446
1447 Some(instrument)
1448 } else {
1449 log::warn!("Instrument not found in cache: {coin}");
1451 None
1452 }
1453 }
1454
1455 pub fn set_account_id(&mut self, account_id: AccountId) {
1459 self.account_id = Some(account_id);
1460 }
1461
1462 pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1464 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1465 let spot_meta = match self.inner.get_spot_meta().await {
1466 Ok(spot_meta) => Some(spot_meta),
1467 Err(e) => {
1468 log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1469 None
1470 }
1471 };
1472
1473 match self.inner.load_all_perp_metas().await {
1475 Ok(all_metas) => {
1476 for (dex_index, meta) in all_metas.iter().enumerate() {
1477 let base = perp_dex_asset_index_base(dex_index);
1478 let settlement_currency = match resolve_perp_settlement_currency(
1479 meta,
1480 spot_meta.as_ref(),
1481 ) {
1482 Ok(settlement_currency) => settlement_currency,
1483 Err(e) => {
1484 return Err(Error::decode(format!(
1485 "failed to resolve perp settlement currency for dex {dex_index}: {e}",
1486 )));
1487 }
1488 };
1489
1490 let perp_defs = parse_perp_instruments_with_settlement(
1491 meta,
1492 base,
1493 settlement_currency.as_str(),
1494 );
1495 log::debug!(
1496 "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1497 perp_defs.len(),
1498 );
1499 defs.extend(perp_defs);
1500 }
1501 }
1502 Err(e) => {
1503 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1504
1505 match self.inner.load_perp_meta().await {
1506 Ok(perp_meta) => {
1507 match resolve_perp_settlement_currency(&perp_meta, spot_meta.as_ref()) {
1508 Ok(settlement_currency) => {
1509 let perp_defs = parse_perp_instruments_with_settlement(
1510 &perp_meta,
1511 0,
1512 settlement_currency.as_str(),
1513 );
1514 log::debug!(
1515 "Loaded Hyperliquid perp defs via fallback: count={}",
1516 perp_defs.len(),
1517 );
1518 defs.extend(perp_defs);
1519 }
1520 Err(e) => {
1521 return Err(Error::decode(format!(
1522 "failed to resolve fallback perp settlement currency: {e}",
1523 )));
1524 }
1525 }
1526 }
1527 Err(e) => {
1528 log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1529 }
1530 }
1531 }
1532 }
1533
1534 if let Some(spot_meta) = spot_meta.as_ref() {
1535 match parse_spot_instruments(spot_meta) {
1536 Ok(spot_defs) => {
1537 log::debug!(
1538 "Loaded Hyperliquid spot definitions: count={}",
1539 spot_defs.len(),
1540 );
1541 defs.extend(spot_defs);
1542 }
1543 Err(e) => {
1544 log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1545 }
1546 }
1547 }
1548
1549 match self.inner.get_outcome_meta().await {
1553 Ok(outcome_meta) => match parse_outcome_instruments(&outcome_meta) {
1554 Ok(outcome_defs) => {
1555 log::debug!(
1556 "Loaded Hyperliquid outcome definitions: count={}",
1557 outcome_defs.len(),
1558 );
1559 defs.extend(outcome_defs);
1560 }
1561 Err(e) => {
1562 log::warn!("Failed to parse Hyperliquid outcome instruments: {e}");
1563 }
1564 },
1565 Err(e) => {
1566 log::debug!("Skipping Hyperliquid outcome metadata: {e}");
1567 }
1568 }
1569
1570 let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1577 let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1578 for def in defs {
1579 if seen_symbols.insert(def.symbol) {
1580 deduped.push(def);
1581 } else {
1582 log::warn!(
1583 "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1584 def.symbol,
1585 def.raw_symbol,
1586 );
1587 }
1588 }
1589 let defs = deduped;
1590
1591 self.asset_indices.rcu(|m| {
1593 for def in &defs {
1594 m.insert(def.symbol, def.asset_index);
1595 }
1596 });
1597 log::debug!(
1598 "Populated asset indices map (count={})",
1599 self.asset_indices.len()
1600 );
1601
1602 Ok(defs)
1603 }
1604
1605 pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1607 let ts_init = self.clock.get_time_ns();
1608 instruments_from_defs_owned(defs, ts_init)
1609 }
1610
1611 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1613 let defs = self.request_instrument_defs().await?;
1614 Ok(self.convert_defs(defs))
1615 }
1616
1617 pub async fn build_all_dex_asset_ctxs_instrument_ids(
1622 &self,
1623 ) -> Result<AHashMap<String, Vec<Option<InstrumentId>>>> {
1624 let all_metas = match self.inner.load_all_perp_metas().await {
1625 Ok(all_metas) => all_metas,
1626 Err(e) => {
1627 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1628 vec![self.inner.load_perp_meta().await?]
1629 }
1630 };
1631
1632 let perp_dexs = match self.inner.load_perp_dexs().await {
1633 Ok(dexs) => Some(dexs),
1634 Err(e) => {
1635 log::warn!("Failed to load perpDexs, inferring dex names from metadata: {e}");
1636 None
1637 }
1638 };
1639
1640 let raw_symbol_to_id =
1641 self.instruments
1642 .load()
1643 .values()
1644 .fold(AHashMap::new(), |mut acc, instrument| {
1645 acc.insert(instrument.raw_symbol().to_string(), instrument.id());
1646 acc
1647 });
1648
1649 let mut mapping = AHashMap::new();
1650
1651 for (dex_index, meta) in all_metas.iter().enumerate() {
1652 let dex_name = resolve_perp_dex_name(dex_index, meta, perp_dexs.as_deref());
1653 let mut instrument_ids = Vec::with_capacity(meta.universe.len());
1654
1655 for asset in &meta.universe {
1656 if let Some(instrument_id) = raw_symbol_to_id.get(&asset.name) {
1657 instrument_ids.push(Some(*instrument_id));
1658 } else {
1659 log::warn!(
1660 "Missing cached Hyperliquid instrument for dex='{}' raw_symbol='{}'",
1661 dex_name,
1662 asset.name
1663 );
1664 instrument_ids.push(None);
1665 }
1666 }
1667
1668 mapping.insert(dex_name, instrument_ids);
1669 }
1670
1671 Ok(mapping)
1672 }
1673
1674 pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1682 self.get_asset_index_for_symbol(Ustr::from(symbol))
1683 }
1684
1685 pub(crate) fn get_asset_index_for_symbol(&self, symbol: Ustr) -> Option<u32> {
1689 self.asset_indices.load().get(&symbol).copied()
1690 }
1691
1692 pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1694 self.get_price_precision_for_symbol(Ustr::from(symbol))
1695 }
1696
1697 pub(crate) fn get_price_precision_for_symbol(&self, symbol: Ustr) -> Option<u8> {
1699 self.instruments
1700 .load()
1701 .get(&symbol)
1702 .map(|inst| inst.price_precision())
1703 }
1704
1705 #[must_use]
1713 pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1714 const SPOT_INDEX_OFFSET: u32 = 10_000;
1715 const BUILDER_PERP_OFFSET: u32 = 100_000;
1716
1717 let guard = self.asset_indices.load();
1718
1719 let mut mapping = AHashMap::new();
1720
1721 for (symbol, &asset_index) in guard.iter() {
1722 if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1724 let pair_index = asset_index - SPOT_INDEX_OFFSET;
1725 let fill_coin = Ustr::from(&format!("@{pair_index}"));
1726 mapping.insert(fill_coin, *symbol);
1727 }
1728 }
1729
1730 self.spot_fill_coins.store(mapping.clone());
1732
1733 mapping
1734 }
1735
1736 #[allow(dead_code)]
1738 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1739 self.inner.load_perp_meta().await
1740 }
1741
1742 #[allow(dead_code)]
1744 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1745 self.inner.load_all_perp_metas().await
1746 }
1747
1748 #[allow(dead_code)]
1750 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1751 self.inner.get_spot_meta().await
1752 }
1753
1754 pub(crate) async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
1756 self.inner.get_outcome_meta().await
1757 }
1758
1759 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1761 self.inner.info_l2_book(coin).await
1762 }
1763
1764 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
1766 self.inner.info_recent_trades(coin).await
1767 }
1768
1769 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1771 self.inner.info_user_fills(user).await
1772 }
1773
1774 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1776 self.inner.info_order_status(user, oid).await
1777 }
1778
1779 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1781 self.inner.info_open_orders(user).await
1782 }
1783
1784 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1786 self.inner.info_frontend_open_orders(user).await
1787 }
1788
1789 pub async fn info_historical_orders(
1791 &self,
1792 user: &str,
1793 ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
1794 self.inner.info_historical_orders(user).await
1795 }
1796
1797 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1799 self.inner.info_clearinghouse_state(user).await
1800 }
1801
1802 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
1804 self.inner.info_spot_clearinghouse_state(user).await
1805 }
1806
1807 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
1809 self.inner.info_user_fees(user).await
1810 }
1811
1812 pub async fn info_candle_snapshot(
1814 &self,
1815 coin: &str,
1816 interval: HyperliquidBarInterval,
1817 start_time: u64,
1818 end_time: u64,
1819 ) -> Result<HyperliquidCandleSnapshot> {
1820 self.inner
1821 .info_candle_snapshot(coin, interval, start_time, end_time)
1822 .await
1823 }
1824
1825 pub async fn info_funding_history(
1827 &self,
1828 coin: &str,
1829 start_time: u64,
1830 end_time: Option<u64>,
1831 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
1832 self.inner
1833 .info_funding_history(coin, start_time, end_time)
1834 .await
1835 }
1836
1837 pub async fn post_action(
1839 &self,
1840 action: &ExchangeAction,
1841 ) -> Result<HyperliquidExchangeResponse> {
1842 self.inner.post_action(action).await
1843 }
1844
1845 pub async fn post_action_exec(
1847 &self,
1848 action: &HyperliquidExecAction,
1849 ) -> Result<HyperliquidExchangeResponse> {
1850 self.inner.post_action_exec(action).await
1851 }
1852
1853 pub fn sign_action_exec_request(
1855 &self,
1856 action: &HyperliquidExecAction,
1857 expires_after: Option<u64>,
1858 ) -> Result<HyperliquidExchangeRequest<HyperliquidExecAction>> {
1859 self.inner.sign_action_exec_request(action, expires_after)
1860 }
1861
1862 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1864 self.inner.info_meta().await
1865 }
1866
1867 pub async fn cancel_order(
1877 &self,
1878 instrument_id: InstrumentId,
1879 client_order_id: Option<ClientOrderId>,
1880 venue_order_id: Option<VenueOrderId>,
1881 ) -> Result<()> {
1882 let symbol = instrument_id.symbol.inner();
1884 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1885 Error::bad_request(format!(
1886 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1887 ))
1888 })?;
1889
1890 let action = if let Some(client_order_id) = client_order_id {
1891 if let Some(cloid) = self.cached_client_order_id_cloid(&client_order_id) {
1892 HyperliquidExecAction::CancelByCloid {
1893 cancels: vec![HyperliquidExecCancelByCloidRequest {
1894 asset: asset_id,
1895 cloid,
1896 }],
1897 fast: None,
1898 }
1899 } else if let Some(oid) = venue_order_id {
1900 let oid_u64 = oid
1901 .as_str()
1902 .parse::<u64>()
1903 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1904 HyperliquidExecAction::Cancel {
1905 cancels: vec![HyperliquidExecCancelOrderRequest {
1906 asset: asset_id,
1907 oid: oid_u64,
1908 }],
1909 fast: None,
1910 }
1911 } else {
1912 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
1913 HyperliquidExecAction::CancelByCloid {
1914 cancels: vec![HyperliquidExecCancelByCloidRequest {
1915 asset: asset_id,
1916 cloid,
1917 }],
1918 fast: None,
1919 }
1920 }
1921 } else if let Some(oid) = venue_order_id {
1922 let oid_u64 = oid
1923 .as_str()
1924 .parse::<u64>()
1925 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1926 HyperliquidExecAction::Cancel {
1927 cancels: vec![HyperliquidExecCancelOrderRequest {
1928 asset: asset_id,
1929 oid: oid_u64,
1930 }],
1931 fast: None,
1932 }
1933 } else {
1934 return Err(Error::bad_request(
1935 "Either client_order_id or venue_order_id must be provided",
1936 ));
1937 };
1938
1939 let response = self.inner.post_action_exec(&action).await?;
1941
1942 match response {
1944 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
1945 HyperliquidExchangeResponse::Status {
1946 status,
1947 response: error_data,
1948 } => Err(Error::bad_request(format!(
1949 "Cancel order failed: status={status}, error={error_data}"
1950 ))),
1951 HyperliquidExchangeResponse::Error { error } => {
1952 Err(Error::bad_request(format!("Cancel order error: {error}")))
1953 }
1954 }
1955 }
1956
1957 #[expect(clippy::too_many_arguments)]
1967 pub async fn modify_order(
1968 &self,
1969 instrument_id: InstrumentId,
1970 venue_order_id: Option<VenueOrderId>,
1971 order_side: OrderSide,
1972 order_type: OrderType,
1973 price: Price,
1974 quantity: Quantity,
1975 trigger_price: Option<Price>,
1976 reduce_only: bool,
1977 post_only: bool,
1978 time_in_force: TimeInForce,
1979 client_order_id: Option<ClientOrderId>,
1980 ) -> Result<()> {
1981 let symbol = instrument_id.symbol.inner();
1982 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1983 Error::bad_request(format!(
1984 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1985 ))
1986 })?;
1987
1988 let oid = match client_order_id
1989 .as_ref()
1990 .and_then(|id| self.unique_cached_client_order_id_cloid(id))
1991 {
1992 Some(cloid) => HyperliquidExecModifyTarget::Cloid(cloid),
1993 None => {
1994 let Some(venue_order_id) = venue_order_id.as_ref() else {
1995 return Err(Error::bad_request(
1996 "venue_order_id or unique cached CLOID is required for modify",
1997 ));
1998 };
1999 HyperliquidExecModifyTarget::from_venue_order_id(venue_order_id)
2000 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?
2001 }
2002 };
2003
2004 let is_buy = matches!(order_side, OrderSide::Buy);
2005 let decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
2006
2007 let normalized_price = if self.normalize_prices {
2008 normalize_price(price.as_decimal(), decimals).normalize()
2009 } else {
2010 price.as_decimal().normalize()
2011 };
2012
2013 let size = quantity.as_decimal().normalize();
2014
2015 let kind = match order_type {
2016 OrderType::Market => HyperliquidExecOrderKind::Limit {
2017 limit: HyperliquidExecLimitParams {
2018 tif: HyperliquidExecTif::Ioc,
2019 },
2020 },
2021 OrderType::Limit => {
2022 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
2023 .map_err(|e| Error::bad_request(format!("{e}")))?;
2024 HyperliquidExecOrderKind::Limit {
2025 limit: HyperliquidExecLimitParams { tif },
2026 }
2027 }
2028 OrderType::StopMarket
2029 | OrderType::StopLimit
2030 | OrderType::MarketIfTouched
2031 | OrderType::LimitIfTouched => {
2032 if let Some(trig_px) = trigger_price {
2033 let trigger_price_decimal = if self.normalize_prices {
2034 normalize_price(trig_px.as_decimal(), decimals).normalize()
2035 } else {
2036 trig_px.as_decimal().normalize()
2037 };
2038 let tpsl = match order_type {
2039 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
2040 _ => HyperliquidExecTpSl::Tp,
2041 };
2042 let is_market = matches!(
2043 order_type,
2044 OrderType::StopMarket | OrderType::MarketIfTouched
2045 );
2046 HyperliquidExecOrderKind::Trigger {
2047 trigger: HyperliquidExecTriggerParams {
2048 is_market,
2049 trigger_px: trigger_price_decimal,
2050 tpsl,
2051 },
2052 }
2053 } else {
2054 return Err(Error::bad_request("Trigger orders require a trigger price"));
2055 }
2056 }
2057 _ => {
2058 return Err(Error::bad_request(format!(
2059 "Order type {order_type:?} not supported for modify"
2060 )));
2061 }
2062 };
2063 let cloid = client_order_id.map(|id| self.get_or_generate_client_order_id_cloid(id));
2064
2065 let order = HyperliquidExecPlaceOrderRequest {
2066 asset: asset_id,
2067 is_buy,
2068 price: normalized_price,
2069 size,
2070 reduce_only,
2071 kind,
2072 cloid,
2073 };
2074
2075 let action = HyperliquidExecAction::Modify {
2076 modify: HyperliquidExecModifyOrderRequest { oid, order },
2077 };
2078
2079 let response = self.inner.post_action_exec(&action).await?;
2080
2081 match response {
2082 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
2083 if let Some(inner_error) = extract_inner_error(&response) {
2084 Err(Error::bad_request(format!(
2085 "Modify order rejected: {inner_error}",
2086 )))
2087 } else {
2088 Ok(())
2089 }
2090 }
2091 HyperliquidExchangeResponse::Status {
2092 status,
2093 response: error_data,
2094 } => Err(Error::bad_request(format!(
2095 "Modify order failed: status={status}, error={error_data}"
2096 ))),
2097 HyperliquidExchangeResponse::Error { error } => {
2098 Err(Error::bad_request(format!("Modify order error: {error}")))
2099 }
2100 }
2101 }
2102
2103 pub async fn submit_split_outcome(
2117 &self,
2118 outcome: u32,
2119 amount: Decimal,
2120 ) -> Result<HyperliquidExchangeResponse> {
2121 let action = HyperliquidExecAction::UserOutcome {
2122 op: HyperliquidExecUserOutcomeOp::SplitOutcome(HyperliquidExecSplitOutcomeParams {
2123 outcome,
2124 amount,
2125 }),
2126 };
2127 self.inner.post_action_exec(&action).await
2128 }
2129
2130 pub async fn submit_merge_outcome(
2141 &self,
2142 outcome: u32,
2143 amount: Option<Decimal>,
2144 ) -> Result<HyperliquidExchangeResponse> {
2145 let action = HyperliquidExecAction::UserOutcome {
2146 op: HyperliquidExecUserOutcomeOp::MergeOutcome(HyperliquidExecMergeOutcomeParams {
2147 outcome,
2148 amount,
2149 }),
2150 };
2151 self.inner.post_action_exec(&action).await
2152 }
2153
2154 pub async fn submit_merge_question(
2164 &self,
2165 question: u32,
2166 amount: Option<Decimal>,
2167 ) -> Result<HyperliquidExchangeResponse> {
2168 let action = HyperliquidExecAction::UserOutcome {
2169 op: HyperliquidExecUserOutcomeOp::MergeQuestion(HyperliquidExecMergeQuestionParams {
2170 question,
2171 amount,
2172 }),
2173 };
2174 self.inner.post_action_exec(&action).await
2175 }
2176
2177 pub async fn submit_negate_outcome(
2187 &self,
2188 question: u32,
2189 outcome: u32,
2190 amount: Decimal,
2191 ) -> Result<HyperliquidExchangeResponse> {
2192 let action = HyperliquidExecAction::UserOutcome {
2193 op: HyperliquidExecUserOutcomeOp::NegateOutcome(HyperliquidExecNegateOutcomeParams {
2194 question,
2195 outcome,
2196 amount,
2197 }),
2198 };
2199 self.inner.post_action_exec(&action).await
2200 }
2201
2202 pub async fn request_order_status_reports(
2214 &self,
2215 user: &str,
2216 instrument_id: Option<InstrumentId>,
2217 ) -> Result<Vec<OrderStatusReport>> {
2218 let account_id = self
2219 .account_id
2220 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2221 let response = self.info_frontend_open_orders(user).await?;
2222
2223 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
2225 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
2226
2227 let mut reports = Vec::new();
2228 let ts_init = self.clock.get_time_ns();
2229
2230 for order_value in orders {
2231 let order: WsBasicOrderData = match serde_json::from_value(order_value.clone()) {
2233 Ok(o) => o,
2234 Err(e) => {
2235 log::warn!("Failed to parse order: {e}");
2236 continue;
2237 }
2238 };
2239
2240 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2242 Some(inst) => inst,
2243 None => continue, };
2245
2246 if let Some(filter_id) = instrument_id
2248 && instrument.id() != filter_id
2249 {
2250 continue;
2251 }
2252
2253 let status = HyperliquidOrderStatusEnum::Open;
2255
2256 match parse_order_status_report_from_basic(
2258 &order,
2259 &status,
2260 &instrument,
2261 account_id,
2262 ts_init,
2263 ) {
2264 Ok(report) => reports.push(report),
2265 Err(e) => log::error!("Failed to parse order status report: {e}"),
2266 }
2267 }
2268
2269 Ok(reports)
2270 }
2271
2272 pub async fn request_historical_order_status_reports(
2278 &self,
2279 user: &str,
2280 instrument_id: Option<InstrumentId>,
2281 ) -> Result<Vec<OrderStatusReport>> {
2282 let account_id = self
2283 .account_id
2284 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2285 let entries = self.info_historical_orders(user).await?;
2286 let mut reports = Vec::new();
2287 let ts_init = self.clock.get_time_ns();
2288
2289 for entry in entries {
2290 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2291 Some(instrument) => instrument,
2292 None => continue,
2293 };
2294
2295 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2296 continue;
2297 }
2298
2299 let order_type = entry.order.order_type.as_deref().unwrap_or_default();
2300 let tpsl = if order_type.starts_with("Take Profit") {
2301 Some(crate::common::enums::HyperliquidTpSl::Tp)
2302 } else if order_type.starts_with("Stop") {
2303 Some(crate::common::enums::HyperliquidTpSl::Sl)
2304 } else {
2305 None
2306 };
2307 let is_market = entry
2308 .order
2309 .order_type
2310 .as_deref()
2311 .is_some_and(|label| label.ends_with("Market"));
2312 let historical_order_type = match tpsl.as_ref() {
2313 Some(tpsl) => parse_trigger_order_type(is_market, tpsl),
2314 None if is_market => OrderType::Market,
2315 None => OrderType::Limit,
2316 };
2317 let order = WsBasicOrderData {
2318 coin: entry.order.coin,
2319 side: entry.order.side,
2320 limit_px: entry.order.limit_px,
2321 sz: entry.order.sz,
2322 oid: entry.order.oid,
2323 timestamp: entry.order.timestamp,
2324 orig_sz: entry.order.orig_sz,
2325 cloid: entry.order.cloid,
2326 tif: entry.order.tif,
2327 reduce_only: entry.order.reduce_only,
2328 trigger_px: entry
2329 .order
2330 .trigger_px
2331 .filter(|price| *price != Decimal::ZERO),
2332 is_market: tpsl.is_some().then_some(is_market),
2333 tpsl,
2334 trigger_activated: None,
2335 trailing_stop: None,
2336 };
2337
2338 match parse_order_status_report_from_basic(
2339 &order,
2340 &entry.status,
2341 &instrument,
2342 account_id,
2343 ts_init,
2344 ) {
2345 Ok(mut report) => {
2346 report.order_type = historical_order_type;
2347 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2348 reports.push(report);
2349 }
2350 Err(e) => log::error!("Failed to parse historical order status report: {e}"),
2351 }
2352 }
2353
2354 Ok(deduplicate_historical_order_reports(reports))
2355 }
2356
2357 pub async fn request_order_status_report(
2367 &self,
2368 user: &str,
2369 oid: u64,
2370 ) -> Result<Option<OrderStatusReport>> {
2371 let account_id = self
2372 .account_id
2373 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2374
2375 let ts_init = self.clock.get_time_ns();
2376
2377 let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
2382 Ok(response) => match serde_json::from_value(response) {
2383 Ok(v) => v,
2384 Err(e) => {
2385 log::warn!("Failed to parse frontend open orders response: {e}");
2386 Vec::new()
2387 }
2388 },
2389 Err(e) => {
2390 log::warn!(
2391 "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
2392 );
2393 Vec::new()
2394 }
2395 };
2396
2397 if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
2398 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2399 Some(inst) => inst,
2400 None => return Ok(None),
2401 };
2402
2403 let status = if order.trigger_activated == Some(true) {
2404 HyperliquidOrderStatusEnum::Triggered
2405 } else {
2406 HyperliquidOrderStatusEnum::Open
2407 };
2408
2409 return match parse_order_status_report_from_basic(
2410 &order,
2411 &status,
2412 &instrument,
2413 account_id,
2414 ts_init,
2415 ) {
2416 Ok(report) => Ok(Some(report)),
2417 Err(e) => {
2418 log::error!("Failed to parse order status report for oid {oid}: {e}");
2419 Ok(None)
2420 }
2421 };
2422 }
2423
2424 let response = self.info_order_status(user, oid).await?;
2426 let entry = match response.into_order() {
2427 Some(e) => e,
2428 None => return Ok(None),
2429 };
2430
2431 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2432 Some(inst) => inst,
2433 None => return Ok(None),
2434 };
2435
2436 let basic = WsBasicOrderData {
2441 coin: entry.order.coin,
2442 side: entry.order.side,
2443 limit_px: entry.order.limit_px,
2444 sz: entry.order.sz,
2445 oid: entry.order.oid,
2446 timestamp: entry.order.timestamp,
2447 orig_sz: entry.order.orig_sz,
2448 cloid: entry.order.cloid,
2449 tif: None,
2450 reduce_only: None,
2451 trigger_px: None,
2452 is_market: None,
2453 tpsl: None,
2454 trigger_activated: None,
2455 trailing_stop: None,
2456 };
2457
2458 match parse_order_status_report_from_basic(
2459 &basic,
2460 &entry.status,
2461 &instrument,
2462 account_id,
2463 ts_init,
2464 ) {
2465 Ok(mut report) => {
2466 if entry.status_timestamp > 0 {
2469 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2470 }
2471 Ok(Some(report))
2472 }
2473 Err(e) => {
2474 log::error!("Failed to parse order status report for oid {oid}: {e}");
2475 Ok(None)
2476 }
2477 }
2478 }
2479
2480 pub async fn request_order_status_report_by_client_order_id(
2489 &self,
2490 user: &str,
2491 client_order_id: &ClientOrderId,
2492 ) -> Result<Option<OrderStatusReport>> {
2493 let account_id = self
2494 .account_id
2495 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2496
2497 let ts_init = self.clock.get_time_ns();
2498
2499 let cached_cloid_hex = self
2500 .cached_client_order_id_cloid(client_order_id)
2501 .map(|cloid| cloid.to_hex());
2502 let cloid = Cloid::from_client_order_id(*client_order_id);
2503 let cloid_hex = cloid.to_hex();
2504
2505 let response = self.info_frontend_open_orders(user).await?;
2506 let orders: Vec<WsBasicOrderData> = match serde_json::from_value(response) {
2507 Ok(v) => v,
2508 Err(e) => {
2509 log::warn!("Failed to parse frontend open orders response: {e}");
2510 return Ok(None);
2511 }
2512 };
2513
2514 let order = match orders.into_iter().find(|o| {
2515 o.cloid
2516 .as_ref()
2517 .is_some_and(|c| cached_cloid_hex.as_ref() == Some(c) || c == &cloid_hex)
2518 }) {
2519 Some(o) => o,
2520 None => return Ok(None),
2521 };
2522
2523 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2524 Some(inst) => inst,
2525 None => return Ok(None),
2526 };
2527
2528 let status = if order.trigger_activated == Some(true) {
2529 HyperliquidOrderStatusEnum::Triggered
2530 } else {
2531 HyperliquidOrderStatusEnum::Open
2532 };
2533
2534 match parse_order_status_report_from_basic(
2535 &order,
2536 &status,
2537 &instrument,
2538 account_id,
2539 ts_init,
2540 ) {
2541 Ok(mut report) => {
2542 report.client_order_id = Some(*client_order_id);
2543 Ok(Some(report))
2544 }
2545 Err(e) => {
2546 log::error!("Failed to parse order status report for cloid {cloid_hex}: {e}");
2547 Ok(None)
2548 }
2549 }
2550 }
2551
2552 pub async fn request_fill_reports(
2566 &self,
2567 user: &str,
2568 instrument_id: Option<InstrumentId>,
2569 ) -> Result<Vec<FillReport>> {
2570 let account_id = self
2571 .account_id
2572 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2573 let fills_response = self.info_user_fills(user).await?;
2574
2575 let mut reports = Vec::new();
2576 let ts_init = self.clock.get_time_ns();
2577
2578 for fill in fills_response {
2579 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
2581 Some(inst) => inst,
2582 None => continue, };
2584
2585 if let Some(filter_id) = instrument_id
2587 && instrument.id() != filter_id
2588 {
2589 continue;
2590 }
2591
2592 match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2594 Ok(report) => reports.push(report),
2595 Err(e) => log::error!("Failed to parse fill report: {e}"),
2596 }
2597 }
2598
2599 Ok(reports)
2600 }
2601
2602 pub async fn request_position_status_reports(
2626 &self,
2627 user: &str,
2628 instrument_id: Option<InstrumentId>,
2629 ) -> Result<Vec<PositionStatusReport>> {
2630 let account_id = self
2631 .account_id
2632 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2633
2634 let filter_product = instrument_id
2635 .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
2636
2637 let fetch_perp = !matches!(
2638 filter_product,
2639 Some(HyperliquidProductType::Spot | HyperliquidProductType::Outcome)
2640 );
2641 let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
2642
2643 let mut reports = Vec::new();
2644 let ts_init = self.clock.get_time_ns();
2645
2646 if !fetch_perp {
2647 let spot_reports = self
2648 .request_spot_position_status_reports(user, instrument_id)
2649 .await?;
2650 reports.extend(spot_reports);
2651 return Ok(reports);
2652 }
2653
2654 let state_response = self.info_clearinghouse_state(user).await?;
2655
2656 let asset_positions: Vec<serde_json::Value> = state_response
2658 .get("assetPositions")
2659 .and_then(|v| v.as_array())
2660 .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
2661 .clone();
2662
2663 for position_value in asset_positions {
2664 let coin = position_value
2666 .get("position")
2667 .and_then(|p| p.get("coin"))
2668 .and_then(|c| c.as_str())
2669 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
2670
2671 let coin_ustr = Ustr::from(coin);
2673 let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
2674 Some(inst) => inst,
2675 None => continue, };
2677
2678 if let Some(filter_id) = instrument_id
2680 && instrument.id() != filter_id
2681 {
2682 continue;
2683 }
2684
2685 match parse_position_status_report(&position_value, &instrument, account_id, ts_init) {
2687 Ok(report) => reports.push(report),
2688 Err(e) => log::error!("Failed to parse position status report: {e}"),
2689 }
2690 }
2691
2692 if fetch_spot {
2695 let spot_reports = self
2696 .request_spot_position_status_reports(user, instrument_id)
2697 .await?;
2698 reports.extend(spot_reports);
2699 }
2700
2701 Ok(reports)
2702 }
2703
2704 pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
2718 let account_id = self
2719 .account_id
2720 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2721 let state_response = self.info_clearinghouse_state(user).await?;
2722 let ts_init = self.clock.get_time_ns();
2723
2724 log::trace!("Clearinghouse state response: {state_response}");
2725
2726 let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
2727 .map_err(|e| {
2728 log::error!("Failed to parse clearinghouse state: {e}");
2729 log::debug!("Raw response: {state_response}");
2730 Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
2731 })?;
2732
2733 let spot_response = self.info_spot_clearinghouse_state(user).await?;
2736 let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
2737 .map_err(|e| {
2738 log::error!("Failed to parse spot clearinghouse state: {e}");
2739 log::debug!("Raw spot response: {spot_response}");
2740 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2741 })?;
2742
2743 let (balances, margins) =
2744 parse_combined_account_balances_and_margins(&perp_state, &spot_state)
2745 .map_err(|e| Error::decode(e.to_string()))?;
2746
2747 Ok(AccountState::new(
2748 account_id,
2749 AccountType::Margin,
2750 balances,
2751 margins,
2752 true, UUID4::new(),
2754 ts_init,
2755 ts_init,
2756 None,
2757 ))
2758 }
2759
2760 pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
2771 let response = self.info_spot_clearinghouse_state(user).await?;
2772
2773 log::trace!("Spot clearinghouse state response: {response}");
2774
2775 let state: SpotClearinghouseState =
2776 serde_json::from_value(response.clone()).map_err(|e| {
2777 log::error!("Failed to parse spot clearinghouse state: {e}");
2778 log::debug!("Raw response: {response}");
2779 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2780 })?;
2781
2782 parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
2783 }
2784
2785 pub async fn request_spot_position_status_reports(
2800 &self,
2801 user: &str,
2802 instrument_id: Option<InstrumentId>,
2803 ) -> Result<Vec<PositionStatusReport>> {
2804 let account_id = self
2805 .account_id
2806 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2807 let response = self.info_spot_clearinghouse_state(user).await?;
2808
2809 let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
2810 log::error!("Failed to parse spot clearinghouse state: {e}");
2811 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2812 })?;
2813
2814 let ts_init = self.clock.get_time_ns();
2815 let mut reports = Vec::with_capacity(state.balances.len());
2816
2817 for balance in &state.balances {
2818 if balance.total.is_zero() {
2819 continue;
2820 }
2821
2822 if balance.coin.as_str() == "USDC" {
2827 continue;
2828 }
2829
2830 let product_type = match HyperliquidProductType::from_symbol(balance.coin.as_str()) {
2831 Ok(HyperliquidProductType::Outcome) => HyperliquidProductType::Outcome,
2832 _ => HyperliquidProductType::Spot,
2833 };
2834
2835 let instrument = match self.get_or_create_instrument(&balance.coin, Some(product_type))
2836 {
2837 Some(inst) => inst,
2838 None => continue,
2839 };
2840
2841 if let Some(filter_id) = instrument_id
2842 && instrument.id() != filter_id
2843 {
2844 continue;
2845 }
2846
2847 match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
2848 Ok(report) => reports.push(report),
2849 Err(e) => log::error!(
2850 "Failed to parse spot position status report for {}: {e}",
2851 balance.coin,
2852 ),
2853 }
2854 }
2855
2856 Ok(reports)
2857 }
2858
2859 pub async fn request_bars(
2876 &self,
2877 bar_type: BarType,
2878 start: Option<chrono::DateTime<chrono::Utc>>,
2879 end: Option<chrono::DateTime<chrono::Utc>>,
2880 limit: Option<u32>,
2881 ) -> Result<Vec<Bar>> {
2882 let instrument_id = bar_type.instrument_id();
2883 let symbol = instrument_id.symbol;
2884
2885 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
2886
2887 let alias = cache_alias_for_symbol(symbol.as_str())
2891 .map(|alias| Ustr::from(alias.as_str()))
2892 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
2893
2894 let instrument = self
2895 .get_or_create_instrument(&alias, product_type)
2896 .ok_or_else(|| {
2897 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
2898 })?;
2899
2900 let coin = instrument.raw_symbol().inner();
2905
2906 let price_precision = instrument.price_precision();
2907 let size_precision = instrument.size_precision();
2908
2909 let interval =
2910 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
2911
2912 let now = chrono::Utc::now();
2914 let end_time = end.unwrap_or(now).timestamp_millis() as u64;
2915 let start_time = if let Some(start) = start {
2916 start.timestamp_millis() as u64
2917 } else {
2918 let spec = bar_type.spec();
2920 let step_ms = match spec.aggregation {
2921 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
2922 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
2923 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
2924 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
2925 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
2926 _ => 60_000,
2927 };
2928 end_time.saturating_sub(1000 * step_ms)
2929 };
2930
2931 let candles = self
2932 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
2933 .await?;
2934
2935 let now_ms = now.timestamp_millis() as u64;
2937
2938 let mut bars: Vec<Bar> = candles
2939 .iter()
2940 .filter(|candle| candle.end_timestamp < now_ms)
2941 .enumerate()
2942 .filter_map(|(i, candle)| {
2943 candle_to_bar(candle, bar_type, price_precision, size_precision)
2944 .map_err(|e| {
2945 log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
2946 e
2947 })
2948 .ok()
2949 })
2950 .collect();
2951
2952 if let Some(limit) = limit
2954 && limit > 0
2955 && bars.len() > limit as usize
2956 {
2957 bars.truncate(limit as usize);
2958 }
2959
2960 log::debug!(
2961 "Received {} bars for {} (filtered {} incomplete)",
2962 bars.len(),
2963 bar_type,
2964 candles.len() - bars.len()
2965 );
2966 Ok(bars)
2967 }
2968
2969 pub async fn request_public_trades(
2978 &self,
2979 instrument_id: InstrumentId,
2980 start: Option<chrono::DateTime<chrono::Utc>>,
2981 end: Option<chrono::DateTime<chrono::Utc>>,
2982 limit: Option<usize>,
2983 ) -> Result<Vec<HyperliquidPublicTrade>> {
2984 let symbol = instrument_id.symbol;
2985 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
2986 let alias = cache_alias_for_symbol(symbol.as_str())
2987 .map(|alias| Ustr::from(alias.as_str()))
2988 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
2989 let instrument = self
2990 .get_or_create_instrument(&alias, product_type)
2991 .ok_or_else(|| {
2992 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
2993 })?;
2994
2995 let raw_trades = match self
2996 .info_recent_trades(instrument.raw_symbol().as_ref())
2997 .await
2998 {
2999 Ok(trades) => trades,
3000 Err(e) if e.is_unprocessable_entity() => {
3001 log::warn!(
3002 "Recent public trades endpoint unavailable for {instrument_id} \
3003 (requires the Hyperliquid indexer); returning empty response"
3004 );
3005 Vec::new()
3006 }
3007 Err(e) => return Err(e),
3008 };
3009
3010 let mut trades: Vec<HyperliquidPublicTrade> = raw_trades
3011 .iter()
3012 .filter_map(|raw| match parse_recent_public_trade(raw, &instrument) {
3013 Ok(trade) => Some(trade),
3014 Err(e) => {
3015 log::warn!("Skipping recent public trade for {instrument_id}: {e}");
3016 None
3017 }
3018 })
3019 .collect();
3020 trades.sort_by_key(|trade| trade.ts_event);
3021
3022 Ok(filter_recent_public_trades(
3023 trades,
3024 datetime_to_unix_nanos(start),
3025 datetime_to_unix_nanos(end),
3026 limit.filter(|limit| *limit > 0),
3027 instrument_id,
3028 ))
3029 }
3030
3031 #[expect(clippy::too_many_arguments)]
3038 pub async fn submit_order(
3039 &self,
3040 instrument_id: InstrumentId,
3041 client_order_id: ClientOrderId,
3042 order_side: OrderSide,
3043 order_type: OrderType,
3044 quantity: Quantity,
3045 time_in_force: TimeInForce,
3046 price: Option<Price>,
3047 trigger_price: Option<Price>,
3048 post_only: bool,
3049 reduce_only: bool,
3050 ) -> Result<OrderStatusReport> {
3051 let symbol = instrument_id.symbol.inner();
3052 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3053 Error::bad_request(format!(
3054 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3055 ))
3056 })?;
3057
3058 let is_buy = matches!(order_side, OrderSide::Buy);
3059 let price_precision = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
3060
3061 let price_decimal = match price {
3062 Some(px) if self.normalize_prices => {
3063 normalize_price(px.as_decimal(), price_precision).normalize()
3064 }
3065 Some(px) => px.as_decimal().normalize(),
3066 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
3067 None if matches!(
3068 order_type,
3069 OrderType::StopMarket | OrderType::MarketIfTouched
3070 ) =>
3071 {
3072 match trigger_price {
3073 Some(tp) => {
3074 let derived = derive_limit_from_trigger(
3075 tp.as_decimal().normalize(),
3076 is_buy,
3077 self.market_order_slippage_bps,
3078 );
3079 let sig_rounded = round_to_sig_figs(derived, 5);
3080 clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
3081 }
3082 None => Decimal::ZERO,
3083 }
3084 }
3085 None => return Err(Error::bad_request("Limit orders require a price")),
3086 };
3087
3088 let size_decimal = quantity.as_decimal().normalize();
3089
3090 let kind = match order_type {
3091 OrderType::Market => HyperliquidExecOrderKind::Limit {
3092 limit: HyperliquidExecLimitParams {
3093 tif: HyperliquidExecTif::Ioc,
3094 },
3095 },
3096 OrderType::Limit => {
3097 let tif = if post_only {
3098 HyperliquidExecTif::Alo
3099 } else {
3100 match time_in_force {
3101 TimeInForce::Gtc => HyperliquidExecTif::Gtc,
3102 TimeInForce::Ioc => HyperliquidExecTif::Ioc,
3103 TimeInForce::Fok
3104 | TimeInForce::Day
3105 | TimeInForce::Gtd
3106 | TimeInForce::AtTheOpen
3107 | TimeInForce::AtTheClose => {
3108 return Err(Error::bad_request(format!(
3109 "Time in force {time_in_force:?} not supported"
3110 )));
3111 }
3112 }
3113 };
3114 HyperliquidExecOrderKind::Limit {
3115 limit: HyperliquidExecLimitParams { tif },
3116 }
3117 }
3118 OrderType::StopMarket
3119 | OrderType::StopLimit
3120 | OrderType::MarketIfTouched
3121 | OrderType::LimitIfTouched => {
3122 if let Some(trig_px) = trigger_price {
3123 let trigger_price_decimal = if self.normalize_prices {
3124 normalize_price(trig_px.as_decimal(), price_precision).normalize()
3125 } else {
3126 trig_px.as_decimal().normalize()
3127 };
3128
3129 let tpsl = match order_type {
3133 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
3134 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
3135 HyperliquidExecTpSl::Tp
3136 }
3137 _ => unreachable!(),
3138 };
3139
3140 let is_market = matches!(
3141 order_type,
3142 OrderType::StopMarket | OrderType::MarketIfTouched
3143 );
3144
3145 HyperliquidExecOrderKind::Trigger {
3146 trigger: HyperliquidExecTriggerParams {
3147 is_market,
3148 trigger_px: trigger_price_decimal,
3149 tpsl,
3150 },
3151 }
3152 } else {
3153 return Err(Error::bad_request("Trigger orders require a trigger price"));
3154 }
3155 }
3156 _ => {
3157 return Err(Error::bad_request(format!(
3158 "Order type {order_type:?} not supported"
3159 )));
3160 }
3161 };
3162
3163 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
3164 let hyperliquid_order = HyperliquidExecPlaceOrderRequest {
3165 asset,
3166 is_buy,
3167 price: price_decimal,
3168 size: size_decimal,
3169 reduce_only,
3170 kind,
3171 cloid: Some(cloid),
3172 };
3173
3174 let builder = self.builder_attribution();
3175
3176 let action = HyperliquidExecAction::Order {
3177 orders: vec![hyperliquid_order],
3178 grouping: HyperliquidExecGrouping::Na,
3179 builder,
3180 };
3181
3182 let response = self.inner.post_action_exec(&action).await?;
3183
3184 self.build_submit_order_report(
3187 instrument_id,
3188 client_order_id,
3189 order_side,
3190 order_type,
3191 quantity,
3192 time_in_force,
3193 price,
3194 trigger_price,
3195 response,
3196 )?
3197 .ok_or_else(|| {
3198 Error::bad_request(
3199 "Single-order submission returned no actionable status (deferred trigger child)",
3200 )
3201 })
3202 }
3203
3204 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
3208 self.submit_order(
3209 order.instrument_id(),
3210 order.client_order_id(),
3211 order.order_side(),
3212 order.order_type(),
3213 order.quantity(),
3214 order.time_in_force(),
3215 order.price(),
3216 order.trigger_price(),
3217 order.is_post_only(),
3218 order.is_reduce_only(),
3219 )
3220 .await
3221 }
3222
3223 #[expect(clippy::too_many_arguments)]
3224 fn create_order_status_report(
3225 &self,
3226 instrument_id: InstrumentId,
3227 client_order_id: Option<ClientOrderId>,
3228 venue_order_id: VenueOrderId,
3229 order_side: OrderSide,
3230 order_type: OrderType,
3231 quantity: Quantity,
3232 time_in_force: TimeInForce,
3233 price: Option<Price>,
3234 trigger_price: Option<Price>,
3235 order_status: OrderStatus,
3236 filled_qty: Quantity,
3237 _instrument: &InstrumentAny,
3238 account_id: AccountId,
3239 ts_init: UnixNanos,
3240 ) -> OrderStatusReport {
3241 let ts_accepted = self.clock.get_time_ns();
3242 let ts_last = ts_accepted;
3243 let report_id = UUID4::new();
3244
3245 let mut report = OrderStatusReport::new(
3246 account_id,
3247 instrument_id,
3248 client_order_id,
3249 venue_order_id,
3250 order_side,
3251 order_type,
3252 time_in_force,
3253 order_status,
3254 quantity,
3255 filled_qty,
3256 ts_accepted,
3257 ts_last,
3258 ts_init,
3259 Some(report_id),
3260 );
3261
3262 if let Some(px) = price {
3263 report = report.with_price(px);
3264 }
3265
3266 if let Some(trig_px) = trigger_price {
3267 report = report
3268 .with_trigger_price(trig_px)
3269 .with_trigger_type(TriggerType::Default);
3270 }
3271
3272 report
3273 }
3274
3275 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
3282 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
3284 let mut client_order_ids = Vec::with_capacity(orders.len());
3285
3286 for order in orders {
3287 let instrument_id = order.instrument_id();
3288 let symbol = instrument_id.symbol.inner();
3289 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3290 Error::bad_request(format!(
3291 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3292 ))
3293 })?;
3294 let price_decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
3295 let request = order_to_hyperliquid_request_with_asset_and_cloid(
3296 order,
3297 asset,
3298 price_decimals,
3299 self.normalize_prices,
3300 self.market_order_slippage_bps,
3301 None,
3302 )
3303 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
3304 client_order_ids.push(order.client_order_id());
3305 hyperliquid_orders.push(request);
3306 }
3307
3308 for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
3309 request.cloid = Some(self.get_or_generate_client_order_id_cloid(client_order_id));
3310 }
3311
3312 let builder = self.builder_attribution();
3313
3314 let grouping =
3315 determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
3316
3317 let action = HyperliquidExecAction::Order {
3318 orders: hyperliquid_orders,
3319 grouping,
3320 builder,
3321 };
3322
3323 let response = self.inner.post_action_exec(&action).await?;
3325
3326 self.build_submit_orders_reports(orders, grouping, response)
3327 }
3328
3329 #[expect(clippy::too_many_arguments)]
3345 pub fn build_submit_order_report(
3346 &self,
3347 instrument_id: InstrumentId,
3348 client_order_id: ClientOrderId,
3349 order_side: OrderSide,
3350 order_type: OrderType,
3351 quantity: Quantity,
3352 time_in_force: TimeInForce,
3353 price: Option<Price>,
3354 trigger_price: Option<Price>,
3355 response: HyperliquidExchangeResponse,
3356 ) -> Result<Option<OrderStatusReport>> {
3357 let order_response = parse_order_response(response)?;
3358
3359 let Some(order_status) = order_response.statuses.first() else {
3360 return Ok(None);
3361 };
3362
3363 let account_id = self
3364 .account_id
3365 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3366 let ts_init = self.clock.get_time_ns();
3367
3368 self.build_status_report(
3369 instrument_id,
3370 client_order_id,
3371 order_side,
3372 order_type,
3373 quantity,
3374 time_in_force,
3375 price,
3376 trigger_price,
3377 order_status,
3378 account_id,
3379 ts_init,
3380 )
3381 }
3382
3383 pub fn build_submit_orders_reports(
3398 &self,
3399 orders: &[&OrderAny],
3400 grouping: HyperliquidExecGrouping,
3401 response: HyperliquidExchangeResponse,
3402 ) -> Result<Vec<OrderStatusReport>> {
3403 let order_response = parse_order_response(response)?;
3404
3405 let account_id = self
3406 .account_id
3407 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3408 let ts_init = self.clock.get_time_ns();
3409
3410 if grouping == HyperliquidExecGrouping::Na && order_response.statuses.len() != orders.len()
3414 {
3415 return Err(Error::bad_request(format!(
3416 "Mismatch between submitted orders ({}) and response statuses ({})",
3417 orders.len(),
3418 order_response.statuses.len()
3419 )));
3420 }
3421
3422 let mut reports = Vec::with_capacity(order_response.statuses.len());
3425 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
3426 if let Some(report) = self.build_status_report(
3427 order.instrument_id(),
3428 order.client_order_id(),
3429 order.order_side(),
3430 order.order_type(),
3431 order.quantity(),
3432 order.time_in_force(),
3433 order.price(),
3434 order.trigger_price(),
3435 order_status,
3436 account_id,
3437 ts_init,
3438 )? {
3439 reports.push(report);
3440 }
3441 }
3442
3443 Ok(reports)
3444 }
3445
3446 #[expect(clippy::too_many_arguments)]
3454 fn build_status_report(
3455 &self,
3456 instrument_id: InstrumentId,
3457 client_order_id: ClientOrderId,
3458 order_side: OrderSide,
3459 order_type: OrderType,
3460 quantity: Quantity,
3461 time_in_force: TimeInForce,
3462 price: Option<Price>,
3463 trigger_price: Option<Price>,
3464 order_status: &HyperliquidExecOrderStatus,
3465 account_id: AccountId,
3466 ts_init: UnixNanos,
3467 ) -> Result<Option<OrderStatusReport>> {
3468 if matches!(order_status, HyperliquidExecOrderStatus::Tag(_)) {
3469 return Ok(None);
3470 }
3471
3472 let symbol = instrument_id.symbol.as_str();
3473 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
3474
3475 let asset = cache_alias_for_symbol(symbol).unwrap_or_else(|| symbol.to_string());
3478 let instrument = self
3479 .get_or_create_instrument(&Ustr::from(asset.as_str()), product_type)
3480 .ok_or_else(|| {
3481 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3482 })?;
3483
3484 let report = match order_status {
3485 HyperliquidExecOrderStatus::Resting { resting } => self.create_order_status_report(
3486 instrument_id,
3487 Some(client_order_id),
3488 VenueOrderId::new(resting.oid.to_string()),
3489 order_side,
3490 order_type,
3491 quantity,
3492 time_in_force,
3493 price,
3494 trigger_price,
3495 OrderStatus::Accepted,
3496 Quantity::zero(instrument.size_precision()),
3497 &instrument,
3498 account_id,
3499 ts_init,
3500 ),
3501 HyperliquidExecOrderStatus::Filled { filled } => {
3502 let filled_qty =
3503 Quantity::from_decimal_dp(filled.total_sz, instrument.size_precision())
3504 .map_err(|e| {
3505 Error::bad_request(format!(
3506 "Invalid filled size {}: {e}",
3507 filled.total_sz
3508 ))
3509 })?;
3510 self.create_order_status_report(
3511 instrument_id,
3512 Some(client_order_id),
3513 VenueOrderId::new(filled.oid.to_string()),
3514 order_side,
3515 order_type,
3516 quantity,
3517 time_in_force,
3518 price,
3519 trigger_price,
3520 OrderStatus::Filled,
3521 filled_qty,
3522 &instrument,
3523 account_id,
3524 ts_init,
3525 )
3526 }
3527 HyperliquidExecOrderStatus::Error { error } => {
3528 return Err(Error::bad_request(format!(
3529 "Order {client_order_id} rejected: {error}"
3530 )));
3531 }
3532 HyperliquidExecOrderStatus::Tag(_) => unreachable!("handled above"),
3533 };
3534
3535 Ok(Some(report))
3536 }
3537}
3538
3539fn parse_order_response(
3544 response: HyperliquidExchangeResponse,
3545) -> Result<HyperliquidExecOrderResponseData> {
3546 let response_data = match response {
3547 HyperliquidExchangeResponse::Status {
3548 status,
3549 response: response_data,
3550 } if status == RESPONSE_STATUS_OK => response_data,
3551 HyperliquidExchangeResponse::Error { error } => {
3552 return Err(Error::bad_request(format!(
3553 "Order submission failed: {error}"
3554 )));
3555 }
3556 _ => return Err(Error::bad_request("Unexpected response format")),
3557 };
3558
3559 let data_value = if let Some(data) = response_data.get("data") {
3560 data.clone()
3561 } else {
3562 response_data
3563 };
3564
3565 serde_json::from_value(data_value)
3566 .map_err(|e| Error::bad_request(format!("Failed to parse order response: {e}")))
3567}
3568
3569fn resolve_perp_dex_name(
3570 dex_index: usize,
3571 meta: &PerpMeta,
3572 perp_dexs: Option<&[Option<PerpDex>]>,
3573) -> String {
3574 if dex_index == 0 {
3575 return String::new();
3576 }
3577
3578 if let Some(dex_name) = perp_dexs
3579 .and_then(|dexs| dexs.get(dex_index))
3580 .and_then(|dex| dex.as_ref())
3581 .map(|dex| dex.name.clone())
3582 {
3583 return dex_name;
3584 }
3585
3586 meta.universe
3587 .iter()
3588 .find_map(|asset| asset.name.split_once(':').map(|(dex, _)| dex.to_string()))
3589 .unwrap_or_default()
3590}
3591
3592fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
3597 if dex_index == 0 {
3598 0
3599 } else {
3600 100_000 + dex_index as u32 * 10_000
3601 }
3602}
3603
3604#[cfg(test)]
3605mod tests {
3606 use std::{net::SocketAddr, sync::Arc};
3607
3608 use axum::{
3609 Router,
3610 extract::State,
3611 http::StatusCode,
3612 response::{IntoResponse, Json, Response},
3613 routing::post,
3614 };
3615 use nautilus_core::{MUTEX_POISONED, time::get_atomic_clock_realtime};
3616 use nautilus_model::{
3617 currencies::CURRENCY_MAP,
3618 enums::{CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce},
3619 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol},
3620 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
3621 types::{Currency, Price, Quantity},
3622 };
3623 use rstest::rstest;
3624 use rust_decimal_macros::dec;
3625 use serde_json::{Value, json};
3626 use ustr::Ustr;
3627
3628 use super::{HyperliquidHttpClient, resolve_perp_dex_name};
3629 use crate::{
3630 common::{
3631 consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS},
3632 enums::{HyperliquidEnvironment, HyperliquidProductType},
3633 },
3634 http::{
3635 models::{Cloid, HyperliquidExchangeResponse, PerpAsset, PerpDex, PerpMeta},
3636 query::InfoRequest,
3637 },
3638 };
3639
3640 const TEST_PRIVATE_KEY: &str =
3641 "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
3642
3643 fn perp_meta_with_assets(names: &[&str]) -> PerpMeta {
3644 PerpMeta {
3645 universe: names
3646 .iter()
3647 .map(|name| PerpAsset {
3648 name: (*name).to_string(),
3649 ..Default::default()
3650 })
3651 .collect(),
3652 margin_tables: Vec::new(),
3653 collateral_token: None,
3654 }
3655 }
3656
3657 #[rstest]
3658 fn resolve_perp_dex_name_uses_empty_string_for_default_dex() {
3659 let meta = perp_meta_with_assets(&["BTC", "ETH"]);
3660 assert_eq!(resolve_perp_dex_name(0, &meta, None), "");
3661 }
3662
3663 #[rstest]
3664 fn resolve_perp_dex_name_prefers_perp_dexs_entry() {
3665 let meta = perp_meta_with_assets(&["xyz:TSLA"]);
3666 let perp_dexs = vec![
3667 None,
3668 Some(PerpDex {
3669 name: "xyz".to_string(),
3670 }),
3671 ];
3672 assert_eq!(resolve_perp_dex_name(1, &meta, Some(&perp_dexs)), "xyz");
3673 }
3674
3675 #[rstest]
3676 fn resolve_perp_dex_name_infers_from_asset_name_when_perp_dexs_missing() {
3677 let meta = perp_meta_with_assets(&["abc:TSLA", "abc:NVDA"]);
3678 assert_eq!(resolve_perp_dex_name(1, &meta, None), "abc");
3679 }
3680
3681 #[rstest]
3682 fn test_build_submit_order_report_elides_waiting_for_fill_tag() {
3683 let mut client =
3687 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3688 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
3689
3690 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
3691 "status": "ok",
3692 "response": {
3693 "type": "order",
3694 "data": {
3695 "statuses": ["waitingForFill"]
3696 }
3697 }
3698 }))
3699 .unwrap();
3700
3701 let result = client
3702 .build_submit_order_report(
3703 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
3704 ClientOrderId::from("O-WAITING-CHILD"),
3705 OrderSide::Buy,
3706 OrderType::StopMarket,
3707 Quantity::from("100"),
3708 TimeInForce::Gtc,
3709 None,
3710 Some(Price::from("0.16136")),
3711 response,
3712 )
3713 .unwrap();
3714
3715 assert!(
3716 result.is_none(),
3717 "Tag status must elide so the order stays SUBMITTED, was {result:?}"
3718 );
3719 }
3720
3721 #[rstest]
3722 fn test_build_submit_order_report_filled_uses_total_sz_decimal() {
3723 let mut client =
3727 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3728 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
3729
3730 let base = Currency::new("ARB", 8, 0, "ARB", CurrencyType::Crypto);
3731 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
3732 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3733 let clock = get_atomic_clock_realtime();
3734 let ts = clock.get_time_ns();
3735 let perp = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
3736 InstrumentId::new(Symbol::new("ARB-USD-PERP"), *HYPERLIQUID_VENUE),
3737 Symbol::new("ARB"),
3738 base,
3739 usd,
3740 usdc,
3741 false,
3742 5,
3743 2,
3744 Price::from("0.00001"),
3745 Quantity::from("0.01"),
3746 None,
3747 None,
3748 None,
3749 None,
3750 None,
3751 None,
3752 None,
3753 None,
3754 None,
3755 None,
3756 None,
3757 None,
3758 None,
3759 None,
3760 ts,
3761 ts,
3762 ));
3763 client.cache_instrument(&perp);
3764
3765 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
3766 "status": "ok",
3767 "response": {
3768 "type": "order",
3769 "data": {
3770 "statuses": [{
3771 "filled": {"totalSz": "0.5", "avgPx": "1.2345", "oid": 778899}
3772 }]
3773 }
3774 }
3775 }))
3776 .unwrap();
3777
3778 let report = client
3779 .build_submit_order_report(
3780 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
3781 ClientOrderId::from("O-FILLED-001"),
3782 OrderSide::Buy,
3783 OrderType::Market,
3784 Quantity::from("0.5"),
3785 TimeInForce::Ioc,
3786 None,
3787 None,
3788 response,
3789 )
3790 .unwrap()
3791 .expect("filled status must produce a report");
3792
3793 assert_eq!(report.order_status, OrderStatus::Filled);
3794 assert_eq!(report.venue_order_id.as_str(), "778899");
3795 assert_eq!(report.filled_qty.as_decimal(), dec!(0.5));
3796 }
3797
3798 #[derive(Clone, Default)]
3799 struct OutcomeMetaServerState {
3800 last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
3801 }
3802
3803 async fn handle_outcome_meta_info(
3804 State(state): State<OutcomeMetaServerState>,
3805 body: axum::body::Bytes,
3806 ) -> Response {
3807 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
3808 return (
3809 StatusCode::BAD_REQUEST,
3810 Json(json!({"error": "Invalid JSON body"})),
3811 )
3812 .into_response();
3813 };
3814
3815 *state.last_request_body.lock().await = Some(request_body.clone());
3816
3817 if request_body.get("type").and_then(|value| value.as_str()) != Some("outcomeMeta") {
3818 return (
3819 StatusCode::BAD_REQUEST,
3820 Json(json!({"error": "Expected outcomeMeta request"})),
3821 )
3822 .into_response();
3823 }
3824
3825 Json(json!({
3826 "outcomes": [
3827 {
3828 "outcome": 123,
3829 "name": "Recurring",
3830 "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m",
3831 "sideSpecs": [
3832 {"name": "Yes"},
3833 {"name": "No"}
3834 ]
3835 }
3836 ]
3837 }))
3838 .into_response()
3839 }
3840
3841 async fn start_outcome_meta_server(state: OutcomeMetaServerState) -> SocketAddr {
3842 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3843 let addr = listener.local_addr().unwrap();
3844 let router = Router::new()
3845 .route("/info", post(handle_outcome_meta_info))
3846 .with_state(state);
3847
3848 tokio::spawn(async move {
3849 axum::serve(listener, router).await.unwrap();
3850 });
3851
3852 addr
3853 }
3854
3855 async fn handle_unresolved_collateral_info(body: axum::body::Bytes) -> Response {
3856 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
3857 return (
3858 StatusCode::BAD_REQUEST,
3859 Json(json!({"error": "Invalid JSON body"})),
3860 )
3861 .into_response();
3862 };
3863
3864 match request_body.get("type").and_then(|value| value.as_str()) {
3865 Some("spotMeta") => (
3866 StatusCode::INTERNAL_SERVER_ERROR,
3867 Json(json!({"error": "spot metadata unavailable"})),
3868 )
3869 .into_response(),
3870 Some("allPerpMetas") => Json(json!([
3871 {
3872 "collateralToken": 360,
3873 "marginTables": [],
3874 "universe": [
3875 {
3876 "maxLeverage": 20,
3877 "name": "km:US500",
3878 "szDecimals": 3
3879 }
3880 ]
3881 }
3882 ]))
3883 .into_response(),
3884 _ => Json(json!({"universe": [], "marginTables": []})).into_response(),
3885 }
3886 }
3887
3888 async fn start_unresolved_collateral_server() -> SocketAddr {
3889 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3890 let addr = listener.local_addr().unwrap();
3891 let router = Router::new().route("/info", post(handle_unresolved_collateral_info));
3892
3893 tokio::spawn(async move {
3894 axum::serve(listener, router).await.unwrap();
3895 });
3896
3897 addr
3898 }
3899
3900 #[rstest]
3901 fn stable_json_roundtrips() {
3902 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
3903 let s = serde_json::to_string(&v).unwrap();
3904 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
3906 assert_eq!(parsed["type"], "l2Book");
3907 assert_eq!(parsed["coin"], "BTC");
3908 assert_eq!(parsed, v);
3909 }
3910
3911 #[rstest]
3912 fn info_pretty_shape() {
3913 let r = InfoRequest::l2_book("BTC");
3914 let val = serde_json::to_value(&r).unwrap();
3915 let pretty = serde_json::to_string_pretty(&val).unwrap();
3916 assert!(pretty.contains("\"type\": \"l2Book\""));
3917 assert!(pretty.contains("\"coin\": \"BTC\""));
3918 }
3919
3920 #[rstest]
3921 fn test_client_order_id_cloid_cache_is_stable_and_first_write_wins() {
3922 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3923 let client_order_id = ClientOrderId::new("O-CLOID-CACHE");
3924 let other_client_order_id = ClientOrderId::new("O-CLOID-CACHE-OTHER");
3925 let duplicate_client_order_id = ClientOrderId::new("O-CLOID-CACHE-DUPLICATE");
3926 let explicit_cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
3927
3928 let first = client.get_or_generate_client_order_id_cloid(client_order_id);
3929 let second = client.get_or_generate_client_order_id_cloid(client_order_id);
3930 client.cache_client_order_id_cloid(client_order_id, explicit_cloid);
3931 client.cache_client_order_id_cloid(other_client_order_id, explicit_cloid);
3932 client.cache_client_order_id_cloid(duplicate_client_order_id, explicit_cloid);
3933
3934 assert_eq!(first, Cloid::from_client_order_id(client_order_id));
3935 assert_eq!(first, second);
3936 assert_eq!(
3937 client.cached_client_order_id_cloid(&client_order_id),
3938 Some(first),
3939 "cache insert must not overwrite an existing generated CLOID",
3940 );
3941 assert_eq!(
3942 client.unique_cached_client_order_id_cloid(&client_order_id),
3943 Some(first),
3944 );
3945 assert_eq!(
3946 client.cached_client_order_id_cloid(&other_client_order_id),
3947 Some(explicit_cloid),
3948 );
3949 assert_eq!(
3950 client.unique_cached_client_order_id_cloid(&other_client_order_id),
3951 None,
3952 "duplicate CLOID mappings are not safe modify targets",
3953 );
3954 assert_eq!(
3955 client.remove_client_order_id_cloid(&client_order_id),
3956 Some(first),
3957 );
3958 assert_eq!(client.cached_client_order_id_cloid(&client_order_id), None);
3959 }
3960
3961 #[rstest]
3962 fn test_builder_attribution_defaults_to_mainnet_builder() {
3963 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3964 let builder = client
3965 .builder_attribution()
3966 .expect("mainnet client should include builder attribution by default");
3967
3968 assert!(client.include_builder_attribution());
3969 assert_eq!(builder.address, NAUTILUS_BUILDER_ADDRESS);
3970 assert_eq!(builder.fee_tenths_bp, 0);
3971 }
3972
3973 #[rstest]
3974 fn test_builder_attribution_disabled_returns_none() {
3975 let mut client =
3976 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3977 client.set_include_builder_attribution(false);
3978
3979 assert!(!client.include_builder_attribution());
3980 assert!(client.builder_attribution().is_none());
3981 }
3982
3983 #[rstest]
3984 fn test_builder_attribution_omitted_on_testnet() {
3985 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
3986
3987 assert!(client.include_builder_attribution());
3988 assert!(client.builder_attribution().is_none());
3989 }
3990
3991 #[rstest]
3992 #[tokio::test]
3993 async fn test_production_client_get_outcome_meta_uses_outcome_meta_request() {
3994 let state = OutcomeMetaServerState::default();
3995 let addr = start_outcome_meta_server(state.clone()).await;
3996 let mut client =
3997 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3998 client.set_base_info_url(format!("http://{addr}/info"));
3999
4000 let meta = client.get_outcome_meta().await.unwrap();
4001 let request_body = state.last_request_body.lock().await.clone().unwrap();
4002
4003 assert_eq!(request_body, json!({"type": "outcomeMeta"}));
4004 assert_eq!(meta.outcomes.len(), 1);
4005 assert_eq!(meta.outcomes[0].outcome, 123);
4006 assert_eq!(meta.outcomes[0].name, "Recurring");
4007 assert_eq!(meta.outcomes[0].side_specs.len(), 2);
4008 assert_eq!(meta.outcomes[0].side_specs[0].name, "Yes");
4009 assert_eq!(meta.outcomes[0].side_specs[1].name, "No");
4010 }
4011
4012 #[rstest]
4013 #[tokio::test]
4014 async fn test_request_instrument_defs_errors_when_non_usdc_collateral_unresolved() {
4015 let addr = start_unresolved_collateral_server().await;
4016 let mut client =
4017 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4018 client.set_base_info_url(format!("http://{addr}/info"));
4019
4020 let err = client.request_instrument_defs().await.unwrap_err();
4021
4022 assert_eq!(
4023 err.to_string(),
4024 "decode error: failed to resolve perp settlement currency for dex 0: \
4025 Spot metadata required to resolve perp collateral token 360",
4026 );
4027 }
4028
4029 #[rstest]
4030 fn test_with_credentials_preserves_explicit_account_address() {
4031 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4032 let client = HyperliquidHttpClient::with_credentials(
4033 Some(TEST_PRIVATE_KEY.to_string()),
4034 None,
4035 Some(account_address),
4036 HyperliquidEnvironment::Mainnet,
4037 60,
4038 None,
4039 )
4040 .unwrap();
4041
4042 assert_eq!(client.get_account_address().unwrap(), account_address);
4043 }
4044
4045 #[rstest]
4046 fn test_from_resolved_credentials_preserves_account_address_without_private_key() {
4047 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4048 let client = HyperliquidHttpClient::from_resolved_credentials(
4049 None,
4050 None,
4051 Some(account_address.to_string()),
4052 HyperliquidEnvironment::Mainnet,
4053 60,
4054 None,
4055 )
4056 .unwrap();
4057
4058 assert_eq!(client.get_account_address().unwrap(), account_address);
4059 }
4060
4061 #[rstest]
4062 fn test_cache_instrument_by_raw_symbol() {
4063 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4064
4065 let base_code = "vntls:vCURSOR";
4067 let quote_code = "USDC";
4068
4069 {
4071 let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
4072 if !currency_map.contains_key(base_code) {
4073 currency_map.insert(
4074 base_code.to_string(),
4075 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
4076 );
4077 }
4078 }
4079
4080 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
4081 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
4082
4083 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
4085 let venue = *HYPERLIQUID_VENUE;
4086 let instrument_id = InstrumentId::new(symbol, venue);
4087
4088 let raw_symbol = Symbol::new(base_code);
4090
4091 let clock = get_atomic_clock_realtime();
4092 let ts = clock.get_time_ns();
4093
4094 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
4095 instrument_id,
4096 raw_symbol,
4097 base_currency,
4098 quote_currency,
4099 8,
4100 8,
4101 Price::from("0.00000001"),
4102 Quantity::from("0.00000001"),
4103 None,
4104 None,
4105 None,
4106 None,
4107 None,
4108 None,
4109 None,
4110 None,
4111 None,
4112 None,
4113 None, None, None, None, ts,
4118 ts,
4119 ));
4120
4121 client.cache_instrument(&instrument);
4123
4124 let instruments = client.instruments.load();
4126 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
4127 assert!(
4128 by_full_symbol.is_some(),
4129 "Instrument should be accessible by full symbol"
4130 );
4131 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
4132
4133 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
4135 assert!(
4136 by_raw_symbol.is_some(),
4137 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
4138 );
4139 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
4140 drop(instruments);
4141
4142 let instruments_by_coin = client.instruments_by_coin.load();
4144 let by_coin =
4145 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
4146 assert!(
4147 by_coin.is_some(),
4148 "Instrument should be accessible by coin and product type"
4149 );
4150 assert_eq!(by_coin.unwrap().id(), instrument.id());
4151 drop(instruments_by_coin);
4152
4153 let retrieved_with_type = client.get_or_create_instrument(
4155 &Ustr::from("vntls:vCURSOR"),
4156 Some(HyperliquidProductType::Spot),
4157 );
4158 assert!(retrieved_with_type.is_some());
4159 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
4160
4161 let retrieved_without_type =
4163 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
4164 assert!(retrieved_without_type.is_some());
4165 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
4166 }
4167
4168 #[rstest]
4169 fn test_get_or_create_instrument_outcome_fallback_no_product_type() {
4170 use nautilus_core::time::get_atomic_clock_realtime;
4176 use nautilus_model::{
4177 enums::AssetClass,
4178 identifiers::{InstrumentId, Symbol},
4179 instruments::{BinaryOption, InstrumentAny},
4180 types::{Currency, Price, Quantity},
4181 };
4182
4183 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4184 let coin = "#500";
4185 let token = "+500";
4186
4187 let usdh = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
4188 let symbol = Symbol::new(token);
4189 let raw_symbol = Symbol::new(coin);
4190 let venue = *HYPERLIQUID_VENUE;
4191 let instrument_id = InstrumentId::new(symbol, venue);
4192
4193 let clock = get_atomic_clock_realtime();
4194 let ts = clock.get_time_ns();
4195
4196 let binary = InstrumentAny::BinaryOption(BinaryOption::new(
4197 instrument_id,
4198 raw_symbol,
4199 AssetClass::Alternative,
4200 usdh,
4201 Default::default(),
4202 Default::default(),
4203 4,
4204 2,
4205 Price::from("0.0001"),
4206 Quantity::from("0.01"),
4207 None,
4208 None,
4209 None,
4210 None,
4211 None,
4212 None,
4213 None,
4214 None,
4215 None,
4216 None,
4217 None,
4218 None,
4219 None,
4220 None,
4221 ts,
4222 ts,
4223 ));
4224
4225 client.cache_instrument(&binary);
4226
4227 let with_type = client
4228 .get_or_create_instrument(&Ustr::from(coin), Some(HyperliquidProductType::Outcome));
4229 assert!(with_type.is_some());
4230 assert_eq!(with_type.unwrap().id(), instrument_id);
4231
4232 let no_type = client.get_or_create_instrument(&Ustr::from(coin), None);
4233 assert!(
4234 no_type.is_some(),
4235 "Outcome coin must resolve through the no-product fallback",
4236 );
4237 assert_eq!(no_type.unwrap().id(), instrument_id);
4238
4239 let missing = client.get_or_create_instrument(&Ustr::from("#9999"), None);
4240 assert!(missing.is_none());
4241 }
4242
4243 #[rstest]
4244 fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
4245 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4250
4251 let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
4252 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4253 let clock = get_atomic_clock_realtime();
4254 let ts = clock.get_time_ns();
4255
4256 let canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
4257 InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
4258 Symbol::new("@107"),
4259 hype,
4260 usdc,
4261 5,
4262 2,
4263 Price::from("0.00001"),
4264 Quantity::from("0.01"),
4265 None,
4266 None,
4267 None,
4268 None,
4269 None,
4270 None,
4271 None,
4272 None,
4273 None,
4274 None,
4275 None,
4276 None,
4277 None,
4278 None,
4279 ts,
4280 ts,
4281 ));
4282
4283 let non_canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
4284 InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
4285 Symbol::new("@999"),
4286 hype,
4287 usdc,
4288 5,
4289 2,
4290 Price::from("0.00001"),
4291 Quantity::from("0.01"),
4292 None,
4293 None,
4294 None,
4295 None,
4296 None,
4297 None,
4298 None,
4299 None,
4300 None,
4301 None,
4302 None,
4303 None,
4304 None,
4305 None,
4306 ts,
4307 ts,
4308 ));
4309
4310 client.cache_instrument(&canonical);
4311 client.cache_instrument(&non_canonical);
4312
4313 let instruments_by_coin = client.instruments_by_coin.load();
4314 let by_base = instruments_by_coin
4315 .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
4316 .expect("base alias must resolve");
4317 assert_eq!(
4318 by_base.raw_symbol().inner().as_str(),
4319 "@107",
4320 "base alias must point to the canonical pair, not the one cached later",
4321 );
4322 }
4323
4324 #[rstest]
4325 fn test_cache_instrument_perp_aliases_sanitized_base() {
4326 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4332
4333 let base_currency = Currency::new(
4334 "dex:STREAMABCD****",
4335 8,
4336 0,
4337 "dex:STREAMABCD****",
4338 CurrencyType::Crypto,
4339 );
4340 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4341 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4342 let clock = get_atomic_clock_realtime();
4343 let ts = clock.get_time_ns();
4344
4345 let hip3 = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
4346 InstrumentId::new(
4347 Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
4348 *HYPERLIQUID_VENUE,
4349 ),
4350 Symbol::new("dex:STREAMABCD****"),
4351 base_currency,
4352 usd,
4353 usdc,
4354 false,
4355 6,
4356 3,
4357 Price::from("0.000001"),
4358 Quantity::from("0.001"),
4359 None,
4360 None,
4361 None,
4362 None,
4363 None,
4364 None,
4365 None,
4366 None,
4367 None,
4368 None,
4369 None,
4370 None,
4371 None,
4372 None,
4373 ts,
4374 ts,
4375 ));
4376
4377 client.cache_instrument(&hip3);
4378
4379 let instruments_by_coin = client.instruments_by_coin.load();
4380 let by_raw = instruments_by_coin
4381 .get(&(
4382 Ustr::from("dex:STREAMABCD****"),
4383 HyperliquidProductType::Perp,
4384 ))
4385 .expect("venue coin lookup must resolve");
4386 assert_eq!(by_raw.id(), hip3.id());
4387
4388 let by_sanitized = instruments_by_coin
4389 .get(&(
4390 Ustr::from("dex:STREAMABCDxxxx"),
4391 HyperliquidProductType::Perp,
4392 ))
4393 .expect("sanitized base lookup must resolve");
4394 assert_eq!(by_sanitized.id(), hip3.id());
4395 drop(instruments_by_coin);
4396
4397 let resolved = client
4399 .get_or_create_instrument(
4400 &Ustr::from("dex:STREAMABCDxxxx"),
4401 Some(HyperliquidProductType::Perp),
4402 )
4403 .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
4404 assert_eq!(resolved.id(), hip3.id());
4405 }
4406}