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::{
33 UUID4, UnixNanos, consts::NAUTILUS_USER_AGENT, time::get_atomic_clock_realtime,
34};
35use nautilus_model::{
36 data::{Bar, BarType},
37 enums::{
38 BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType,
39 },
40 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
41 instruments::{CurrencyPair, Instrument, InstrumentAny},
42 orders::{Order, OrderAny},
43 reports::{FillReport, OrderStatusReport, PositionStatusReport},
44 types::{Currency, Price, Quantity},
45};
46use nautilus_network::{
47 http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
48 ratelimiter::quota::Quota,
49};
50use rust_decimal::Decimal;
51use serde_json::Value;
52use ustr::Ustr;
53
54use crate::{
55 common::{
56 consts::{HYPERLIQUID_VENUE, exchange_url, info_url},
57 credential::{Secrets, VaultAddress},
58 enums::{
59 HyperliquidBarInterval, HyperliquidOrderStatus as HyperliquidOrderStatusEnum,
60 HyperliquidProductType,
61 },
62 parse::{
63 bar_type_to_interval, extract_asset_id_from_symbol, orders_to_hyperliquid_requests,
64 },
65 },
66 http::{
67 error::{Error, Result},
68 models::{
69 Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeRequest,
70 HyperliquidExchangeResponse, HyperliquidExecAction,
71 HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
72 HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecOrderKind,
73 HyperliquidExecOrderResponseData, HyperliquidExecOrderStatus,
74 HyperliquidExecPlaceOrderRequest, HyperliquidExecTif, HyperliquidExecTpSl,
75 HyperliquidExecTriggerParams, HyperliquidFills, HyperliquidL2Book, HyperliquidMeta,
76 HyperliquidOrderStatus, PerpMeta, PerpMetaAndCtxs, SpotMeta, SpotMetaAndCtxs,
77 },
78 parse::{
79 HyperliquidInstrumentDef, instruments_from_defs_owned, parse_perp_instruments,
80 parse_spot_instruments,
81 },
82 query::{ExchangeAction, InfoRequest},
83 rate_limits::{
84 RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
85 info_base_weight, info_extra_weight,
86 },
87 },
88 signing::{
89 HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
90 },
91};
92
93pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
95 LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
96
97#[derive(Debug, Clone)]
102#[cfg_attr(
103 feature = "python",
104 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.hyperliquid")
105)]
106pub struct HyperliquidRawHttpClient {
107 client: HttpClient,
108 is_testnet: bool,
109 base_info: String,
110 base_exchange: String,
111 signer: Option<HyperliquidEip712Signer>,
112 nonce_manager: Option<Arc<NonceManager>>,
113 vault_address: Option<VaultAddress>,
114 rest_limiter: Arc<WeightedLimiter>,
115 rate_limit_backoff_base: Duration,
116 rate_limit_backoff_cap: Duration,
117 rate_limit_max_attempts_info: u32,
118}
119
120impl HyperliquidRawHttpClient {
121 pub fn new(
127 is_testnet: bool,
128 timeout_secs: Option<u64>,
129 proxy_url: Option<String>,
130 ) -> std::result::Result<Self, HttpClientError> {
131 Ok(Self {
132 client: HttpClient::new(
133 Self::default_headers(),
134 vec![],
135 vec![],
136 Some(*HYPERLIQUID_REST_QUOTA),
137 timeout_secs,
138 proxy_url,
139 )?,
140 is_testnet,
141 base_info: info_url(is_testnet).to_string(),
142 base_exchange: exchange_url(is_testnet).to_string(),
143 signer: None,
144 nonce_manager: None,
145 vault_address: None,
146 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
147 rate_limit_backoff_base: Duration::from_millis(125),
148 rate_limit_backoff_cap: Duration::from_secs(5),
149 rate_limit_max_attempts_info: 3,
150 })
151 }
152
153 pub fn with_credentials(
160 secrets: &Secrets,
161 timeout_secs: Option<u64>,
162 proxy_url: Option<String>,
163 ) -> std::result::Result<Self, HttpClientError> {
164 let signer = HyperliquidEip712Signer::new(secrets.private_key.clone());
165 let nonce_manager = Arc::new(NonceManager::new());
166
167 Ok(Self {
168 client: HttpClient::new(
169 Self::default_headers(),
170 vec![],
171 vec![],
172 Some(*HYPERLIQUID_REST_QUOTA),
173 timeout_secs,
174 proxy_url,
175 )?,
176 is_testnet: secrets.is_testnet,
177 base_info: info_url(secrets.is_testnet).to_string(),
178 base_exchange: exchange_url(secrets.is_testnet).to_string(),
179 signer: Some(signer),
180 nonce_manager: Some(nonce_manager),
181 vault_address: secrets.vault_address,
182 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
183 rate_limit_backoff_base: Duration::from_millis(125),
184 rate_limit_backoff_cap: Duration::from_secs(5),
185 rate_limit_max_attempts_info: 3,
186 })
187 }
188
189 pub fn from_env() -> Result<Self> {
195 let secrets =
196 Secrets::from_env().map_err(|_| Error::auth("missing credentials in environment"))?;
197 Self::with_credentials(&secrets, None, None)
198 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
199 }
200
201 pub fn from_credentials(
207 private_key: &str,
208 vault_address: Option<&str>,
209 is_testnet: bool,
210 timeout_secs: Option<u64>,
211 proxy_url: Option<String>,
212 ) -> Result<Self> {
213 let secrets = Secrets::from_private_key(private_key, vault_address, is_testnet)
214 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
215 Self::with_credentials(&secrets, timeout_secs, proxy_url)
216 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
217 }
218
219 #[must_use]
221 pub fn with_rate_limits(mut self) -> Self {
222 self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
223 self.rate_limit_backoff_base = Duration::from_millis(125);
224 self.rate_limit_backoff_cap = Duration::from_secs(5);
225 self.rate_limit_max_attempts_info = 3;
226 self
227 }
228
229 #[must_use]
231 pub fn is_testnet(&self) -> bool {
232 self.is_testnet
233 }
234
235 pub fn get_user_address(&self) -> Result<String> {
241 self.signer
242 .as_ref()
243 .ok_or_else(|| Error::auth("No signer configured"))?
244 .address()
245 }
246
247 fn default_headers() -> HashMap<String, String> {
249 HashMap::from([
250 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
251 ("Content-Type".to_string(), "application/json".to_string()),
252 ])
253 }
254
255 fn signer_id(&self) -> Result<SignerId> {
256 Ok(SignerId("hyperliquid:default".into()))
257 }
258
259 fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
261 let retry_after = headers.get("retry-after")?;
262 retry_after.parse::<u64>().ok().map(|s| s * 1000) }
264
265 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
267 let request = InfoRequest::meta();
268 let response = self.send_info_request(&request).await?;
269 serde_json::from_value(response).map_err(Error::Serde)
270 }
271
272 pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
274 let request = InfoRequest::spot_meta();
275 let response = self.send_info_request(&request).await?;
276 serde_json::from_value(response).map_err(Error::Serde)
277 }
278
279 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
281 let request = InfoRequest::meta_and_asset_ctxs();
282 let response = self.send_info_request(&request).await?;
283 serde_json::from_value(response).map_err(Error::Serde)
284 }
285
286 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
288 let request = InfoRequest::spot_meta_and_asset_ctxs();
289 let response = self.send_info_request(&request).await?;
290 serde_json::from_value(response).map_err(Error::Serde)
291 }
292
293 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
294 let request = InfoRequest::meta();
295 let response = self.send_info_request(&request).await?;
296 serde_json::from_value(response).map_err(Error::Serde)
297 }
298
299 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
301 let request = InfoRequest::l2_book(coin);
302 let response = self.send_info_request(&request).await?;
303 serde_json::from_value(response).map_err(Error::Serde)
304 }
305
306 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
308 let request = InfoRequest::user_fills(user);
309 let response = self.send_info_request(&request).await?;
310 serde_json::from_value(response).map_err(Error::Serde)
311 }
312
313 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
315 let request = InfoRequest::order_status(user, oid);
316 let response = self.send_info_request(&request).await?;
317 serde_json::from_value(response).map_err(Error::Serde)
318 }
319
320 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
322 let request = InfoRequest::open_orders(user);
323 self.send_info_request(&request).await
324 }
325
326 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
328 let request = InfoRequest::frontend_open_orders(user);
329 self.send_info_request(&request).await
330 }
331
332 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
334 let request = InfoRequest::clearinghouse_state(user);
335 self.send_info_request(&request).await
336 }
337
338 pub async fn info_candle_snapshot(
340 &self,
341 coin: &str,
342 interval: HyperliquidBarInterval,
343 start_time: u64,
344 end_time: u64,
345 ) -> Result<HyperliquidCandleSnapshot> {
346 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
347 let response = self.send_info_request(&request).await?;
348
349 log::trace!(
350 "Candle snapshot raw response (len={}): {:?}",
351 response.as_array().map_or(0, |a| a.len()),
352 response
353 );
354
355 serde_json::from_value(response).map_err(Error::Serde)
356 }
357
358 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
360 self.send_info_request(request).await
361 }
362
363 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
365 let base_w = info_base_weight(request);
366 self.rest_limiter.acquire(base_w).await;
367
368 let mut attempt = 0u32;
369 loop {
370 let response = self.http_roundtrip_info(request).await?;
371
372 if response.status.is_success() {
373 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
375 let extra = info_extra_weight(request, &val);
376 if extra > 0 {
377 self.rest_limiter.debit_extra(extra).await;
378 log::debug!(
379 "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
380 );
381 }
382 return Ok(val);
383 }
384
385 if response.status.as_u16() == 429 {
387 if attempt >= self.rate_limit_max_attempts_info {
388 let ra = self.parse_retry_after_simple(&response.headers);
389 return Err(Error::rate_limit("info", base_w, ra));
390 }
391 let delay = self
392 .parse_retry_after_simple(&response.headers)
393 .map_or_else(
394 || {
395 backoff_full_jitter(
396 attempt,
397 self.rate_limit_backoff_base,
398 self.rate_limit_backoff_cap,
399 )
400 },
401 Duration::from_millis,
402 );
403 log::warn!(
404 "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
405 delay.as_millis()
406 );
407 attempt += 1;
408 tokio::time::sleep(delay).await;
409 self.rest_limiter.acquire(1).await;
411 continue;
412 }
413
414 if (response.status.is_server_error() || response.status.as_u16() == 408)
416 && attempt < self.rate_limit_max_attempts_info
417 {
418 let delay = backoff_full_jitter(
419 attempt,
420 self.rate_limit_backoff_base,
421 self.rate_limit_backoff_cap,
422 );
423 log::warn!(
424 "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
425 response.status.as_u16(),
426 delay.as_millis()
427 );
428 attempt += 1;
429 tokio::time::sleep(delay).await;
430 continue;
431 }
432
433 let error_body = String::from_utf8_lossy(&response.body);
435 return Err(Error::http(
436 response.status.as_u16(),
437 error_body.to_string(),
438 ));
439 }
440 }
441
442 async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
444 let url = &self.base_info;
445 let body = serde_json::to_value(request).map_err(Error::Serde)?;
446 let body_bytes = serde_json::to_string(&body)
447 .map_err(Error::Serde)?
448 .into_bytes();
449
450 self.client
451 .request(
452 Method::POST,
453 url.clone(),
454 None,
455 None,
456 Some(body_bytes),
457 None,
458 None,
459 )
460 .await
461 .map_err(Error::from_http_client)
462 }
463
464 pub async fn post_action(
466 &self,
467 action: &ExchangeAction,
468 ) -> Result<HyperliquidExchangeResponse> {
469 let w = exchange_weight(action);
470 self.rest_limiter.acquire(w).await;
471
472 let signer = self
473 .signer
474 .as_ref()
475 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
476
477 let nonce_manager = self
478 .nonce_manager
479 .as_ref()
480 .ok_or_else(|| Error::auth("nonce manager missing"))?;
481
482 let signer_id = self.signer_id()?;
483 let time_nonce = nonce_manager.next(signer_id)?;
484
485 let action_value = serde_json::to_value(action)
486 .context("serialize exchange action")
487 .map_err(|e| Error::bad_request(e.to_string()))?;
488
489 let action_bytes = rmp_serde::to_vec_named(action)
491 .context("serialize action with MessagePack")
492 .map_err(|e| Error::bad_request(e.to_string()))?;
493
494 let sign_request = SignRequest {
495 action: action_value.clone(),
496 action_bytes: Some(action_bytes),
497 time_nonce,
498 action_type: HyperliquidActionType::L1,
499 is_testnet: self.is_testnet,
500 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
501 };
502
503 let sig = signer.sign(&sign_request)?.signature;
504
505 let nonce_u64 = time_nonce.as_millis() as u64;
506
507 let request = if let Some(vault) = self.vault_address {
508 HyperliquidExchangeRequest::with_vault(
509 action.clone(),
510 nonce_u64,
511 sig,
512 vault.to_string(),
513 )
514 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
515 } else {
516 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
517 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
518 };
519
520 let response = self.http_roundtrip_exchange(&request).await?;
521
522 if response.status.is_success() {
523 let parsed_response: HyperliquidExchangeResponse =
524 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
525
526 match &parsed_response {
528 HyperliquidExchangeResponse::Status {
529 status,
530 response: response_data,
531 } if status == "err" => {
532 let error_msg = response_data
533 .as_str()
534 .map_or_else(|| response_data.to_string(), |s| s.to_string());
535 log::error!("Hyperliquid API returned error: {error_msg}");
536 Err(Error::bad_request(format!("API error: {error_msg}")))
537 }
538 HyperliquidExchangeResponse::Error { error } => {
539 log::error!("Hyperliquid API returned error: {error}");
540 Err(Error::bad_request(format!("API error: {error}")))
541 }
542 _ => Ok(parsed_response),
543 }
544 } else if response.status.as_u16() == 429 {
545 let ra = self.parse_retry_after_simple(&response.headers);
546 Err(Error::rate_limit("exchange", w, ra))
547 } else {
548 let error_body = String::from_utf8_lossy(&response.body);
549 log::error!(
550 "Exchange API error (status {}): {}",
551 response.status.as_u16(),
552 error_body
553 );
554 Err(Error::http(
555 response.status.as_u16(),
556 error_body.to_string(),
557 ))
558 }
559 }
560
561 pub async fn post_action_exec(
566 &self,
567 action: &HyperliquidExecAction,
568 ) -> Result<HyperliquidExchangeResponse> {
569 let w = match action {
570 HyperliquidExecAction::Order { orders, .. } => 1 + (orders.len() as u32 / 40),
571 HyperliquidExecAction::Cancel { cancels } => 1 + (cancels.len() as u32 / 40),
572 HyperliquidExecAction::CancelByCloid { cancels } => 1 + (cancels.len() as u32 / 40),
573 HyperliquidExecAction::BatchModify { modifies } => 1 + (modifies.len() as u32 / 40),
574 _ => 1,
575 };
576 self.rest_limiter.acquire(w).await;
577
578 let signer = self
579 .signer
580 .as_ref()
581 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
582
583 let nonce_manager = self
584 .nonce_manager
585 .as_ref()
586 .ok_or_else(|| Error::auth("nonce manager missing"))?;
587
588 let signer_id = self.signer_id()?;
589 let time_nonce = nonce_manager.next(signer_id)?;
590 let action_value = serde_json::to_value(action)
593 .context("serialize exchange action")
594 .map_err(|e| Error::bad_request(e.to_string()))?;
595
596 let action_bytes = rmp_serde::to_vec_named(action)
598 .context("serialize action with MessagePack")
599 .map_err(|e| Error::bad_request(e.to_string()))?;
600
601 let sig = signer
602 .sign(&SignRequest {
603 action: action_value.clone(),
604 action_bytes: Some(action_bytes),
605 time_nonce,
606 action_type: HyperliquidActionType::L1,
607 is_testnet: self.is_testnet,
608 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
609 })?
610 .signature;
611
612 let request = if let Some(vault) = self.vault_address {
613 HyperliquidExchangeRequest::with_vault(
614 action.clone(),
615 time_nonce.as_millis() as u64,
616 sig,
617 vault.to_string(),
618 )
619 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
620 } else {
621 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
622 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
623 };
624
625 let response = self.http_roundtrip_exchange(&request).await?;
626
627 if response.status.is_success() {
628 let parsed_response: HyperliquidExchangeResponse =
629 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
630
631 match &parsed_response {
633 HyperliquidExchangeResponse::Status {
634 status,
635 response: response_data,
636 } if status == "err" => {
637 let error_msg = response_data
638 .as_str()
639 .map_or_else(|| response_data.to_string(), |s| s.to_string());
640 log::error!("Hyperliquid API returned error: {error_msg}");
641 Err(Error::bad_request(format!("API error: {error_msg}")))
642 }
643 HyperliquidExchangeResponse::Error { error } => {
644 log::error!("Hyperliquid API returned error: {error}");
645 Err(Error::bad_request(format!("API error: {error}")))
646 }
647 _ => Ok(parsed_response),
648 }
649 } else if response.status.as_u16() == 429 {
650 let ra = self.parse_retry_after_simple(&response.headers);
651 Err(Error::rate_limit("exchange", w, ra))
652 } else {
653 let error_body = String::from_utf8_lossy(&response.body);
654 Err(Error::http(
655 response.status.as_u16(),
656 error_body.to_string(),
657 ))
658 }
659 }
660
661 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
664 self.rest_limiter.snapshot().await
665 }
666 async fn http_roundtrip_exchange<T>(
667 &self,
668 request: &HyperliquidExchangeRequest<T>,
669 ) -> Result<HttpResponse>
670 where
671 T: serde::Serialize,
672 {
673 let url = &self.base_exchange;
674 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
675 let body_bytes = body.into_bytes();
676
677 let response = self
678 .client
679 .request(
680 Method::POST,
681 url.clone(),
682 None,
683 None,
684 Some(body_bytes),
685 None,
686 None,
687 )
688 .await
689 .map_err(Error::from_http_client)?;
690
691 Ok(response)
692 }
693}
694
695#[derive(Debug, Clone)]
701#[cfg_attr(
702 feature = "python",
703 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.hyperliquid")
704)]
705pub struct HyperliquidHttpClient {
706 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
707 instruments: Arc<RwLock<AHashMap<Ustr, InstrumentAny>>>,
708 instruments_by_coin: Arc<RwLock<AHashMap<(Ustr, HyperliquidProductType), InstrumentAny>>>,
709 account_id: Option<AccountId>,
710}
711
712impl Default for HyperliquidHttpClient {
713 fn default() -> Self {
714 Self::new(true, None, None).expect("Failed to create default Hyperliquid HTTP client")
715 }
716}
717
718impl HyperliquidHttpClient {
719 pub fn new(
725 is_testnet: bool,
726 timeout_secs: Option<u64>,
727 proxy_url: Option<String>,
728 ) -> std::result::Result<Self, HttpClientError> {
729 let raw_client = HyperliquidRawHttpClient::new(is_testnet, timeout_secs, proxy_url)?;
730 Ok(Self {
731 inner: Arc::new(raw_client),
732 instruments: Arc::new(RwLock::new(AHashMap::new())),
733 instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
734 account_id: None,
735 })
736 }
737
738 pub fn with_credentials(
744 secrets: &Secrets,
745 timeout_secs: Option<u64>,
746 proxy_url: Option<String>,
747 ) -> std::result::Result<Self, HttpClientError> {
748 let raw_client =
749 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
750 Ok(Self {
751 inner: Arc::new(raw_client),
752 instruments: Arc::new(RwLock::new(AHashMap::new())),
753 instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
754 account_id: None,
755 })
756 }
757
758 pub fn from_env() -> Result<Self> {
764 let raw_client = HyperliquidRawHttpClient::from_env()?;
765 Ok(Self {
766 inner: Arc::new(raw_client),
767 instruments: Arc::new(RwLock::new(AHashMap::new())),
768 instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
769 account_id: None,
770 })
771 }
772
773 pub fn from_credentials(
779 private_key: &str,
780 vault_address: Option<&str>,
781 is_testnet: bool,
782 timeout_secs: Option<u64>,
783 proxy_url: Option<String>,
784 ) -> Result<Self> {
785 let raw_client = HyperliquidRawHttpClient::from_credentials(
786 private_key,
787 vault_address,
788 is_testnet,
789 timeout_secs,
790 proxy_url,
791 )?;
792 Ok(Self {
793 inner: Arc::new(raw_client),
794 instruments: Arc::new(RwLock::new(AHashMap::new())),
795 instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
796 account_id: None,
797 })
798 }
799
800 #[must_use]
802 pub fn is_testnet(&self) -> bool {
803 self.inner.is_testnet()
804 }
805
806 pub fn get_user_address(&self) -> Result<String> {
812 self.inner.get_user_address()
813 }
814
815 pub fn cache_instrument(&self, instrument: InstrumentAny) {
824 let full_symbol = instrument.symbol().inner();
825 let coin = instrument.raw_symbol().inner();
826
827 {
828 let mut instruments = self
829 .instruments
830 .write()
831 .expect("Failed to acquire write lock");
832
833 instruments.insert(full_symbol, instrument.clone());
834
835 instruments.insert(coin, instrument.clone());
837 }
838
839 if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
841 let mut instruments_by_coin = self
842 .instruments_by_coin
843 .write()
844 .expect("Failed to acquire write lock");
845 instruments_by_coin.insert((coin, product_type), instrument);
846 } else {
847 log::warn!("Unable to determine product type for symbol: {full_symbol}");
848 }
849 }
850
851 fn get_or_create_instrument(
867 &self,
868 coin: &Ustr,
869 product_type: Option<HyperliquidProductType>,
870 ) -> Option<InstrumentAny> {
871 if let Some(pt) = product_type {
872 let instruments_by_coin = self
873 .instruments_by_coin
874 .read()
875 .expect("Failed to acquire read lock");
876
877 if let Some(instrument) = instruments_by_coin.get(&(*coin, pt)) {
878 return Some(instrument.clone());
879 }
880 }
881
882 if product_type.is_none() {
884 let instruments_by_coin = self
885 .instruments_by_coin
886 .read()
887 .expect("Failed to acquire read lock");
888
889 if let Some(instrument) =
890 instruments_by_coin.get(&(*coin, HyperliquidProductType::Perp))
891 {
892 return Some(instrument.clone());
893 }
894 if let Some(instrument) =
895 instruments_by_coin.get(&(*coin, HyperliquidProductType::Spot))
896 {
897 return Some(instrument.clone());
898 }
899 }
900
901 if coin.as_str().starts_with("vntls:") {
903 log::info!("Creating synthetic instrument for vault token: {coin}");
904
905 let clock = nautilus_core::time::get_atomic_clock_realtime();
906 let ts_event = clock.get_time_ns();
907
908 let symbol_str = format!("{coin}-USDC-SPOT");
910 let symbol = Symbol::new(&symbol_str);
911 let venue = *HYPERLIQUID_VENUE;
912 let instrument_id = InstrumentId::new(symbol, venue);
913
914 let base_currency = Currency::new(
916 coin.as_str(),
917 8, 0, coin.as_str(),
920 CurrencyType::Crypto,
921 );
922
923 let quote_currency = Currency::new(
924 "USDC",
925 6, 0,
927 "USDC",
928 CurrencyType::Crypto,
929 );
930
931 let price_increment = Price::from("0.00000001");
932 let size_increment = Quantity::from("0.00000001");
933
934 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
935 instrument_id,
936 symbol,
937 base_currency,
938 quote_currency,
939 8, 8, price_increment,
942 size_increment,
943 None, None, None, None, None, None, None, None, None, None, None, None, ts_event,
956 ts_event,
957 ));
958
959 self.cache_instrument(instrument.clone());
960
961 Some(instrument)
962 } else {
963 log::warn!("Instrument not found in cache: {coin}");
965 None
966 }
967 }
968
969 pub fn set_account_id(&mut self, account_id: AccountId) {
973 self.account_id = Some(account_id);
974 }
975
976 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
978 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
979
980 match self.inner.load_perp_meta().await {
981 Ok(perp_meta) => match parse_perp_instruments(&perp_meta) {
982 Ok(perp_defs) => {
983 log::debug!(
984 "Loaded Hyperliquid perp definitions: count={}",
985 perp_defs.len(),
986 );
987 defs.extend(perp_defs);
988 }
989 Err(e) => {
990 log::warn!("Failed to parse Hyperliquid perp instruments: {e}");
991 }
992 },
993 Err(e) => {
994 log::warn!("Failed to load Hyperliquid perp metadata: {e}");
995 }
996 }
997
998 match self.inner.get_spot_meta().await {
999 Ok(spot_meta) => match parse_spot_instruments(&spot_meta) {
1000 Ok(spot_defs) => {
1001 log::debug!(
1002 "Loaded Hyperliquid spot definitions: count={}",
1003 spot_defs.len(),
1004 );
1005 defs.extend(spot_defs);
1006 }
1007 Err(e) => {
1008 log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1009 }
1010 },
1011 Err(e) => {
1012 log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1013 }
1014 }
1015
1016 Ok(instruments_from_defs_owned(defs))
1017 }
1018
1019 #[allow(dead_code)]
1021 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1022 self.inner.load_perp_meta().await
1023 }
1024
1025 #[allow(dead_code)]
1027 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1028 self.inner.get_spot_meta().await
1029 }
1030
1031 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1033 self.inner.info_l2_book(coin).await
1034 }
1035
1036 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1038 self.inner.info_user_fills(user).await
1039 }
1040
1041 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1043 self.inner.info_order_status(user, oid).await
1044 }
1045
1046 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1048 self.inner.info_open_orders(user).await
1049 }
1050
1051 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1053 self.inner.info_frontend_open_orders(user).await
1054 }
1055
1056 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1058 self.inner.info_clearinghouse_state(user).await
1059 }
1060
1061 pub async fn info_candle_snapshot(
1063 &self,
1064 coin: &str,
1065 interval: HyperliquidBarInterval,
1066 start_time: u64,
1067 end_time: u64,
1068 ) -> Result<HyperliquidCandleSnapshot> {
1069 self.inner
1070 .info_candle_snapshot(coin, interval, start_time, end_time)
1071 .await
1072 }
1073
1074 pub async fn post_action(
1076 &self,
1077 action: &ExchangeAction,
1078 ) -> Result<HyperliquidExchangeResponse> {
1079 self.inner.post_action(action).await
1080 }
1081
1082 pub async fn post_action_exec(
1084 &self,
1085 action: &HyperliquidExecAction,
1086 ) -> Result<HyperliquidExchangeResponse> {
1087 self.inner.post_action_exec(action).await
1088 }
1089
1090 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1092 self.inner.info_meta().await
1093 }
1094
1095 pub async fn cancel_order(
1105 &self,
1106 instrument_id: InstrumentId,
1107 client_order_id: Option<ClientOrderId>,
1108 venue_order_id: Option<VenueOrderId>,
1109 ) -> Result<()> {
1110 let symbol = instrument_id.symbol.as_str();
1112 let asset_id = extract_asset_id_from_symbol(symbol)
1113 .map_err(|e| Error::bad_request(format!("Failed to extract asset ID: {e}")))?;
1114
1115 let action = if let Some(cloid) = client_order_id {
1117 let cloid_hex = Cloid::from_hex(cloid)
1118 .map_err(|e| Error::bad_request(format!("Invalid client order ID format: {e}")))?;
1119 let cancel_req = HyperliquidExecCancelByCloidRequest {
1120 asset: asset_id,
1121 cloid: cloid_hex,
1122 };
1123 HyperliquidExecAction::CancelByCloid {
1124 cancels: vec![cancel_req],
1125 }
1126 } else if let Some(oid) = venue_order_id {
1127 let oid_u64 = oid
1128 .as_str()
1129 .parse::<u64>()
1130 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1131 let cancel_req = HyperliquidExecCancelOrderRequest {
1132 asset: asset_id,
1133 oid: oid_u64,
1134 };
1135 HyperliquidExecAction::Cancel {
1136 cancels: vec![cancel_req],
1137 }
1138 } else {
1139 return Err(Error::bad_request(
1140 "Either client_order_id or venue_order_id must be provided",
1141 ));
1142 };
1143
1144 let response = self.inner.post_action_exec(&action).await?;
1146
1147 match response {
1149 HyperliquidExchangeResponse::Status { status, .. } if status == "ok" => Ok(()),
1150 HyperliquidExchangeResponse::Status {
1151 status,
1152 response: error_data,
1153 } => Err(Error::bad_request(format!(
1154 "Cancel order failed: status={status}, error={error_data}"
1155 ))),
1156 HyperliquidExchangeResponse::Error { error } => {
1157 Err(Error::bad_request(format!("Cancel order error: {error}")))
1158 }
1159 }
1160 }
1161
1162 pub async fn request_order_status_reports(
1178 &self,
1179 user: &str,
1180 instrument_id: Option<InstrumentId>,
1181 ) -> Result<Vec<OrderStatusReport>> {
1182 let response = self.info_frontend_open_orders(user).await?;
1183
1184 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1186 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1187
1188 let mut reports = Vec::new();
1189 let ts_init = UnixNanos::default();
1190
1191 for order_value in orders {
1192 let order: crate::websocket::messages::WsBasicOrderData =
1194 match serde_json::from_value(order_value.clone()) {
1195 Ok(o) => o,
1196 Err(e) => {
1197 log::warn!("Failed to parse order: {e}");
1198 continue;
1199 }
1200 };
1201
1202 let instrument = match self.get_or_create_instrument(&order.coin, None) {
1204 Some(inst) => inst,
1205 None => continue, };
1207
1208 if let Some(filter_id) = instrument_id
1210 && instrument.id() != filter_id
1211 {
1212 continue;
1213 }
1214
1215 let status = HyperliquidOrderStatusEnum::Open;
1217
1218 match crate::http::parse::parse_order_status_report_from_basic(
1220 &order,
1221 &status,
1222 &instrument,
1223 self.account_id.expect("account_id not set"),
1224 ts_init,
1225 ) {
1226 Ok(report) => reports.push(report),
1227 Err(e) => log::error!("Failed to parse order status report: {e}"),
1228 }
1229 }
1230
1231 Ok(reports)
1232 }
1233
1234 pub async fn request_fill_reports(
1250 &self,
1251 user: &str,
1252 instrument_id: Option<InstrumentId>,
1253 ) -> Result<Vec<FillReport>> {
1254 let fills_response = self.info_user_fills(user).await?;
1255
1256 let mut reports = Vec::new();
1257 let ts_init = UnixNanos::default();
1258
1259 for fill in fills_response {
1260 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
1262 Some(inst) => inst,
1263 None => continue, };
1265
1266 if let Some(filter_id) = instrument_id
1268 && instrument.id() != filter_id
1269 {
1270 continue;
1271 }
1272
1273 match crate::http::parse::parse_fill_report(
1275 &fill,
1276 &instrument,
1277 self.account_id.expect("account_id not set"),
1278 ts_init,
1279 ) {
1280 Ok(report) => reports.push(report),
1281 Err(e) => log::error!("Failed to parse fill report: {e}"),
1282 }
1283 }
1284
1285 Ok(reports)
1286 }
1287
1288 pub async fn request_position_status_reports(
1304 &self,
1305 user: &str,
1306 instrument_id: Option<InstrumentId>,
1307 ) -> Result<Vec<PositionStatusReport>> {
1308 let state_response = self.info_clearinghouse_state(user).await?;
1309
1310 let asset_positions: Vec<serde_json::Value> = state_response
1312 .get("assetPositions")
1313 .and_then(|v| v.as_array())
1314 .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
1315 .clone();
1316
1317 let mut reports = Vec::new();
1318 let ts_init = UnixNanos::default();
1319
1320 for position_value in asset_positions {
1321 let coin = position_value
1323 .get("position")
1324 .and_then(|p| p.get("coin"))
1325 .and_then(|c| c.as_str())
1326 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
1327
1328 let coin_ustr = Ustr::from(coin);
1330 let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
1331 Some(inst) => inst,
1332 None => continue, };
1334
1335 if let Some(filter_id) = instrument_id
1337 && instrument.id() != filter_id
1338 {
1339 continue;
1340 }
1341
1342 match crate::http::parse::parse_position_status_report(
1344 &position_value,
1345 &instrument,
1346 self.account_id.expect("account_id not set"),
1347 ts_init,
1348 ) {
1349 Ok(report) => reports.push(report),
1350 Err(e) => log::error!("Failed to parse position status report: {e}"),
1351 }
1352 }
1353
1354 Ok(reports)
1355 }
1356
1357 pub async fn request_bars(
1374 &self,
1375 bar_type: BarType,
1376 start: Option<chrono::DateTime<chrono::Utc>>,
1377 end: Option<chrono::DateTime<chrono::Utc>>,
1378 limit: Option<u32>,
1379 ) -> Result<Vec<Bar>> {
1380 let instrument_id = bar_type.instrument_id();
1381 let symbol = instrument_id.symbol;
1382
1383 let coin = Ustr::from(
1384 symbol
1385 .as_str()
1386 .split('-')
1387 .next()
1388 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?,
1389 );
1390
1391 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
1392 let instrument = self
1393 .get_or_create_instrument(&coin, product_type)
1394 .ok_or_else(|| {
1395 Error::bad_request(format!("Instrument not found in cache: {instrument_id}"))
1396 })?;
1397
1398 let price_precision = instrument.price_precision();
1399 let size_precision = instrument.size_precision();
1400
1401 let interval =
1402 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
1403
1404 let now = chrono::Utc::now();
1406 let end_time = end.unwrap_or(now).timestamp_millis() as u64;
1407 let start_time = if let Some(start) = start {
1408 start.timestamp_millis() as u64
1409 } else {
1410 let spec = bar_type.spec();
1412 let step_ms = match spec.aggregation {
1413 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
1414 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
1415 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
1416 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
1417 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
1418 _ => 60_000,
1419 };
1420 end_time.saturating_sub(1000 * step_ms)
1421 };
1422
1423 let candles = self
1424 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
1425 .await?;
1426
1427 let now_ms = now.timestamp_millis() as u64;
1429
1430 let mut bars: Vec<Bar> = candles
1431 .iter()
1432 .filter(|candle| candle.end_timestamp < now_ms)
1433 .enumerate()
1434 .filter_map(|(i, candle)| {
1435 crate::data::candle_to_bar(candle, bar_type, price_precision, size_precision)
1436 .map_err(|e| {
1437 log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
1438 e
1439 })
1440 .ok()
1441 })
1442 .collect();
1443
1444 if let Some(limit) = limit
1446 && limit > 0
1447 && bars.len() > limit as usize
1448 {
1449 bars.truncate(limit as usize);
1450 }
1451
1452 log::debug!(
1453 "Received {} bars for {} (filtered {} incomplete)",
1454 bars.len(),
1455 bar_type,
1456 candles.len() - bars.len()
1457 );
1458 Ok(bars)
1459 }
1460 #[allow(clippy::too_many_arguments)]
1468 pub async fn submit_order(
1469 &self,
1470 instrument_id: InstrumentId,
1471 client_order_id: ClientOrderId,
1472 order_side: OrderSide,
1473 order_type: OrderType,
1474 quantity: Quantity,
1475 time_in_force: TimeInForce,
1476 price: Option<Price>,
1477 trigger_price: Option<Price>,
1478 post_only: bool,
1479 reduce_only: bool,
1480 ) -> Result<OrderStatusReport> {
1481 let symbol = instrument_id.symbol.as_str();
1482 let asset = extract_asset_id_from_symbol(symbol)
1483 .map_err(|e| Error::bad_request(format!("Failed to extract asset ID: {e}")))?;
1484
1485 let is_buy = matches!(order_side, OrderSide::Buy);
1486
1487 let price_decimal = match price {
1489 Some(px) => px.as_decimal(),
1490 None => {
1491 if matches!(
1492 order_type,
1493 OrderType::Market | OrderType::StopMarket | OrderType::MarketIfTouched
1494 ) {
1495 Decimal::ZERO
1496 } else {
1497 return Err(Error::bad_request("Limit orders require a price"));
1498 }
1499 }
1500 };
1501
1502 let size_decimal = quantity.as_decimal();
1504
1505 let kind = match order_type {
1507 OrderType::Market => HyperliquidExecOrderKind::Limit {
1508 limit: HyperliquidExecLimitParams {
1509 tif: HyperliquidExecTif::Ioc,
1510 },
1511 },
1512 OrderType::Limit => {
1513 let tif = if post_only {
1514 HyperliquidExecTif::Alo
1515 } else {
1516 match time_in_force {
1517 TimeInForce::Gtc => HyperliquidExecTif::Gtc,
1518 TimeInForce::Ioc => HyperliquidExecTif::Ioc,
1519 TimeInForce::Fok => HyperliquidExecTif::Ioc, TimeInForce::Day
1521 | TimeInForce::Gtd
1522 | TimeInForce::AtTheOpen
1523 | TimeInForce::AtTheClose => {
1524 return Err(Error::bad_request(format!(
1525 "Time in force {time_in_force:?} not supported"
1526 )));
1527 }
1528 }
1529 };
1530 HyperliquidExecOrderKind::Limit {
1531 limit: HyperliquidExecLimitParams { tif },
1532 }
1533 }
1534 OrderType::StopMarket
1535 | OrderType::StopLimit
1536 | OrderType::MarketIfTouched
1537 | OrderType::LimitIfTouched => {
1538 if let Some(trig_px) = trigger_price {
1539 let trigger_price_decimal = trig_px.as_decimal();
1540
1541 let tpsl = match order_type {
1545 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1546 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
1547 HyperliquidExecTpSl::Tp
1548 }
1549 _ => unreachable!(),
1550 };
1551
1552 let is_market = matches!(
1553 order_type,
1554 OrderType::StopMarket | OrderType::MarketIfTouched
1555 );
1556
1557 HyperliquidExecOrderKind::Trigger {
1558 trigger: HyperliquidExecTriggerParams {
1559 is_market,
1560 trigger_px: trigger_price_decimal,
1561 tpsl,
1562 },
1563 }
1564 } else {
1565 return Err(Error::bad_request("Trigger orders require a trigger price"));
1566 }
1567 }
1568 _ => {
1569 return Err(Error::bad_request(format!(
1570 "Order type {order_type:?} not supported"
1571 )));
1572 }
1573 };
1574
1575 let hyperliquid_order =
1577 HyperliquidExecPlaceOrderRequest {
1578 asset,
1579 is_buy,
1580 price: price_decimal,
1581 size: size_decimal,
1582 reduce_only,
1583 kind,
1584 cloid: Some(Cloid::from_hex(client_order_id).map_err(|e| {
1585 Error::bad_request(format!("Invalid client order ID format: {e}"))
1586 })?),
1587 };
1588
1589 let action = HyperliquidExecAction::Order {
1591 orders: vec![hyperliquid_order],
1592 grouping: HyperliquidExecGrouping::Na,
1593 builder: None,
1594 };
1595
1596 let response = self.inner.post_action_exec(&action).await?;
1598
1599 match response {
1601 HyperliquidExchangeResponse::Status {
1602 status,
1603 response: response_data,
1604 } if status == "ok" => {
1605 let data_value = if let Some(data) = response_data.get("data") {
1606 data.clone()
1607 } else {
1608 response_data
1609 };
1610
1611 let order_response: HyperliquidExecOrderResponseData =
1612 serde_json::from_value(data_value).map_err(|e| {
1613 Error::bad_request(format!("Failed to parse order response: {e}"))
1614 })?;
1615
1616 let order_status = order_response
1617 .statuses
1618 .first()
1619 .ok_or_else(|| Error::bad_request("No order status in response"))?;
1620
1621 let symbol_str = instrument_id.symbol.as_str();
1622 let asset_str = symbol_str
1623 .trim_end_matches("-PERP")
1624 .trim_end_matches("-USD");
1625
1626 let product_type = HyperliquidProductType::from_symbol(symbol_str).ok();
1627 let instrument = self
1628 .get_or_create_instrument(&Ustr::from(asset_str), product_type)
1629 .ok_or_else(|| {
1630 Error::bad_request(format!("Instrument not found for {asset_str}"))
1631 })?;
1632
1633 let account_id = self
1634 .account_id
1635 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1636 let ts_init = UnixNanos::default();
1637
1638 match order_status {
1639 HyperliquidExecOrderStatus::Resting { resting } => self
1640 .create_order_status_report(
1641 instrument_id,
1642 Some(client_order_id),
1643 VenueOrderId::new(resting.oid.to_string()),
1644 order_side,
1645 order_type,
1646 quantity,
1647 time_in_force,
1648 price,
1649 trigger_price,
1650 OrderStatus::Accepted,
1651 Quantity::new(0.0, instrument.size_precision()),
1652 &instrument,
1653 account_id,
1654 ts_init,
1655 ),
1656 HyperliquidExecOrderStatus::Filled { filled } => {
1657 let filled_qty = Quantity::new(
1658 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
1659 instrument.size_precision(),
1660 );
1661 self.create_order_status_report(
1662 instrument_id,
1663 Some(client_order_id),
1664 VenueOrderId::new(filled.oid.to_string()),
1665 order_side,
1666 order_type,
1667 quantity,
1668 time_in_force,
1669 price,
1670 trigger_price,
1671 OrderStatus::Filled,
1672 filled_qty,
1673 &instrument,
1674 account_id,
1675 ts_init,
1676 )
1677 }
1678 HyperliquidExecOrderStatus::Error { error } => {
1679 Err(Error::bad_request(format!("Order rejected: {error}")))
1680 }
1681 }
1682 }
1683 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
1684 "Order submission failed: {error}"
1685 ))),
1686 _ => Err(Error::bad_request("Unexpected response format")),
1687 }
1688 }
1689
1690 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
1694 self.submit_order(
1695 order.instrument_id(),
1696 order.client_order_id(),
1697 order.order_side(),
1698 order.order_type(),
1699 order.quantity(),
1700 order.time_in_force(),
1701 order.price(),
1702 order.trigger_price(),
1703 order.is_post_only(),
1704 order.is_reduce_only(),
1705 )
1706 .await
1707 }
1708
1709 #[allow(clippy::too_many_arguments)]
1711 fn create_order_status_report(
1712 &self,
1713 instrument_id: InstrumentId,
1714 client_order_id: Option<ClientOrderId>,
1715 venue_order_id: VenueOrderId,
1716 order_side: OrderSide,
1717 order_type: OrderType,
1718 quantity: Quantity,
1719 time_in_force: TimeInForce,
1720 price: Option<Price>,
1721 trigger_price: Option<Price>,
1722 order_status: OrderStatus,
1723 filled_qty: Quantity,
1724 _instrument: &InstrumentAny,
1725 account_id: AccountId,
1726 ts_init: UnixNanos,
1727 ) -> Result<OrderStatusReport> {
1728 let clock = get_atomic_clock_realtime();
1729 let ts_accepted = clock.get_time_ns();
1730 let ts_last = ts_accepted;
1731 let report_id = UUID4::new();
1732
1733 let mut report = OrderStatusReport::new(
1734 account_id,
1735 instrument_id,
1736 client_order_id,
1737 venue_order_id,
1738 order_side,
1739 order_type,
1740 time_in_force,
1741 order_status,
1742 quantity,
1743 filled_qty,
1744 ts_accepted,
1745 ts_last,
1746 ts_init,
1747 Some(report_id),
1748 );
1749
1750 if let Some(px) = price {
1752 report = report.with_price(px);
1753 }
1754
1755 if let Some(trig_px) = trigger_price {
1757 report = report
1758 .with_trigger_price(trig_px)
1759 .with_trigger_type(TriggerType::Default);
1760 }
1761
1762 Ok(report)
1763 }
1764
1765 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
1775 let hyperliquid_orders = orders_to_hyperliquid_requests(orders)
1777 .map_err(|e| Error::bad_request(format!("Failed to convert orders: {e}")))?;
1778
1779 let action = HyperliquidExecAction::Order {
1781 orders: hyperliquid_orders,
1782 grouping: HyperliquidExecGrouping::Na,
1783 builder: None,
1784 };
1785
1786 let response = self.inner.post_action_exec(&action).await?;
1788
1789 match response {
1791 HyperliquidExchangeResponse::Status {
1792 status,
1793 response: response_data,
1794 } if status == "ok" => {
1795 let data_value = if let Some(data) = response_data.get("data") {
1798 data.clone()
1799 } else {
1800 response_data
1801 };
1802
1803 let order_response: HyperliquidExecOrderResponseData =
1805 serde_json::from_value(data_value).map_err(|e| {
1806 Error::bad_request(format!("Failed to parse order response: {e}"))
1807 })?;
1808
1809 let account_id = self
1810 .account_id
1811 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1812 let ts_init = UnixNanos::default();
1813
1814 if order_response.statuses.len() != orders.len() {
1816 return Err(Error::bad_request(format!(
1817 "Mismatch between submitted orders ({}) and response statuses ({})",
1818 orders.len(),
1819 order_response.statuses.len()
1820 )));
1821 }
1822
1823 let mut reports = Vec::new();
1824
1825 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
1827 let instrument_id = order.instrument_id();
1829 let symbol = instrument_id.symbol.as_str();
1830 let asset = symbol.trim_end_matches("-PERP").trim_end_matches("-USD");
1831
1832 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
1833 let instrument = self
1834 .get_or_create_instrument(&Ustr::from(asset), product_type)
1835 .ok_or_else(|| {
1836 Error::bad_request(format!("Instrument not found for {asset}"))
1837 })?;
1838
1839 let report = match order_status {
1841 HyperliquidExecOrderStatus::Resting { resting } => {
1842 self.create_order_status_report(
1844 order.instrument_id(),
1845 Some(order.client_order_id()),
1846 VenueOrderId::new(resting.oid.to_string()),
1847 order.order_side(),
1848 order.order_type(),
1849 order.quantity(),
1850 order.time_in_force(),
1851 order.price(),
1852 order.trigger_price(),
1853 OrderStatus::Accepted,
1854 Quantity::new(0.0, instrument.size_precision()),
1855 &instrument,
1856 account_id,
1857 ts_init,
1858 )?
1859 }
1860 HyperliquidExecOrderStatus::Filled { filled } => {
1861 let filled_qty = Quantity::new(
1863 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
1864 instrument.size_precision(),
1865 );
1866 self.create_order_status_report(
1867 order.instrument_id(),
1868 Some(order.client_order_id()),
1869 VenueOrderId::new(filled.oid.to_string()),
1870 order.order_side(),
1871 order.order_type(),
1872 order.quantity(),
1873 order.time_in_force(),
1874 order.price(),
1875 order.trigger_price(),
1876 OrderStatus::Filled,
1877 filled_qty,
1878 &instrument,
1879 account_id,
1880 ts_init,
1881 )?
1882 }
1883 HyperliquidExecOrderStatus::Error { error } => {
1884 return Err(Error::bad_request(format!(
1885 "Order {} rejected: {error}",
1886 order.client_order_id()
1887 )));
1888 }
1889 };
1890
1891 reports.push(report);
1892 }
1893
1894 Ok(reports)
1895 }
1896 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
1897 "Order submission failed: {error}"
1898 ))),
1899 _ => Err(Error::bad_request("Unexpected response format")),
1900 }
1901 }
1902}
1903
1904#[cfg(test)]
1905mod tests {
1906 use nautilus_core::MUTEX_POISONED;
1907 use nautilus_model::instruments::{Instrument, InstrumentAny};
1908 use rstest::rstest;
1909 use ustr::Ustr;
1910
1911 use super::HyperliquidHttpClient;
1912 use crate::{common::enums::HyperliquidProductType, http::query::InfoRequest};
1913
1914 #[rstest]
1915 fn stable_json_roundtrips() {
1916 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
1917 let s = serde_json::to_string(&v).unwrap();
1918 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1920 assert_eq!(parsed["type"], "l2Book");
1921 assert_eq!(parsed["coin"], "BTC");
1922 assert_eq!(parsed, v);
1923 }
1924
1925 #[rstest]
1926 fn info_pretty_shape() {
1927 let r = InfoRequest::l2_book("BTC");
1928 let val = serde_json::to_value(&r).unwrap();
1929 let pretty = serde_json::to_string_pretty(&val).unwrap();
1930 assert!(pretty.contains("\"type\": \"l2Book\""));
1931 assert!(pretty.contains("\"coin\": \"BTC\""));
1932 }
1933
1934 #[rstest]
1935 fn test_cache_instrument_by_raw_symbol() {
1936 use nautilus_core::time::get_atomic_clock_realtime;
1937 use nautilus_model::{
1938 currencies::CURRENCY_MAP,
1939 enums::CurrencyType,
1940 identifiers::{InstrumentId, Symbol},
1941 instruments::CurrencyPair,
1942 types::{Currency, Price, Quantity},
1943 };
1944
1945 let client = HyperliquidHttpClient::new(true, None, None).unwrap();
1946
1947 let base_code = "vntls:vCURSOR";
1949 let quote_code = "USDC";
1950
1951 {
1953 let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
1954 if !currency_map.contains_key(base_code) {
1955 currency_map.insert(
1956 base_code.to_string(),
1957 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
1958 );
1959 }
1960 }
1961
1962 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
1963 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
1964
1965 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
1967 let venue = *crate::common::consts::HYPERLIQUID_VENUE;
1968 let instrument_id = InstrumentId::new(symbol, venue);
1969
1970 let raw_symbol = Symbol::new(base_code);
1972
1973 let clock = get_atomic_clock_realtime();
1974 let ts = clock.get_time_ns();
1975
1976 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1977 instrument_id,
1978 raw_symbol,
1979 base_currency,
1980 quote_currency,
1981 8,
1982 8,
1983 Price::from("0.00000001"),
1984 Quantity::from("0.00000001"),
1985 None,
1986 None,
1987 None,
1988 None,
1989 None,
1990 None,
1991 None,
1992 None,
1993 None,
1994 None,
1995 None,
1996 None,
1997 ts,
1998 ts,
1999 ));
2000
2001 client.cache_instrument(instrument.clone());
2003
2004 let instruments = client.instruments.read().unwrap();
2006 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
2007 assert!(
2008 by_full_symbol.is_some(),
2009 "Instrument should be accessible by full symbol"
2010 );
2011 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
2012
2013 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
2015 assert!(
2016 by_raw_symbol.is_some(),
2017 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
2018 );
2019 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
2020 drop(instruments);
2021
2022 let instruments_by_coin = client.instruments_by_coin.read().unwrap();
2024 let by_coin =
2025 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
2026 assert!(
2027 by_coin.is_some(),
2028 "Instrument should be accessible by coin and product type"
2029 );
2030 assert_eq!(by_coin.unwrap().id(), instrument.id());
2031 drop(instruments_by_coin);
2032
2033 let retrieved_with_type = client.get_or_create_instrument(
2035 &Ustr::from("vntls:vCURSOR"),
2036 Some(HyperliquidProductType::Spot),
2037 );
2038 assert!(retrieved_with_type.is_some());
2039 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
2040
2041 let retrieved_without_type =
2043 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
2044 assert!(retrieved_without_type.is_some());
2045 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
2046 }
2047}