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 with_rate_limits(mut self) -> Self {
183 self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
184 self.rate_limit_backoff_base = Duration::from_millis(125);
185 self.rate_limit_backoff_cap = Duration::from_secs(5);
186 self.rate_limit_max_attempts_info = 3;
187 self
188 }
189
190 #[must_use]
192 pub fn is_testnet(&self) -> bool {
193 self.is_testnet
194 }
195
196 pub fn get_user_address(&self) -> Result<String> {
202 self.signer
203 .as_ref()
204 .ok_or_else(|| Error::auth("No signer configured"))?
205 .address()
206 }
207
208 pub fn add_instrument(&self, instrument: InstrumentAny) {
219 let mut instruments = self
220 .instruments
221 .write()
222 .expect("Failed to acquire write lock");
223
224 let nautilus_symbol = instrument.id().symbol.inner();
226 instruments.insert(nautilus_symbol, instrument.clone());
227
228 if let Some(base_currency) = instrument.base_currency() {
231 let coin_key = Ustr::from(base_currency.code.as_str());
232 instruments.insert(coin_key, instrument);
233 }
234 }
235
236 fn get_or_create_instrument(&self, coin: &Ustr) -> Option<InstrumentAny> {
252 {
254 let instruments = self
255 .instruments
256 .read()
257 .expect("Failed to acquire read lock");
258 if let Some(instrument) = instruments.get(coin) {
259 return Some(instrument.clone());
260 }
261 }
262
263 if coin.as_str().starts_with("vntls:") {
265 tracing::info!("Creating synthetic instrument for vault token: {}", coin);
266
267 let clock = nautilus_core::time::get_atomic_clock_realtime();
268 let ts_event = clock.get_time_ns();
269
270 let symbol_str = format!("{}-USDC-SPOT", coin);
272 let symbol = nautilus_model::identifiers::Symbol::new(&symbol_str);
273 let venue = *HYPERLIQUID_VENUE;
274 let instrument_id = nautilus_model::identifiers::InstrumentId::new(symbol, venue);
275
276 let base_currency = nautilus_model::types::Currency::new(
278 coin.as_str(),
279 8, 0, coin.as_str(),
282 nautilus_model::enums::CurrencyType::Crypto,
283 );
284
285 let quote_currency = nautilus_model::types::Currency::new(
286 "USDC",
287 6, 0,
289 "USDC",
290 nautilus_model::enums::CurrencyType::Crypto,
291 );
292
293 let price_increment = nautilus_model::types::Price::from("0.00000001");
294 let size_increment = nautilus_model::types::Quantity::from("0.00000001");
295
296 let instrument =
297 InstrumentAny::CurrencyPair(nautilus_model::instruments::CurrencyPair::new(
298 instrument_id,
299 symbol,
300 base_currency,
301 quote_currency,
302 8, 8, price_increment,
305 size_increment,
306 None, None, None, None, None, None, None, None, None, None, None, None, ts_event,
319 ts_event,
320 ));
321
322 self.add_instrument(instrument.clone());
324
325 Some(instrument)
326 } else {
327 tracing::warn!("Instrument not found in cache: {}", coin);
329 None
330 }
331 }
332
333 pub fn set_account_id(&mut self, account_id: AccountId) {
337 self.account_id = Some(account_id);
338 }
339
340 fn default_headers() -> HashMap<String, String> {
342 HashMap::from([
343 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
344 ("Content-Type".to_string(), "application/json".to_string()),
345 ])
346 }
347 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
351 let request = InfoRequest::meta();
352 let response = self.send_info_request(&request).await?;
353 serde_json::from_value(response).map_err(Error::Serde)
354 }
355
356 pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
358 let request = InfoRequest::spot_meta();
359 let response = self.send_info_request(&request).await?;
360 serde_json::from_value(response).map_err(Error::Serde)
361 }
362
363 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
365 let request = InfoRequest::meta_and_asset_ctxs();
366 let response = self.send_info_request(&request).await?;
367 serde_json::from_value(response).map_err(Error::Serde)
368 }
369
370 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
372 let request = InfoRequest::spot_meta_and_asset_ctxs();
373 let response = self.send_info_request(&request).await?;
374 serde_json::from_value(response).map_err(Error::Serde)
375 }
376
377 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
379 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
380
381 match self.load_perp_meta().await {
382 Ok(perp_meta) => match parse_perp_instruments(&perp_meta) {
383 Ok(perp_defs) => {
384 tracing::debug!(
385 count = perp_defs.len(),
386 "Loaded Hyperliquid perp definitions"
387 );
388 defs.extend(perp_defs);
389 }
390 Err(err) => {
391 tracing::warn!(%err, "Failed to parse Hyperliquid perp instruments");
392 }
393 },
394 Err(err) => {
395 tracing::warn!(%err, "Failed to load Hyperliquid perp metadata");
396 }
397 }
398
399 match self.get_spot_meta().await {
400 Ok(spot_meta) => match parse_spot_instruments(&spot_meta) {
401 Ok(spot_defs) => {
402 tracing::debug!(
403 count = spot_defs.len(),
404 "Loaded Hyperliquid spot definitions"
405 );
406 defs.extend(spot_defs);
407 }
408 Err(err) => {
409 tracing::warn!(%err, "Failed to parse Hyperliquid spot instruments");
410 }
411 },
412 Err(err) => {
413 tracing::warn!(%err, "Failed to load Hyperliquid spot metadata");
414 }
415 }
416
417 Ok(instruments_from_defs_owned(defs))
418 }
419
420 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
421 let request = InfoRequest::meta();
422 let response = self.send_info_request(&request).await?;
423 serde_json::from_value(response).map_err(Error::Serde)
424 }
425
426 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
428 let request = InfoRequest::l2_book(coin);
429 let response = self.send_info_request(&request).await?;
430 serde_json::from_value(response).map_err(Error::Serde)
431 }
432
433 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
435 let request = InfoRequest::user_fills(user);
436 let response = self.send_info_request(&request).await?;
437 serde_json::from_value(response).map_err(Error::Serde)
438 }
439
440 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
442 let request = InfoRequest::order_status(user, oid);
443 let response = self.send_info_request(&request).await?;
444 serde_json::from_value(response).map_err(Error::Serde)
445 }
446
447 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
449 let request = InfoRequest::open_orders(user);
450 self.send_info_request(&request).await
451 }
452
453 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
455 let request = InfoRequest::frontend_open_orders(user);
456 self.send_info_request(&request).await
457 }
458
459 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
461 let request = InfoRequest::clearinghouse_state(user);
462 self.send_info_request(&request).await
463 }
464
465 pub async fn info_candle_snapshot(
473 &self,
474 coin: &str,
475 interval: &str,
476 start_time: u64,
477 end_time: u64,
478 ) -> Result<crate::http::models::HyperliquidCandleSnapshot> {
479 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
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 send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
486 self.send_info_request(request).await
487 }
488
489 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
491 let base_w = info_base_weight(request);
492 self.rest_limiter.acquire(base_w).await;
493
494 let mut attempt = 0u32;
495 loop {
496 let response = self.http_roundtrip_info(request).await?;
497
498 if response.status.is_success() {
499 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
501 let extra = info_extra_weight(request, &val);
502 if extra > 0 {
503 self.rest_limiter.debit_extra(extra).await;
504 tracing::debug!(endpoint=?request, base_w, extra, "info: debited extra weight");
505 }
506 return Ok(val);
507 }
508
509 if response.status.as_u16() == 429 {
511 if attempt >= self.rate_limit_max_attempts_info {
512 let ra = self.parse_retry_after_simple(&response.headers);
513 return Err(Error::rate_limit("info", base_w, ra));
514 }
515 let delay = self
516 .parse_retry_after_simple(&response.headers)
517 .map(Duration::from_millis)
518 .unwrap_or_else(|| {
519 backoff_full_jitter(
520 attempt,
521 self.rate_limit_backoff_base,
522 self.rate_limit_backoff_cap,
523 )
524 });
525 tracing::warn!(endpoint=?request, attempt, wait_ms=?delay.as_millis(), "429 Too Many Requests; backing off");
526 attempt += 1;
527 sleep(delay).await;
528 self.rest_limiter.acquire(1).await;
530 continue;
531 }
532
533 if (response.status.is_server_error() || response.status.as_u16() == 408)
535 && attempt < self.rate_limit_max_attempts_info
536 {
537 let delay = backoff_full_jitter(
538 attempt,
539 self.rate_limit_backoff_base,
540 self.rate_limit_backoff_cap,
541 );
542 tracing::warn!(endpoint=?request, attempt, status=?response.status.as_u16(), wait_ms=?delay.as_millis(), "transient error; retrying");
543 attempt += 1;
544 sleep(delay).await;
545 continue;
546 }
547
548 let error_body = String::from_utf8_lossy(&response.body);
550 return Err(Error::http(
551 response.status.as_u16(),
552 error_body.to_string(),
553 ));
554 }
555 }
556
557 async fn http_roundtrip_info(
559 &self,
560 request: &InfoRequest,
561 ) -> Result<nautilus_network::http::HttpResponse> {
562 let url = &self.base_info;
563 let body = serde_json::to_value(request).map_err(Error::Serde)?;
564 let body_bytes = serde_json::to_string(&body)
565 .map_err(Error::Serde)?
566 .into_bytes();
567
568 self.client
569 .request(
570 Method::POST,
571 url.clone(),
572 None,
573 Some(body_bytes),
574 None,
575 None,
576 )
577 .await
578 .map_err(Error::from_http_client)
579 }
580
581 fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
583 let retry_after = headers.get("retry-after")?;
584 retry_after.parse::<u64>().ok().map(|s| s * 1000) }
586
587 pub async fn post_action(
591 &self,
592 action: &ExchangeAction,
593 ) -> Result<HyperliquidExchangeResponse> {
594 let w = exchange_weight(action);
595 self.rest_limiter.acquire(w).await;
596
597 let signer = self
598 .signer
599 .as_ref()
600 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
601
602 let nonce_manager = self
603 .nonce_manager
604 .as_ref()
605 .ok_or_else(|| Error::auth("nonce manager missing"))?;
606
607 let signer_id = self.signer_id()?;
608 let time_nonce = nonce_manager.next(signer_id.clone())?;
609 nonce_manager.validate_local(signer_id, time_nonce)?;
610
611 let action_value = serde_json::to_value(action)
612 .context("serialize exchange action")
613 .map_err(|e| Error::bad_request(e.to_string()))?;
614
615 let sig = signer
616 .sign(&SignRequest {
617 action: action_value.clone(),
618 time_nonce,
619 action_type: HyperliquidActionType::UserSigned,
620 })?
621 .signature;
622
623 let request = if let Some(vault) = self.vault_address {
624 HyperliquidExchangeRequest::with_vault(
625 action.clone(),
626 time_nonce.as_millis() as u64,
627 sig,
628 vault.to_string(),
629 )
630 } else {
631 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
632 };
633
634 let response = self.http_roundtrip_exchange(&request).await?;
635
636 if response.status.is_success() {
637 serde_json::from_slice(&response.body).map_err(Error::Serde)
638 } else if response.status.as_u16() == 429 {
639 let ra = self.parse_retry_after_simple(&response.headers);
640 Err(Error::rate_limit("exchange", w, ra))
641 } else {
642 let error_body = String::from_utf8_lossy(&response.body);
643 Err(Error::http(
644 response.status.as_u16(),
645 error_body.to_string(),
646 ))
647 }
648 }
649
650 pub async fn submit_order(
660 &self,
661 order: &nautilus_model::orders::any::OrderAny,
662 ) -> Result<nautilus_model::reports::OrderStatusReport> {
663 let hyperliquid_order = order_to_hyperliquid_request(order)
665 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
666
667 let orders_value = serde_json::json!([hyperliquid_order]);
669
670 let action = ExchangeAction::order(orders_value);
672
673 let response = self.post_action(&action).await?;
675
676 match response {
678 HyperliquidExchangeResponse::Status {
679 status,
680 response: response_data,
681 } if status == "ok" => {
682 let order_response: crate::http::models::HyperliquidExecOrderResponseData =
684 serde_json::from_value(response_data.clone()).map_err(|e| {
685 Error::bad_request(format!("Failed to parse order response: {e}"))
686 })?;
687
688 let order_status = order_response
690 .statuses
691 .first()
692 .ok_or_else(|| Error::bad_request("No order status in response"))?;
693
694 let instrument_id = order.instrument_id();
696 let symbol = instrument_id.symbol.as_str();
697 let asset = symbol.trim_end_matches("-PERP").trim_end_matches("-USD");
698
699 let instrument = self
701 .get_or_create_instrument(&Ustr::from(asset))
702 .ok_or_else(|| {
703 Error::bad_request(format!("Instrument not found for {asset}"))
704 })?;
705
706 let account_id = self
707 .account_id
708 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
709 let ts_init = nautilus_core::UnixNanos::default();
710
711 match order_status {
713 crate::http::models::HyperliquidExecOrderStatus::Resting { resting } => {
714 self.create_order_status_report(
716 order.instrument_id(),
717 Some(order.client_order_id()),
718 nautilus_model::identifiers::VenueOrderId::new(resting.oid.to_string()),
719 order.order_side(),
720 order.order_type(),
721 order.quantity(),
722 order.time_in_force(),
723 order.price(),
724 order.trigger_price(),
725 nautilus_model::enums::OrderStatus::Accepted,
726 nautilus_model::types::Quantity::new(0.0, instrument.size_precision()),
727 &instrument,
728 account_id,
729 ts_init,
730 )
731 }
732 crate::http::models::HyperliquidExecOrderStatus::Filled { filled } => {
733 let filled_qty = nautilus_model::types::Quantity::new(
735 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
736 instrument.size_precision(),
737 );
738 self.create_order_status_report(
739 order.instrument_id(),
740 Some(order.client_order_id()),
741 nautilus_model::identifiers::VenueOrderId::new(filled.oid.to_string()),
742 order.order_side(),
743 order.order_type(),
744 order.quantity(),
745 order.time_in_force(),
746 order.price(),
747 order.trigger_price(),
748 nautilus_model::enums::OrderStatus::Filled,
749 filled_qty,
750 &instrument,
751 account_id,
752 ts_init,
753 )
754 }
755 crate::http::models::HyperliquidExecOrderStatus::Error { error } => {
756 Err(Error::bad_request(format!("Order rejected: {error}")))
757 }
758 }
759 }
760 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
761 "Order submission failed: {error}"
762 ))),
763 _ => Err(Error::bad_request("Unexpected response format")),
764 }
765 }
766
767 #[allow(clippy::too_many_arguments)]
769 fn create_order_status_report(
770 &self,
771 instrument_id: nautilus_model::identifiers::InstrumentId,
772 client_order_id: Option<nautilus_model::identifiers::ClientOrderId>,
773 venue_order_id: nautilus_model::identifiers::VenueOrderId,
774 order_side: nautilus_model::enums::OrderSide,
775 order_type: nautilus_model::enums::OrderType,
776 quantity: nautilus_model::types::Quantity,
777 time_in_force: nautilus_model::enums::TimeInForce,
778 price: Option<nautilus_model::types::Price>,
779 trigger_price: Option<nautilus_model::types::Price>,
780 order_status: nautilus_model::enums::OrderStatus,
781 filled_qty: nautilus_model::types::Quantity,
782 _instrument: &nautilus_model::instruments::InstrumentAny,
783 account_id: nautilus_model::identifiers::AccountId,
784 ts_init: nautilus_core::UnixNanos,
785 ) -> Result<nautilus_model::reports::OrderStatusReport> {
786 use nautilus_core::time::get_atomic_clock_realtime;
787
788 let clock = get_atomic_clock_realtime();
789 let ts_accepted = clock.get_time_ns();
790 let ts_last = ts_accepted;
791 let report_id = nautilus_core::UUID4::new();
792
793 let mut report = nautilus_model::reports::OrderStatusReport::new(
794 account_id,
795 instrument_id,
796 client_order_id,
797 venue_order_id,
798 order_side,
799 order_type,
800 time_in_force,
801 order_status,
802 quantity,
803 filled_qty,
804 ts_accepted,
805 ts_last,
806 ts_init,
807 Some(report_id),
808 );
809
810 if let Some(px) = price {
812 report = report.with_price(px);
813 }
814
815 if let Some(trig_px) = trigger_price {
817 report = report
818 .with_trigger_price(trig_px)
819 .with_trigger_type(nautilus_model::enums::TriggerType::Default);
820 }
821
822 Ok(report)
823 }
824
825 pub async fn submit_orders(
835 &self,
836 orders: &[&nautilus_model::orders::any::OrderAny],
837 ) -> Result<Vec<nautilus_model::reports::OrderStatusReport>> {
838 use crate::common::parse::orders_to_hyperliquid_requests;
839
840 let hyperliquid_orders = orders_to_hyperliquid_requests(orders)
842 .map_err(|e| Error::bad_request(format!("Failed to convert orders: {e}")))?;
843
844 let orders_value = serde_json::to_value(hyperliquid_orders)
846 .map_err(|e| Error::bad_request(format!("Failed to serialize orders: {e}")))?;
847
848 let action = ExchangeAction::order(orders_value);
850
851 let response = self.post_action(&action).await?;
853
854 match response {
856 HyperliquidExchangeResponse::Status {
857 status,
858 response: response_data,
859 } if status == "ok" => {
860 let order_response: crate::http::models::HyperliquidExecOrderResponseData =
862 serde_json::from_value(response_data.clone()).map_err(|e| {
863 Error::bad_request(format!("Failed to parse order response: {e}"))
864 })?;
865
866 let account_id = self
867 .account_id
868 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
869 let ts_init = nautilus_core::UnixNanos::default();
870
871 if order_response.statuses.len() != orders.len() {
873 return Err(Error::bad_request(format!(
874 "Mismatch between submitted orders ({}) and response statuses ({})",
875 orders.len(),
876 order_response.statuses.len()
877 )));
878 }
879
880 let mut reports = Vec::new();
881
882 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
884 let instrument_id = order.instrument_id();
886 let symbol = instrument_id.symbol.as_str();
887 let asset = symbol.trim_end_matches("-PERP").trim_end_matches("-USD"); let instrument = self
889 .get_or_create_instrument(&Ustr::from(asset))
890 .ok_or_else(|| {
891 Error::bad_request(format!("Instrument not found for {asset}"))
892 })?;
893
894 let report = match order_status {
896 crate::http::models::HyperliquidExecOrderStatus::Resting { resting } => {
897 self.create_order_status_report(
899 order.instrument_id(),
900 Some(order.client_order_id()),
901 nautilus_model::identifiers::VenueOrderId::new(
902 resting.oid.to_string(),
903 ),
904 order.order_side(),
905 order.order_type(),
906 order.quantity(),
907 order.time_in_force(),
908 order.price(),
909 order.trigger_price(),
910 nautilus_model::enums::OrderStatus::Accepted,
911 nautilus_model::types::Quantity::new(
912 0.0,
913 instrument.size_precision(),
914 ),
915 &instrument,
916 account_id,
917 ts_init,
918 )?
919 }
920 crate::http::models::HyperliquidExecOrderStatus::Filled { filled } => {
921 let filled_qty = nautilus_model::types::Quantity::new(
923 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
924 instrument.size_precision(),
925 );
926 self.create_order_status_report(
927 order.instrument_id(),
928 Some(order.client_order_id()),
929 nautilus_model::identifiers::VenueOrderId::new(
930 filled.oid.to_string(),
931 ),
932 order.order_side(),
933 order.order_type(),
934 order.quantity(),
935 order.time_in_force(),
936 order.price(),
937 order.trigger_price(),
938 nautilus_model::enums::OrderStatus::Filled,
939 filled_qty,
940 &instrument,
941 account_id,
942 ts_init,
943 )?
944 }
945 crate::http::models::HyperliquidExecOrderStatus::Error { error } => {
946 return Err(Error::bad_request(format!(
947 "Order {} rejected: {error}",
948 order.client_order_id()
949 )));
950 }
951 };
952
953 reports.push(report);
954 }
955
956 Ok(reports)
957 }
958 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
959 "Order submission failed: {error}"
960 ))),
961 _ => Err(Error::bad_request("Unexpected response format")),
962 }
963 }
964
965 async fn http_roundtrip_exchange(
967 &self,
968 request: &HyperliquidExchangeRequest<ExchangeAction>,
969 ) -> Result<nautilus_network::http::HttpResponse> {
970 let url = &self.base_exchange;
971 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
972 let body_bytes = body.into_bytes();
973
974 self.client
975 .request(
976 Method::POST,
977 url.clone(),
978 None,
979 Some(body_bytes),
980 None,
981 None,
982 )
983 .await
984 .map_err(Error::from_http_client)
985 }
986
987 pub async fn request_order_status_reports(
999 &self,
1000 user: &str,
1001 instrument_id: Option<nautilus_model::identifiers::InstrumentId>,
1002 ) -> Result<Vec<nautilus_model::reports::OrderStatusReport>> {
1003 let response = self.info_frontend_open_orders(user).await?;
1004
1005 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1007 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1008
1009 let mut reports = Vec::new();
1010 let ts_init = nautilus_core::UnixNanos::default();
1011
1012 for order_value in orders {
1013 let order: crate::websocket::messages::WsBasicOrderData =
1015 match serde_json::from_value(order_value.clone()) {
1016 Ok(o) => o,
1017 Err(e) => {
1018 tracing::warn!("Failed to parse order: {}", e);
1019 continue;
1020 }
1021 };
1022
1023 let instrument = match self.get_or_create_instrument(&order.coin) {
1025 Some(inst) => inst,
1026 None => continue, };
1028
1029 if let Some(filter_id) = instrument_id
1031 && instrument.id() != filter_id
1032 {
1033 continue;
1034 }
1035
1036 let status = "open";
1038
1039 match crate::http::parse::parse_order_status_report_from_basic(
1041 &order,
1042 status,
1043 &instrument,
1044 self.account_id.unwrap_or_default(),
1045 ts_init,
1046 ) {
1047 Ok(report) => reports.push(report),
1048 Err(e) => tracing::error!("Failed to parse order status report: {e}"),
1049 }
1050 }
1051
1052 Ok(reports)
1053 }
1054
1055 pub async fn request_fill_reports(
1067 &self,
1068 user: &str,
1069 instrument_id: Option<nautilus_model::identifiers::InstrumentId>,
1070 ) -> Result<Vec<nautilus_model::reports::FillReport>> {
1071 let fills_response = self.info_user_fills(user).await?;
1072
1073 let mut reports = Vec::new();
1074 let ts_init = nautilus_core::UnixNanos::default();
1075
1076 for fill in fills_response {
1077 let instrument = match self.get_or_create_instrument(&fill.coin) {
1079 Some(inst) => inst,
1080 None => continue, };
1082
1083 if let Some(filter_id) = instrument_id
1085 && instrument.id() != filter_id
1086 {
1087 continue;
1088 }
1089
1090 match crate::http::parse::parse_fill_report(
1092 &fill,
1093 &instrument,
1094 self.account_id.unwrap_or_default(),
1095 ts_init,
1096 ) {
1097 Ok(report) => reports.push(report),
1098 Err(e) => tracing::error!("Failed to parse fill report: {e}"),
1099 }
1100 }
1101
1102 Ok(reports)
1103 }
1104
1105 pub async fn request_position_status_reports(
1117 &self,
1118 user: &str,
1119 instrument_id: Option<nautilus_model::identifiers::InstrumentId>,
1120 ) -> Result<Vec<nautilus_model::reports::PositionStatusReport>> {
1121 let state_response = self.info_clearinghouse_state(user).await?;
1122
1123 let asset_positions: Vec<serde_json::Value> = state_response
1125 .get("assetPositions")
1126 .and_then(|v| v.as_array())
1127 .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
1128 .clone();
1129
1130 let mut reports = Vec::new();
1131 let ts_init = nautilus_core::UnixNanos::default();
1132
1133 for position_value in asset_positions {
1134 let coin = position_value
1136 .get("position")
1137 .and_then(|p| p.get("coin"))
1138 .and_then(|c| c.as_str())
1139 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
1140
1141 let coin_ustr = Ustr::from(coin);
1143 let instrument = match self.get_or_create_instrument(&coin_ustr) {
1144 Some(inst) => inst,
1145 None => continue, };
1147
1148 if let Some(filter_id) = instrument_id
1150 && instrument.id() != filter_id
1151 {
1152 continue;
1153 }
1154
1155 match crate::http::parse::parse_position_status_report(
1157 &position_value,
1158 &instrument,
1159 self.account_id.unwrap_or_default(),
1160 ts_init,
1161 ) {
1162 Ok(report) => reports.push(report),
1163 Err(e) => tracing::error!("Failed to parse position status report: {e}"),
1164 }
1165 }
1166
1167 Ok(reports)
1168 }
1169
1170 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
1172 self.rest_limiter.snapshot().await
1173 }
1174
1175 fn signer_id(&self) -> Result<SignerId> {
1178 Ok(SignerId("hyperliquid:default".into()))
1179 }
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184 use nautilus_model::instruments::{Instrument, InstrumentAny};
1185 use rstest::rstest;
1186 use ustr::Ustr;
1187
1188 use super::HyperliquidHttpClient;
1189 use crate::http::query::InfoRequest;
1190
1191 #[rstest]
1192 fn stable_json_roundtrips() {
1193 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
1194 let s = serde_json::to_string(&v).unwrap();
1195 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1197 assert_eq!(parsed["type"], "l2Book");
1198 assert_eq!(parsed["coin"], "BTC");
1199 assert_eq!(parsed, v);
1200 }
1201
1202 #[rstest]
1203 fn info_pretty_shape() {
1204 let r = InfoRequest::l2_book("BTC");
1205 let val = serde_json::to_value(&r).unwrap();
1206 let pretty = serde_json::to_string_pretty(&val).unwrap();
1207 assert!(pretty.contains("\"type\": \"l2Book\""));
1208 assert!(pretty.contains("\"coin\": \"BTC\""));
1209 }
1210
1211 #[rstest]
1212 fn test_add_instrument_dual_key_storage() {
1213 use nautilus_core::time::get_atomic_clock_realtime;
1214 use nautilus_model::{
1215 currencies::CURRENCY_MAP,
1216 enums::CurrencyType,
1217 identifiers::{InstrumentId, Symbol},
1218 instruments::CurrencyPair,
1219 types::{Currency, Price, Quantity},
1220 };
1221
1222 let client = HyperliquidHttpClient::new(true, None);
1223
1224 let base_code = "vntls:vCURSOR";
1226 let quote_code = "USDC";
1227
1228 {
1230 let mut currency_map = CURRENCY_MAP.lock().unwrap();
1231 if !currency_map.contains_key(base_code) {
1232 currency_map.insert(
1233 base_code.to_string(),
1234 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
1235 );
1236 }
1237 }
1238
1239 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
1240 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
1241
1242 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
1243 let venue = *crate::common::consts::HYPERLIQUID_VENUE;
1244 let instrument_id = InstrumentId::new(symbol, venue);
1245
1246 let clock = get_atomic_clock_realtime();
1247 let ts = clock.get_time_ns();
1248
1249 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1250 instrument_id,
1251 symbol,
1252 base_currency,
1253 quote_currency,
1254 8,
1255 8,
1256 Price::from("0.00000001"),
1257 Quantity::from("0.00000001"),
1258 None,
1259 None,
1260 None,
1261 None,
1262 None,
1263 None,
1264 None,
1265 None,
1266 None,
1267 None,
1268 None,
1269 None,
1270 ts,
1271 ts,
1272 ));
1273
1274 client.add_instrument(instrument.clone());
1276
1277 let instruments = client.instruments.read().unwrap();
1279 let by_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
1280 assert!(
1281 by_symbol.is_some(),
1282 "Instrument should be accessible by Nautilus symbol"
1283 );
1284
1285 let by_coin = instruments.get(&Ustr::from("vntls:vCURSOR"));
1287 assert!(
1288 by_coin.is_some(),
1289 "Instrument should be accessible by Hyperliquid coin identifier"
1290 );
1291
1292 assert_eq!(by_symbol.unwrap().id(), by_coin.unwrap().id());
1294 }
1295}