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.adapters")
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.adapters")
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(
1168 &self,
1169 user: &str,
1170 instrument_id: Option<InstrumentId>,
1171 ) -> Result<Vec<OrderStatusReport>> {
1172 let response = self.info_frontend_open_orders(user).await?;
1173
1174 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1176 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1177
1178 let mut reports = Vec::new();
1179 let ts_init = UnixNanos::default();
1180
1181 for order_value in orders {
1182 let order: crate::websocket::messages::WsBasicOrderData =
1184 match serde_json::from_value(order_value.clone()) {
1185 Ok(o) => o,
1186 Err(e) => {
1187 tracing::warn!("Failed to parse order: {e}");
1188 continue;
1189 }
1190 };
1191
1192 let instrument = match self.get_or_create_instrument(&order.coin, None) {
1194 Some(inst) => inst,
1195 None => continue, };
1197
1198 if let Some(filter_id) = instrument_id
1200 && instrument.id() != filter_id
1201 {
1202 continue;
1203 }
1204
1205 let status = HyperliquidOrderStatusEnum::Open;
1207
1208 match crate::http::parse::parse_order_status_report_from_basic(
1210 &order,
1211 &status,
1212 &instrument,
1213 self.account_id.unwrap_or_default(),
1214 ts_init,
1215 ) {
1216 Ok(report) => reports.push(report),
1217 Err(e) => tracing::error!("Failed to parse order status report: {e}"),
1218 }
1219 }
1220
1221 Ok(reports)
1222 }
1223
1224 pub async fn request_fill_reports(
1236 &self,
1237 user: &str,
1238 instrument_id: Option<InstrumentId>,
1239 ) -> Result<Vec<FillReport>> {
1240 let fills_response = self.info_user_fills(user).await?;
1241
1242 let mut reports = Vec::new();
1243 let ts_init = UnixNanos::default();
1244
1245 for fill in fills_response {
1246 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
1248 Some(inst) => inst,
1249 None => continue, };
1251
1252 if let Some(filter_id) = instrument_id
1254 && instrument.id() != filter_id
1255 {
1256 continue;
1257 }
1258
1259 match crate::http::parse::parse_fill_report(
1261 &fill,
1262 &instrument,
1263 self.account_id.unwrap_or_default(),
1264 ts_init,
1265 ) {
1266 Ok(report) => reports.push(report),
1267 Err(e) => tracing::error!("Failed to parse fill report: {e}"),
1268 }
1269 }
1270
1271 Ok(reports)
1272 }
1273
1274 pub async fn request_position_status_reports(
1286 &self,
1287 user: &str,
1288 instrument_id: Option<InstrumentId>,
1289 ) -> Result<Vec<PositionStatusReport>> {
1290 let state_response = self.info_clearinghouse_state(user).await?;
1291
1292 let asset_positions: Vec<serde_json::Value> = state_response
1294 .get("assetPositions")
1295 .and_then(|v| v.as_array())
1296 .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
1297 .clone();
1298
1299 let mut reports = Vec::new();
1300 let ts_init = UnixNanos::default();
1301
1302 for position_value in asset_positions {
1303 let coin = position_value
1305 .get("position")
1306 .and_then(|p| p.get("coin"))
1307 .and_then(|c| c.as_str())
1308 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
1309
1310 let coin_ustr = Ustr::from(coin);
1312 let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
1313 Some(inst) => inst,
1314 None => continue, };
1316
1317 if let Some(filter_id) = instrument_id
1319 && instrument.id() != filter_id
1320 {
1321 continue;
1322 }
1323
1324 match crate::http::parse::parse_position_status_report(
1326 &position_value,
1327 &instrument,
1328 self.account_id.unwrap_or_default(),
1329 ts_init,
1330 ) {
1331 Ok(report) => reports.push(report),
1332 Err(e) => tracing::error!("Failed to parse position status report: {e}"),
1333 }
1334 }
1335
1336 Ok(reports)
1337 }
1338
1339 pub async fn request_bars(
1356 &self,
1357 bar_type: BarType,
1358 start: Option<chrono::DateTime<chrono::Utc>>,
1359 end: Option<chrono::DateTime<chrono::Utc>>,
1360 limit: Option<u32>,
1361 ) -> Result<Vec<Bar>> {
1362 let instrument_id = bar_type.instrument_id();
1363 let symbol = instrument_id.symbol;
1364
1365 let coin = Ustr::from(
1366 symbol
1367 .as_str()
1368 .split('-')
1369 .next()
1370 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?,
1371 );
1372
1373 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
1374 let instrument = self
1375 .get_or_create_instrument(&coin, product_type)
1376 .ok_or_else(|| {
1377 Error::bad_request(format!("Instrument not found in cache: {instrument_id}"))
1378 })?;
1379
1380 let price_precision = instrument.price_precision();
1381 let size_precision = instrument.size_precision();
1382
1383 let interval =
1384 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
1385
1386 let now = chrono::Utc::now();
1388 let end_time = end.unwrap_or(now).timestamp_millis() as u64;
1389 let start_time = if let Some(start) = start {
1390 start.timestamp_millis() as u64
1391 } else {
1392 let spec = bar_type.spec();
1394 let step_ms = match spec.aggregation {
1395 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
1396 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
1397 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
1398 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
1399 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
1400 _ => 60_000,
1401 };
1402 end_time.saturating_sub(1000 * step_ms)
1403 };
1404
1405 let candles = self
1406 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
1407 .await?;
1408
1409 let now_ms = now.timestamp_millis() as u64;
1411
1412 let mut bars: Vec<Bar> = candles
1413 .iter()
1414 .filter(|candle| candle.end_timestamp < now_ms)
1415 .enumerate()
1416 .filter_map(|(i, candle)| {
1417 crate::data::candle_to_bar(candle, bar_type, price_precision, size_precision)
1418 .map_err(|e| {
1419 tracing::error!(
1420 "Failed to convert candle {} to bar: {:?} error: {e}",
1421 i,
1422 candle
1423 );
1424 e
1425 })
1426 .ok()
1427 })
1428 .collect();
1429
1430 if let Some(limit) = limit
1432 && limit > 0
1433 && bars.len() > limit as usize
1434 {
1435 bars.truncate(limit as usize);
1436 }
1437
1438 tracing::debug!(
1439 "Received {} bars for {} (filtered {} incomplete)",
1440 bars.len(),
1441 bar_type,
1442 candles.len() - bars.len()
1443 );
1444 Ok(bars)
1445 }
1446 #[allow(clippy::too_many_arguments)]
1454 pub async fn submit_order(
1455 &self,
1456 instrument_id: InstrumentId,
1457 client_order_id: ClientOrderId,
1458 order_side: OrderSide,
1459 order_type: OrderType,
1460 quantity: Quantity,
1461 time_in_force: TimeInForce,
1462 price: Option<Price>,
1463 trigger_price: Option<Price>,
1464 post_only: bool,
1465 reduce_only: bool,
1466 ) -> Result<OrderStatusReport> {
1467 let symbol = instrument_id.symbol.as_str();
1468 let asset = extract_asset_id_from_symbol(symbol)
1469 .map_err(|e| Error::bad_request(format!("Failed to extract asset ID: {e}")))?;
1470
1471 let is_buy = matches!(order_side, OrderSide::Buy);
1472
1473 let price_decimal = match price {
1475 Some(px) => px.as_decimal(),
1476 None => {
1477 if matches!(
1478 order_type,
1479 OrderType::Market | OrderType::StopMarket | OrderType::MarketIfTouched
1480 ) {
1481 Decimal::ZERO
1482 } else {
1483 return Err(Error::bad_request("Limit orders require a price"));
1484 }
1485 }
1486 };
1487
1488 let size_decimal = quantity.as_decimal();
1490
1491 let kind = match order_type {
1493 OrderType::Market => HyperliquidExecOrderKind::Limit {
1494 limit: HyperliquidExecLimitParams {
1495 tif: HyperliquidExecTif::Ioc,
1496 },
1497 },
1498 OrderType::Limit => {
1499 let tif = if post_only {
1500 HyperliquidExecTif::Alo
1501 } else {
1502 match time_in_force {
1503 TimeInForce::Gtc => HyperliquidExecTif::Gtc,
1504 TimeInForce::Ioc => HyperliquidExecTif::Ioc,
1505 TimeInForce::Fok => HyperliquidExecTif::Ioc, TimeInForce::Day
1507 | TimeInForce::Gtd
1508 | TimeInForce::AtTheOpen
1509 | TimeInForce::AtTheClose => {
1510 return Err(Error::bad_request(format!(
1511 "Time in force {time_in_force:?} not supported"
1512 )));
1513 }
1514 }
1515 };
1516 HyperliquidExecOrderKind::Limit {
1517 limit: HyperliquidExecLimitParams { tif },
1518 }
1519 }
1520 OrderType::StopMarket
1521 | OrderType::StopLimit
1522 | OrderType::MarketIfTouched
1523 | OrderType::LimitIfTouched => {
1524 if let Some(trig_px) = trigger_price {
1525 let trigger_price_decimal = trig_px.as_decimal();
1526
1527 let tpsl = match order_type {
1531 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1532 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
1533 HyperliquidExecTpSl::Tp
1534 }
1535 _ => unreachable!(),
1536 };
1537
1538 let is_market = matches!(
1539 order_type,
1540 OrderType::StopMarket | OrderType::MarketIfTouched
1541 );
1542
1543 HyperliquidExecOrderKind::Trigger {
1544 trigger: HyperliquidExecTriggerParams {
1545 is_market,
1546 trigger_px: trigger_price_decimal,
1547 tpsl,
1548 },
1549 }
1550 } else {
1551 return Err(Error::bad_request("Trigger orders require a trigger price"));
1552 }
1553 }
1554 _ => {
1555 return Err(Error::bad_request(format!(
1556 "Order type {order_type:?} not supported"
1557 )));
1558 }
1559 };
1560
1561 let hyperliquid_order =
1563 HyperliquidExecPlaceOrderRequest {
1564 asset,
1565 is_buy,
1566 price: price_decimal,
1567 size: size_decimal,
1568 reduce_only,
1569 kind,
1570 cloid: Some(Cloid::from_hex(client_order_id).map_err(|e| {
1571 Error::bad_request(format!("Invalid client order ID format: {e}"))
1572 })?),
1573 };
1574
1575 let action = HyperliquidExecAction::Order {
1577 orders: vec![hyperliquid_order],
1578 grouping: HyperliquidExecGrouping::Na,
1579 builder: None,
1580 };
1581
1582 let response = self.inner.post_action_exec(&action).await?;
1584
1585 match response {
1587 HyperliquidExchangeResponse::Status {
1588 status,
1589 response: response_data,
1590 } if status == "ok" => {
1591 let data_value = if let Some(data) = response_data.get("data") {
1592 data.clone()
1593 } else {
1594 response_data
1595 };
1596
1597 let order_response: HyperliquidExecOrderResponseData =
1598 serde_json::from_value(data_value).map_err(|e| {
1599 Error::bad_request(format!("Failed to parse order response: {e}"))
1600 })?;
1601
1602 let order_status = order_response
1603 .statuses
1604 .first()
1605 .ok_or_else(|| Error::bad_request("No order status in response"))?;
1606
1607 let symbol_str = instrument_id.symbol.as_str();
1608 let asset_str = symbol_str
1609 .trim_end_matches("-PERP")
1610 .trim_end_matches("-USD");
1611
1612 let product_type = HyperliquidProductType::from_symbol(symbol_str).ok();
1613 let instrument = self
1614 .get_or_create_instrument(&Ustr::from(asset_str), product_type)
1615 .ok_or_else(|| {
1616 Error::bad_request(format!("Instrument not found for {asset_str}"))
1617 })?;
1618
1619 let account_id = self
1620 .account_id
1621 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1622 let ts_init = UnixNanos::default();
1623
1624 match order_status {
1625 HyperliquidExecOrderStatus::Resting { resting } => self
1626 .create_order_status_report(
1627 instrument_id,
1628 Some(client_order_id),
1629 VenueOrderId::new(resting.oid.to_string()),
1630 order_side,
1631 order_type,
1632 quantity,
1633 time_in_force,
1634 price,
1635 trigger_price,
1636 OrderStatus::Accepted,
1637 Quantity::new(0.0, instrument.size_precision()),
1638 &instrument,
1639 account_id,
1640 ts_init,
1641 ),
1642 HyperliquidExecOrderStatus::Filled { filled } => {
1643 let filled_qty = Quantity::new(
1644 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
1645 instrument.size_precision(),
1646 );
1647 self.create_order_status_report(
1648 instrument_id,
1649 Some(client_order_id),
1650 VenueOrderId::new(filled.oid.to_string()),
1651 order_side,
1652 order_type,
1653 quantity,
1654 time_in_force,
1655 price,
1656 trigger_price,
1657 OrderStatus::Filled,
1658 filled_qty,
1659 &instrument,
1660 account_id,
1661 ts_init,
1662 )
1663 }
1664 HyperliquidExecOrderStatus::Error { error } => {
1665 Err(Error::bad_request(format!("Order rejected: {error}")))
1666 }
1667 }
1668 }
1669 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
1670 "Order submission failed: {error}"
1671 ))),
1672 _ => Err(Error::bad_request("Unexpected response format")),
1673 }
1674 }
1675
1676 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
1680 self.submit_order(
1681 order.instrument_id(),
1682 order.client_order_id(),
1683 order.order_side(),
1684 order.order_type(),
1685 order.quantity(),
1686 order.time_in_force(),
1687 order.price(),
1688 order.trigger_price(),
1689 order.is_post_only(),
1690 order.is_reduce_only(),
1691 )
1692 .await
1693 }
1694
1695 #[allow(clippy::too_many_arguments)]
1697 fn create_order_status_report(
1698 &self,
1699 instrument_id: InstrumentId,
1700 client_order_id: Option<ClientOrderId>,
1701 venue_order_id: VenueOrderId,
1702 order_side: OrderSide,
1703 order_type: OrderType,
1704 quantity: Quantity,
1705 time_in_force: TimeInForce,
1706 price: Option<Price>,
1707 trigger_price: Option<Price>,
1708 order_status: OrderStatus,
1709 filled_qty: Quantity,
1710 _instrument: &InstrumentAny,
1711 account_id: AccountId,
1712 ts_init: UnixNanos,
1713 ) -> Result<OrderStatusReport> {
1714 let clock = get_atomic_clock_realtime();
1715 let ts_accepted = clock.get_time_ns();
1716 let ts_last = ts_accepted;
1717 let report_id = UUID4::new();
1718
1719 let mut report = OrderStatusReport::new(
1720 account_id,
1721 instrument_id,
1722 client_order_id,
1723 venue_order_id,
1724 order_side,
1725 order_type,
1726 time_in_force,
1727 order_status,
1728 quantity,
1729 filled_qty,
1730 ts_accepted,
1731 ts_last,
1732 ts_init,
1733 Some(report_id),
1734 );
1735
1736 if let Some(px) = price {
1738 report = report.with_price(px);
1739 }
1740
1741 if let Some(trig_px) = trigger_price {
1743 report = report
1744 .with_trigger_price(trig_px)
1745 .with_trigger_type(TriggerType::Default);
1746 }
1747
1748 Ok(report)
1749 }
1750
1751 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
1761 let hyperliquid_orders = orders_to_hyperliquid_requests(orders)
1763 .map_err(|e| Error::bad_request(format!("Failed to convert orders: {e}")))?;
1764
1765 let action = HyperliquidExecAction::Order {
1767 orders: hyperliquid_orders,
1768 grouping: HyperliquidExecGrouping::Na,
1769 builder: None,
1770 };
1771
1772 let response = self.inner.post_action_exec(&action).await?;
1774
1775 match response {
1777 HyperliquidExchangeResponse::Status {
1778 status,
1779 response: response_data,
1780 } if status == "ok" => {
1781 let data_value = if let Some(data) = response_data.get("data") {
1784 data.clone()
1785 } else {
1786 response_data
1787 };
1788
1789 let order_response: HyperliquidExecOrderResponseData =
1791 serde_json::from_value(data_value).map_err(|e| {
1792 Error::bad_request(format!("Failed to parse order response: {e}"))
1793 })?;
1794
1795 let account_id = self
1796 .account_id
1797 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1798 let ts_init = UnixNanos::default();
1799
1800 if order_response.statuses.len() != orders.len() {
1802 return Err(Error::bad_request(format!(
1803 "Mismatch between submitted orders ({}) and response statuses ({})",
1804 orders.len(),
1805 order_response.statuses.len()
1806 )));
1807 }
1808
1809 let mut reports = Vec::new();
1810
1811 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
1813 let instrument_id = order.instrument_id();
1815 let symbol = instrument_id.symbol.as_str();
1816 let asset = symbol.trim_end_matches("-PERP").trim_end_matches("-USD");
1817
1818 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
1819 let instrument = self
1820 .get_or_create_instrument(&Ustr::from(asset), product_type)
1821 .ok_or_else(|| {
1822 Error::bad_request(format!("Instrument not found for {asset}"))
1823 })?;
1824
1825 let report = match order_status {
1827 HyperliquidExecOrderStatus::Resting { resting } => {
1828 self.create_order_status_report(
1830 order.instrument_id(),
1831 Some(order.client_order_id()),
1832 VenueOrderId::new(resting.oid.to_string()),
1833 order.order_side(),
1834 order.order_type(),
1835 order.quantity(),
1836 order.time_in_force(),
1837 order.price(),
1838 order.trigger_price(),
1839 OrderStatus::Accepted,
1840 Quantity::new(0.0, instrument.size_precision()),
1841 &instrument,
1842 account_id,
1843 ts_init,
1844 )?
1845 }
1846 HyperliquidExecOrderStatus::Filled { filled } => {
1847 let filled_qty = Quantity::new(
1849 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
1850 instrument.size_precision(),
1851 );
1852 self.create_order_status_report(
1853 order.instrument_id(),
1854 Some(order.client_order_id()),
1855 VenueOrderId::new(filled.oid.to_string()),
1856 order.order_side(),
1857 order.order_type(),
1858 order.quantity(),
1859 order.time_in_force(),
1860 order.price(),
1861 order.trigger_price(),
1862 OrderStatus::Filled,
1863 filled_qty,
1864 &instrument,
1865 account_id,
1866 ts_init,
1867 )?
1868 }
1869 HyperliquidExecOrderStatus::Error { error } => {
1870 return Err(Error::bad_request(format!(
1871 "Order {} rejected: {error}",
1872 order.client_order_id()
1873 )));
1874 }
1875 };
1876
1877 reports.push(report);
1878 }
1879
1880 Ok(reports)
1881 }
1882 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
1883 "Order submission failed: {error}"
1884 ))),
1885 _ => Err(Error::bad_request("Unexpected response format")),
1886 }
1887 }
1888}
1889
1890#[cfg(test)]
1891mod tests {
1892 use nautilus_core::MUTEX_POISONED;
1893 use nautilus_model::instruments::{Instrument, InstrumentAny};
1894 use rstest::rstest;
1895 use ustr::Ustr;
1896
1897 use super::HyperliquidHttpClient;
1898 use crate::{common::enums::HyperliquidProductType, http::query::InfoRequest};
1899
1900 #[rstest]
1901 fn stable_json_roundtrips() {
1902 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
1903 let s = serde_json::to_string(&v).unwrap();
1904 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1906 assert_eq!(parsed["type"], "l2Book");
1907 assert_eq!(parsed["coin"], "BTC");
1908 assert_eq!(parsed, v);
1909 }
1910
1911 #[rstest]
1912 fn info_pretty_shape() {
1913 let r = InfoRequest::l2_book("BTC");
1914 let val = serde_json::to_value(&r).unwrap();
1915 let pretty = serde_json::to_string_pretty(&val).unwrap();
1916 assert!(pretty.contains("\"type\": \"l2Book\""));
1917 assert!(pretty.contains("\"coin\": \"BTC\""));
1918 }
1919
1920 #[rstest]
1921 fn test_cache_instrument_by_raw_symbol() {
1922 use nautilus_core::time::get_atomic_clock_realtime;
1923 use nautilus_model::{
1924 currencies::CURRENCY_MAP,
1925 enums::CurrencyType,
1926 identifiers::{InstrumentId, Symbol},
1927 instruments::CurrencyPair,
1928 types::{Currency, Price, Quantity},
1929 };
1930
1931 let client = HyperliquidHttpClient::new(true, None, None).unwrap();
1932
1933 let base_code = "vntls:vCURSOR";
1935 let quote_code = "USDC";
1936
1937 {
1939 let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
1940 if !currency_map.contains_key(base_code) {
1941 currency_map.insert(
1942 base_code.to_string(),
1943 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
1944 );
1945 }
1946 }
1947
1948 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
1949 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
1950
1951 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
1953 let venue = *crate::common::consts::HYPERLIQUID_VENUE;
1954 let instrument_id = InstrumentId::new(symbol, venue);
1955
1956 let raw_symbol = Symbol::new(base_code);
1958
1959 let clock = get_atomic_clock_realtime();
1960 let ts = clock.get_time_ns();
1961
1962 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1963 instrument_id,
1964 raw_symbol,
1965 base_currency,
1966 quote_currency,
1967 8,
1968 8,
1969 Price::from("0.00000001"),
1970 Quantity::from("0.00000001"),
1971 None,
1972 None,
1973 None,
1974 None,
1975 None,
1976 None,
1977 None,
1978 None,
1979 None,
1980 None,
1981 None,
1982 None,
1983 ts,
1984 ts,
1985 ));
1986
1987 client.cache_instrument(instrument.clone());
1989
1990 let instruments = client.instruments.read().unwrap();
1992 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
1993 assert!(
1994 by_full_symbol.is_some(),
1995 "Instrument should be accessible by full symbol"
1996 );
1997 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
1998
1999 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
2001 assert!(
2002 by_raw_symbol.is_some(),
2003 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
2004 );
2005 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
2006 drop(instruments);
2007
2008 let instruments_by_coin = client.instruments_by_coin.read().unwrap();
2010 let by_coin =
2011 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
2012 assert!(
2013 by_coin.is_some(),
2014 "Instrument should be accessible by coin and product type"
2015 );
2016 assert_eq!(by_coin.unwrap().id(), instrument.id());
2017 drop(instruments_by_coin);
2018
2019 let retrieved_with_type = client.get_or_create_instrument(
2021 &Ustr::from("vntls:vCURSOR"),
2022 Some(HyperliquidProductType::Spot),
2023 );
2024 assert!(retrieved_with_type.is_some());
2025 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
2026
2027 let retrieved_without_type =
2029 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
2030 assert!(retrieved_without_type.is_some());
2031 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
2032 }
2033}