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