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 tracing::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 tracing::debug!(endpoint=?request, base_w, extra, "info: debited extra weight");
379 }
380 return Ok(val);
381 }
382
383 if response.status.as_u16() == 429 {
385 if attempt >= self.rate_limit_max_attempts_info {
386 let ra = self.parse_retry_after_simple(&response.headers);
387 return Err(Error::rate_limit("info", base_w, ra));
388 }
389 let delay = self
390 .parse_retry_after_simple(&response.headers)
391 .map_or_else(
392 || {
393 backoff_full_jitter(
394 attempt,
395 self.rate_limit_backoff_base,
396 self.rate_limit_backoff_cap,
397 )
398 },
399 Duration::from_millis,
400 );
401 tracing::warn!(endpoint=?request, attempt, wait_ms=?delay.as_millis(), "429 Too Many Requests; backing off");
402 attempt += 1;
403 tokio::time::sleep(delay).await;
404 self.rest_limiter.acquire(1).await;
406 continue;
407 }
408
409 if (response.status.is_server_error() || response.status.as_u16() == 408)
411 && attempt < self.rate_limit_max_attempts_info
412 {
413 let delay = backoff_full_jitter(
414 attempt,
415 self.rate_limit_backoff_base,
416 self.rate_limit_backoff_cap,
417 );
418 tracing::warn!(endpoint=?request, attempt, status=?response.status.as_u16(), wait_ms=?delay.as_millis(), "transient error; retrying");
419 attempt += 1;
420 tokio::time::sleep(delay).await;
421 continue;
422 }
423
424 let error_body = String::from_utf8_lossy(&response.body);
426 return Err(Error::http(
427 response.status.as_u16(),
428 error_body.to_string(),
429 ));
430 }
431 }
432
433 async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
435 let url = &self.base_info;
436 let body = serde_json::to_value(request).map_err(Error::Serde)?;
437 let body_bytes = serde_json::to_string(&body)
438 .map_err(Error::Serde)?
439 .into_bytes();
440
441 self.client
442 .request(
443 Method::POST,
444 url.clone(),
445 None,
446 None,
447 Some(body_bytes),
448 None,
449 None,
450 )
451 .await
452 .map_err(Error::from_http_client)
453 }
454
455 pub async fn post_action(
457 &self,
458 action: &ExchangeAction,
459 ) -> Result<HyperliquidExchangeResponse> {
460 let w = exchange_weight(action);
461 self.rest_limiter.acquire(w).await;
462
463 let signer = self
464 .signer
465 .as_ref()
466 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
467
468 let nonce_manager = self
469 .nonce_manager
470 .as_ref()
471 .ok_or_else(|| Error::auth("nonce manager missing"))?;
472
473 let signer_id = self.signer_id()?;
474 let time_nonce = nonce_manager.next(signer_id)?;
475
476 let action_value = serde_json::to_value(action)
477 .context("serialize exchange action")
478 .map_err(|e| Error::bad_request(e.to_string()))?;
479
480 let action_bytes = rmp_serde::to_vec_named(action)
482 .context("serialize action with MessagePack")
483 .map_err(|e| Error::bad_request(e.to_string()))?;
484
485 let sign_request = SignRequest {
486 action: action_value.clone(),
487 action_bytes: Some(action_bytes),
488 time_nonce,
489 action_type: HyperliquidActionType::L1,
490 is_testnet: self.is_testnet,
491 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
492 };
493
494 let sig = signer.sign(&sign_request)?.signature;
495
496 let nonce_u64 = time_nonce.as_millis() as u64;
497
498 let request = if let Some(vault) = self.vault_address {
499 HyperliquidExchangeRequest::with_vault(
500 action.clone(),
501 nonce_u64,
502 sig,
503 vault.to_string(),
504 )
505 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
506 } else {
507 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
508 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
509 };
510
511 let response = self.http_roundtrip_exchange(&request).await?;
512
513 if response.status.is_success() {
514 let parsed_response: HyperliquidExchangeResponse =
515 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
516
517 match &parsed_response {
519 HyperliquidExchangeResponse::Status {
520 status,
521 response: response_data,
522 } if status == "err" => {
523 let error_msg = response_data
524 .as_str()
525 .map_or_else(|| response_data.to_string(), |s| s.to_string());
526 tracing::error!("Hyperliquid API returned error: {error_msg}");
527 Err(Error::bad_request(format!("API error: {error_msg}")))
528 }
529 HyperliquidExchangeResponse::Error { error } => {
530 tracing::error!("Hyperliquid API returned error: {error}");
531 Err(Error::bad_request(format!("API error: {error}")))
532 }
533 _ => Ok(parsed_response),
534 }
535 } else if response.status.as_u16() == 429 {
536 let ra = self.parse_retry_after_simple(&response.headers);
537 Err(Error::rate_limit("exchange", w, ra))
538 } else {
539 let error_body = String::from_utf8_lossy(&response.body);
540 tracing::error!(
541 "Exchange API error (status {}): {}",
542 response.status.as_u16(),
543 error_body
544 );
545 Err(Error::http(
546 response.status.as_u16(),
547 error_body.to_string(),
548 ))
549 }
550 }
551
552 pub async fn post_action_exec(
557 &self,
558 action: &HyperliquidExecAction,
559 ) -> Result<HyperliquidExchangeResponse> {
560 let w = match action {
561 HyperliquidExecAction::Order { orders, .. } => 1 + (orders.len() as u32 / 40),
562 HyperliquidExecAction::Cancel { cancels } => 1 + (cancels.len() as u32 / 40),
563 HyperliquidExecAction::CancelByCloid { cancels } => 1 + (cancels.len() as u32 / 40),
564 HyperliquidExecAction::BatchModify { modifies } => 1 + (modifies.len() as u32 / 40),
565 _ => 1,
566 };
567 self.rest_limiter.acquire(w).await;
568
569 let signer = self
570 .signer
571 .as_ref()
572 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
573
574 let nonce_manager = self
575 .nonce_manager
576 .as_ref()
577 .ok_or_else(|| Error::auth("nonce manager missing"))?;
578
579 let signer_id = self.signer_id()?;
580 let time_nonce = nonce_manager.next(signer_id)?;
581 let action_value = serde_json::to_value(action)
584 .context("serialize exchange action")
585 .map_err(|e| Error::bad_request(e.to_string()))?;
586
587 let action_bytes = rmp_serde::to_vec_named(action)
589 .context("serialize action with MessagePack")
590 .map_err(|e| Error::bad_request(e.to_string()))?;
591
592 let sig = signer
593 .sign(&SignRequest {
594 action: action_value.clone(),
595 action_bytes: Some(action_bytes),
596 time_nonce,
597 action_type: HyperliquidActionType::L1,
598 is_testnet: self.is_testnet,
599 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
600 })?
601 .signature;
602
603 let request = if let Some(vault) = self.vault_address {
604 HyperliquidExchangeRequest::with_vault(
605 action.clone(),
606 time_nonce.as_millis() as u64,
607 sig,
608 vault.to_string(),
609 )
610 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
611 } else {
612 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
613 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
614 };
615
616 let response = self.http_roundtrip_exchange(&request).await?;
617
618 if response.status.is_success() {
619 let parsed_response: HyperliquidExchangeResponse =
620 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
621
622 match &parsed_response {
624 HyperliquidExchangeResponse::Status {
625 status,
626 response: response_data,
627 } if status == "err" => {
628 let error_msg = response_data
629 .as_str()
630 .map_or_else(|| response_data.to_string(), |s| s.to_string());
631 tracing::error!("Hyperliquid API returned error: {error_msg}");
632 Err(Error::bad_request(format!("API error: {error_msg}")))
633 }
634 HyperliquidExchangeResponse::Error { error } => {
635 tracing::error!("Hyperliquid API returned error: {error}");
636 Err(Error::bad_request(format!("API error: {error}")))
637 }
638 _ => Ok(parsed_response),
639 }
640 } else if response.status.as_u16() == 429 {
641 let ra = self.parse_retry_after_simple(&response.headers);
642 Err(Error::rate_limit("exchange", w, ra))
643 } else {
644 let error_body = String::from_utf8_lossy(&response.body);
645 Err(Error::http(
646 response.status.as_u16(),
647 error_body.to_string(),
648 ))
649 }
650 }
651
652 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
655 self.rest_limiter.snapshot().await
656 }
657 async fn http_roundtrip_exchange<T>(
658 &self,
659 request: &HyperliquidExchangeRequest<T>,
660 ) -> Result<HttpResponse>
661 where
662 T: serde::Serialize,
663 {
664 let url = &self.base_exchange;
665 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
666 let body_bytes = body.into_bytes();
667
668 let response = self
669 .client
670 .request(
671 Method::POST,
672 url.clone(),
673 None,
674 None,
675 Some(body_bytes),
676 None,
677 None,
678 )
679 .await
680 .map_err(Error::from_http_client)?;
681
682 Ok(response)
683 }
684}
685
686#[derive(Debug, Clone)]
692#[cfg_attr(
693 feature = "python",
694 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.hyperliquid")
695)]
696pub struct HyperliquidHttpClient {
697 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
698 instruments: Arc<RwLock<AHashMap<Ustr, InstrumentAny>>>,
699 instruments_by_coin: Arc<RwLock<AHashMap<(Ustr, HyperliquidProductType), InstrumentAny>>>,
700 account_id: Option<AccountId>,
701}
702
703impl Default for HyperliquidHttpClient {
704 fn default() -> Self {
705 Self::new(true, None, None).expect("Failed to create default Hyperliquid HTTP client")
706 }
707}
708
709impl HyperliquidHttpClient {
710 pub fn new(
716 is_testnet: bool,
717 timeout_secs: Option<u64>,
718 proxy_url: Option<String>,
719 ) -> std::result::Result<Self, HttpClientError> {
720 let raw_client = HyperliquidRawHttpClient::new(is_testnet, timeout_secs, proxy_url)?;
721 Ok(Self {
722 inner: Arc::new(raw_client),
723 instruments: Arc::new(RwLock::new(AHashMap::new())),
724 instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
725 account_id: None,
726 })
727 }
728
729 pub fn with_credentials(
735 secrets: &Secrets,
736 timeout_secs: Option<u64>,
737 proxy_url: Option<String>,
738 ) -> std::result::Result<Self, HttpClientError> {
739 let raw_client =
740 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
741 Ok(Self {
742 inner: Arc::new(raw_client),
743 instruments: Arc::new(RwLock::new(AHashMap::new())),
744 instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
745 account_id: None,
746 })
747 }
748
749 pub fn from_env() -> Result<Self> {
755 let raw_client = HyperliquidRawHttpClient::from_env()?;
756 Ok(Self {
757 inner: Arc::new(raw_client),
758 instruments: Arc::new(RwLock::new(AHashMap::new())),
759 instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
760 account_id: None,
761 })
762 }
763
764 pub fn from_credentials(
770 private_key: &str,
771 vault_address: Option<&str>,
772 is_testnet: bool,
773 timeout_secs: Option<u64>,
774 proxy_url: Option<String>,
775 ) -> Result<Self> {
776 let raw_client = HyperliquidRawHttpClient::from_credentials(
777 private_key,
778 vault_address,
779 is_testnet,
780 timeout_secs,
781 proxy_url,
782 )?;
783 Ok(Self {
784 inner: Arc::new(raw_client),
785 instruments: Arc::new(RwLock::new(AHashMap::new())),
786 instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
787 account_id: None,
788 })
789 }
790
791 #[must_use]
793 pub fn is_testnet(&self) -> bool {
794 self.inner.is_testnet()
795 }
796
797 pub fn get_user_address(&self) -> Result<String> {
803 self.inner.get_user_address()
804 }
805
806 pub fn cache_instrument(&self, instrument: InstrumentAny) {
815 let full_symbol = instrument.symbol().inner();
816 let coin = instrument.raw_symbol().inner();
817
818 {
819 let mut instruments = self
820 .instruments
821 .write()
822 .expect("Failed to acquire write lock");
823
824 instruments.insert(full_symbol, instrument.clone());
825
826 instruments.insert(coin, instrument.clone());
828 }
829
830 if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
832 let mut instruments_by_coin = self
833 .instruments_by_coin
834 .write()
835 .expect("Failed to acquire write lock");
836 instruments_by_coin.insert((coin, product_type), instrument);
837 } else {
838 tracing::warn!(
839 "Unable to determine product type for symbol: {}",
840 full_symbol
841 );
842 }
843 }
844
845 fn get_or_create_instrument(
861 &self,
862 coin: &Ustr,
863 product_type: Option<HyperliquidProductType>,
864 ) -> Option<InstrumentAny> {
865 if let Some(pt) = product_type {
866 let instruments_by_coin = self
867 .instruments_by_coin
868 .read()
869 .expect("Failed to acquire read lock");
870
871 if let Some(instrument) = instruments_by_coin.get(&(*coin, pt)) {
872 return Some(instrument.clone());
873 }
874 }
875
876 if product_type.is_none() {
878 let instruments_by_coin = self
879 .instruments_by_coin
880 .read()
881 .expect("Failed to acquire read lock");
882
883 if let Some(instrument) =
884 instruments_by_coin.get(&(*coin, HyperliquidProductType::Perp))
885 {
886 return Some(instrument.clone());
887 }
888 if let Some(instrument) =
889 instruments_by_coin.get(&(*coin, HyperliquidProductType::Spot))
890 {
891 return Some(instrument.clone());
892 }
893 }
894
895 if coin.as_str().starts_with("vntls:") {
897 tracing::info!("Creating synthetic instrument for vault token: {coin}");
898
899 let clock = nautilus_core::time::get_atomic_clock_realtime();
900 let ts_event = clock.get_time_ns();
901
902 let symbol_str = format!("{coin}-USDC-SPOT");
904 let symbol = Symbol::new(&symbol_str);
905 let venue = *HYPERLIQUID_VENUE;
906 let instrument_id = InstrumentId::new(symbol, venue);
907
908 let base_currency = Currency::new(
910 coin.as_str(),
911 8, 0, coin.as_str(),
914 CurrencyType::Crypto,
915 );
916
917 let quote_currency = Currency::new(
918 "USDC",
919 6, 0,
921 "USDC",
922 CurrencyType::Crypto,
923 );
924
925 let price_increment = Price::from("0.00000001");
926 let size_increment = Quantity::from("0.00000001");
927
928 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
929 instrument_id,
930 symbol,
931 base_currency,
932 quote_currency,
933 8, 8, price_increment,
936 size_increment,
937 None, None, None, None, None, None, None, None, None, None, None, None, ts_event,
950 ts_event,
951 ));
952
953 self.cache_instrument(instrument.clone());
954
955 Some(instrument)
956 } else {
957 tracing::warn!("Instrument not found in cache: {coin}");
959 None
960 }
961 }
962
963 pub fn set_account_id(&mut self, account_id: AccountId) {
967 self.account_id = Some(account_id);
968 }
969
970 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
972 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
973
974 match self.inner.load_perp_meta().await {
975 Ok(perp_meta) => match parse_perp_instruments(&perp_meta) {
976 Ok(perp_defs) => {
977 tracing::debug!(
978 count = perp_defs.len(),
979 "Loaded Hyperliquid perp definitions"
980 );
981 defs.extend(perp_defs);
982 }
983 Err(e) => {
984 tracing::warn!(%e, "Failed to parse Hyperliquid perp instruments");
985 }
986 },
987 Err(e) => {
988 tracing::warn!(%e, "Failed to load Hyperliquid perp metadata");
989 }
990 }
991
992 match self.inner.get_spot_meta().await {
993 Ok(spot_meta) => match parse_spot_instruments(&spot_meta) {
994 Ok(spot_defs) => {
995 tracing::debug!(
996 count = spot_defs.len(),
997 "Loaded Hyperliquid spot definitions"
998 );
999 defs.extend(spot_defs);
1000 }
1001 Err(e) => {
1002 tracing::warn!(%e, "Failed to parse Hyperliquid spot instruments");
1003 }
1004 },
1005 Err(e) => {
1006 tracing::warn!(%e, "Failed to load Hyperliquid spot metadata");
1007 }
1008 }
1009
1010 Ok(instruments_from_defs_owned(defs))
1011 }
1012
1013 #[allow(dead_code)]
1015 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1016 self.inner.load_perp_meta().await
1017 }
1018
1019 #[allow(dead_code)]
1021 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1022 self.inner.get_spot_meta().await
1023 }
1024
1025 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1027 self.inner.info_l2_book(coin).await
1028 }
1029
1030 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1032 self.inner.info_user_fills(user).await
1033 }
1034
1035 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1037 self.inner.info_order_status(user, oid).await
1038 }
1039
1040 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1042 self.inner.info_open_orders(user).await
1043 }
1044
1045 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1047 self.inner.info_frontend_open_orders(user).await
1048 }
1049
1050 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1052 self.inner.info_clearinghouse_state(user).await
1053 }
1054
1055 pub async fn info_candle_snapshot(
1057 &self,
1058 coin: &str,
1059 interval: HyperliquidBarInterval,
1060 start_time: u64,
1061 end_time: u64,
1062 ) -> Result<HyperliquidCandleSnapshot> {
1063 self.inner
1064 .info_candle_snapshot(coin, interval, start_time, end_time)
1065 .await
1066 }
1067
1068 pub async fn post_action(
1070 &self,
1071 action: &ExchangeAction,
1072 ) -> Result<HyperliquidExchangeResponse> {
1073 self.inner.post_action(action).await
1074 }
1075
1076 pub async fn post_action_exec(
1078 &self,
1079 action: &HyperliquidExecAction,
1080 ) -> Result<HyperliquidExchangeResponse> {
1081 self.inner.post_action_exec(action).await
1082 }
1083
1084 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1086 self.inner.info_meta().await
1087 }
1088
1089 pub async fn cancel_order(
1099 &self,
1100 instrument_id: InstrumentId,
1101 client_order_id: Option<ClientOrderId>,
1102 venue_order_id: Option<VenueOrderId>,
1103 ) -> Result<()> {
1104 let symbol = instrument_id.symbol.as_str();
1106 let asset_id = extract_asset_id_from_symbol(symbol)
1107 .map_err(|e| Error::bad_request(format!("Failed to extract asset ID: {e}")))?;
1108
1109 let action = if let Some(cloid) = client_order_id {
1111 let cloid_hex = Cloid::from_hex(cloid)
1112 .map_err(|e| Error::bad_request(format!("Invalid client order ID format: {e}")))?;
1113 let cancel_req = HyperliquidExecCancelByCloidRequest {
1114 asset: asset_id,
1115 cloid: cloid_hex,
1116 };
1117 HyperliquidExecAction::CancelByCloid {
1118 cancels: vec![cancel_req],
1119 }
1120 } else if let Some(oid) = venue_order_id {
1121 let oid_u64 = oid
1122 .as_str()
1123 .parse::<u64>()
1124 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1125 let cancel_req = HyperliquidExecCancelOrderRequest {
1126 asset: asset_id,
1127 oid: oid_u64,
1128 };
1129 HyperliquidExecAction::Cancel {
1130 cancels: vec![cancel_req],
1131 }
1132 } else {
1133 return Err(Error::bad_request(
1134 "Either client_order_id or venue_order_id must be provided",
1135 ));
1136 };
1137
1138 let response = self.inner.post_action_exec(&action).await?;
1140
1141 match response {
1143 HyperliquidExchangeResponse::Status { status, .. } if status == "ok" => Ok(()),
1144 HyperliquidExchangeResponse::Status {
1145 status,
1146 response: error_data,
1147 } => Err(Error::bad_request(format!(
1148 "Cancel order failed: status={status}, error={error_data}"
1149 ))),
1150 HyperliquidExchangeResponse::Error { error } => {
1151 Err(Error::bad_request(format!("Cancel order error: {error}")))
1152 }
1153 }
1154 }
1155
1156 pub async fn request_order_status_reports(
1172 &self,
1173 user: &str,
1174 instrument_id: Option<InstrumentId>,
1175 ) -> Result<Vec<OrderStatusReport>> {
1176 let response = self.info_frontend_open_orders(user).await?;
1177
1178 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1180 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1181
1182 let mut reports = Vec::new();
1183 let ts_init = UnixNanos::default();
1184
1185 for order_value in orders {
1186 let order: crate::websocket::messages::WsBasicOrderData =
1188 match serde_json::from_value(order_value.clone()) {
1189 Ok(o) => o,
1190 Err(e) => {
1191 tracing::warn!("Failed to parse order: {e}");
1192 continue;
1193 }
1194 };
1195
1196 let instrument = match self.get_or_create_instrument(&order.coin, None) {
1198 Some(inst) => inst,
1199 None => continue, };
1201
1202 if let Some(filter_id) = instrument_id
1204 && instrument.id() != filter_id
1205 {
1206 continue;
1207 }
1208
1209 let status = HyperliquidOrderStatusEnum::Open;
1211
1212 match crate::http::parse::parse_order_status_report_from_basic(
1214 &order,
1215 &status,
1216 &instrument,
1217 self.account_id.expect("account_id not set"),
1218 ts_init,
1219 ) {
1220 Ok(report) => reports.push(report),
1221 Err(e) => tracing::error!("Failed to parse order status report: {e}"),
1222 }
1223 }
1224
1225 Ok(reports)
1226 }
1227
1228 pub async fn request_fill_reports(
1244 &self,
1245 user: &str,
1246 instrument_id: Option<InstrumentId>,
1247 ) -> Result<Vec<FillReport>> {
1248 let fills_response = self.info_user_fills(user).await?;
1249
1250 let mut reports = Vec::new();
1251 let ts_init = UnixNanos::default();
1252
1253 for fill in fills_response {
1254 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
1256 Some(inst) => inst,
1257 None => continue, };
1259
1260 if let Some(filter_id) = instrument_id
1262 && instrument.id() != filter_id
1263 {
1264 continue;
1265 }
1266
1267 match crate::http::parse::parse_fill_report(
1269 &fill,
1270 &instrument,
1271 self.account_id.expect("account_id not set"),
1272 ts_init,
1273 ) {
1274 Ok(report) => reports.push(report),
1275 Err(e) => tracing::error!("Failed to parse fill report: {e}"),
1276 }
1277 }
1278
1279 Ok(reports)
1280 }
1281
1282 pub async fn request_position_status_reports(
1298 &self,
1299 user: &str,
1300 instrument_id: Option<InstrumentId>,
1301 ) -> Result<Vec<PositionStatusReport>> {
1302 let state_response = self.info_clearinghouse_state(user).await?;
1303
1304 let asset_positions: Vec<serde_json::Value> = state_response
1306 .get("assetPositions")
1307 .and_then(|v| v.as_array())
1308 .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
1309 .clone();
1310
1311 let mut reports = Vec::new();
1312 let ts_init = UnixNanos::default();
1313
1314 for position_value in asset_positions {
1315 let coin = position_value
1317 .get("position")
1318 .and_then(|p| p.get("coin"))
1319 .and_then(|c| c.as_str())
1320 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
1321
1322 let coin_ustr = Ustr::from(coin);
1324 let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
1325 Some(inst) => inst,
1326 None => continue, };
1328
1329 if let Some(filter_id) = instrument_id
1331 && instrument.id() != filter_id
1332 {
1333 continue;
1334 }
1335
1336 match crate::http::parse::parse_position_status_report(
1338 &position_value,
1339 &instrument,
1340 self.account_id.expect("account_id not set"),
1341 ts_init,
1342 ) {
1343 Ok(report) => reports.push(report),
1344 Err(e) => tracing::error!("Failed to parse position status report: {e}"),
1345 }
1346 }
1347
1348 Ok(reports)
1349 }
1350
1351 pub async fn request_bars(
1368 &self,
1369 bar_type: BarType,
1370 start: Option<chrono::DateTime<chrono::Utc>>,
1371 end: Option<chrono::DateTime<chrono::Utc>>,
1372 limit: Option<u32>,
1373 ) -> Result<Vec<Bar>> {
1374 let instrument_id = bar_type.instrument_id();
1375 let symbol = instrument_id.symbol;
1376
1377 let coin = Ustr::from(
1378 symbol
1379 .as_str()
1380 .split('-')
1381 .next()
1382 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?,
1383 );
1384
1385 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
1386 let instrument = self
1387 .get_or_create_instrument(&coin, product_type)
1388 .ok_or_else(|| {
1389 Error::bad_request(format!("Instrument not found in cache: {instrument_id}"))
1390 })?;
1391
1392 let price_precision = instrument.price_precision();
1393 let size_precision = instrument.size_precision();
1394
1395 let interval =
1396 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
1397
1398 let now = chrono::Utc::now();
1400 let end_time = end.unwrap_or(now).timestamp_millis() as u64;
1401 let start_time = if let Some(start) = start {
1402 start.timestamp_millis() as u64
1403 } else {
1404 let spec = bar_type.spec();
1406 let step_ms = match spec.aggregation {
1407 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
1408 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
1409 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
1410 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
1411 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
1412 _ => 60_000,
1413 };
1414 end_time.saturating_sub(1000 * step_ms)
1415 };
1416
1417 let candles = self
1418 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
1419 .await?;
1420
1421 let now_ms = now.timestamp_millis() as u64;
1423
1424 let mut bars: Vec<Bar> = candles
1425 .iter()
1426 .filter(|candle| candle.end_timestamp < now_ms)
1427 .enumerate()
1428 .filter_map(|(i, candle)| {
1429 crate::data::candle_to_bar(candle, bar_type, price_precision, size_precision)
1430 .map_err(|e| {
1431 tracing::error!(
1432 "Failed to convert candle {} to bar: {:?} error: {e}",
1433 i,
1434 candle
1435 );
1436 e
1437 })
1438 .ok()
1439 })
1440 .collect();
1441
1442 if let Some(limit) = limit
1444 && limit > 0
1445 && bars.len() > limit as usize
1446 {
1447 bars.truncate(limit as usize);
1448 }
1449
1450 tracing::debug!(
1451 "Received {} bars for {} (filtered {} incomplete)",
1452 bars.len(),
1453 bar_type,
1454 candles.len() - bars.len()
1455 );
1456 Ok(bars)
1457 }
1458 #[allow(clippy::too_many_arguments)]
1466 pub async fn submit_order(
1467 &self,
1468 instrument_id: InstrumentId,
1469 client_order_id: ClientOrderId,
1470 order_side: OrderSide,
1471 order_type: OrderType,
1472 quantity: Quantity,
1473 time_in_force: TimeInForce,
1474 price: Option<Price>,
1475 trigger_price: Option<Price>,
1476 post_only: bool,
1477 reduce_only: bool,
1478 ) -> Result<OrderStatusReport> {
1479 let symbol = instrument_id.symbol.as_str();
1480 let asset = extract_asset_id_from_symbol(symbol)
1481 .map_err(|e| Error::bad_request(format!("Failed to extract asset ID: {e}")))?;
1482
1483 let is_buy = matches!(order_side, OrderSide::Buy);
1484
1485 let price_decimal = match price {
1487 Some(px) => px.as_decimal(),
1488 None => {
1489 if matches!(
1490 order_type,
1491 OrderType::Market | OrderType::StopMarket | OrderType::MarketIfTouched
1492 ) {
1493 Decimal::ZERO
1494 } else {
1495 return Err(Error::bad_request("Limit orders require a price"));
1496 }
1497 }
1498 };
1499
1500 let size_decimal = quantity.as_decimal();
1502
1503 let kind = match order_type {
1505 OrderType::Market => HyperliquidExecOrderKind::Limit {
1506 limit: HyperliquidExecLimitParams {
1507 tif: HyperliquidExecTif::Ioc,
1508 },
1509 },
1510 OrderType::Limit => {
1511 let tif = if post_only {
1512 HyperliquidExecTif::Alo
1513 } else {
1514 match time_in_force {
1515 TimeInForce::Gtc => HyperliquidExecTif::Gtc,
1516 TimeInForce::Ioc => HyperliquidExecTif::Ioc,
1517 TimeInForce::Fok => HyperliquidExecTif::Ioc, TimeInForce::Day
1519 | TimeInForce::Gtd
1520 | TimeInForce::AtTheOpen
1521 | TimeInForce::AtTheClose => {
1522 return Err(Error::bad_request(format!(
1523 "Time in force {time_in_force:?} not supported"
1524 )));
1525 }
1526 }
1527 };
1528 HyperliquidExecOrderKind::Limit {
1529 limit: HyperliquidExecLimitParams { tif },
1530 }
1531 }
1532 OrderType::StopMarket
1533 | OrderType::StopLimit
1534 | OrderType::MarketIfTouched
1535 | OrderType::LimitIfTouched => {
1536 if let Some(trig_px) = trigger_price {
1537 let trigger_price_decimal = trig_px.as_decimal();
1538
1539 let tpsl = match order_type {
1543 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1544 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
1545 HyperliquidExecTpSl::Tp
1546 }
1547 _ => unreachable!(),
1548 };
1549
1550 let is_market = matches!(
1551 order_type,
1552 OrderType::StopMarket | OrderType::MarketIfTouched
1553 );
1554
1555 HyperliquidExecOrderKind::Trigger {
1556 trigger: HyperliquidExecTriggerParams {
1557 is_market,
1558 trigger_px: trigger_price_decimal,
1559 tpsl,
1560 },
1561 }
1562 } else {
1563 return Err(Error::bad_request("Trigger orders require a trigger price"));
1564 }
1565 }
1566 _ => {
1567 return Err(Error::bad_request(format!(
1568 "Order type {order_type:?} not supported"
1569 )));
1570 }
1571 };
1572
1573 let hyperliquid_order =
1575 HyperliquidExecPlaceOrderRequest {
1576 asset,
1577 is_buy,
1578 price: price_decimal,
1579 size: size_decimal,
1580 reduce_only,
1581 kind,
1582 cloid: Some(Cloid::from_hex(client_order_id).map_err(|e| {
1583 Error::bad_request(format!("Invalid client order ID format: {e}"))
1584 })?),
1585 };
1586
1587 let action = HyperliquidExecAction::Order {
1589 orders: vec![hyperliquid_order],
1590 grouping: HyperliquidExecGrouping::Na,
1591 builder: None,
1592 };
1593
1594 let response = self.inner.post_action_exec(&action).await?;
1596
1597 match response {
1599 HyperliquidExchangeResponse::Status {
1600 status,
1601 response: response_data,
1602 } if status == "ok" => {
1603 let data_value = if let Some(data) = response_data.get("data") {
1604 data.clone()
1605 } else {
1606 response_data
1607 };
1608
1609 let order_response: HyperliquidExecOrderResponseData =
1610 serde_json::from_value(data_value).map_err(|e| {
1611 Error::bad_request(format!("Failed to parse order response: {e}"))
1612 })?;
1613
1614 let order_status = order_response
1615 .statuses
1616 .first()
1617 .ok_or_else(|| Error::bad_request("No order status in response"))?;
1618
1619 let symbol_str = instrument_id.symbol.as_str();
1620 let asset_str = symbol_str
1621 .trim_end_matches("-PERP")
1622 .trim_end_matches("-USD");
1623
1624 let product_type = HyperliquidProductType::from_symbol(symbol_str).ok();
1625 let instrument = self
1626 .get_or_create_instrument(&Ustr::from(asset_str), product_type)
1627 .ok_or_else(|| {
1628 Error::bad_request(format!("Instrument not found for {asset_str}"))
1629 })?;
1630
1631 let account_id = self
1632 .account_id
1633 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1634 let ts_init = UnixNanos::default();
1635
1636 match order_status {
1637 HyperliquidExecOrderStatus::Resting { resting } => self
1638 .create_order_status_report(
1639 instrument_id,
1640 Some(client_order_id),
1641 VenueOrderId::new(resting.oid.to_string()),
1642 order_side,
1643 order_type,
1644 quantity,
1645 time_in_force,
1646 price,
1647 trigger_price,
1648 OrderStatus::Accepted,
1649 Quantity::new(0.0, instrument.size_precision()),
1650 &instrument,
1651 account_id,
1652 ts_init,
1653 ),
1654 HyperliquidExecOrderStatus::Filled { filled } => {
1655 let filled_qty = Quantity::new(
1656 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
1657 instrument.size_precision(),
1658 );
1659 self.create_order_status_report(
1660 instrument_id,
1661 Some(client_order_id),
1662 VenueOrderId::new(filled.oid.to_string()),
1663 order_side,
1664 order_type,
1665 quantity,
1666 time_in_force,
1667 price,
1668 trigger_price,
1669 OrderStatus::Filled,
1670 filled_qty,
1671 &instrument,
1672 account_id,
1673 ts_init,
1674 )
1675 }
1676 HyperliquidExecOrderStatus::Error { error } => {
1677 Err(Error::bad_request(format!("Order rejected: {error}")))
1678 }
1679 }
1680 }
1681 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
1682 "Order submission failed: {error}"
1683 ))),
1684 _ => Err(Error::bad_request("Unexpected response format")),
1685 }
1686 }
1687
1688 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
1692 self.submit_order(
1693 order.instrument_id(),
1694 order.client_order_id(),
1695 order.order_side(),
1696 order.order_type(),
1697 order.quantity(),
1698 order.time_in_force(),
1699 order.price(),
1700 order.trigger_price(),
1701 order.is_post_only(),
1702 order.is_reduce_only(),
1703 )
1704 .await
1705 }
1706
1707 #[allow(clippy::too_many_arguments)]
1709 fn create_order_status_report(
1710 &self,
1711 instrument_id: InstrumentId,
1712 client_order_id: Option<ClientOrderId>,
1713 venue_order_id: VenueOrderId,
1714 order_side: OrderSide,
1715 order_type: OrderType,
1716 quantity: Quantity,
1717 time_in_force: TimeInForce,
1718 price: Option<Price>,
1719 trigger_price: Option<Price>,
1720 order_status: OrderStatus,
1721 filled_qty: Quantity,
1722 _instrument: &InstrumentAny,
1723 account_id: AccountId,
1724 ts_init: UnixNanos,
1725 ) -> Result<OrderStatusReport> {
1726 let clock = get_atomic_clock_realtime();
1727 let ts_accepted = clock.get_time_ns();
1728 let ts_last = ts_accepted;
1729 let report_id = UUID4::new();
1730
1731 let mut report = OrderStatusReport::new(
1732 account_id,
1733 instrument_id,
1734 client_order_id,
1735 venue_order_id,
1736 order_side,
1737 order_type,
1738 time_in_force,
1739 order_status,
1740 quantity,
1741 filled_qty,
1742 ts_accepted,
1743 ts_last,
1744 ts_init,
1745 Some(report_id),
1746 );
1747
1748 if let Some(px) = price {
1750 report = report.with_price(px);
1751 }
1752
1753 if let Some(trig_px) = trigger_price {
1755 report = report
1756 .with_trigger_price(trig_px)
1757 .with_trigger_type(TriggerType::Default);
1758 }
1759
1760 Ok(report)
1761 }
1762
1763 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
1773 let hyperliquid_orders = orders_to_hyperliquid_requests(orders)
1775 .map_err(|e| Error::bad_request(format!("Failed to convert orders: {e}")))?;
1776
1777 let action = HyperliquidExecAction::Order {
1779 orders: hyperliquid_orders,
1780 grouping: HyperliquidExecGrouping::Na,
1781 builder: None,
1782 };
1783
1784 let response = self.inner.post_action_exec(&action).await?;
1786
1787 match response {
1789 HyperliquidExchangeResponse::Status {
1790 status,
1791 response: response_data,
1792 } if status == "ok" => {
1793 let data_value = if let Some(data) = response_data.get("data") {
1796 data.clone()
1797 } else {
1798 response_data
1799 };
1800
1801 let order_response: HyperliquidExecOrderResponseData =
1803 serde_json::from_value(data_value).map_err(|e| {
1804 Error::bad_request(format!("Failed to parse order response: {e}"))
1805 })?;
1806
1807 let account_id = self
1808 .account_id
1809 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1810 let ts_init = UnixNanos::default();
1811
1812 if order_response.statuses.len() != orders.len() {
1814 return Err(Error::bad_request(format!(
1815 "Mismatch between submitted orders ({}) and response statuses ({})",
1816 orders.len(),
1817 order_response.statuses.len()
1818 )));
1819 }
1820
1821 let mut reports = Vec::new();
1822
1823 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
1825 let instrument_id = order.instrument_id();
1827 let symbol = instrument_id.symbol.as_str();
1828 let asset = symbol.trim_end_matches("-PERP").trim_end_matches("-USD");
1829
1830 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
1831 let instrument = self
1832 .get_or_create_instrument(&Ustr::from(asset), product_type)
1833 .ok_or_else(|| {
1834 Error::bad_request(format!("Instrument not found for {asset}"))
1835 })?;
1836
1837 let report = match order_status {
1839 HyperliquidExecOrderStatus::Resting { resting } => {
1840 self.create_order_status_report(
1842 order.instrument_id(),
1843 Some(order.client_order_id()),
1844 VenueOrderId::new(resting.oid.to_string()),
1845 order.order_side(),
1846 order.order_type(),
1847 order.quantity(),
1848 order.time_in_force(),
1849 order.price(),
1850 order.trigger_price(),
1851 OrderStatus::Accepted,
1852 Quantity::new(0.0, instrument.size_precision()),
1853 &instrument,
1854 account_id,
1855 ts_init,
1856 )?
1857 }
1858 HyperliquidExecOrderStatus::Filled { filled } => {
1859 let filled_qty = Quantity::new(
1861 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
1862 instrument.size_precision(),
1863 );
1864 self.create_order_status_report(
1865 order.instrument_id(),
1866 Some(order.client_order_id()),
1867 VenueOrderId::new(filled.oid.to_string()),
1868 order.order_side(),
1869 order.order_type(),
1870 order.quantity(),
1871 order.time_in_force(),
1872 order.price(),
1873 order.trigger_price(),
1874 OrderStatus::Filled,
1875 filled_qty,
1876 &instrument,
1877 account_id,
1878 ts_init,
1879 )?
1880 }
1881 HyperliquidExecOrderStatus::Error { error } => {
1882 return Err(Error::bad_request(format!(
1883 "Order {} rejected: {error}",
1884 order.client_order_id()
1885 )));
1886 }
1887 };
1888
1889 reports.push(report);
1890 }
1891
1892 Ok(reports)
1893 }
1894 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
1895 "Order submission failed: {error}"
1896 ))),
1897 _ => Err(Error::bad_request("Unexpected response format")),
1898 }
1899 }
1900}
1901
1902#[cfg(test)]
1903mod tests {
1904 use nautilus_core::MUTEX_POISONED;
1905 use nautilus_model::instruments::{Instrument, InstrumentAny};
1906 use rstest::rstest;
1907 use ustr::Ustr;
1908
1909 use super::HyperliquidHttpClient;
1910 use crate::{common::enums::HyperliquidProductType, http::query::InfoRequest};
1911
1912 #[rstest]
1913 fn stable_json_roundtrips() {
1914 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
1915 let s = serde_json::to_string(&v).unwrap();
1916 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1918 assert_eq!(parsed["type"], "l2Book");
1919 assert_eq!(parsed["coin"], "BTC");
1920 assert_eq!(parsed, v);
1921 }
1922
1923 #[rstest]
1924 fn info_pretty_shape() {
1925 let r = InfoRequest::l2_book("BTC");
1926 let val = serde_json::to_value(&r).unwrap();
1927 let pretty = serde_json::to_string_pretty(&val).unwrap();
1928 assert!(pretty.contains("\"type\": \"l2Book\""));
1929 assert!(pretty.contains("\"coin\": \"BTC\""));
1930 }
1931
1932 #[rstest]
1933 fn test_cache_instrument_by_raw_symbol() {
1934 use nautilus_core::time::get_atomic_clock_realtime;
1935 use nautilus_model::{
1936 currencies::CURRENCY_MAP,
1937 enums::CurrencyType,
1938 identifiers::{InstrumentId, Symbol},
1939 instruments::CurrencyPair,
1940 types::{Currency, Price, Quantity},
1941 };
1942
1943 let client = HyperliquidHttpClient::new(true, None, None).unwrap();
1944
1945 let base_code = "vntls:vCURSOR";
1947 let quote_code = "USDC";
1948
1949 {
1951 let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
1952 if !currency_map.contains_key(base_code) {
1953 currency_map.insert(
1954 base_code.to_string(),
1955 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
1956 );
1957 }
1958 }
1959
1960 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
1961 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
1962
1963 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
1965 let venue = *crate::common::consts::HYPERLIQUID_VENUE;
1966 let instrument_id = InstrumentId::new(symbol, venue);
1967
1968 let raw_symbol = Symbol::new(base_code);
1970
1971 let clock = get_atomic_clock_realtime();
1972 let ts = clock.get_time_ns();
1973
1974 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1975 instrument_id,
1976 raw_symbol,
1977 base_currency,
1978 quote_currency,
1979 8,
1980 8,
1981 Price::from("0.00000001"),
1982 Quantity::from("0.00000001"),
1983 None,
1984 None,
1985 None,
1986 None,
1987 None,
1988 None,
1989 None,
1990 None,
1991 None,
1992 None,
1993 None,
1994 None,
1995 ts,
1996 ts,
1997 ));
1998
1999 client.cache_instrument(instrument.clone());
2001
2002 let instruments = client.instruments.read().unwrap();
2004 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
2005 assert!(
2006 by_full_symbol.is_some(),
2007 "Instrument should be accessible by full symbol"
2008 );
2009 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
2010
2011 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
2013 assert!(
2014 by_raw_symbol.is_some(),
2015 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
2016 );
2017 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
2018 drop(instruments);
2019
2020 let instruments_by_coin = client.instruments_by_coin.read().unwrap();
2022 let by_coin =
2023 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
2024 assert!(
2025 by_coin.is_some(),
2026 "Instrument should be accessible by coin and product type"
2027 );
2028 assert_eq!(by_coin.unwrap().id(), instrument.id());
2029 drop(instruments_by_coin);
2030
2031 let retrieved_with_type = client.get_or_create_instrument(
2033 &Ustr::from("vntls:vCURSOR"),
2034 Some(HyperliquidProductType::Spot),
2035 );
2036 assert!(retrieved_with_type.is_some());
2037 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
2038
2039 let retrieved_without_type =
2041 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
2042 assert!(retrieved_without_type.is_some());
2043 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
2044 }
2045}