1use std::{
24 collections::HashMap,
25 num::NonZeroU32,
26 sync::{Arc, LazyLock, RwLock},
27 time::Duration,
28};
29
30use ahash::AHashMap;
31use anyhow::Context;
32use nautilus_core::consts::NAUTILUS_USER_AGENT;
33use nautilus_model::{
34 identifiers::AccountId,
35 instruments::{Instrument, InstrumentAny},
36 orders::Order,
37};
38use nautilus_network::{http::HttpClient, ratelimiter::quota::Quota};
39use reqwest::{Method, header::USER_AGENT};
40use serde_json::Value;
41use tokio::time::sleep;
42use ustr::Ustr;
43
44use crate::{
45 common::{
46 consts::{HYPERLIQUID_VENUE, exchange_url, info_url},
47 credential::{Secrets, VaultAddress},
48 parse::order_to_hyperliquid_request,
49 },
50 http::{
51 error::{Error, Result},
52 models::{
53 HyperliquidExchangeRequest, HyperliquidExchangeResponse, HyperliquidFills,
54 HyperliquidL2Book, HyperliquidMeta, HyperliquidOrderStatus, PerpMeta, PerpMetaAndCtxs,
55 SpotMeta, SpotMetaAndCtxs,
56 },
57 parse::{
58 HyperliquidInstrumentDef, instruments_from_defs_owned, parse_perp_instruments,
59 parse_spot_instruments,
60 },
61 query::{ExchangeAction, InfoRequest},
62 rate_limits::{
63 RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
64 info_base_weight, info_extra_weight,
65 },
66 },
67 signing::{
68 HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
69 },
70};
71
72pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
74 LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
75
76#[derive(Debug, Clone)]
82#[cfg_attr(
83 feature = "python",
84 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.adapters")
85)]
86pub struct HyperliquidHttpClient {
87 client: HttpClient,
88 is_testnet: bool,
89 base_info: String,
90 base_exchange: String,
91 signer: Option<HyperliquidEip712Signer>,
92 nonce_manager: Option<Arc<NonceManager>>,
93 vault_address: Option<VaultAddress>,
94 rest_limiter: Arc<WeightedLimiter>,
95 rate_limit_backoff_base: Duration,
96 rate_limit_backoff_cap: Duration,
97 rate_limit_max_attempts_info: u32,
98 instruments: Arc<RwLock<AHashMap<Ustr, InstrumentAny>>>,
99 account_id: Option<AccountId>,
100}
101
102impl Default for HyperliquidHttpClient {
103 fn default() -> Self {
104 Self::new(true, None) }
106}
107
108impl HyperliquidHttpClient {
109 #[must_use]
115 pub fn new(is_testnet: bool, timeout_secs: Option<u64>) -> Self {
116 Self {
117 client: HttpClient::new(
118 Self::default_headers(),
119 vec![],
120 vec![],
121 Some(*HYPERLIQUID_REST_QUOTA),
122 timeout_secs,
123 ),
124 is_testnet,
125 base_info: info_url(is_testnet).to_string(),
126 base_exchange: exchange_url(is_testnet).to_string(),
127 signer: None,
128 nonce_manager: None,
129 vault_address: None,
130 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
131 rate_limit_backoff_base: Duration::from_millis(125),
132 rate_limit_backoff_cap: Duration::from_secs(5),
133 rate_limit_max_attempts_info: 3,
134 instruments: Arc::new(RwLock::new(AHashMap::new())),
135 account_id: None,
136 }
137 }
138
139 #[must_use]
142 pub fn with_credentials(secrets: &Secrets, timeout_secs: Option<u64>) -> Self {
143 let signer = HyperliquidEip712Signer::new(secrets.private_key.clone());
144 let nonce_manager = Arc::new(NonceManager::new());
145
146 Self {
147 client: HttpClient::new(
148 Self::default_headers(),
149 vec![],
150 vec![],
151 Some(*HYPERLIQUID_REST_QUOTA),
152 timeout_secs,
153 ),
154 is_testnet: secrets.is_testnet,
155 base_info: info_url(secrets.is_testnet).to_string(),
156 base_exchange: exchange_url(secrets.is_testnet).to_string(),
157 signer: Some(signer),
158 nonce_manager: Some(nonce_manager),
159 vault_address: secrets.vault_address,
160 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
161 rate_limit_backoff_base: Duration::from_millis(125),
162 rate_limit_backoff_cap: Duration::from_secs(5),
163 rate_limit_max_attempts_info: 3,
164 instruments: Arc::new(RwLock::new(AHashMap::new())),
165 account_id: None,
166 }
167 }
168
169 pub fn from_env() -> Result<Self> {
176 let secrets =
177 Secrets::from_env().map_err(|_| Error::auth("missing credentials in environment"))?;
178 Ok(Self::with_credentials(&secrets, None))
179 }
180
181 pub fn from_credentials(
194 private_key: &str,
195 vault_address: Option<&str>,
196 is_testnet: bool,
197 timeout_secs: Option<u64>,
198 ) -> Result<Self> {
199 let secrets = Secrets::from_private_key(private_key, vault_address, is_testnet)
200 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
201 Ok(Self::with_credentials(&secrets, timeout_secs))
202 }
203
204 pub fn with_rate_limits(mut self) -> Self {
206 self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
207 self.rate_limit_backoff_base = Duration::from_millis(125);
208 self.rate_limit_backoff_cap = Duration::from_secs(5);
209 self.rate_limit_max_attempts_info = 3;
210 self
211 }
212
213 #[must_use]
215 pub fn is_testnet(&self) -> bool {
216 self.is_testnet
217 }
218
219 pub fn get_user_address(&self) -> Result<String> {
225 self.signer
226 .as_ref()
227 .ok_or_else(|| Error::auth("No signer configured"))?
228 .address()
229 }
230
231 pub fn add_instrument(&self, instrument: InstrumentAny) {
242 let mut instruments = self
243 .instruments
244 .write()
245 .expect("Failed to acquire write lock");
246
247 let nautilus_symbol = instrument.id().symbol.inner();
249 instruments.insert(nautilus_symbol, instrument.clone());
250
251 if let Some(base_currency) = instrument.base_currency() {
254 let coin_key = Ustr::from(base_currency.code.as_str());
255 instruments.insert(coin_key, instrument);
256 }
257 }
258
259 fn get_or_create_instrument(&self, coin: &Ustr) -> Option<InstrumentAny> {
275 {
277 let instruments = self
278 .instruments
279 .read()
280 .expect("Failed to acquire read lock");
281 if let Some(instrument) = instruments.get(coin) {
282 return Some(instrument.clone());
283 }
284 }
285
286 if coin.as_str().starts_with("vntls:") {
288 tracing::info!("Creating synthetic instrument for vault token: {}", coin);
289
290 let clock = nautilus_core::time::get_atomic_clock_realtime();
291 let ts_event = clock.get_time_ns();
292
293 let symbol_str = format!("{}-USDC-SPOT", coin);
295 let symbol = nautilus_model::identifiers::Symbol::new(&symbol_str);
296 let venue = *HYPERLIQUID_VENUE;
297 let instrument_id = nautilus_model::identifiers::InstrumentId::new(symbol, venue);
298
299 let base_currency = nautilus_model::types::Currency::new(
301 coin.as_str(),
302 8, 0, coin.as_str(),
305 nautilus_model::enums::CurrencyType::Crypto,
306 );
307
308 let quote_currency = nautilus_model::types::Currency::new(
309 "USDC",
310 6, 0,
312 "USDC",
313 nautilus_model::enums::CurrencyType::Crypto,
314 );
315
316 let price_increment = nautilus_model::types::Price::from("0.00000001");
317 let size_increment = nautilus_model::types::Quantity::from("0.00000001");
318
319 let instrument =
320 InstrumentAny::CurrencyPair(nautilus_model::instruments::CurrencyPair::new(
321 instrument_id,
322 symbol,
323 base_currency,
324 quote_currency,
325 8, 8, price_increment,
328 size_increment,
329 None, None, None, None, None, None, None, None, None, None, None, None, ts_event,
342 ts_event,
343 ));
344
345 self.add_instrument(instrument.clone());
347
348 Some(instrument)
349 } else {
350 tracing::warn!("Instrument not found in cache: {}", coin);
352 None
353 }
354 }
355
356 pub fn set_account_id(&mut self, account_id: AccountId) {
360 self.account_id = Some(account_id);
361 }
362
363 fn default_headers() -> HashMap<String, String> {
365 HashMap::from([
366 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
367 ("Content-Type".to_string(), "application/json".to_string()),
368 ])
369 }
370 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
374 let request = InfoRequest::meta();
375 let response = self.send_info_request(&request).await?;
376 serde_json::from_value(response).map_err(Error::Serde)
377 }
378
379 pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
381 let request = InfoRequest::spot_meta();
382 let response = self.send_info_request(&request).await?;
383 serde_json::from_value(response).map_err(Error::Serde)
384 }
385
386 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
388 let request = InfoRequest::meta_and_asset_ctxs();
389 let response = self.send_info_request(&request).await?;
390 serde_json::from_value(response).map_err(Error::Serde)
391 }
392
393 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
395 let request = InfoRequest::spot_meta_and_asset_ctxs();
396 let response = self.send_info_request(&request).await?;
397 serde_json::from_value(response).map_err(Error::Serde)
398 }
399
400 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
402 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
403
404 match self.load_perp_meta().await {
405 Ok(perp_meta) => match parse_perp_instruments(&perp_meta) {
406 Ok(perp_defs) => {
407 tracing::debug!(
408 count = perp_defs.len(),
409 "Loaded Hyperliquid perp definitions"
410 );
411 defs.extend(perp_defs);
412 }
413 Err(e) => {
414 tracing::warn!(%e, "Failed to parse Hyperliquid perp instruments");
415 }
416 },
417 Err(e) => {
418 tracing::warn!(%e, "Failed to load Hyperliquid perp metadata");
419 }
420 }
421
422 match self.get_spot_meta().await {
423 Ok(spot_meta) => match parse_spot_instruments(&spot_meta) {
424 Ok(spot_defs) => {
425 tracing::debug!(
426 count = spot_defs.len(),
427 "Loaded Hyperliquid spot definitions"
428 );
429 defs.extend(spot_defs);
430 }
431 Err(e) => {
432 tracing::warn!(%e, "Failed to parse Hyperliquid spot instruments");
433 }
434 },
435 Err(e) => {
436 tracing::warn!(%e, "Failed to load Hyperliquid spot metadata");
437 }
438 }
439
440 Ok(instruments_from_defs_owned(defs))
441 }
442
443 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
444 let request = InfoRequest::meta();
445 let response = self.send_info_request(&request).await?;
446 serde_json::from_value(response).map_err(Error::Serde)
447 }
448
449 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
451 let request = InfoRequest::l2_book(coin);
452 let response = self.send_info_request(&request).await?;
453 serde_json::from_value(response).map_err(Error::Serde)
454 }
455
456 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
458 let request = InfoRequest::user_fills(user);
459 let response = self.send_info_request(&request).await?;
460 serde_json::from_value(response).map_err(Error::Serde)
461 }
462
463 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
465 let request = InfoRequest::order_status(user, oid);
466 let response = self.send_info_request(&request).await?;
467 serde_json::from_value(response).map_err(Error::Serde)
468 }
469
470 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
472 let request = InfoRequest::open_orders(user);
473 self.send_info_request(&request).await
474 }
475
476 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
478 let request = InfoRequest::frontend_open_orders(user);
479 self.send_info_request(&request).await
480 }
481
482 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
484 let request = InfoRequest::clearinghouse_state(user);
485 self.send_info_request(&request).await
486 }
487
488 pub async fn info_candle_snapshot(
496 &self,
497 coin: &str,
498 interval: &str,
499 start_time: u64,
500 end_time: u64,
501 ) -> Result<crate::http::models::HyperliquidCandleSnapshot> {
502 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
503 let response = self.send_info_request(&request).await?;
504 serde_json::from_value(response).map_err(Error::Serde)
505 }
506
507 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
509 self.send_info_request(request).await
510 }
511
512 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
514 let base_w = info_base_weight(request);
515 self.rest_limiter.acquire(base_w).await;
516
517 let mut attempt = 0u32;
518 loop {
519 let response = self.http_roundtrip_info(request).await?;
520
521 if response.status.is_success() {
522 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
524 let extra = info_extra_weight(request, &val);
525 if extra > 0 {
526 self.rest_limiter.debit_extra(extra).await;
527 tracing::debug!(endpoint=?request, base_w, extra, "info: debited extra weight");
528 }
529 return Ok(val);
530 }
531
532 if response.status.as_u16() == 429 {
534 if attempt >= self.rate_limit_max_attempts_info {
535 let ra = self.parse_retry_after_simple(&response.headers);
536 return Err(Error::rate_limit("info", base_w, ra));
537 }
538 let delay = self
539 .parse_retry_after_simple(&response.headers)
540 .map_or_else(
541 || {
542 backoff_full_jitter(
543 attempt,
544 self.rate_limit_backoff_base,
545 self.rate_limit_backoff_cap,
546 )
547 },
548 Duration::from_millis,
549 );
550 tracing::warn!(endpoint=?request, attempt, wait_ms=?delay.as_millis(), "429 Too Many Requests; backing off");
551 attempt += 1;
552 sleep(delay).await;
553 self.rest_limiter.acquire(1).await;
555 continue;
556 }
557
558 if (response.status.is_server_error() || response.status.as_u16() == 408)
560 && attempt < self.rate_limit_max_attempts_info
561 {
562 let delay = backoff_full_jitter(
563 attempt,
564 self.rate_limit_backoff_base,
565 self.rate_limit_backoff_cap,
566 );
567 tracing::warn!(endpoint=?request, attempt, status=?response.status.as_u16(), wait_ms=?delay.as_millis(), "transient error; retrying");
568 attempt += 1;
569 sleep(delay).await;
570 continue;
571 }
572
573 let error_body = String::from_utf8_lossy(&response.body);
575 return Err(Error::http(
576 response.status.as_u16(),
577 error_body.to_string(),
578 ));
579 }
580 }
581
582 async fn http_roundtrip_info(
584 &self,
585 request: &InfoRequest,
586 ) -> Result<nautilus_network::http::HttpResponse> {
587 let url = &self.base_info;
588 let body = serde_json::to_value(request).map_err(Error::Serde)?;
589 let body_bytes = serde_json::to_string(&body)
590 .map_err(Error::Serde)?
591 .into_bytes();
592
593 self.client
594 .request(
595 Method::POST,
596 url.clone(),
597 None,
598 Some(body_bytes),
599 None,
600 None,
601 )
602 .await
603 .map_err(Error::from_http_client)
604 }
605
606 fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
608 let retry_after = headers.get("retry-after")?;
609 retry_after.parse::<u64>().ok().map(|s| s * 1000) }
611
612 pub async fn post_action(
616 &self,
617 action: &ExchangeAction,
618 ) -> Result<HyperliquidExchangeResponse> {
619 let w = exchange_weight(action);
620 self.rest_limiter.acquire(w).await;
621
622 let signer = self
623 .signer
624 .as_ref()
625 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
626
627 let nonce_manager = self
628 .nonce_manager
629 .as_ref()
630 .ok_or_else(|| Error::auth("nonce manager missing"))?;
631
632 let signer_id = self.signer_id()?;
633 let time_nonce = nonce_manager.next(signer_id)?;
634
635 let action_value = serde_json::to_value(action)
636 .context("serialize exchange action")
637 .map_err(|e| Error::bad_request(e.to_string()))?;
638
639 let action_bytes = rmp_serde::to_vec_named(action)
641 .context("serialize action with MessagePack")
642 .map_err(|e| Error::bad_request(e.to_string()))?;
643
644 let sign_request = SignRequest {
645 action: action_value.clone(),
646 action_bytes: Some(action_bytes),
647 time_nonce,
648 action_type: HyperliquidActionType::L1,
649 is_testnet: self.is_testnet,
650 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
651 };
652
653 let sig = signer.sign(&sign_request)?.signature;
654
655 let nonce_u64 = time_nonce.as_millis() as u64;
656
657 let request = if let Some(vault) = self.vault_address {
658 HyperliquidExchangeRequest::with_vault(
659 action.clone(),
660 nonce_u64,
661 sig,
662 vault.to_string(),
663 )
664 .map_err(|e| Error::bad_request(format!("Failed to create request: {}", e)))?
665 } else {
666 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
667 .map_err(|e| Error::bad_request(format!("Failed to create request: {}", e)))?
668 };
669
670 let response = self.http_roundtrip_exchange(&request).await?;
671
672 if response.status.is_success() {
673 let parsed_response: HyperliquidExchangeResponse =
674 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
675
676 match &parsed_response {
678 HyperliquidExchangeResponse::Status {
679 status,
680 response: response_data,
681 } if status == "err" => {
682 let error_msg = response_data
683 .as_str()
684 .map_or_else(|| response_data.to_string(), |s| s.to_string());
685 tracing::error!("Hyperliquid API returned error: {}", error_msg);
686 Err(Error::bad_request(format!("API error: {}", error_msg)))
687 }
688 HyperliquidExchangeResponse::Error { error } => {
689 tracing::error!("Hyperliquid API returned error: {}", error);
690 Err(Error::bad_request(format!("API error: {}", error)))
691 }
692 _ => Ok(parsed_response),
693 }
694 } else if response.status.as_u16() == 429 {
695 let ra = self.parse_retry_after_simple(&response.headers);
696 Err(Error::rate_limit("exchange", w, ra))
697 } else {
698 let error_body = String::from_utf8_lossy(&response.body);
699 tracing::error!(
700 "Exchange API error (status {}): {}",
701 response.status.as_u16(),
702 error_body
703 );
704 Err(Error::http(
705 response.status.as_u16(),
706 error_body.to_string(),
707 ))
708 }
709 }
710
711 pub async fn post_action_exec(
716 &self,
717 action: &crate::http::models::HyperliquidExecAction,
718 ) -> Result<HyperliquidExchangeResponse> {
719 use crate::http::models::HyperliquidExecAction;
720
721 let w = match action {
722 HyperliquidExecAction::Order { orders, .. } => 1 + (orders.len() as u32 / 40),
723 HyperliquidExecAction::Cancel { cancels } => 1 + (cancels.len() as u32 / 40),
724 HyperliquidExecAction::CancelByCloid { cancels } => 1 + (cancels.len() as u32 / 40),
725 HyperliquidExecAction::BatchModify { modifies } => 1 + (modifies.len() as u32 / 40),
726 _ => 1,
727 };
728 self.rest_limiter.acquire(w).await;
729
730 let signer = self
731 .signer
732 .as_ref()
733 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
734
735 let nonce_manager = self
736 .nonce_manager
737 .as_ref()
738 .ok_or_else(|| Error::auth("nonce manager missing"))?;
739
740 let signer_id = self.signer_id()?;
741 let time_nonce = nonce_manager.next(signer_id)?;
742 let action_value = serde_json::to_value(action)
745 .context("serialize exchange action")
746 .map_err(|e| Error::bad_request(e.to_string()))?;
747
748 let action_bytes = rmp_serde::to_vec_named(action)
750 .context("serialize action with MessagePack")
751 .map_err(|e| Error::bad_request(e.to_string()))?;
752
753 let sig = signer
754 .sign(&SignRequest {
755 action: action_value.clone(),
756 action_bytes: Some(action_bytes),
757 time_nonce,
758 action_type: HyperliquidActionType::L1,
759 is_testnet: self.is_testnet,
760 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
761 })?
762 .signature;
763
764 let request = if let Some(vault) = self.vault_address {
765 HyperliquidExchangeRequest::with_vault(
766 action.clone(),
767 time_nonce.as_millis() as u64,
768 sig,
769 vault.to_string(),
770 )
771 .map_err(|e| Error::bad_request(format!("Failed to create request: {}", e)))?
772 } else {
773 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
774 .map_err(|e| Error::bad_request(format!("Failed to create request: {}", e)))?
775 };
776
777 let response = self.http_roundtrip_exchange(&request).await?;
778
779 if response.status.is_success() {
780 let parsed_response: HyperliquidExchangeResponse =
781 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
782
783 match &parsed_response {
785 HyperliquidExchangeResponse::Status {
786 status,
787 response: response_data,
788 } if status == "err" => {
789 let error_msg = response_data
790 .as_str()
791 .map_or_else(|| response_data.to_string(), |s| s.to_string());
792 tracing::error!("Hyperliquid API returned error: {}", error_msg);
793 Err(Error::bad_request(format!("API error: {}", error_msg)))
794 }
795 HyperliquidExchangeResponse::Error { error } => {
796 tracing::error!("Hyperliquid API returned error: {}", error);
797 Err(Error::bad_request(format!("API error: {}", error)))
798 }
799 _ => Ok(parsed_response),
800 }
801 } else if response.status.as_u16() == 429 {
802 let ra = self.parse_retry_after_simple(&response.headers);
803 Err(Error::rate_limit("exchange", w, ra))
804 } else {
805 let error_body = String::from_utf8_lossy(&response.body);
806 Err(Error::http(
807 response.status.as_u16(),
808 error_body.to_string(),
809 ))
810 }
811 }
812
813 pub async fn submit_order(
823 &self,
824 order: &nautilus_model::orders::any::OrderAny,
825 ) -> Result<nautilus_model::reports::OrderStatusReport> {
826 let hyperliquid_order = order_to_hyperliquid_request(order)
828 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
829
830 let action = crate::http::models::HyperliquidExecAction::Order {
832 orders: vec![hyperliquid_order],
833 grouping: crate::http::models::HyperliquidExecGrouping::Na,
834 builder: None,
835 };
836
837 let response = self.post_action_exec(&action).await?;
839
840 match response {
842 HyperliquidExchangeResponse::Status {
843 status,
844 response: response_data,
845 } if status == "ok" => {
846 let data_value = if let Some(data) = response_data.get("data") {
849 data.clone()
850 } else {
851 response_data
852 };
853
854 let order_response: crate::http::models::HyperliquidExecOrderResponseData =
856 serde_json::from_value(data_value).map_err(|e| {
857 Error::bad_request(format!("Failed to parse order response: {e}"))
858 })?;
859
860 let order_status = order_response
862 .statuses
863 .first()
864 .ok_or_else(|| Error::bad_request("No order status in response"))?;
865
866 let instrument_id = order.instrument_id();
868 let symbol = instrument_id.symbol.as_str();
869 let asset = symbol.trim_end_matches("-PERP").trim_end_matches("-USD");
870
871 let instrument = self
873 .get_or_create_instrument(&Ustr::from(asset))
874 .ok_or_else(|| {
875 Error::bad_request(format!("Instrument not found for {asset}"))
876 })?;
877
878 let account_id = self
879 .account_id
880 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
881 let ts_init = nautilus_core::UnixNanos::default();
882
883 match order_status {
885 crate::http::models::HyperliquidExecOrderStatus::Resting { resting } => {
886 self.create_order_status_report(
888 order.instrument_id(),
889 Some(order.client_order_id()),
890 nautilus_model::identifiers::VenueOrderId::new(resting.oid.to_string()),
891 order.order_side(),
892 order.order_type(),
893 order.quantity(),
894 order.time_in_force(),
895 order.price(),
896 order.trigger_price(),
897 nautilus_model::enums::OrderStatus::Accepted,
898 nautilus_model::types::Quantity::new(0.0, instrument.size_precision()),
899 &instrument,
900 account_id,
901 ts_init,
902 )
903 }
904 crate::http::models::HyperliquidExecOrderStatus::Filled { filled } => {
905 let filled_qty = nautilus_model::types::Quantity::new(
907 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
908 instrument.size_precision(),
909 );
910 self.create_order_status_report(
911 order.instrument_id(),
912 Some(order.client_order_id()),
913 nautilus_model::identifiers::VenueOrderId::new(filled.oid.to_string()),
914 order.order_side(),
915 order.order_type(),
916 order.quantity(),
917 order.time_in_force(),
918 order.price(),
919 order.trigger_price(),
920 nautilus_model::enums::OrderStatus::Filled,
921 filled_qty,
922 &instrument,
923 account_id,
924 ts_init,
925 )
926 }
927 crate::http::models::HyperliquidExecOrderStatus::Error { error } => {
928 Err(Error::bad_request(format!("Order rejected: {error}")))
929 }
930 }
931 }
932 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
933 "Order submission failed: {error}"
934 ))),
935 _ => Err(Error::bad_request("Unexpected response format")),
936 }
937 }
938
939 #[allow(clippy::too_many_arguments)]
941 fn create_order_status_report(
942 &self,
943 instrument_id: nautilus_model::identifiers::InstrumentId,
944 client_order_id: Option<nautilus_model::identifiers::ClientOrderId>,
945 venue_order_id: nautilus_model::identifiers::VenueOrderId,
946 order_side: nautilus_model::enums::OrderSide,
947 order_type: nautilus_model::enums::OrderType,
948 quantity: nautilus_model::types::Quantity,
949 time_in_force: nautilus_model::enums::TimeInForce,
950 price: Option<nautilus_model::types::Price>,
951 trigger_price: Option<nautilus_model::types::Price>,
952 order_status: nautilus_model::enums::OrderStatus,
953 filled_qty: nautilus_model::types::Quantity,
954 _instrument: &nautilus_model::instruments::InstrumentAny,
955 account_id: nautilus_model::identifiers::AccountId,
956 ts_init: nautilus_core::UnixNanos,
957 ) -> Result<nautilus_model::reports::OrderStatusReport> {
958 use nautilus_core::time::get_atomic_clock_realtime;
959
960 let clock = get_atomic_clock_realtime();
961 let ts_accepted = clock.get_time_ns();
962 let ts_last = ts_accepted;
963 let report_id = nautilus_core::UUID4::new();
964
965 let mut report = nautilus_model::reports::OrderStatusReport::new(
966 account_id,
967 instrument_id,
968 client_order_id,
969 venue_order_id,
970 order_side,
971 order_type,
972 time_in_force,
973 order_status,
974 quantity,
975 filled_qty,
976 ts_accepted,
977 ts_last,
978 ts_init,
979 Some(report_id),
980 );
981
982 if let Some(px) = price {
984 report = report.with_price(px);
985 }
986
987 if let Some(trig_px) = trigger_price {
989 report = report
990 .with_trigger_price(trig_px)
991 .with_trigger_type(nautilus_model::enums::TriggerType::Default);
992 }
993
994 Ok(report)
995 }
996
997 pub async fn submit_orders(
1007 &self,
1008 orders: &[&nautilus_model::orders::any::OrderAny],
1009 ) -> Result<Vec<nautilus_model::reports::OrderStatusReport>> {
1010 use crate::common::parse::orders_to_hyperliquid_requests;
1011
1012 let hyperliquid_orders = orders_to_hyperliquid_requests(orders)
1014 .map_err(|e| Error::bad_request(format!("Failed to convert orders: {e}")))?;
1015
1016 let action = crate::http::models::HyperliquidExecAction::Order {
1018 orders: hyperliquid_orders,
1019 grouping: crate::http::models::HyperliquidExecGrouping::Na,
1020 builder: None,
1021 };
1022
1023 let response = self.post_action_exec(&action).await?;
1025
1026 match response {
1028 HyperliquidExchangeResponse::Status {
1029 status,
1030 response: response_data,
1031 } if status == "ok" => {
1032 let data_value = if let Some(data) = response_data.get("data") {
1035 data.clone()
1036 } else {
1037 response_data
1038 };
1039
1040 let order_response: crate::http::models::HyperliquidExecOrderResponseData =
1042 serde_json::from_value(data_value).map_err(|e| {
1043 Error::bad_request(format!("Failed to parse order response: {e}"))
1044 })?;
1045
1046 let account_id = self
1047 .account_id
1048 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1049 let ts_init = nautilus_core::UnixNanos::default();
1050
1051 if order_response.statuses.len() != orders.len() {
1053 return Err(Error::bad_request(format!(
1054 "Mismatch between submitted orders ({}) and response statuses ({})",
1055 orders.len(),
1056 order_response.statuses.len()
1057 )));
1058 }
1059
1060 let mut reports = Vec::new();
1061
1062 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
1064 let instrument_id = order.instrument_id();
1066 let symbol = instrument_id.symbol.as_str();
1067 let asset = symbol.trim_end_matches("-PERP").trim_end_matches("-USD"); let instrument = self
1069 .get_or_create_instrument(&Ustr::from(asset))
1070 .ok_or_else(|| {
1071 Error::bad_request(format!("Instrument not found for {asset}"))
1072 })?;
1073
1074 let report = match order_status {
1076 crate::http::models::HyperliquidExecOrderStatus::Resting { resting } => {
1077 self.create_order_status_report(
1079 order.instrument_id(),
1080 Some(order.client_order_id()),
1081 nautilus_model::identifiers::VenueOrderId::new(
1082 resting.oid.to_string(),
1083 ),
1084 order.order_side(),
1085 order.order_type(),
1086 order.quantity(),
1087 order.time_in_force(),
1088 order.price(),
1089 order.trigger_price(),
1090 nautilus_model::enums::OrderStatus::Accepted,
1091 nautilus_model::types::Quantity::new(
1092 0.0,
1093 instrument.size_precision(),
1094 ),
1095 &instrument,
1096 account_id,
1097 ts_init,
1098 )?
1099 }
1100 crate::http::models::HyperliquidExecOrderStatus::Filled { filled } => {
1101 let filled_qty = nautilus_model::types::Quantity::new(
1103 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
1104 instrument.size_precision(),
1105 );
1106 self.create_order_status_report(
1107 order.instrument_id(),
1108 Some(order.client_order_id()),
1109 nautilus_model::identifiers::VenueOrderId::new(
1110 filled.oid.to_string(),
1111 ),
1112 order.order_side(),
1113 order.order_type(),
1114 order.quantity(),
1115 order.time_in_force(),
1116 order.price(),
1117 order.trigger_price(),
1118 nautilus_model::enums::OrderStatus::Filled,
1119 filled_qty,
1120 &instrument,
1121 account_id,
1122 ts_init,
1123 )?
1124 }
1125 crate::http::models::HyperliquidExecOrderStatus::Error { error } => {
1126 return Err(Error::bad_request(format!(
1127 "Order {} rejected: {error}",
1128 order.client_order_id()
1129 )));
1130 }
1131 };
1132
1133 reports.push(report);
1134 }
1135
1136 Ok(reports)
1137 }
1138 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
1139 "Order submission failed: {error}"
1140 ))),
1141 _ => Err(Error::bad_request("Unexpected response format")),
1142 }
1143 }
1144
1145 async fn http_roundtrip_exchange<T>(
1147 &self,
1148 request: &HyperliquidExchangeRequest<T>,
1149 ) -> Result<nautilus_network::http::HttpResponse>
1150 where
1151 T: serde::Serialize,
1152 {
1153 let url = &self.base_exchange;
1154 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
1155 let body_bytes = body.into_bytes();
1156
1157 let response = self
1158 .client
1159 .request(
1160 Method::POST,
1161 url.clone(),
1162 None,
1163 Some(body_bytes),
1164 None,
1165 None,
1166 )
1167 .await
1168 .map_err(Error::from_http_client)?;
1169
1170 Ok(response)
1171 }
1172
1173 pub async fn request_order_status_reports(
1185 &self,
1186 user: &str,
1187 instrument_id: Option<nautilus_model::identifiers::InstrumentId>,
1188 ) -> Result<Vec<nautilus_model::reports::OrderStatusReport>> {
1189 let response = self.info_frontend_open_orders(user).await?;
1190
1191 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1193 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1194
1195 let mut reports = Vec::new();
1196 let ts_init = nautilus_core::UnixNanos::default();
1197
1198 for order_value in orders {
1199 let order: crate::websocket::messages::WsBasicOrderData =
1201 match serde_json::from_value(order_value.clone()) {
1202 Ok(o) => o,
1203 Err(e) => {
1204 tracing::warn!("Failed to parse order: {}", e);
1205 continue;
1206 }
1207 };
1208
1209 let instrument = match self.get_or_create_instrument(&order.coin) {
1211 Some(inst) => inst,
1212 None => continue, };
1214
1215 if let Some(filter_id) = instrument_id
1217 && instrument.id() != filter_id
1218 {
1219 continue;
1220 }
1221
1222 let status = "open";
1224
1225 match crate::http::parse::parse_order_status_report_from_basic(
1227 &order,
1228 status,
1229 &instrument,
1230 self.account_id.unwrap_or_default(),
1231 ts_init,
1232 ) {
1233 Ok(report) => reports.push(report),
1234 Err(e) => tracing::error!("Failed to parse order status report: {e}"),
1235 }
1236 }
1237
1238 Ok(reports)
1239 }
1240
1241 pub async fn request_fill_reports(
1253 &self,
1254 user: &str,
1255 instrument_id: Option<nautilus_model::identifiers::InstrumentId>,
1256 ) -> Result<Vec<nautilus_model::reports::FillReport>> {
1257 let fills_response = self.info_user_fills(user).await?;
1258
1259 let mut reports = Vec::new();
1260 let ts_init = nautilus_core::UnixNanos::default();
1261
1262 for fill in fills_response {
1263 let instrument = match self.get_or_create_instrument(&fill.coin) {
1265 Some(inst) => inst,
1266 None => continue, };
1268
1269 if let Some(filter_id) = instrument_id
1271 && instrument.id() != filter_id
1272 {
1273 continue;
1274 }
1275
1276 match crate::http::parse::parse_fill_report(
1278 &fill,
1279 &instrument,
1280 self.account_id.unwrap_or_default(),
1281 ts_init,
1282 ) {
1283 Ok(report) => reports.push(report),
1284 Err(e) => tracing::error!("Failed to parse fill report: {e}"),
1285 }
1286 }
1287
1288 Ok(reports)
1289 }
1290
1291 pub async fn request_position_status_reports(
1303 &self,
1304 user: &str,
1305 instrument_id: Option<nautilus_model::identifiers::InstrumentId>,
1306 ) -> Result<Vec<nautilus_model::reports::PositionStatusReport>> {
1307 let state_response = self.info_clearinghouse_state(user).await?;
1308
1309 let asset_positions: Vec<serde_json::Value> = state_response
1311 .get("assetPositions")
1312 .and_then(|v| v.as_array())
1313 .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
1314 .clone();
1315
1316 let mut reports = Vec::new();
1317 let ts_init = nautilus_core::UnixNanos::default();
1318
1319 for position_value in asset_positions {
1320 let coin = position_value
1322 .get("position")
1323 .and_then(|p| p.get("coin"))
1324 .and_then(|c| c.as_str())
1325 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
1326
1327 let coin_ustr = Ustr::from(coin);
1329 let instrument = match self.get_or_create_instrument(&coin_ustr) {
1330 Some(inst) => inst,
1331 None => continue, };
1333
1334 if let Some(filter_id) = instrument_id
1336 && instrument.id() != filter_id
1337 {
1338 continue;
1339 }
1340
1341 match crate::http::parse::parse_position_status_report(
1343 &position_value,
1344 &instrument,
1345 self.account_id.unwrap_or_default(),
1346 ts_init,
1347 ) {
1348 Ok(report) => reports.push(report),
1349 Err(e) => tracing::error!("Failed to parse position status report: {e}"),
1350 }
1351 }
1352
1353 Ok(reports)
1354 }
1355
1356 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
1358 self.rest_limiter.snapshot().await
1359 }
1360
1361 fn signer_id(&self) -> Result<SignerId> {
1364 Ok(SignerId("hyperliquid:default".into()))
1365 }
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370 use nautilus_core::MUTEX_POISONED;
1371 use nautilus_model::instruments::{Instrument, InstrumentAny};
1372 use rstest::rstest;
1373 use ustr::Ustr;
1374
1375 use super::HyperliquidHttpClient;
1376 use crate::http::query::InfoRequest;
1377
1378 #[rstest]
1379 fn stable_json_roundtrips() {
1380 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
1381 let s = serde_json::to_string(&v).unwrap();
1382 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1384 assert_eq!(parsed["type"], "l2Book");
1385 assert_eq!(parsed["coin"], "BTC");
1386 assert_eq!(parsed, v);
1387 }
1388
1389 #[rstest]
1390 fn info_pretty_shape() {
1391 let r = InfoRequest::l2_book("BTC");
1392 let val = serde_json::to_value(&r).unwrap();
1393 let pretty = serde_json::to_string_pretty(&val).unwrap();
1394 assert!(pretty.contains("\"type\": \"l2Book\""));
1395 assert!(pretty.contains("\"coin\": \"BTC\""));
1396 }
1397
1398 #[rstest]
1399 fn test_add_instrument_dual_key_storage() {
1400 use nautilus_core::time::get_atomic_clock_realtime;
1401 use nautilus_model::{
1402 currencies::CURRENCY_MAP,
1403 enums::CurrencyType,
1404 identifiers::{InstrumentId, Symbol},
1405 instruments::CurrencyPair,
1406 types::{Currency, Price, Quantity},
1407 };
1408
1409 let client = HyperliquidHttpClient::new(true, None);
1410
1411 let base_code = "vntls:vCURSOR";
1413 let quote_code = "USDC";
1414
1415 {
1417 let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
1418 if !currency_map.contains_key(base_code) {
1419 currency_map.insert(
1420 base_code.to_string(),
1421 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
1422 );
1423 }
1424 }
1425
1426 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
1427 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
1428
1429 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
1430 let venue = *crate::common::consts::HYPERLIQUID_VENUE;
1431 let instrument_id = InstrumentId::new(symbol, venue);
1432
1433 let clock = get_atomic_clock_realtime();
1434 let ts = clock.get_time_ns();
1435
1436 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1437 instrument_id,
1438 symbol,
1439 base_currency,
1440 quote_currency,
1441 8,
1442 8,
1443 Price::from("0.00000001"),
1444 Quantity::from("0.00000001"),
1445 None,
1446 None,
1447 None,
1448 None,
1449 None,
1450 None,
1451 None,
1452 None,
1453 None,
1454 None,
1455 None,
1456 None,
1457 ts,
1458 ts,
1459 ));
1460
1461 client.add_instrument(instrument);
1463
1464 let instruments = client.instruments.read().unwrap();
1466 let by_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
1467 assert!(
1468 by_symbol.is_some(),
1469 "Instrument should be accessible by Nautilus symbol"
1470 );
1471
1472 let by_coin = instruments.get(&Ustr::from("vntls:vCURSOR"));
1474 assert!(
1475 by_coin.is_some(),
1476 "Instrument should be accessible by Hyperliquid coin identifier"
1477 );
1478
1479 assert_eq!(by_symbol.unwrap().id(), by_coin.unwrap().id());
1481 }
1482}