1use std::{
24 collections::HashMap,
25 env,
26 num::NonZeroU32,
27 sync::{Arc, LazyLock, RwLock},
28 time::Duration,
29};
30
31use ahash::AHashMap;
32use anyhow::Context;
33use nautilus_core::{
34 UUID4, UnixNanos, consts::NAUTILUS_USER_AGENT, time::get_atomic_clock_realtime,
35};
36use nautilus_model::{
37 data::{Bar, BarType},
38 enums::{
39 AccountType, BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce,
40 TriggerType,
41 },
42 events::AccountState,
43 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
44 instruments::{CurrencyPair, Instrument, InstrumentAny},
45 orders::{Order, OrderAny},
46 reports::{FillReport, OrderStatusReport, PositionStatusReport},
47 types::{AccountBalance, Currency, Money, Price, Quantity},
48};
49use nautilus_network::{
50 http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
51 ratelimiter::quota::Quota,
52};
53use rust_decimal::Decimal;
54use serde_json::Value;
55use ustr::Ustr;
56
57use crate::{
58 common::{
59 consts::{
60 HYPERLIQUID_VENUE, NAUTILUS_BUILDER_FEE_ADDRESS, NAUTILUS_BUILDER_FEE_TENTHS_BP,
61 exchange_url, info_url,
62 },
63 credential::{Secrets, VaultAddress},
64 enums::{
65 HyperliquidBarInterval, HyperliquidOrderStatus as HyperliquidOrderStatusEnum,
66 HyperliquidProductType,
67 },
68 parse::{bar_type_to_interval, order_to_hyperliquid_request_with_asset},
69 },
70 http::{
71 error::{Error, Result},
72 models::{
73 Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeRequest,
74 HyperliquidExchangeResponse, HyperliquidExecAction, HyperliquidExecBuilderFee,
75 HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
76 HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecOrderKind,
77 HyperliquidExecOrderResponseData, HyperliquidExecOrderStatus,
78 HyperliquidExecPlaceOrderRequest, HyperliquidExecTif, HyperliquidExecTpSl,
79 HyperliquidExecTriggerParams, HyperliquidFills, HyperliquidL2Book, HyperliquidMeta,
80 HyperliquidOrderStatus, PerpMeta, PerpMetaAndCtxs, SpotMeta, SpotMetaAndCtxs,
81 },
82 parse::{
83 HyperliquidInstrumentDef, instruments_from_defs_owned, parse_perp_instruments,
84 parse_spot_instruments,
85 },
86 query::{ExchangeAction, InfoRequest},
87 rate_limits::{
88 RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
89 info_base_weight, info_extra_weight,
90 },
91 },
92 signing::{
93 HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
94 },
95};
96
97pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
99 LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
100
101#[derive(Debug, Clone)]
106#[cfg_attr(
107 feature = "python",
108 pyo3::pyclass(
109 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
110 from_py_object
111 )
112)]
113pub struct HyperliquidRawHttpClient {
114 client: HttpClient,
115 is_testnet: bool,
116 base_info: String,
117 base_exchange: String,
118 signer: Option<HyperliquidEip712Signer>,
119 nonce_manager: Option<Arc<NonceManager>>,
120 vault_address: Option<VaultAddress>,
121 rest_limiter: Arc<WeightedLimiter>,
122 rate_limit_backoff_base: Duration,
123 rate_limit_backoff_cap: Duration,
124 rate_limit_max_attempts_info: u32,
125}
126
127impl HyperliquidRawHttpClient {
128 pub fn new(
134 is_testnet: bool,
135 timeout_secs: Option<u64>,
136 proxy_url: Option<String>,
137 ) -> std::result::Result<Self, HttpClientError> {
138 Ok(Self {
139 client: HttpClient::new(
140 Self::default_headers(),
141 vec![],
142 vec![],
143 Some(*HYPERLIQUID_REST_QUOTA),
144 timeout_secs,
145 proxy_url,
146 )?,
147 is_testnet,
148 base_info: info_url(is_testnet).to_string(),
149 base_exchange: exchange_url(is_testnet).to_string(),
150 signer: None,
151 nonce_manager: None,
152 vault_address: None,
153 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
154 rate_limit_backoff_base: Duration::from_millis(125),
155 rate_limit_backoff_cap: Duration::from_secs(5),
156 rate_limit_max_attempts_info: 3,
157 })
158 }
159
160 pub fn with_credentials(
167 secrets: &Secrets,
168 timeout_secs: Option<u64>,
169 proxy_url: Option<String>,
170 ) -> std::result::Result<Self, HttpClientError> {
171 let signer = HyperliquidEip712Signer::new(secrets.private_key.clone());
172 let nonce_manager = Arc::new(NonceManager::new());
173
174 Ok(Self {
175 client: HttpClient::new(
176 Self::default_headers(),
177 vec![],
178 vec![],
179 Some(*HYPERLIQUID_REST_QUOTA),
180 timeout_secs,
181 proxy_url,
182 )?,
183 is_testnet: secrets.is_testnet,
184 base_info: info_url(secrets.is_testnet).to_string(),
185 base_exchange: exchange_url(secrets.is_testnet).to_string(),
186 signer: Some(signer),
187 nonce_manager: Some(nonce_manager),
188 vault_address: secrets.vault_address,
189 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
190 rate_limit_backoff_base: Duration::from_millis(125),
191 rate_limit_backoff_cap: Duration::from_secs(5),
192 rate_limit_max_attempts_info: 3,
193 })
194 }
195
196 pub fn from_env(is_testnet: bool) -> Result<Self> {
202 let secrets = Secrets::from_env(is_testnet)
203 .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
204 Self::with_credentials(&secrets, None, None)
205 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
206 }
207
208 pub fn from_credentials(
214 private_key: &str,
215 vault_address: Option<&str>,
216 is_testnet: bool,
217 timeout_secs: Option<u64>,
218 proxy_url: Option<String>,
219 ) -> Result<Self> {
220 let secrets = Secrets::from_private_key(private_key, vault_address, is_testnet)
221 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
222 Self::with_credentials(&secrets, timeout_secs, proxy_url)
223 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
224 }
225
226 #[must_use]
228 pub fn with_rate_limits(mut self) -> Self {
229 self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
230 self.rate_limit_backoff_base = Duration::from_millis(125);
231 self.rate_limit_backoff_cap = Duration::from_secs(5);
232 self.rate_limit_max_attempts_info = 3;
233 self
234 }
235
236 #[must_use]
238 pub fn is_testnet(&self) -> bool {
239 self.is_testnet
240 }
241
242 pub fn get_user_address(&self) -> Result<String> {
248 self.signer
249 .as_ref()
250 .ok_or_else(|| Error::auth("No signer configured"))?
251 .address()
252 }
253
254 pub fn get_account_address(&self) -> Result<String> {
261 if let Some(vault) = &self.vault_address {
262 Ok(vault.to_hex())
263 } else {
264 self.get_user_address()
265 }
266 }
267
268 fn default_headers() -> HashMap<String, String> {
270 HashMap::from([
271 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
272 ("Content-Type".to_string(), "application/json".to_string()),
273 ])
274 }
275
276 fn signer_id(&self) -> Result<SignerId> {
277 Ok(SignerId("hyperliquid:default".into()))
278 }
279
280 fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
282 let retry_after = headers.get("retry-after")?;
283 retry_after.parse::<u64>().ok().map(|s| s * 1000) }
285
286 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
288 let request = InfoRequest::meta();
289 let response = self.send_info_request(&request).await?;
290 serde_json::from_value(response).map_err(Error::Serde)
291 }
292
293 pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
295 let request = InfoRequest::spot_meta();
296 let response = self.send_info_request(&request).await?;
297 serde_json::from_value(response).map_err(Error::Serde)
298 }
299
300 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
302 let request = InfoRequest::meta_and_asset_ctxs();
303 let response = self.send_info_request(&request).await?;
304 serde_json::from_value(response).map_err(Error::Serde)
305 }
306
307 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
309 let request = InfoRequest::spot_meta_and_asset_ctxs();
310 let response = self.send_info_request(&request).await?;
311 serde_json::from_value(response).map_err(Error::Serde)
312 }
313
314 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
315 let request = InfoRequest::meta();
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_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
322 let request = InfoRequest::l2_book(coin);
323 let response = self.send_info_request(&request).await?;
324 serde_json::from_value(response).map_err(Error::Serde)
325 }
326
327 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
329 let request = InfoRequest::user_fills(user);
330 let response = self.send_info_request(&request).await?;
331 serde_json::from_value(response).map_err(Error::Serde)
332 }
333
334 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
336 let request = InfoRequest::order_status(user, oid);
337 let response = self.send_info_request(&request).await?;
338 serde_json::from_value(response).map_err(Error::Serde)
339 }
340
341 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
343 let request = InfoRequest::open_orders(user);
344 self.send_info_request(&request).await
345 }
346
347 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
349 let request = InfoRequest::frontend_open_orders(user);
350 self.send_info_request(&request).await
351 }
352
353 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
355 let request = InfoRequest::clearinghouse_state(user);
356 self.send_info_request(&request).await
357 }
358
359 pub async fn info_candle_snapshot(
361 &self,
362 coin: &str,
363 interval: HyperliquidBarInterval,
364 start_time: u64,
365 end_time: u64,
366 ) -> Result<HyperliquidCandleSnapshot> {
367 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
368 let response = self.send_info_request(&request).await?;
369
370 log::trace!(
371 "Candle snapshot raw response (len={}): {:?}",
372 response.as_array().map_or(0, |a| a.len()),
373 response
374 );
375
376 serde_json::from_value(response).map_err(Error::Serde)
377 }
378
379 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
381 self.send_info_request(request).await
382 }
383
384 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
386 let base_w = info_base_weight(request);
387 self.rest_limiter.acquire(base_w).await;
388
389 let mut attempt = 0u32;
390 loop {
391 let response = self.http_roundtrip_info(request).await?;
392
393 if response.status.is_success() {
394 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
396 let extra = info_extra_weight(request, &val);
397 if extra > 0 {
398 self.rest_limiter.debit_extra(extra).await;
399 log::debug!(
400 "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
401 );
402 }
403 return Ok(val);
404 }
405
406 if response.status.as_u16() == 429 {
408 if attempt >= self.rate_limit_max_attempts_info {
409 let ra = self.parse_retry_after_simple(&response.headers);
410 return Err(Error::rate_limit("info", base_w, ra));
411 }
412 let delay = self
413 .parse_retry_after_simple(&response.headers)
414 .map_or_else(
415 || {
416 backoff_full_jitter(
417 attempt,
418 self.rate_limit_backoff_base,
419 self.rate_limit_backoff_cap,
420 )
421 },
422 Duration::from_millis,
423 );
424 log::warn!(
425 "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
426 delay.as_millis()
427 );
428 attempt += 1;
429 tokio::time::sleep(delay).await;
430 self.rest_limiter.acquire(1).await;
432 continue;
433 }
434
435 if (response.status.is_server_error() || response.status.as_u16() == 408)
437 && attempt < self.rate_limit_max_attempts_info
438 {
439 let delay = backoff_full_jitter(
440 attempt,
441 self.rate_limit_backoff_base,
442 self.rate_limit_backoff_cap,
443 );
444 log::warn!(
445 "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
446 response.status.as_u16(),
447 delay.as_millis()
448 );
449 attempt += 1;
450 tokio::time::sleep(delay).await;
451 continue;
452 }
453
454 let error_body = String::from_utf8_lossy(&response.body);
456 return Err(Error::http(
457 response.status.as_u16(),
458 error_body.to_string(),
459 ));
460 }
461 }
462
463 async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
465 let url = &self.base_info;
466 let body = serde_json::to_value(request).map_err(Error::Serde)?;
467 let body_bytes = serde_json::to_string(&body)
468 .map_err(Error::Serde)?
469 .into_bytes();
470
471 self.client
472 .request(
473 Method::POST,
474 url.clone(),
475 None,
476 None,
477 Some(body_bytes),
478 None,
479 None,
480 )
481 .await
482 .map_err(Error::from_http_client)
483 }
484
485 pub async fn post_action(
487 &self,
488 action: &ExchangeAction,
489 ) -> Result<HyperliquidExchangeResponse> {
490 let w = exchange_weight(action);
491 self.rest_limiter.acquire(w).await;
492
493 let signer = self
494 .signer
495 .as_ref()
496 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
497
498 let nonce_manager = self
499 .nonce_manager
500 .as_ref()
501 .ok_or_else(|| Error::auth("nonce manager missing"))?;
502
503 let signer_id = self.signer_id()?;
504 let time_nonce = nonce_manager.next(signer_id)?;
505
506 let action_value = serde_json::to_value(action)
507 .context("serialize exchange action")
508 .map_err(|e| Error::bad_request(e.to_string()))?;
509
510 let action_bytes = rmp_serde::to_vec_named(action)
512 .context("serialize action with MessagePack")
513 .map_err(|e| Error::bad_request(e.to_string()))?;
514
515 let sign_request = SignRequest {
516 action: action_value.clone(),
517 action_bytes: Some(action_bytes),
518 time_nonce,
519 action_type: HyperliquidActionType::L1,
520 is_testnet: self.is_testnet,
521 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
522 };
523
524 let sig = signer.sign(&sign_request)?.signature;
525
526 let nonce_u64 = time_nonce.as_millis() as u64;
527
528 let request = if let Some(vault) = self.vault_address {
529 HyperliquidExchangeRequest::with_vault(
530 action.clone(),
531 nonce_u64,
532 sig,
533 vault.to_string(),
534 )
535 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
536 } else {
537 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
538 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
539 };
540
541 let response = self.http_roundtrip_exchange(&request).await?;
542
543 if response.status.is_success() {
544 let parsed_response: HyperliquidExchangeResponse =
545 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
546
547 match &parsed_response {
549 HyperliquidExchangeResponse::Status {
550 status,
551 response: response_data,
552 } if status == "err" => {
553 let error_msg = response_data
554 .as_str()
555 .map_or_else(|| response_data.to_string(), |s| s.to_string());
556 log::error!("Hyperliquid API returned error: {error_msg}");
557 Err(Error::bad_request(format!("API error: {error_msg}")))
558 }
559 HyperliquidExchangeResponse::Error { error } => {
560 log::error!("Hyperliquid API returned error: {error}");
561 Err(Error::bad_request(format!("API error: {error}")))
562 }
563 _ => Ok(parsed_response),
564 }
565 } else if response.status.as_u16() == 429 {
566 let ra = self.parse_retry_after_simple(&response.headers);
567 Err(Error::rate_limit("exchange", w, ra))
568 } else {
569 let error_body = String::from_utf8_lossy(&response.body);
570 log::error!(
571 "Exchange API error (status {}): {}",
572 response.status.as_u16(),
573 error_body
574 );
575 Err(Error::http(
576 response.status.as_u16(),
577 error_body.to_string(),
578 ))
579 }
580 }
581
582 pub async fn post_action_exec(
587 &self,
588 action: &HyperliquidExecAction,
589 ) -> Result<HyperliquidExchangeResponse> {
590 let w = match action {
591 HyperliquidExecAction::Order { orders, .. } => 1 + (orders.len() as u32 / 40),
592 HyperliquidExecAction::Cancel { cancels } => 1 + (cancels.len() as u32 / 40),
593 HyperliquidExecAction::CancelByCloid { cancels } => 1 + (cancels.len() as u32 / 40),
594 HyperliquidExecAction::BatchModify { modifies } => 1 + (modifies.len() as u32 / 40),
595 _ => 1,
596 };
597 self.rest_limiter.acquire(w).await;
598
599 let signer = self
600 .signer
601 .as_ref()
602 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
603
604 let nonce_manager = self
605 .nonce_manager
606 .as_ref()
607 .ok_or_else(|| Error::auth("nonce manager missing"))?;
608
609 let signer_id = self.signer_id()?;
610 let time_nonce = nonce_manager.next(signer_id)?;
611 let action_value = serde_json::to_value(action)
614 .context("serialize exchange action")
615 .map_err(|e| Error::bad_request(e.to_string()))?;
616
617 let action_bytes = rmp_serde::to_vec_named(action)
619 .context("serialize action with MessagePack")
620 .map_err(|e| Error::bad_request(e.to_string()))?;
621
622 let sig = signer
623 .sign(&SignRequest {
624 action: action_value.clone(),
625 action_bytes: Some(action_bytes),
626 time_nonce,
627 action_type: HyperliquidActionType::L1,
628 is_testnet: self.is_testnet,
629 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
630 })?
631 .signature;
632
633 let request = if let Some(vault) = self.vault_address {
634 HyperliquidExchangeRequest::with_vault(
635 action.clone(),
636 time_nonce.as_millis() as u64,
637 sig,
638 vault.to_string(),
639 )
640 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
641 } else {
642 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
643 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
644 };
645
646 let response = self.http_roundtrip_exchange(&request).await?;
647
648 if response.status.is_success() {
649 let parsed_response: HyperliquidExchangeResponse =
650 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
651
652 match &parsed_response {
654 HyperliquidExchangeResponse::Status {
655 status,
656 response: response_data,
657 } if status == "err" => {
658 let error_msg = response_data
659 .as_str()
660 .map_or_else(|| response_data.to_string(), |s| s.to_string());
661 log::error!("Hyperliquid API returned error: {error_msg}");
662 Err(Error::bad_request(format!("API error: {error_msg}")))
663 }
664 HyperliquidExchangeResponse::Error { error } => {
665 log::error!("Hyperliquid API returned error: {error}");
666 Err(Error::bad_request(format!("API error: {error}")))
667 }
668 _ => Ok(parsed_response),
669 }
670 } else if response.status.as_u16() == 429 {
671 let ra = self.parse_retry_after_simple(&response.headers);
672 Err(Error::rate_limit("exchange", w, ra))
673 } else {
674 let error_body = String::from_utf8_lossy(&response.body);
675 Err(Error::http(
676 response.status.as_u16(),
677 error_body.to_string(),
678 ))
679 }
680 }
681
682 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
685 self.rest_limiter.snapshot().await
686 }
687 async fn http_roundtrip_exchange<T>(
688 &self,
689 request: &HyperliquidExchangeRequest<T>,
690 ) -> Result<HttpResponse>
691 where
692 T: serde::Serialize,
693 {
694 let url = &self.base_exchange;
695 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
696 let body_bytes = body.into_bytes();
697
698 let response = self
699 .client
700 .request(
701 Method::POST,
702 url.clone(),
703 None,
704 None,
705 Some(body_bytes),
706 None,
707 None,
708 )
709 .await
710 .map_err(Error::from_http_client)?;
711
712 Ok(response)
713 }
714}
715
716#[derive(Debug, Clone)]
722#[cfg_attr(
723 feature = "python",
724 pyo3::pyclass(
725 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
726 from_py_object
727 )
728)]
729pub struct HyperliquidHttpClient {
730 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
731 instruments: Arc<RwLock<AHashMap<Ustr, InstrumentAny>>>,
732 instruments_by_coin: Arc<RwLock<AHashMap<(Ustr, HyperliquidProductType), InstrumentAny>>>,
733 asset_indices: Arc<RwLock<AHashMap<Ustr, u32>>>,
735 spot_fill_coins: Arc<RwLock<AHashMap<Ustr, Ustr>>>,
737 account_id: Option<AccountId>,
738}
739
740impl Default for HyperliquidHttpClient {
741 fn default() -> Self {
742 Self::new(true, None, None).expect("Failed to create default Hyperliquid HTTP client")
743 }
744}
745
746impl HyperliquidHttpClient {
747 pub fn new(
753 is_testnet: bool,
754 timeout_secs: Option<u64>,
755 proxy_url: Option<String>,
756 ) -> std::result::Result<Self, HttpClientError> {
757 let raw_client = HyperliquidRawHttpClient::new(is_testnet, timeout_secs, proxy_url)?;
758 Ok(Self {
759 inner: Arc::new(raw_client),
760 instruments: Arc::new(RwLock::new(AHashMap::new())),
761 instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
762 asset_indices: Arc::new(RwLock::new(AHashMap::new())),
763 spot_fill_coins: Arc::new(RwLock::new(AHashMap::new())),
764 account_id: None,
765 })
766 }
767
768 pub fn with_secrets(
774 secrets: &Secrets,
775 timeout_secs: Option<u64>,
776 proxy_url: Option<String>,
777 ) -> std::result::Result<Self, HttpClientError> {
778 let raw_client =
779 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
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 asset_indices: Arc::new(RwLock::new(AHashMap::new())),
785 spot_fill_coins: Arc::new(RwLock::new(AHashMap::new())),
786 account_id: None,
787 })
788 }
789
790 pub fn from_env(is_testnet: bool) -> Result<Self> {
796 let raw_client = HyperliquidRawHttpClient::from_env(is_testnet)?;
797 Ok(Self {
798 inner: Arc::new(raw_client),
799 instruments: Arc::new(RwLock::new(AHashMap::new())),
800 instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
801 asset_indices: Arc::new(RwLock::new(AHashMap::new())),
802 spot_fill_coins: Arc::new(RwLock::new(AHashMap::new())),
803 account_id: None,
804 })
805 }
806
807 pub fn with_credentials(
820 private_key: Option<String>,
821 vault_address: Option<String>,
822 is_testnet: bool,
823 timeout_secs: Option<u64>,
824 proxy_url: Option<String>,
825 ) -> Result<Self> {
826 let pk_env_var = if is_testnet {
828 "HYPERLIQUID_TESTNET_PK"
829 } else {
830 "HYPERLIQUID_PK"
831 };
832 let vault_env_var = if is_testnet {
833 "HYPERLIQUID_TESTNET_VAULT"
834 } else {
835 "HYPERLIQUID_VAULT"
836 };
837
838 let resolved_pk = match private_key {
840 Some(pk) => Some(pk),
841 None => env::var(pk_env_var).ok(),
842 };
843
844 let resolved_vault = match vault_address {
846 Some(vault) => Some(vault),
847 None => env::var(vault_env_var).ok(),
848 };
849
850 match resolved_pk {
851 Some(pk) => {
852 let raw_client = HyperliquidRawHttpClient::from_credentials(
853 &pk,
854 resolved_vault.as_deref(),
855 is_testnet,
856 timeout_secs,
857 proxy_url,
858 )?;
859 Ok(Self {
860 inner: Arc::new(raw_client),
861 instruments: Arc::new(RwLock::new(AHashMap::new())),
862 instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
863 asset_indices: Arc::new(RwLock::new(AHashMap::new())),
864 spot_fill_coins: Arc::new(RwLock::new(AHashMap::new())),
865 account_id: None,
866 })
867 }
868 None => {
869 Self::new(is_testnet, timeout_secs, proxy_url)
871 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
872 }
873 }
874 }
875
876 pub fn from_credentials(
882 private_key: &str,
883 vault_address: Option<&str>,
884 is_testnet: bool,
885 timeout_secs: Option<u64>,
886 proxy_url: Option<String>,
887 ) -> Result<Self> {
888 let raw_client = HyperliquidRawHttpClient::from_credentials(
889 private_key,
890 vault_address,
891 is_testnet,
892 timeout_secs,
893 proxy_url,
894 )?;
895 Ok(Self {
896 inner: Arc::new(raw_client),
897 instruments: Arc::new(RwLock::new(AHashMap::new())),
898 instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
899 asset_indices: Arc::new(RwLock::new(AHashMap::new())),
900 spot_fill_coins: Arc::new(RwLock::new(AHashMap::new())),
901 account_id: None,
902 })
903 }
904
905 #[must_use]
907 pub fn is_testnet(&self) -> bool {
908 self.inner.is_testnet()
909 }
910
911 pub fn get_user_address(&self) -> Result<String> {
917 self.inner.get_user_address()
918 }
919
920 pub fn get_account_address(&self) -> Result<String> {
927 self.inner.get_account_address()
928 }
929
930 pub fn cache_instrument(&self, instrument: InstrumentAny) {
939 let full_symbol = instrument.symbol().inner();
940 let coin = instrument.raw_symbol().inner();
941
942 {
943 let mut instruments = self
944 .instruments
945 .write()
946 .expect("Failed to acquire write lock");
947
948 instruments.insert(full_symbol, instrument.clone());
949
950 instruments.insert(coin, instrument.clone());
952 }
953
954 if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
956 let mut instruments_by_coin = self
957 .instruments_by_coin
958 .write()
959 .expect("Failed to acquire write lock");
960 instruments_by_coin.insert((coin, product_type), instrument);
961 } else {
962 log::warn!("Unable to determine product type for symbol: {full_symbol}");
963 }
964 }
965
966 fn get_or_create_instrument(
982 &self,
983 coin: &Ustr,
984 product_type: Option<HyperliquidProductType>,
985 ) -> Option<InstrumentAny> {
986 if let Some(pt) = product_type {
987 let instruments_by_coin = self
988 .instruments_by_coin
989 .read()
990 .expect("Failed to acquire read lock");
991
992 if let Some(instrument) = instruments_by_coin.get(&(*coin, pt)) {
993 return Some(instrument.clone());
994 }
995 }
996
997 if product_type.is_none() {
999 let instruments_by_coin = self
1000 .instruments_by_coin
1001 .read()
1002 .expect("Failed to acquire read lock");
1003
1004 if let Some(instrument) =
1005 instruments_by_coin.get(&(*coin, HyperliquidProductType::Perp))
1006 {
1007 return Some(instrument.clone());
1008 }
1009 if let Some(instrument) =
1010 instruments_by_coin.get(&(*coin, HyperliquidProductType::Spot))
1011 {
1012 return Some(instrument.clone());
1013 }
1014 }
1015
1016 if coin.as_str().starts_with('@') {
1018 let spot_fill_coins = self
1019 .spot_fill_coins
1020 .read()
1021 .expect("Failed to acquire read lock");
1022 if let Some(symbol) = spot_fill_coins.get(coin) {
1023 let instruments = self
1026 .instruments
1027 .read()
1028 .expect("Failed to acquire read lock");
1029 if let Some(instrument) = instruments.get(symbol) {
1030 return Some(instrument.clone());
1031 }
1032 }
1033 }
1034
1035 if coin.as_str().starts_with("vntls:") {
1037 log::info!("Creating synthetic instrument for vault token: {coin}");
1038
1039 let clock = nautilus_core::time::get_atomic_clock_realtime();
1040 let ts_event = clock.get_time_ns();
1041
1042 let symbol_str = format!("{coin}-USDC-SPOT");
1044 let symbol = Symbol::new(&symbol_str);
1045 let venue = *HYPERLIQUID_VENUE;
1046 let instrument_id = InstrumentId::new(symbol, venue);
1047
1048 let base_currency = Currency::new(
1050 coin.as_str(),
1051 8, 0, coin.as_str(),
1054 CurrencyType::Crypto,
1055 );
1056
1057 let quote_currency = Currency::new(
1058 "USDC",
1059 6, 0,
1061 "USDC",
1062 CurrencyType::Crypto,
1063 );
1064
1065 let price_increment = Price::from("0.00000001");
1066 let size_increment = Quantity::from("0.00000001");
1067
1068 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1069 instrument_id,
1070 symbol,
1071 base_currency,
1072 quote_currency,
1073 8, 8, price_increment,
1076 size_increment,
1077 None, None, None, None, None, None, None, None, None, None, None, None, ts_event,
1090 ts_event,
1091 ));
1092
1093 self.cache_instrument(instrument.clone());
1094
1095 Some(instrument)
1096 } else {
1097 log::warn!("Instrument not found in cache: {coin}");
1099 None
1100 }
1101 }
1102
1103 pub fn set_account_id(&mut self, account_id: AccountId) {
1107 self.account_id = Some(account_id);
1108 }
1109
1110 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1116 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1117
1118 match self.inner.load_perp_meta().await {
1119 Ok(perp_meta) => match parse_perp_instruments(&perp_meta) {
1120 Ok(perp_defs) => {
1121 log::debug!(
1122 "Loaded Hyperliquid perp definitions: count={}",
1123 perp_defs.len(),
1124 );
1125 defs.extend(perp_defs);
1126 }
1127 Err(e) => {
1128 log::warn!("Failed to parse Hyperliquid perp instruments: {e}");
1129 }
1130 },
1131 Err(e) => {
1132 log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1133 }
1134 }
1135
1136 match self.inner.get_spot_meta().await {
1137 Ok(spot_meta) => match parse_spot_instruments(&spot_meta) {
1138 Ok(spot_defs) => {
1139 log::debug!(
1140 "Loaded Hyperliquid spot definitions: count={}",
1141 spot_defs.len(),
1142 );
1143 defs.extend(spot_defs);
1144 }
1145 Err(e) => {
1146 log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1147 }
1148 },
1149 Err(e) => {
1150 log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1151 }
1152 }
1153
1154 {
1156 let mut asset_indices = self
1157 .asset_indices
1158 .write()
1159 .expect("Failed to acquire write lock");
1160 for def in &defs {
1161 asset_indices.insert(def.symbol, def.asset_index);
1162 }
1163 log::debug!(
1164 "Populated asset indices map (count={})",
1165 asset_indices.len()
1166 );
1167 }
1168
1169 Ok(instruments_from_defs_owned(defs))
1170 }
1171
1172 pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1184 let asset_indices = self
1185 .asset_indices
1186 .read()
1187 .expect("Failed to acquire read lock");
1188 asset_indices.get(&Ustr::from(symbol)).copied()
1189 }
1190
1191 #[must_use]
1203 pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1204 const SPOT_INDEX_OFFSET: u32 = 10000;
1205
1206 let asset_indices = self
1207 .asset_indices
1208 .read()
1209 .expect("Failed to acquire read lock");
1210
1211 let mut mapping = AHashMap::new();
1212 for (symbol, &asset_index) in asset_indices.iter() {
1213 if asset_index >= SPOT_INDEX_OFFSET {
1215 let pair_index = asset_index - SPOT_INDEX_OFFSET;
1216 let fill_coin = Ustr::from(&format!("@{pair_index}"));
1217 mapping.insert(fill_coin, *symbol);
1218 }
1219 }
1220
1221 {
1223 let mut spot_fill_coins = self
1224 .spot_fill_coins
1225 .write()
1226 .expect("Failed to acquire write lock");
1227 *spot_fill_coins = mapping.clone();
1228 }
1229
1230 mapping
1231 }
1232
1233 #[allow(dead_code)]
1235 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1236 self.inner.load_perp_meta().await
1237 }
1238
1239 #[allow(dead_code)]
1241 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1242 self.inner.get_spot_meta().await
1243 }
1244
1245 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1247 self.inner.info_l2_book(coin).await
1248 }
1249
1250 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1252 self.inner.info_user_fills(user).await
1253 }
1254
1255 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1257 self.inner.info_order_status(user, oid).await
1258 }
1259
1260 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1262 self.inner.info_open_orders(user).await
1263 }
1264
1265 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1267 self.inner.info_frontend_open_orders(user).await
1268 }
1269
1270 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1272 self.inner.info_clearinghouse_state(user).await
1273 }
1274
1275 pub async fn info_candle_snapshot(
1277 &self,
1278 coin: &str,
1279 interval: HyperliquidBarInterval,
1280 start_time: u64,
1281 end_time: u64,
1282 ) -> Result<HyperliquidCandleSnapshot> {
1283 self.inner
1284 .info_candle_snapshot(coin, interval, start_time, end_time)
1285 .await
1286 }
1287
1288 pub async fn post_action(
1290 &self,
1291 action: &ExchangeAction,
1292 ) -> Result<HyperliquidExchangeResponse> {
1293 self.inner.post_action(action).await
1294 }
1295
1296 pub async fn post_action_exec(
1298 &self,
1299 action: &HyperliquidExecAction,
1300 ) -> Result<HyperliquidExchangeResponse> {
1301 self.inner.post_action_exec(action).await
1302 }
1303
1304 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1306 self.inner.info_meta().await
1307 }
1308
1309 pub async fn cancel_order(
1319 &self,
1320 instrument_id: InstrumentId,
1321 client_order_id: Option<ClientOrderId>,
1322 venue_order_id: Option<VenueOrderId>,
1323 ) -> Result<()> {
1324 let symbol = instrument_id.symbol.as_str();
1326 let asset_id = self.get_asset_index(symbol).ok_or_else(|| {
1327 Error::bad_request(format!(
1328 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1329 ))
1330 })?;
1331
1332 let action = if let Some(cloid) = client_order_id {
1334 let cloid_hash = Cloid::from_client_order_id(cloid);
1336 let cancel_req = HyperliquidExecCancelByCloidRequest {
1337 asset: asset_id,
1338 cloid: cloid_hash,
1339 };
1340 HyperliquidExecAction::CancelByCloid {
1341 cancels: vec![cancel_req],
1342 }
1343 } else if let Some(oid) = venue_order_id {
1344 let oid_u64 = oid
1345 .as_str()
1346 .parse::<u64>()
1347 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1348 let cancel_req = HyperliquidExecCancelOrderRequest {
1349 asset: asset_id,
1350 oid: oid_u64,
1351 };
1352 HyperliquidExecAction::Cancel {
1353 cancels: vec![cancel_req],
1354 }
1355 } else {
1356 return Err(Error::bad_request(
1357 "Either client_order_id or venue_order_id must be provided",
1358 ));
1359 };
1360
1361 let response = self.inner.post_action_exec(&action).await?;
1363
1364 match response {
1366 HyperliquidExchangeResponse::Status { status, .. } if status == "ok" => Ok(()),
1367 HyperliquidExchangeResponse::Status {
1368 status,
1369 response: error_data,
1370 } => Err(Error::bad_request(format!(
1371 "Cancel order failed: status={status}, error={error_data}"
1372 ))),
1373 HyperliquidExchangeResponse::Error { error } => {
1374 Err(Error::bad_request(format!("Cancel order error: {error}")))
1375 }
1376 }
1377 }
1378
1379 pub async fn request_order_status_reports(
1395 &self,
1396 user: &str,
1397 instrument_id: Option<InstrumentId>,
1398 ) -> Result<Vec<OrderStatusReport>> {
1399 let response = self.info_frontend_open_orders(user).await?;
1400
1401 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1403 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1404
1405 let mut reports = Vec::new();
1406 let ts_init = UnixNanos::default();
1407
1408 for order_value in orders {
1409 let order: crate::websocket::messages::WsBasicOrderData =
1411 match serde_json::from_value(order_value.clone()) {
1412 Ok(o) => o,
1413 Err(e) => {
1414 log::warn!("Failed to parse order: {e}");
1415 continue;
1416 }
1417 };
1418
1419 let instrument = match self.get_or_create_instrument(&order.coin, None) {
1421 Some(inst) => inst,
1422 None => continue, };
1424
1425 if let Some(filter_id) = instrument_id
1427 && instrument.id() != filter_id
1428 {
1429 continue;
1430 }
1431
1432 let status = HyperliquidOrderStatusEnum::Open;
1434
1435 match crate::http::parse::parse_order_status_report_from_basic(
1437 &order,
1438 &status,
1439 &instrument,
1440 self.account_id.expect("account_id not set"),
1441 ts_init,
1442 ) {
1443 Ok(report) => reports.push(report),
1444 Err(e) => log::error!("Failed to parse order status report: {e}"),
1445 }
1446 }
1447
1448 Ok(reports)
1449 }
1450
1451 pub async fn request_fill_reports(
1467 &self,
1468 user: &str,
1469 instrument_id: Option<InstrumentId>,
1470 ) -> Result<Vec<FillReport>> {
1471 let fills_response = self.info_user_fills(user).await?;
1472
1473 let mut reports = Vec::new();
1474 let ts_init = UnixNanos::default();
1475
1476 for fill in fills_response {
1477 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
1479 Some(inst) => inst,
1480 None => continue, };
1482
1483 if let Some(filter_id) = instrument_id
1485 && instrument.id() != filter_id
1486 {
1487 continue;
1488 }
1489
1490 match crate::http::parse::parse_fill_report(
1492 &fill,
1493 &instrument,
1494 self.account_id.expect("account_id not set"),
1495 ts_init,
1496 ) {
1497 Ok(report) => reports.push(report),
1498 Err(e) => log::error!("Failed to parse fill report: {e}"),
1499 }
1500 }
1501
1502 Ok(reports)
1503 }
1504
1505 pub async fn request_position_status_reports(
1521 &self,
1522 user: &str,
1523 instrument_id: Option<InstrumentId>,
1524 ) -> Result<Vec<PositionStatusReport>> {
1525 let state_response = self.info_clearinghouse_state(user).await?;
1526
1527 let asset_positions: Vec<serde_json::Value> = state_response
1529 .get("assetPositions")
1530 .and_then(|v| v.as_array())
1531 .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
1532 .clone();
1533
1534 let mut reports = Vec::new();
1535 let ts_init = UnixNanos::default();
1536
1537 for position_value in asset_positions {
1538 let coin = position_value
1540 .get("position")
1541 .and_then(|p| p.get("coin"))
1542 .and_then(|c| c.as_str())
1543 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
1544
1545 let coin_ustr = Ustr::from(coin);
1547 let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
1548 Some(inst) => inst,
1549 None => continue, };
1551
1552 if let Some(filter_id) = instrument_id
1554 && instrument.id() != filter_id
1555 {
1556 continue;
1557 }
1558
1559 match crate::http::parse::parse_position_status_report(
1561 &position_value,
1562 &instrument,
1563 self.account_id.expect("account_id not set"),
1564 ts_init,
1565 ) {
1566 Ok(report) => reports.push(report),
1567 Err(e) => log::error!("Failed to parse position status report: {e}"),
1568 }
1569 }
1570
1571 Ok(reports)
1572 }
1573
1574 pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
1586 let state_response = self.info_clearinghouse_state(user).await?;
1587 let ts_init = get_atomic_clock_realtime().get_time_ns();
1588
1589 log::trace!("Clearinghouse state response: {state_response}");
1590
1591 let state: crate::http::models::ClearinghouseState =
1593 serde_json::from_value(state_response.clone()).map_err(|e| {
1594 log::error!("Failed to parse clearinghouse state: {e}");
1595 log::debug!("Raw response: {state_response}");
1596 Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
1597 })?;
1598
1599 let usdc = Currency::new("USDC", 6, 0, "0.000001", CurrencyType::Crypto);
1601
1602 let balances = if let Some(margin) = &state.cross_margin_summary {
1604 let mut total = margin.total_raw_usd.max(Decimal::ZERO);
1605 let free = state.withdrawable.unwrap_or(total).max(Decimal::ZERO);
1606
1607 if free > total {
1609 log::debug!("Adjusting total ({total}) to match withdrawable ({free})");
1610 total = free;
1611 }
1612
1613 let locked = (total - free).max(Decimal::ZERO);
1614
1615 vec![AccountBalance::new(
1616 Money::from_decimal(total, usdc).map_err(|e| Error::decode(e.to_string()))?,
1617 Money::from_decimal(locked, usdc).map_err(|e| Error::decode(e.to_string()))?,
1618 Money::from_decimal(free, usdc).map_err(|e| Error::decode(e.to_string()))?,
1619 )]
1620 } else {
1621 let free = state
1623 .withdrawable
1624 .unwrap_or(Decimal::ZERO)
1625 .max(Decimal::ZERO);
1626
1627 vec![AccountBalance::new(
1628 Money::from_decimal(free, usdc).map_err(|e| Error::decode(e.to_string()))?,
1629 Money::zero(usdc),
1630 Money::from_decimal(free, usdc).map_err(|e| Error::decode(e.to_string()))?,
1631 )]
1632 };
1633
1634 let account_id = self.account_id.expect("account_id not set");
1635
1636 Ok(AccountState::new(
1637 account_id,
1638 AccountType::Margin,
1639 balances,
1640 vec![], true, UUID4::new(),
1643 ts_init,
1644 ts_init,
1645 None,
1646 ))
1647 }
1648
1649 pub async fn request_bars(
1666 &self,
1667 bar_type: BarType,
1668 start: Option<chrono::DateTime<chrono::Utc>>,
1669 end: Option<chrono::DateTime<chrono::Utc>>,
1670 limit: Option<u32>,
1671 ) -> Result<Vec<Bar>> {
1672 let instrument_id = bar_type.instrument_id();
1673 let symbol = instrument_id.symbol;
1674
1675 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
1676
1677 let base = Ustr::from(
1679 symbol
1680 .as_str()
1681 .split('-')
1682 .next()
1683 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?,
1684 );
1685
1686 let instrument = self
1687 .get_or_create_instrument(&base, product_type)
1688 .ok_or_else(|| {
1689 Error::bad_request(format!("Instrument not found in cache: {instrument_id}"))
1690 })?;
1691
1692 let coin = instrument.raw_symbol().inner();
1697
1698 let price_precision = instrument.price_precision();
1699 let size_precision = instrument.size_precision();
1700
1701 let interval =
1702 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
1703
1704 let now = chrono::Utc::now();
1706 let end_time = end.unwrap_or(now).timestamp_millis() as u64;
1707 let start_time = if let Some(start) = start {
1708 start.timestamp_millis() as u64
1709 } else {
1710 let spec = bar_type.spec();
1712 let step_ms = match spec.aggregation {
1713 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
1714 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
1715 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
1716 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
1717 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
1718 _ => 60_000,
1719 };
1720 end_time.saturating_sub(1000 * step_ms)
1721 };
1722
1723 let candles = self
1724 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
1725 .await?;
1726
1727 let now_ms = now.timestamp_millis() as u64;
1729
1730 let mut bars: Vec<Bar> = candles
1731 .iter()
1732 .filter(|candle| candle.end_timestamp < now_ms)
1733 .enumerate()
1734 .filter_map(|(i, candle)| {
1735 crate::data::candle_to_bar(candle, bar_type, price_precision, size_precision)
1736 .map_err(|e| {
1737 log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
1738 e
1739 })
1740 .ok()
1741 })
1742 .collect();
1743
1744 if let Some(limit) = limit
1746 && limit > 0
1747 && bars.len() > limit as usize
1748 {
1749 bars.truncate(limit as usize);
1750 }
1751
1752 log::debug!(
1753 "Received {} bars for {} (filtered {} incomplete)",
1754 bars.len(),
1755 bar_type,
1756 candles.len() - bars.len()
1757 );
1758 Ok(bars)
1759 }
1760 #[allow(clippy::too_many_arguments)]
1767 pub async fn submit_order(
1768 &self,
1769 instrument_id: InstrumentId,
1770 client_order_id: ClientOrderId,
1771 order_side: OrderSide,
1772 order_type: OrderType,
1773 quantity: Quantity,
1774 time_in_force: TimeInForce,
1775 price: Option<Price>,
1776 trigger_price: Option<Price>,
1777 post_only: bool,
1778 reduce_only: bool,
1779 ) -> Result<OrderStatusReport> {
1780 let symbol = instrument_id.symbol.as_str();
1781 let asset = self.get_asset_index(symbol).ok_or_else(|| {
1782 Error::bad_request(format!(
1783 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1784 ))
1785 })?;
1786
1787 let is_buy = matches!(order_side, OrderSide::Buy);
1788
1789 let price_decimal = match price {
1790 Some(px) => px.as_decimal().normalize(),
1791 None => {
1792 if matches!(
1793 order_type,
1794 OrderType::Market | OrderType::StopMarket | OrderType::MarketIfTouched
1795 ) {
1796 Decimal::ZERO
1797 } else {
1798 return Err(Error::bad_request("Limit orders require a price"));
1799 }
1800 }
1801 };
1802
1803 let size_decimal = quantity.as_decimal().normalize();
1804
1805 let kind = match order_type {
1806 OrderType::Market => HyperliquidExecOrderKind::Limit {
1807 limit: HyperliquidExecLimitParams {
1808 tif: HyperliquidExecTif::Ioc,
1809 },
1810 },
1811 OrderType::Limit => {
1812 let tif = if post_only {
1813 HyperliquidExecTif::Alo
1814 } else {
1815 match time_in_force {
1816 TimeInForce::Gtc => HyperliquidExecTif::Gtc,
1817 TimeInForce::Ioc => HyperliquidExecTif::Ioc,
1818 TimeInForce::Fok
1819 | TimeInForce::Day
1820 | TimeInForce::Gtd
1821 | TimeInForce::AtTheOpen
1822 | TimeInForce::AtTheClose => {
1823 return Err(Error::bad_request(format!(
1824 "Time in force {time_in_force:?} not supported"
1825 )));
1826 }
1827 }
1828 };
1829 HyperliquidExecOrderKind::Limit {
1830 limit: HyperliquidExecLimitParams { tif },
1831 }
1832 }
1833 OrderType::StopMarket
1834 | OrderType::StopLimit
1835 | OrderType::MarketIfTouched
1836 | OrderType::LimitIfTouched => {
1837 if let Some(trig_px) = trigger_price {
1838 let trigger_price_decimal = trig_px.as_decimal().normalize();
1839
1840 let tpsl = match order_type {
1844 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1845 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
1846 HyperliquidExecTpSl::Tp
1847 }
1848 _ => unreachable!(),
1849 };
1850
1851 let is_market = matches!(
1852 order_type,
1853 OrderType::StopMarket | OrderType::MarketIfTouched
1854 );
1855
1856 HyperliquidExecOrderKind::Trigger {
1857 trigger: HyperliquidExecTriggerParams {
1858 is_market,
1859 trigger_px: trigger_price_decimal,
1860 tpsl,
1861 },
1862 }
1863 } else {
1864 return Err(Error::bad_request("Trigger orders require a trigger price"));
1865 }
1866 }
1867 _ => {
1868 return Err(Error::bad_request(format!(
1869 "Order type {order_type:?} not supported"
1870 )));
1871 }
1872 };
1873
1874 let hyperliquid_order = HyperliquidExecPlaceOrderRequest {
1875 asset,
1876 is_buy,
1877 price: price_decimal,
1878 size: size_decimal,
1879 reduce_only,
1880 kind,
1881 cloid: Some(Cloid::from_client_order_id(client_order_id)),
1882 };
1883
1884 let action = HyperliquidExecAction::Order {
1885 orders: vec![hyperliquid_order],
1886 grouping: HyperliquidExecGrouping::Na,
1887 builder: Some(HyperliquidExecBuilderFee {
1888 address: NAUTILUS_BUILDER_FEE_ADDRESS.to_string(),
1889 fee_tenths_bp: NAUTILUS_BUILDER_FEE_TENTHS_BP,
1890 }),
1891 };
1892
1893 let response = self.inner.post_action_exec(&action).await?;
1894
1895 match response {
1896 HyperliquidExchangeResponse::Status {
1897 status,
1898 response: response_data,
1899 } if status == "ok" => {
1900 let data_value = if let Some(data) = response_data.get("data") {
1901 data.clone()
1902 } else {
1903 response_data
1904 };
1905
1906 let order_response: HyperliquidExecOrderResponseData =
1907 serde_json::from_value(data_value).map_err(|e| {
1908 Error::bad_request(format!("Failed to parse order response: {e}"))
1909 })?;
1910
1911 let order_status = order_response
1912 .statuses
1913 .first()
1914 .ok_or_else(|| Error::bad_request("No order status in response"))?;
1915
1916 let symbol_str = instrument_id.symbol.as_str();
1917 let product_type = HyperliquidProductType::from_symbol(symbol_str).ok();
1918
1919 let asset_str = symbol_str.split('-').next().unwrap_or(symbol_str);
1921 let instrument = self
1922 .get_or_create_instrument(&Ustr::from(asset_str), product_type)
1923 .ok_or_else(|| {
1924 Error::bad_request(format!("Instrument not found for {asset_str}"))
1925 })?;
1926
1927 let account_id = self
1928 .account_id
1929 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1930 let ts_init = UnixNanos::default();
1931
1932 match order_status {
1933 HyperliquidExecOrderStatus::Resting { resting } => self
1934 .create_order_status_report(
1935 instrument_id,
1936 Some(client_order_id),
1937 VenueOrderId::new(resting.oid.to_string()),
1938 order_side,
1939 order_type,
1940 quantity,
1941 time_in_force,
1942 price,
1943 trigger_price,
1944 OrderStatus::Accepted,
1945 Quantity::new(0.0, instrument.size_precision()),
1946 &instrument,
1947 account_id,
1948 ts_init,
1949 ),
1950 HyperliquidExecOrderStatus::Filled { filled } => {
1951 let filled_qty = Quantity::new(
1952 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
1953 instrument.size_precision(),
1954 );
1955 self.create_order_status_report(
1956 instrument_id,
1957 Some(client_order_id),
1958 VenueOrderId::new(filled.oid.to_string()),
1959 order_side,
1960 order_type,
1961 quantity,
1962 time_in_force,
1963 price,
1964 trigger_price,
1965 OrderStatus::Filled,
1966 filled_qty,
1967 &instrument,
1968 account_id,
1969 ts_init,
1970 )
1971 }
1972 HyperliquidExecOrderStatus::Error { error } => {
1973 Err(Error::bad_request(format!("Order rejected: {error}")))
1974 }
1975 }
1976 }
1977 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
1978 "Order submission failed: {error}"
1979 ))),
1980 _ => Err(Error::bad_request("Unexpected response format")),
1981 }
1982 }
1983
1984 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
1988 self.submit_order(
1989 order.instrument_id(),
1990 order.client_order_id(),
1991 order.order_side(),
1992 order.order_type(),
1993 order.quantity(),
1994 order.time_in_force(),
1995 order.price(),
1996 order.trigger_price(),
1997 order.is_post_only(),
1998 order.is_reduce_only(),
1999 )
2000 .await
2001 }
2002
2003 #[allow(clippy::too_many_arguments)]
2005 fn create_order_status_report(
2006 &self,
2007 instrument_id: InstrumentId,
2008 client_order_id: Option<ClientOrderId>,
2009 venue_order_id: VenueOrderId,
2010 order_side: OrderSide,
2011 order_type: OrderType,
2012 quantity: Quantity,
2013 time_in_force: TimeInForce,
2014 price: Option<Price>,
2015 trigger_price: Option<Price>,
2016 order_status: OrderStatus,
2017 filled_qty: Quantity,
2018 _instrument: &InstrumentAny,
2019 account_id: AccountId,
2020 ts_init: UnixNanos,
2021 ) -> Result<OrderStatusReport> {
2022 let clock = get_atomic_clock_realtime();
2023 let ts_accepted = clock.get_time_ns();
2024 let ts_last = ts_accepted;
2025 let report_id = UUID4::new();
2026
2027 let mut report = OrderStatusReport::new(
2028 account_id,
2029 instrument_id,
2030 client_order_id,
2031 venue_order_id,
2032 order_side,
2033 order_type,
2034 time_in_force,
2035 order_status,
2036 quantity,
2037 filled_qty,
2038 ts_accepted,
2039 ts_last,
2040 ts_init,
2041 Some(report_id),
2042 );
2043
2044 if let Some(px) = price {
2045 report = report.with_price(px);
2046 }
2047
2048 if let Some(trig_px) = trigger_price {
2049 report = report
2050 .with_trigger_price(trig_px)
2051 .with_trigger_type(TriggerType::Default);
2052 }
2053
2054 Ok(report)
2055 }
2056
2057 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
2064 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
2066
2067 for order in orders {
2068 let instrument_id = order.instrument_id();
2069 let symbol = instrument_id.symbol.as_str();
2070 let asset = self.get_asset_index(symbol).ok_or_else(|| {
2071 Error::bad_request(format!(
2072 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2073 ))
2074 })?;
2075 let request = order_to_hyperliquid_request_with_asset(order, asset)
2076 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
2077 hyperliquid_orders.push(request);
2078 }
2079
2080 let action = HyperliquidExecAction::Order {
2081 orders: hyperliquid_orders,
2082 grouping: HyperliquidExecGrouping::Na,
2083 builder: Some(HyperliquidExecBuilderFee {
2084 address: NAUTILUS_BUILDER_FEE_ADDRESS.to_string(),
2085 fee_tenths_bp: NAUTILUS_BUILDER_FEE_TENTHS_BP,
2086 }),
2087 };
2088
2089 let response = self.inner.post_action_exec(&action).await?;
2091
2092 match response {
2094 HyperliquidExchangeResponse::Status {
2095 status,
2096 response: response_data,
2097 } if status == "ok" => {
2098 let data_value = if let Some(data) = response_data.get("data") {
2101 data.clone()
2102 } else {
2103 response_data
2104 };
2105
2106 let order_response: HyperliquidExecOrderResponseData =
2108 serde_json::from_value(data_value).map_err(|e| {
2109 Error::bad_request(format!("Failed to parse order response: {e}"))
2110 })?;
2111
2112 let account_id = self
2113 .account_id
2114 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2115 let ts_init = UnixNanos::default();
2116
2117 if order_response.statuses.len() != orders.len() {
2119 return Err(Error::bad_request(format!(
2120 "Mismatch between submitted orders ({}) and response statuses ({})",
2121 orders.len(),
2122 order_response.statuses.len()
2123 )));
2124 }
2125
2126 let mut reports = Vec::new();
2127
2128 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
2130 let instrument_id = order.instrument_id();
2132 let symbol = instrument_id.symbol.as_str();
2133 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
2134
2135 let asset = symbol.split('-').next().unwrap_or(symbol);
2137 let instrument = self
2138 .get_or_create_instrument(&Ustr::from(asset), product_type)
2139 .ok_or_else(|| {
2140 Error::bad_request(format!("Instrument not found for {asset}"))
2141 })?;
2142
2143 let report = match order_status {
2145 HyperliquidExecOrderStatus::Resting { resting } => {
2146 self.create_order_status_report(
2148 order.instrument_id(),
2149 Some(order.client_order_id()),
2150 VenueOrderId::new(resting.oid.to_string()),
2151 order.order_side(),
2152 order.order_type(),
2153 order.quantity(),
2154 order.time_in_force(),
2155 order.price(),
2156 order.trigger_price(),
2157 OrderStatus::Accepted,
2158 Quantity::new(0.0, instrument.size_precision()),
2159 &instrument,
2160 account_id,
2161 ts_init,
2162 )?
2163 }
2164 HyperliquidExecOrderStatus::Filled { filled } => {
2165 let filled_qty = Quantity::new(
2167 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
2168 instrument.size_precision(),
2169 );
2170 self.create_order_status_report(
2171 order.instrument_id(),
2172 Some(order.client_order_id()),
2173 VenueOrderId::new(filled.oid.to_string()),
2174 order.order_side(),
2175 order.order_type(),
2176 order.quantity(),
2177 order.time_in_force(),
2178 order.price(),
2179 order.trigger_price(),
2180 OrderStatus::Filled,
2181 filled_qty,
2182 &instrument,
2183 account_id,
2184 ts_init,
2185 )?
2186 }
2187 HyperliquidExecOrderStatus::Error { error } => {
2188 return Err(Error::bad_request(format!(
2189 "Order {} rejected: {error}",
2190 order.client_order_id()
2191 )));
2192 }
2193 };
2194
2195 reports.push(report);
2196 }
2197
2198 Ok(reports)
2199 }
2200 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
2201 "Order submission failed: {error}"
2202 ))),
2203 _ => Err(Error::bad_request("Unexpected response format")),
2204 }
2205 }
2206}
2207
2208#[cfg(test)]
2209mod tests {
2210 use nautilus_core::MUTEX_POISONED;
2211 use nautilus_model::instruments::{Instrument, InstrumentAny};
2212 use rstest::rstest;
2213 use ustr::Ustr;
2214
2215 use super::HyperliquidHttpClient;
2216 use crate::{common::enums::HyperliquidProductType, http::query::InfoRequest};
2217
2218 #[rstest]
2219 fn stable_json_roundtrips() {
2220 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
2221 let s = serde_json::to_string(&v).unwrap();
2222 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
2224 assert_eq!(parsed["type"], "l2Book");
2225 assert_eq!(parsed["coin"], "BTC");
2226 assert_eq!(parsed, v);
2227 }
2228
2229 #[rstest]
2230 fn info_pretty_shape() {
2231 let r = InfoRequest::l2_book("BTC");
2232 let val = serde_json::to_value(&r).unwrap();
2233 let pretty = serde_json::to_string_pretty(&val).unwrap();
2234 assert!(pretty.contains("\"type\": \"l2Book\""));
2235 assert!(pretty.contains("\"coin\": \"BTC\""));
2236 }
2237
2238 #[rstest]
2239 fn test_cache_instrument_by_raw_symbol() {
2240 use nautilus_core::time::get_atomic_clock_realtime;
2241 use nautilus_model::{
2242 currencies::CURRENCY_MAP,
2243 enums::CurrencyType,
2244 identifiers::{InstrumentId, Symbol},
2245 instruments::CurrencyPair,
2246 types::{Currency, Price, Quantity},
2247 };
2248
2249 let client = HyperliquidHttpClient::new(true, None, None).unwrap();
2250
2251 let base_code = "vntls:vCURSOR";
2253 let quote_code = "USDC";
2254
2255 {
2257 let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
2258 if !currency_map.contains_key(base_code) {
2259 currency_map.insert(
2260 base_code.to_string(),
2261 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
2262 );
2263 }
2264 }
2265
2266 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
2267 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
2268
2269 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
2271 let venue = *crate::common::consts::HYPERLIQUID_VENUE;
2272 let instrument_id = InstrumentId::new(symbol, venue);
2273
2274 let raw_symbol = Symbol::new(base_code);
2276
2277 let clock = get_atomic_clock_realtime();
2278 let ts = clock.get_time_ns();
2279
2280 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
2281 instrument_id,
2282 raw_symbol,
2283 base_currency,
2284 quote_currency,
2285 8,
2286 8,
2287 Price::from("0.00000001"),
2288 Quantity::from("0.00000001"),
2289 None,
2290 None,
2291 None,
2292 None,
2293 None,
2294 None,
2295 None,
2296 None,
2297 None,
2298 None,
2299 None,
2300 None,
2301 ts,
2302 ts,
2303 ));
2304
2305 client.cache_instrument(instrument.clone());
2307
2308 let instruments = client.instruments.read().unwrap();
2310 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
2311 assert!(
2312 by_full_symbol.is_some(),
2313 "Instrument should be accessible by full symbol"
2314 );
2315 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
2316
2317 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
2319 assert!(
2320 by_raw_symbol.is_some(),
2321 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
2322 );
2323 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
2324 drop(instruments);
2325
2326 let instruments_by_coin = client.instruments_by_coin.read().unwrap();
2328 let by_coin =
2329 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
2330 assert!(
2331 by_coin.is_some(),
2332 "Instrument should be accessible by coin and product type"
2333 );
2334 assert_eq!(by_coin.unwrap().id(), instrument.id());
2335 drop(instruments_by_coin);
2336
2337 let retrieved_with_type = client.get_or_create_instrument(
2339 &Ustr::from("vntls:vCURSOR"),
2340 Some(HyperliquidProductType::Spot),
2341 );
2342 assert!(retrieved_with_type.is_some());
2343 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
2344
2345 let retrieved_without_type =
2347 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
2348 assert!(retrieved_without_type.is_some());
2349 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
2350 }
2351}