1use std::{
24 collections::HashMap,
25 env,
26 num::NonZeroU32,
27 sync::{Arc, LazyLock},
28 time::Duration,
29};
30
31use ahash::AHashMap;
32use anyhow::Context;
33use nautilus_core::{
34 AtomicMap, UUID4, UnixNanos,
35 consts::NAUTILUS_USER_AGENT,
36 time::{AtomicTime, get_atomic_clock_realtime},
37};
38use nautilus_model::{
39 data::{Bar, BarType},
40 enums::{
41 AccountType, BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce,
42 TriggerType,
43 },
44 events::AccountState,
45 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
46 instruments::{CurrencyPair, Instrument, InstrumentAny},
47 orders::{Order, OrderAny},
48 reports::{FillReport, OrderStatusReport, PositionStatusReport},
49 types::{AccountBalance, Currency, Money, Price, Quantity},
50};
51use nautilus_network::{
52 http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
53 ratelimiter::quota::Quota,
54};
55use rust_decimal::Decimal;
56use serde_json::Value;
57use ustr::Ustr;
58
59use crate::{
60 common::{
61 consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url},
62 credential::{Secrets, VaultAddress},
63 enums::{
64 HyperliquidBarInterval, HyperliquidOrderStatus as HyperliquidOrderStatusEnum,
65 HyperliquidProductType,
66 },
67 parse::{
68 bar_type_to_interval, clamp_price_to_precision, derive_limit_from_trigger,
69 extract_inner_error, normalize_price, order_to_hyperliquid_request_with_asset,
70 round_to_sig_figs, time_in_force_to_hyperliquid_tif,
71 },
72 },
73 data::candle_to_bar,
74 http::{
75 error::{Error, Result},
76 models::{
77 ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeRequest,
78 HyperliquidExchangeResponse, HyperliquidExecAction, HyperliquidExecBuilderFee,
79 HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
80 HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecModifyOrderRequest,
81 HyperliquidExecOrderKind, HyperliquidExecOrderResponseData, HyperliquidExecOrderStatus,
82 HyperliquidExecPlaceOrderRequest, HyperliquidExecTif, HyperliquidExecTpSl,
83 HyperliquidExecTriggerParams, HyperliquidFills, HyperliquidL2Book, HyperliquidMeta,
84 HyperliquidOrderStatus, PerpMeta, PerpMetaAndCtxs, RESPONSE_STATUS_OK, SpotMeta,
85 SpotMetaAndCtxs,
86 },
87 parse::{
88 HyperliquidInstrumentDef, instruments_from_defs_owned, parse_fill_report,
89 parse_order_status_report_from_basic, parse_perp_instruments,
90 parse_position_status_report, parse_spot_instruments,
91 },
92 query::{ExchangeAction, InfoRequest},
93 rate_limits::{
94 RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
95 info_base_weight, info_extra_weight,
96 },
97 },
98 signing::{
99 HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
100 },
101 websocket::messages::WsBasicOrderData,
102};
103
104pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
106 LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
107
108#[derive(Debug, Clone)]
113#[cfg_attr(
114 feature = "python",
115 pyo3::pyclass(
116 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
117 from_py_object
118 )
119)]
120#[cfg_attr(
121 feature = "python",
122 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.hyperliquid")
123)]
124pub struct HyperliquidRawHttpClient {
125 client: HttpClient,
126 is_testnet: bool,
127 base_info: String,
128 base_exchange: String,
129 signer: Option<HyperliquidEip712Signer>,
130 nonce_manager: Option<Arc<NonceManager>>,
131 vault_address: Option<VaultAddress>,
132 rest_limiter: Arc<WeightedLimiter>,
133 rate_limit_backoff_base: Duration,
134 rate_limit_backoff_cap: Duration,
135 rate_limit_max_attempts_info: u32,
136}
137
138impl HyperliquidRawHttpClient {
139 pub fn new(
145 is_testnet: bool,
146 timeout_secs: u64,
147 proxy_url: Option<String>,
148 ) -> std::result::Result<Self, HttpClientError> {
149 Ok(Self {
150 client: HttpClient::new(
151 Self::default_headers(),
152 vec![],
153 vec![],
154 Some(*HYPERLIQUID_REST_QUOTA),
155 Some(timeout_secs),
156 proxy_url,
157 )?,
158 is_testnet,
159 base_info: info_url(is_testnet).to_string(),
160 base_exchange: exchange_url(is_testnet).to_string(),
161 signer: None,
162 nonce_manager: None,
163 vault_address: None,
164 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
165 rate_limit_backoff_base: Duration::from_millis(125),
166 rate_limit_backoff_cap: Duration::from_secs(5),
167 rate_limit_max_attempts_info: 3,
168 })
169 }
170
171 pub fn with_credentials(
178 secrets: &Secrets,
179 timeout_secs: u64,
180 proxy_url: Option<String>,
181 ) -> std::result::Result<Self, HttpClientError> {
182 let signer = HyperliquidEip712Signer::new(secrets.private_key.clone());
183 let nonce_manager = Arc::new(NonceManager::new());
184
185 Ok(Self {
186 client: HttpClient::new(
187 Self::default_headers(),
188 vec![],
189 vec![],
190 Some(*HYPERLIQUID_REST_QUOTA),
191 Some(timeout_secs),
192 proxy_url,
193 )?,
194 is_testnet: secrets.is_testnet,
195 base_info: info_url(secrets.is_testnet).to_string(),
196 base_exchange: exchange_url(secrets.is_testnet).to_string(),
197 signer: Some(signer),
198 nonce_manager: Some(nonce_manager),
199 vault_address: secrets.vault_address,
200 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
201 rate_limit_backoff_base: Duration::from_millis(125),
202 rate_limit_backoff_cap: Duration::from_secs(5),
203 rate_limit_max_attempts_info: 3,
204 })
205 }
206
207 pub fn set_base_info_url(&mut self, url: String) {
209 self.base_info = url;
210 }
211
212 pub fn set_base_exchange_url(&mut self, url: String) {
214 self.base_exchange = url;
215 }
216
217 pub fn from_env(is_testnet: bool) -> Result<Self> {
223 let secrets = Secrets::from_env(is_testnet)
224 .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
225 Self::with_credentials(&secrets, 60, None)
226 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
227 }
228
229 pub fn from_credentials(
235 private_key: &str,
236 vault_address: Option<&str>,
237 is_testnet: bool,
238 timeout_secs: u64,
239 proxy_url: Option<String>,
240 ) -> Result<Self> {
241 let secrets = Secrets::from_private_key(private_key, vault_address, is_testnet)
242 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
243 Self::with_credentials(&secrets, timeout_secs, proxy_url)
244 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
245 }
246
247 #[must_use]
249 pub fn with_rate_limits(mut self) -> Self {
250 self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
251 self.rate_limit_backoff_base = Duration::from_millis(125);
252 self.rate_limit_backoff_cap = Duration::from_secs(5);
253 self.rate_limit_max_attempts_info = 3;
254 self
255 }
256
257 #[must_use]
259 pub fn is_testnet(&self) -> bool {
260 self.is_testnet
261 }
262
263 pub fn get_user_address(&self) -> Result<String> {
269 self.signer
270 .as_ref()
271 .ok_or_else(|| Error::auth("No signer configured"))?
272 .address()
273 }
274
275 #[must_use]
277 pub fn has_vault_address(&self) -> bool {
278 self.vault_address.is_some()
279 }
280
281 pub fn get_account_address(&self) -> Result<String> {
288 if let Some(vault) = &self.vault_address {
289 Ok(vault.to_hex())
290 } else {
291 self.get_user_address()
292 }
293 }
294
295 fn default_headers() -> HashMap<String, String> {
296 HashMap::from([
297 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
298 ("Content-Type".to_string(), "application/json".to_string()),
299 ])
300 }
301
302 fn signer_id(&self) -> SignerId {
303 SignerId("hyperliquid:default".into())
304 }
305
306 fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
307 let retry_after = headers.get("retry-after")?;
308 retry_after.parse::<u64>().ok().map(|s| s * 1000) }
310
311 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
313 let request = InfoRequest::meta();
314 let response = self.send_info_request(&request).await?;
315 serde_json::from_value(response).map_err(Error::Serde)
316 }
317
318 pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
320 let request = InfoRequest::spot_meta();
321 let response = self.send_info_request(&request).await?;
322 serde_json::from_value(response).map_err(Error::Serde)
323 }
324
325 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
327 let request = InfoRequest::meta_and_asset_ctxs();
328 let response = self.send_info_request(&request).await?;
329 serde_json::from_value(response).map_err(Error::Serde)
330 }
331
332 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
334 let request = InfoRequest::spot_meta_and_asset_ctxs();
335 let response = self.send_info_request(&request).await?;
336 serde_json::from_value(response).map_err(Error::Serde)
337 }
338
339 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
340 let request = InfoRequest::meta();
341 let response = self.send_info_request(&request).await?;
342 serde_json::from_value(response).map_err(Error::Serde)
343 }
344
345 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
347 let request = InfoRequest::all_perp_metas();
348 let response = self.send_info_request(&request).await?;
349 serde_json::from_value(response).map_err(Error::Serde)
350 }
351
352 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
354 let request = InfoRequest::l2_book(coin);
355 let response = self.send_info_request(&request).await?;
356 serde_json::from_value(response).map_err(Error::Serde)
357 }
358
359 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
361 let request = InfoRequest::user_fills(user);
362 let response = self.send_info_request(&request).await?;
363 serde_json::from_value(response).map_err(Error::Serde)
364 }
365
366 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
368 let request = InfoRequest::order_status(user, oid);
369 let response = self.send_info_request(&request).await?;
370 serde_json::from_value(response).map_err(Error::Serde)
371 }
372
373 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
375 let request = InfoRequest::open_orders(user);
376 self.send_info_request(&request).await
377 }
378
379 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
381 let request = InfoRequest::frontend_open_orders(user);
382 self.send_info_request(&request).await
383 }
384
385 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
387 let request = InfoRequest::clearinghouse_state(user);
388 self.send_info_request(&request).await
389 }
390
391 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
393 let request = InfoRequest::user_fees(user);
394 self.send_info_request(&request).await
395 }
396
397 pub async fn info_candle_snapshot(
399 &self,
400 coin: &str,
401 interval: HyperliquidBarInterval,
402 start_time: u64,
403 end_time: u64,
404 ) -> Result<HyperliquidCandleSnapshot> {
405 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
406 let response = self.send_info_request(&request).await?;
407
408 log::trace!(
409 "Candle snapshot raw response (len={}): {:?}",
410 response.as_array().map_or(0, |a| a.len()),
411 response
412 );
413
414 serde_json::from_value(response).map_err(Error::Serde)
415 }
416
417 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
419 self.send_info_request(request).await
420 }
421
422 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
423 let base_w = info_base_weight(request);
424 self.rest_limiter.acquire(base_w).await;
425
426 let mut attempt = 0u32;
427 loop {
428 let response = self.http_roundtrip_info(request).await?;
429
430 if response.status.is_success() {
431 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
433 let extra = info_extra_weight(request, &val);
434 if extra > 0 {
435 self.rest_limiter.debit_extra(extra).await;
436 log::debug!(
437 "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
438 );
439 }
440 return Ok(val);
441 }
442
443 if response.status.as_u16() == 429 {
445 if attempt >= self.rate_limit_max_attempts_info {
446 let ra = self.parse_retry_after_simple(&response.headers);
447 return Err(Error::rate_limit("info", base_w, ra));
448 }
449 let delay = self
450 .parse_retry_after_simple(&response.headers)
451 .map_or_else(
452 || {
453 backoff_full_jitter(
454 attempt,
455 self.rate_limit_backoff_base,
456 self.rate_limit_backoff_cap,
457 )
458 },
459 Duration::from_millis,
460 );
461 log::warn!(
462 "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
463 delay.as_millis()
464 );
465 attempt += 1;
466 tokio::time::sleep(delay).await;
467 self.rest_limiter.acquire(1).await;
469 continue;
470 }
471
472 if (response.status.is_server_error() || response.status.as_u16() == 408)
474 && attempt < self.rate_limit_max_attempts_info
475 {
476 let delay = backoff_full_jitter(
477 attempt,
478 self.rate_limit_backoff_base,
479 self.rate_limit_backoff_cap,
480 );
481 log::warn!(
482 "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
483 response.status.as_u16(),
484 delay.as_millis()
485 );
486 attempt += 1;
487 tokio::time::sleep(delay).await;
488 continue;
489 }
490
491 let error_body = String::from_utf8_lossy(&response.body);
493 return Err(Error::http(
494 response.status.as_u16(),
495 error_body.to_string(),
496 ));
497 }
498 }
499
500 async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
501 let url = &self.base_info;
502 let body = serde_json::to_value(request).map_err(Error::Serde)?;
503 let body_bytes = serde_json::to_string(&body)
504 .map_err(Error::Serde)?
505 .into_bytes();
506
507 self.client
508 .request(
509 Method::POST,
510 url.clone(),
511 None,
512 None,
513 Some(body_bytes),
514 None,
515 None,
516 )
517 .await
518 .map_err(Error::from_http_client)
519 }
520
521 pub async fn post_action(
523 &self,
524 action: &ExchangeAction,
525 ) -> Result<HyperliquidExchangeResponse> {
526 let w = exchange_weight(action);
527 self.rest_limiter.acquire(w).await;
528
529 let signer = self
530 .signer
531 .as_ref()
532 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
533
534 let nonce_manager = self
535 .nonce_manager
536 .as_ref()
537 .ok_or_else(|| Error::auth("nonce manager missing"))?;
538
539 let signer_id = self.signer_id();
540 let time_nonce = nonce_manager.next(signer_id)?;
541
542 let action_value = serde_json::to_value(action)
543 .context("serialize exchange action")
544 .map_err(|e| Error::bad_request(e.to_string()))?;
545
546 let action_bytes = rmp_serde::to_vec_named(action)
548 .context("serialize action with MessagePack")
549 .map_err(|e| Error::bad_request(e.to_string()))?;
550
551 let sign_request = SignRequest {
552 action: action_value.clone(),
553 action_bytes: Some(action_bytes),
554 time_nonce,
555 action_type: HyperliquidActionType::L1,
556 is_testnet: self.is_testnet,
557 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
558 };
559
560 let sig = signer.sign(&sign_request)?.signature;
561
562 let nonce_u64 = time_nonce.as_millis() as u64;
563
564 let request = if let Some(vault) = self.vault_address {
565 HyperliquidExchangeRequest::with_vault(
566 action.clone(),
567 nonce_u64,
568 &sig,
569 vault.to_string(),
570 )
571 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
572 } else {
573 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, &sig)
574 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
575 };
576
577 let response = self.http_roundtrip_exchange(&request).await?;
578
579 if response.status.is_success() {
580 let parsed_response: HyperliquidExchangeResponse =
581 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
582
583 match &parsed_response {
585 HyperliquidExchangeResponse::Status {
586 status,
587 response: response_data,
588 } if status == "err" => {
589 let error_msg = response_data
590 .as_str()
591 .map_or_else(|| response_data.to_string(), |s| s.to_string());
592 log::error!("Hyperliquid API returned error: {error_msg}");
593 Err(Error::bad_request(format!("API error: {error_msg}")))
594 }
595 HyperliquidExchangeResponse::Error { error } => {
596 log::error!("Hyperliquid API returned error: {error}");
597 Err(Error::bad_request(format!("API error: {error}")))
598 }
599 _ => Ok(parsed_response),
600 }
601 } else if response.status.as_u16() == 429 {
602 let ra = self.parse_retry_after_simple(&response.headers);
603 Err(Error::rate_limit("exchange", w, ra))
604 } else {
605 let error_body = String::from_utf8_lossy(&response.body);
606 log::error!(
607 "Exchange API error (status {}): {}",
608 response.status.as_u16(),
609 error_body
610 );
611 Err(Error::http(
612 response.status.as_u16(),
613 error_body.to_string(),
614 ))
615 }
616 }
617
618 pub async fn post_action_exec(
623 &self,
624 action: &HyperliquidExecAction,
625 ) -> Result<HyperliquidExchangeResponse> {
626 let w = match action {
627 HyperliquidExecAction::Order { orders, .. } => 1 + (orders.len() as u32 / 40),
628 HyperliquidExecAction::Cancel { cancels } => 1 + (cancels.len() as u32 / 40),
629 HyperliquidExecAction::CancelByCloid { cancels } => 1 + (cancels.len() as u32 / 40),
630 HyperliquidExecAction::BatchModify { modifies } => 1 + (modifies.len() as u32 / 40),
631 _ => 1,
632 };
633 self.rest_limiter.acquire(w).await;
634
635 let signer = self
636 .signer
637 .as_ref()
638 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
639
640 let nonce_manager = self
641 .nonce_manager
642 .as_ref()
643 .ok_or_else(|| Error::auth("nonce manager missing"))?;
644
645 let signer_id = self.signer_id();
646 let time_nonce = nonce_manager.next(signer_id)?;
647 let action_value = serde_json::to_value(action)
650 .context("serialize exchange action")
651 .map_err(|e| Error::bad_request(e.to_string()))?;
652
653 let action_bytes = rmp_serde::to_vec_named(action)
655 .context("serialize action with MessagePack")
656 .map_err(|e| Error::bad_request(e.to_string()))?;
657
658 let sig = signer
659 .sign(&SignRequest {
660 action: action_value.clone(),
661 action_bytes: Some(action_bytes),
662 time_nonce,
663 action_type: HyperliquidActionType::L1,
664 is_testnet: self.is_testnet,
665 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
666 })?
667 .signature;
668
669 let request = if let Some(vault) = self.vault_address {
670 HyperliquidExchangeRequest::with_vault(
671 action.clone(),
672 time_nonce.as_millis() as u64,
673 &sig,
674 vault.to_string(),
675 )
676 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
677 } else {
678 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, &sig)
679 .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
680 };
681
682 let response = self.http_roundtrip_exchange(&request).await?;
683
684 if response.status.is_success() {
685 let parsed_response: HyperliquidExchangeResponse =
686 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
687
688 match &parsed_response {
690 HyperliquidExchangeResponse::Status {
691 status,
692 response: response_data,
693 } if status == "err" => {
694 let error_msg = response_data
695 .as_str()
696 .map_or_else(|| response_data.to_string(), |s| s.to_string());
697 log::error!("Hyperliquid API returned error: {error_msg}");
698 Err(Error::bad_request(format!("API error: {error_msg}")))
699 }
700 HyperliquidExchangeResponse::Error { error } => {
701 log::error!("Hyperliquid API returned error: {error}");
702 Err(Error::bad_request(format!("API error: {error}")))
703 }
704 _ => Ok(parsed_response),
705 }
706 } else if response.status.as_u16() == 429 {
707 let ra = self.parse_retry_after_simple(&response.headers);
708 Err(Error::rate_limit("exchange", w, ra))
709 } else {
710 let error_body = String::from_utf8_lossy(&response.body);
711 Err(Error::http(
712 response.status.as_u16(),
713 error_body.to_string(),
714 ))
715 }
716 }
717
718 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
721 self.rest_limiter.snapshot().await
722 }
723 async fn http_roundtrip_exchange<T>(
724 &self,
725 request: &HyperliquidExchangeRequest<T>,
726 ) -> Result<HttpResponse>
727 where
728 T: serde::Serialize,
729 {
730 let url = &self.base_exchange;
731 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
732 let body_bytes = body.into_bytes();
733
734 let response = self
735 .client
736 .request(
737 Method::POST,
738 url.clone(),
739 None,
740 None,
741 Some(body_bytes),
742 None,
743 None,
744 )
745 .await
746 .map_err(Error::from_http_client)?;
747
748 Ok(response)
749 }
750}
751
752#[derive(Debug, Clone)]
758#[cfg_attr(
759 feature = "python",
760 pyo3::pyclass(
761 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
762 from_py_object
763 )
764)]
765#[cfg_attr(
766 feature = "python",
767 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.hyperliquid")
768)]
769pub struct HyperliquidHttpClient {
770 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
771 clock: &'static AtomicTime,
772 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
773 instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
774 asset_indices: Arc<AtomicMap<Ustr, u32>>,
776 spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
778 account_id: Option<AccountId>,
779 account_address: Option<String>,
783 normalize_prices: bool,
784}
785
786impl Default for HyperliquidHttpClient {
787 fn default() -> Self {
788 Self::new(true, 60, None).expect("Failed to create default Hyperliquid HTTP client")
789 }
790}
791
792impl HyperliquidHttpClient {
793 pub fn new(
799 is_testnet: bool,
800 timeout_secs: u64,
801 proxy_url: Option<String>,
802 ) -> std::result::Result<Self, HttpClientError> {
803 let raw_client = HyperliquidRawHttpClient::new(is_testnet, timeout_secs, proxy_url)?;
804 Ok(Self::from_raw(raw_client))
805 }
806
807 pub fn with_secrets(
813 secrets: &Secrets,
814 timeout_secs: u64,
815 proxy_url: Option<String>,
816 ) -> std::result::Result<Self, HttpClientError> {
817 let raw_client =
818 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
819 Ok(Self::from_raw(raw_client))
820 }
821
822 fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
823 Self {
824 inner: Arc::new(raw_client),
825 clock: get_atomic_clock_realtime(),
826 instruments: Arc::new(AtomicMap::new()),
827 instruments_by_coin: Arc::new(AtomicMap::new()),
828 asset_indices: Arc::new(AtomicMap::new()),
829 spot_fill_coins: Arc::new(AtomicMap::new()),
830 account_id: None,
831 account_address: None,
832 normalize_prices: true,
833 }
834 }
835
836 pub fn set_base_info_url(&mut self, url: String) {
842 Arc::get_mut(&mut self.inner)
843 .expect("cannot override URL: Arc has multiple references")
844 .set_base_info_url(url);
845 }
846
847 pub fn set_base_exchange_url(&mut self, url: String) {
853 Arc::get_mut(&mut self.inner)
854 .expect("cannot override URL: Arc has multiple references")
855 .set_base_exchange_url(url);
856 }
857
858 pub fn from_env(is_testnet: bool) -> Result<Self> {
864 let raw_client = HyperliquidRawHttpClient::from_env(is_testnet)?;
865 Ok(Self {
866 inner: Arc::new(raw_client),
867 clock: get_atomic_clock_realtime(),
868 instruments: Arc::new(AtomicMap::new()),
869 instruments_by_coin: Arc::new(AtomicMap::new()),
870 asset_indices: Arc::new(AtomicMap::new()),
871 spot_fill_coins: Arc::new(AtomicMap::new()),
872 account_id: None,
873 account_address: None,
874 normalize_prices: true,
875 })
876 }
877
878 pub fn with_credentials(
891 private_key: Option<String>,
892 vault_address: Option<String>,
893 account_address: Option<String>,
894 is_testnet: bool,
895 timeout_secs: u64,
896 proxy_url: Option<String>,
897 ) -> Result<Self> {
898 let pk_env_var = if is_testnet {
900 "HYPERLIQUID_TESTNET_PK"
901 } else {
902 "HYPERLIQUID_PK"
903 };
904 let vault_env_var = if is_testnet {
905 "HYPERLIQUID_TESTNET_VAULT"
906 } else {
907 "HYPERLIQUID_VAULT"
908 };
909
910 let resolved_pk = match private_key {
912 Some(pk) => Some(pk),
913 None => env::var(pk_env_var).ok(),
914 };
915
916 let resolved_vault = match vault_address {
918 Some(vault) => Some(vault),
919 None => env::var(vault_env_var).ok(),
920 };
921
922 let resolved_account_address = match account_address {
924 Some(addr) => Some(addr),
925 None => env::var("HYPERLIQUID_ACCOUNT_ADDRESS").ok(),
926 };
927
928 match resolved_pk {
929 Some(pk) => {
930 let raw_client = HyperliquidRawHttpClient::from_credentials(
931 &pk,
932 resolved_vault.as_deref(),
933 is_testnet,
934 timeout_secs,
935 proxy_url,
936 )?;
937 Ok(Self {
938 inner: Arc::new(raw_client),
939 clock: get_atomic_clock_realtime(),
940 instruments: Arc::new(AtomicMap::new()),
941 instruments_by_coin: Arc::new(AtomicMap::new()),
942 asset_indices: Arc::new(AtomicMap::new()),
943 spot_fill_coins: Arc::new(AtomicMap::new()),
944 account_id: None,
945 account_address: resolved_account_address,
946 normalize_prices: true,
947 })
948 }
949 None => {
950 Self::new(is_testnet, timeout_secs, proxy_url)
952 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
953 }
954 }
955 }
956
957 pub fn from_credentials(
963 private_key: &str,
964 vault_address: Option<&str>,
965 is_testnet: bool,
966 timeout_secs: u64,
967 proxy_url: Option<String>,
968 ) -> Result<Self> {
969 let raw_client = HyperliquidRawHttpClient::from_credentials(
970 private_key,
971 vault_address,
972 is_testnet,
973 timeout_secs,
974 proxy_url,
975 )?;
976 Ok(Self {
977 inner: Arc::new(raw_client),
978 clock: get_atomic_clock_realtime(),
979 instruments: Arc::new(AtomicMap::new()),
980 instruments_by_coin: Arc::new(AtomicMap::new()),
981 asset_indices: Arc::new(AtomicMap::new()),
982 spot_fill_coins: Arc::new(AtomicMap::new()),
983 account_id: None,
984 account_address: None,
985 normalize_prices: true,
986 })
987 }
988
989 #[must_use]
991 pub fn is_testnet(&self) -> bool {
992 self.inner.is_testnet()
993 }
994
995 #[must_use]
997 pub fn normalize_prices(&self) -> bool {
998 self.normalize_prices
999 }
1000
1001 pub fn set_normalize_prices(&mut self, value: bool) {
1003 self.normalize_prices = value;
1004 }
1005
1006 pub fn get_user_address(&self) -> Result<String> {
1012 self.inner.get_user_address()
1013 }
1014
1015 #[must_use]
1017 pub fn has_vault_address(&self) -> bool {
1018 self.inner.has_vault_address()
1019 }
1020
1021 pub fn get_account_address(&self) -> Result<String> {
1029 if let Some(addr) = &self.account_address {
1030 return Ok(addr.clone());
1031 }
1032 self.inner.get_account_address()
1033 }
1034
1035 pub fn set_account_address(&mut self, address: Option<String>) {
1037 self.account_address = address;
1038 }
1039
1040 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1045 let full_symbol = instrument.symbol().inner();
1046 let coin = instrument.raw_symbol().inner();
1047
1048 self.instruments.rcu(|m| {
1049 m.insert(full_symbol, instrument.clone());
1050 m.insert(coin, instrument.clone());
1052 });
1053
1054 if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
1056 self.instruments_by_coin.rcu(|m| {
1057 m.insert((coin, product_type), instrument.clone());
1058
1059 if coin.as_str().starts_with('@')
1063 && let Some(base) = full_symbol.as_str().split('-').next()
1064 {
1065 let base_ustr = Ustr::from(base);
1066 if base_ustr != coin {
1067 m.insert((base_ustr, product_type), instrument.clone());
1068 }
1069 }
1070 });
1071 } else {
1072 log::warn!("Unable to determine product type for symbol: {full_symbol}");
1073 }
1074 }
1075
1076 fn get_or_create_instrument(
1077 &self,
1078 coin: &Ustr,
1079 product_type: Option<HyperliquidProductType>,
1080 ) -> Option<InstrumentAny> {
1081 if let Some(pt) = product_type
1082 && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1083 {
1084 return Some(instrument.clone());
1085 }
1086
1087 if product_type.is_none() {
1089 let guard = self.instruments_by_coin.load();
1090
1091 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1092 return Some(instrument.clone());
1093 }
1094
1095 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1096 return Some(instrument.clone());
1097 }
1098 }
1099
1100 if coin.as_str().starts_with('@')
1102 && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1103 {
1104 if let Some(instrument) = self.instruments.load().get(symbol) {
1107 return Some(instrument.clone());
1108 }
1109 }
1110
1111 if coin.as_str().starts_with("vntls:") {
1113 log::info!("Creating synthetic instrument for vault token: {coin}");
1114
1115 let ts_event = self.clock.get_time_ns();
1116
1117 let symbol_str = format!("{coin}-USDC-SPOT");
1119 let symbol = Symbol::new(&symbol_str);
1120 let venue = *HYPERLIQUID_VENUE;
1121 let instrument_id = InstrumentId::new(symbol, venue);
1122
1123 let base_currency = Currency::new(
1125 coin.as_str(),
1126 8, 0, coin.as_str(),
1129 CurrencyType::Crypto,
1130 );
1131
1132 let quote_currency = Currency::new(
1133 "USDC",
1134 6, 0,
1136 "USDC",
1137 CurrencyType::Crypto,
1138 );
1139
1140 let price_increment = Price::from("0.00000001");
1141 let size_increment = Quantity::from("0.00000001");
1142
1143 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1144 instrument_id,
1145 symbol,
1146 base_currency,
1147 quote_currency,
1148 8, 8, price_increment,
1151 size_increment,
1152 None, None, None, None, None, None, None, None, None, None, None, None, None, ts_event,
1166 ts_event,
1167 ));
1168
1169 self.cache_instrument(&instrument);
1170
1171 Some(instrument)
1172 } else {
1173 log::warn!("Instrument not found in cache: {coin}");
1175 None
1176 }
1177 }
1178
1179 pub fn set_account_id(&mut self, account_id: AccountId) {
1183 self.account_id = Some(account_id);
1184 }
1185
1186 pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1188 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1189
1190 match self.inner.load_all_perp_metas().await {
1192 Ok(all_metas) => {
1193 for (dex_index, meta) in all_metas.iter().enumerate() {
1194 let base = perp_dex_asset_index_base(dex_index);
1195
1196 match parse_perp_instruments(meta, base) {
1197 Ok(perp_defs) => {
1198 log::debug!(
1199 "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1200 perp_defs.len(),
1201 );
1202 defs.extend(perp_defs);
1203 }
1204 Err(e) => {
1205 log::warn!("Failed to parse perp instruments for dex {dex_index}: {e}");
1206 }
1207 }
1208 }
1209 }
1210 Err(e) => {
1211 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1212 match self.inner.load_perp_meta().await {
1213 Ok(perp_meta) => match parse_perp_instruments(&perp_meta, 0) {
1214 Ok(perp_defs) => {
1215 log::debug!(
1216 "Loaded Hyperliquid perp defs via fallback: count={}",
1217 perp_defs.len(),
1218 );
1219 defs.extend(perp_defs);
1220 }
1221 Err(e) => {
1222 log::warn!("Failed to parse perp instruments: {e}");
1223 }
1224 },
1225 Err(e) => {
1226 log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1227 }
1228 }
1229 }
1230 }
1231
1232 match self.inner.get_spot_meta().await {
1233 Ok(spot_meta) => match parse_spot_instruments(&spot_meta) {
1234 Ok(spot_defs) => {
1235 log::debug!(
1236 "Loaded Hyperliquid spot definitions: count={}",
1237 spot_defs.len(),
1238 );
1239 defs.extend(spot_defs);
1240 }
1241 Err(e) => {
1242 log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1243 }
1244 },
1245 Err(e) => {
1246 log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1247 }
1248 }
1249
1250 self.asset_indices.rcu(|m| {
1252 for def in &defs {
1253 m.insert(def.symbol, def.asset_index);
1254 }
1255 });
1256 log::debug!(
1257 "Populated asset indices map (count={})",
1258 self.asset_indices.len()
1259 );
1260
1261 Ok(defs)
1262 }
1263
1264 pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1266 let ts_init = self.clock.get_time_ns();
1267 instruments_from_defs_owned(defs, ts_init)
1268 }
1269
1270 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1272 let defs = self.request_instrument_defs().await?;
1273 Ok(self.convert_defs(defs))
1274 }
1275
1276 pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1284 self.asset_indices.load().get(&Ustr::from(symbol)).copied()
1285 }
1286
1287 pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1289 self.instruments
1290 .load()
1291 .get(&Ustr::from(symbol))
1292 .map(|inst| inst.price_precision())
1293 }
1294
1295 #[must_use]
1303 pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1304 const SPOT_INDEX_OFFSET: u32 = 10_000;
1305 const BUILDER_PERP_OFFSET: u32 = 100_000;
1306
1307 let guard = self.asset_indices.load();
1308
1309 let mut mapping = AHashMap::new();
1310 for (symbol, &asset_index) in guard.iter() {
1311 if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1313 let pair_index = asset_index - SPOT_INDEX_OFFSET;
1314 let fill_coin = Ustr::from(&format!("@{pair_index}"));
1315 mapping.insert(fill_coin, *symbol);
1316 }
1317 }
1318
1319 self.spot_fill_coins.store(mapping.clone());
1321
1322 mapping
1323 }
1324
1325 #[allow(dead_code)]
1327 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1328 self.inner.load_perp_meta().await
1329 }
1330
1331 #[allow(dead_code)]
1333 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1334 self.inner.load_all_perp_metas().await
1335 }
1336
1337 #[allow(dead_code)]
1339 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1340 self.inner.get_spot_meta().await
1341 }
1342
1343 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1345 self.inner.info_l2_book(coin).await
1346 }
1347
1348 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1350 self.inner.info_user_fills(user).await
1351 }
1352
1353 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1355 self.inner.info_order_status(user, oid).await
1356 }
1357
1358 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1360 self.inner.info_open_orders(user).await
1361 }
1362
1363 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1365 self.inner.info_frontend_open_orders(user).await
1366 }
1367
1368 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1370 self.inner.info_clearinghouse_state(user).await
1371 }
1372
1373 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
1375 self.inner.info_user_fees(user).await
1376 }
1377
1378 pub async fn info_candle_snapshot(
1380 &self,
1381 coin: &str,
1382 interval: HyperliquidBarInterval,
1383 start_time: u64,
1384 end_time: u64,
1385 ) -> Result<HyperliquidCandleSnapshot> {
1386 self.inner
1387 .info_candle_snapshot(coin, interval, start_time, end_time)
1388 .await
1389 }
1390
1391 pub async fn post_action(
1393 &self,
1394 action: &ExchangeAction,
1395 ) -> Result<HyperliquidExchangeResponse> {
1396 self.inner.post_action(action).await
1397 }
1398
1399 pub async fn post_action_exec(
1401 &self,
1402 action: &HyperliquidExecAction,
1403 ) -> Result<HyperliquidExchangeResponse> {
1404 self.inner.post_action_exec(action).await
1405 }
1406
1407 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1409 self.inner.info_meta().await
1410 }
1411
1412 pub async fn cancel_order(
1422 &self,
1423 instrument_id: InstrumentId,
1424 client_order_id: Option<ClientOrderId>,
1425 venue_order_id: Option<VenueOrderId>,
1426 ) -> Result<()> {
1427 let symbol = instrument_id.symbol.as_str();
1429 let asset_id = self.get_asset_index(symbol).ok_or_else(|| {
1430 Error::bad_request(format!(
1431 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1432 ))
1433 })?;
1434
1435 let action = if let Some(cloid) = client_order_id {
1437 let cloid_hash = Cloid::from_client_order_id(cloid);
1439 let cancel_req = HyperliquidExecCancelByCloidRequest {
1440 asset: asset_id,
1441 cloid: cloid_hash,
1442 };
1443 HyperliquidExecAction::CancelByCloid {
1444 cancels: vec![cancel_req],
1445 }
1446 } else if let Some(oid) = venue_order_id {
1447 let oid_u64 = oid
1448 .as_str()
1449 .parse::<u64>()
1450 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1451 let cancel_req = HyperliquidExecCancelOrderRequest {
1452 asset: asset_id,
1453 oid: oid_u64,
1454 };
1455 HyperliquidExecAction::Cancel {
1456 cancels: vec![cancel_req],
1457 }
1458 } else {
1459 return Err(Error::bad_request(
1460 "Either client_order_id or venue_order_id must be provided",
1461 ));
1462 };
1463
1464 let response = self.inner.post_action_exec(&action).await?;
1466
1467 match response {
1469 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
1470 HyperliquidExchangeResponse::Status {
1471 status,
1472 response: error_data,
1473 } => Err(Error::bad_request(format!(
1474 "Cancel order failed: status={status}, error={error_data}"
1475 ))),
1476 HyperliquidExchangeResponse::Error { error } => {
1477 Err(Error::bad_request(format!("Cancel order error: {error}")))
1478 }
1479 }
1480 }
1481
1482 #[allow(clippy::too_many_arguments)]
1492 pub async fn modify_order(
1493 &self,
1494 instrument_id: InstrumentId,
1495 venue_order_id: VenueOrderId,
1496 order_side: OrderSide,
1497 order_type: OrderType,
1498 price: Price,
1499 quantity: Quantity,
1500 trigger_price: Option<Price>,
1501 reduce_only: bool,
1502 post_only: bool,
1503 time_in_force: TimeInForce,
1504 client_order_id: Option<ClientOrderId>,
1505 ) -> Result<()> {
1506 let symbol = instrument_id.symbol.as_str();
1507 let asset_id = self.get_asset_index(symbol).ok_or_else(|| {
1508 Error::bad_request(format!(
1509 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1510 ))
1511 })?;
1512
1513 let oid: u64 = venue_order_id
1514 .as_str()
1515 .parse()
1516 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1517
1518 let is_buy = matches!(order_side, OrderSide::Buy);
1519 let decimals = self.get_price_precision(symbol).unwrap_or(2);
1520
1521 let normalized_price = if self.normalize_prices {
1522 normalize_price(price.as_decimal(), decimals).normalize()
1523 } else {
1524 price.as_decimal().normalize()
1525 };
1526
1527 let size = quantity.as_decimal().normalize();
1528 let cloid = client_order_id.map(Cloid::from_client_order_id);
1529
1530 let kind = match order_type {
1531 OrderType::Market => HyperliquidExecOrderKind::Limit {
1532 limit: HyperliquidExecLimitParams {
1533 tif: HyperliquidExecTif::Ioc,
1534 },
1535 },
1536 OrderType::Limit => {
1537 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
1538 .map_err(|e| Error::bad_request(format!("{e}")))?;
1539 HyperliquidExecOrderKind::Limit {
1540 limit: HyperliquidExecLimitParams { tif },
1541 }
1542 }
1543 OrderType::StopMarket
1544 | OrderType::StopLimit
1545 | OrderType::MarketIfTouched
1546 | OrderType::LimitIfTouched => {
1547 if let Some(trig_px) = trigger_price {
1548 let trigger_price_decimal = if self.normalize_prices {
1549 normalize_price(trig_px.as_decimal(), decimals).normalize()
1550 } else {
1551 trig_px.as_decimal().normalize()
1552 };
1553 let tpsl = match order_type {
1554 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1555 _ => HyperliquidExecTpSl::Tp,
1556 };
1557 let is_market = matches!(
1558 order_type,
1559 OrderType::StopMarket | OrderType::MarketIfTouched
1560 );
1561 HyperliquidExecOrderKind::Trigger {
1562 trigger: HyperliquidExecTriggerParams {
1563 is_market,
1564 trigger_px: trigger_price_decimal,
1565 tpsl,
1566 },
1567 }
1568 } else {
1569 return Err(Error::bad_request("Trigger orders require a trigger price"));
1570 }
1571 }
1572 _ => {
1573 return Err(Error::bad_request(format!(
1574 "Order type {order_type:?} not supported for modify"
1575 )));
1576 }
1577 };
1578
1579 let order = HyperliquidExecPlaceOrderRequest {
1580 asset: asset_id,
1581 is_buy,
1582 price: normalized_price,
1583 size,
1584 reduce_only,
1585 kind,
1586 cloid,
1587 };
1588
1589 let action = HyperliquidExecAction::Modify {
1590 modify: HyperliquidExecModifyOrderRequest { oid, order },
1591 };
1592
1593 let response = self.inner.post_action_exec(&action).await?;
1594
1595 match response {
1596 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
1597 if let Some(inner_error) = extract_inner_error(&response) {
1598 Err(Error::bad_request(format!(
1599 "Modify order rejected: {inner_error}",
1600 )))
1601 } else {
1602 Ok(())
1603 }
1604 }
1605 HyperliquidExchangeResponse::Status {
1606 status,
1607 response: error_data,
1608 } => Err(Error::bad_request(format!(
1609 "Modify order failed: status={status}, error={error_data}"
1610 ))),
1611 HyperliquidExchangeResponse::Error { error } => {
1612 Err(Error::bad_request(format!("Modify order error: {error}")))
1613 }
1614 }
1615 }
1616
1617 pub async fn request_order_status_reports(
1629 &self,
1630 user: &str,
1631 instrument_id: Option<InstrumentId>,
1632 ) -> Result<Vec<OrderStatusReport>> {
1633 let account_id = self
1634 .account_id
1635 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1636 let response = self.info_frontend_open_orders(user).await?;
1637
1638 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1640 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1641
1642 let mut reports = Vec::new();
1643 let ts_init = self.clock.get_time_ns();
1644
1645 for order_value in orders {
1646 let order: WsBasicOrderData = match serde_json::from_value(order_value.clone()) {
1648 Ok(o) => o,
1649 Err(e) => {
1650 log::warn!("Failed to parse order: {e}");
1651 continue;
1652 }
1653 };
1654
1655 let instrument = match self.get_or_create_instrument(&order.coin, None) {
1657 Some(inst) => inst,
1658 None => continue, };
1660
1661 if let Some(filter_id) = instrument_id
1663 && instrument.id() != filter_id
1664 {
1665 continue;
1666 }
1667
1668 let status = HyperliquidOrderStatusEnum::Open;
1670
1671 match parse_order_status_report_from_basic(
1673 &order,
1674 &status,
1675 &instrument,
1676 account_id,
1677 ts_init,
1678 ) {
1679 Ok(report) => reports.push(report),
1680 Err(e) => log::error!("Failed to parse order status report: {e}"),
1681 }
1682 }
1683
1684 Ok(reports)
1685 }
1686
1687 pub async fn request_order_status_report(
1697 &self,
1698 user: &str,
1699 oid: u64,
1700 ) -> Result<Option<OrderStatusReport>> {
1701 let account_id = self
1702 .account_id
1703 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1704
1705 let ts_init = self.clock.get_time_ns();
1706
1707 let response = self.info_frontend_open_orders(user).await?;
1709 let orders: Vec<WsBasicOrderData> = match serde_json::from_value(response) {
1710 Ok(v) => v,
1711 Err(e) => {
1712 log::warn!("Failed to parse frontend open orders response: {e}");
1713 Vec::new()
1714 }
1715 };
1716
1717 if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
1718 let instrument = match self.get_or_create_instrument(&order.coin, None) {
1719 Some(inst) => inst,
1720 None => return Ok(None),
1721 };
1722
1723 let status = if order.trigger_activated == Some(true) {
1724 HyperliquidOrderStatusEnum::Triggered
1725 } else {
1726 HyperliquidOrderStatusEnum::Open
1727 };
1728
1729 return match parse_order_status_report_from_basic(
1730 &order,
1731 &status,
1732 &instrument,
1733 account_id,
1734 ts_init,
1735 ) {
1736 Ok(report) => Ok(Some(report)),
1737 Err(e) => {
1738 log::error!("Failed to parse order status report for oid {oid}: {e}");
1739 Ok(None)
1740 }
1741 };
1742 }
1743
1744 let response = self.info_order_status(user, oid).await?;
1746 let entry = match response.statuses.into_iter().next() {
1747 Some(e) => e,
1748 None => return Ok(None),
1749 };
1750
1751 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
1752 Some(inst) => inst,
1753 None => return Ok(None),
1754 };
1755
1756 let basic = WsBasicOrderData {
1761 coin: entry.order.coin,
1762 side: entry.order.side,
1763 limit_px: entry.order.limit_px,
1764 sz: entry.order.sz,
1765 oid: entry.order.oid,
1766 timestamp: entry.order.timestamp,
1767 orig_sz: entry.order.orig_sz,
1768 cloid: None,
1769 trigger_px: None,
1770 is_market: None,
1771 tpsl: None,
1772 trigger_activated: None,
1773 trailing_stop: None,
1774 };
1775
1776 match parse_order_status_report_from_basic(
1777 &basic,
1778 &entry.status,
1779 &instrument,
1780 account_id,
1781 ts_init,
1782 ) {
1783 Ok(mut report) => {
1784 if entry.status_timestamp > 0 {
1787 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
1788 }
1789 Ok(Some(report))
1790 }
1791 Err(e) => {
1792 log::error!("Failed to parse order status report for oid {oid}: {e}");
1793 Ok(None)
1794 }
1795 }
1796 }
1797
1798 pub async fn request_order_status_report_by_client_order_id(
1807 &self,
1808 user: &str,
1809 client_order_id: &ClientOrderId,
1810 ) -> Result<Option<OrderStatusReport>> {
1811 let account_id = self
1812 .account_id
1813 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1814
1815 let ts_init = self.clock.get_time_ns();
1816
1817 let cloid_hex = Cloid::from_client_order_id(*client_order_id).to_hex();
1818
1819 let response = self.info_frontend_open_orders(user).await?;
1820 let orders: Vec<WsBasicOrderData> = match serde_json::from_value(response) {
1821 Ok(v) => v,
1822 Err(e) => {
1823 log::warn!("Failed to parse frontend open orders response: {e}");
1824 return Ok(None);
1825 }
1826 };
1827
1828 let order = match orders
1829 .into_iter()
1830 .find(|o| o.cloid.as_ref().is_some_and(|c| c == &cloid_hex))
1831 {
1832 Some(o) => o,
1833 None => return Ok(None),
1834 };
1835
1836 let instrument = match self.get_or_create_instrument(&order.coin, None) {
1837 Some(inst) => inst,
1838 None => return Ok(None),
1839 };
1840
1841 let status = if order.trigger_activated == Some(true) {
1842 HyperliquidOrderStatusEnum::Triggered
1843 } else {
1844 HyperliquidOrderStatusEnum::Open
1845 };
1846
1847 match parse_order_status_report_from_basic(
1848 &order,
1849 &status,
1850 &instrument,
1851 account_id,
1852 ts_init,
1853 ) {
1854 Ok(mut report) => {
1855 report.client_order_id = Some(*client_order_id);
1856 Ok(Some(report))
1857 }
1858 Err(e) => {
1859 log::error!("Failed to parse order status report for cloid {cloid_hex}: {e}");
1860 Ok(None)
1861 }
1862 }
1863 }
1864
1865 pub async fn request_fill_reports(
1879 &self,
1880 user: &str,
1881 instrument_id: Option<InstrumentId>,
1882 ) -> Result<Vec<FillReport>> {
1883 let account_id = self
1884 .account_id
1885 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1886 let fills_response = self.info_user_fills(user).await?;
1887
1888 let mut reports = Vec::new();
1889 let ts_init = self.clock.get_time_ns();
1890
1891 for fill in fills_response {
1892 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
1894 Some(inst) => inst,
1895 None => continue, };
1897
1898 if let Some(filter_id) = instrument_id
1900 && instrument.id() != filter_id
1901 {
1902 continue;
1903 }
1904
1905 match parse_fill_report(&fill, &instrument, account_id, ts_init) {
1907 Ok(report) => reports.push(report),
1908 Err(e) => log::error!("Failed to parse fill report: {e}"),
1909 }
1910 }
1911
1912 Ok(reports)
1913 }
1914
1915 pub async fn request_position_status_reports(
1929 &self,
1930 user: &str,
1931 instrument_id: Option<InstrumentId>,
1932 ) -> Result<Vec<PositionStatusReport>> {
1933 let account_id = self
1934 .account_id
1935 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1936 let state_response = self.info_clearinghouse_state(user).await?;
1937
1938 let asset_positions: Vec<serde_json::Value> = state_response
1940 .get("assetPositions")
1941 .and_then(|v| v.as_array())
1942 .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
1943 .clone();
1944
1945 let mut reports = Vec::new();
1946 let ts_init = self.clock.get_time_ns();
1947
1948 for position_value in asset_positions {
1949 let coin = position_value
1951 .get("position")
1952 .and_then(|p| p.get("coin"))
1953 .and_then(|c| c.as_str())
1954 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
1955
1956 let coin_ustr = Ustr::from(coin);
1958 let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
1959 Some(inst) => inst,
1960 None => continue, };
1962
1963 if let Some(filter_id) = instrument_id
1965 && instrument.id() != filter_id
1966 {
1967 continue;
1968 }
1969
1970 match parse_position_status_report(&position_value, &instrument, account_id, ts_init) {
1972 Ok(report) => reports.push(report),
1973 Err(e) => log::error!("Failed to parse position status report: {e}"),
1974 }
1975 }
1976
1977 Ok(reports)
1978 }
1979
1980 pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
1988 let account_id = self
1989 .account_id
1990 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1991 let state_response = self.info_clearinghouse_state(user).await?;
1992 let ts_init = self.clock.get_time_ns();
1993
1994 log::trace!("Clearinghouse state response: {state_response}");
1995
1996 let state: ClearinghouseState =
1998 serde_json::from_value(state_response.clone()).map_err(|e| {
1999 log::error!("Failed to parse clearinghouse state: {e}");
2000 log::debug!("Raw response: {state_response}");
2001 Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
2002 })?;
2003
2004 let usdc = Currency::new("USDC", 6, 0, "0.000001", CurrencyType::Crypto);
2006
2007 let balances = if let Some(margin) = &state.cross_margin_summary {
2009 let mut total = margin.total_raw_usd.max(Decimal::ZERO);
2010 let free = state.withdrawable.unwrap_or(total).max(Decimal::ZERO);
2011
2012 if free > total {
2014 log::debug!("Adjusting total ({total}) to match withdrawable ({free})");
2015 total = free;
2016 }
2017
2018 let locked = (total - free).max(Decimal::ZERO);
2019
2020 vec![AccountBalance::new(
2021 Money::from_decimal(total, usdc).map_err(|e| Error::decode(e.to_string()))?,
2022 Money::from_decimal(locked, usdc).map_err(|e| Error::decode(e.to_string()))?,
2023 Money::from_decimal(free, usdc).map_err(|e| Error::decode(e.to_string()))?,
2024 )]
2025 } else {
2026 let free = state
2028 .withdrawable
2029 .unwrap_or(Decimal::ZERO)
2030 .max(Decimal::ZERO);
2031
2032 vec![AccountBalance::new(
2033 Money::from_decimal(free, usdc).map_err(|e| Error::decode(e.to_string()))?,
2034 Money::zero(usdc),
2035 Money::from_decimal(free, usdc).map_err(|e| Error::decode(e.to_string()))?,
2036 )]
2037 };
2038
2039 Ok(AccountState::new(
2040 account_id,
2041 AccountType::Margin,
2042 balances,
2043 vec![], true, UUID4::new(),
2046 ts_init,
2047 ts_init,
2048 None,
2049 ))
2050 }
2051
2052 pub async fn request_bars(
2069 &self,
2070 bar_type: BarType,
2071 start: Option<chrono::DateTime<chrono::Utc>>,
2072 end: Option<chrono::DateTime<chrono::Utc>>,
2073 limit: Option<u32>,
2074 ) -> Result<Vec<Bar>> {
2075 let instrument_id = bar_type.instrument_id();
2076 let symbol = instrument_id.symbol;
2077
2078 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
2079
2080 let base = Ustr::from(
2082 symbol
2083 .as_str()
2084 .split('-')
2085 .next()
2086 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?,
2087 );
2088
2089 let instrument = self
2090 .get_or_create_instrument(&base, product_type)
2091 .ok_or_else(|| {
2092 Error::bad_request(format!("Instrument not found in cache: {instrument_id}"))
2093 })?;
2094
2095 let coin = instrument.raw_symbol().inner();
2100
2101 let price_precision = instrument.price_precision();
2102 let size_precision = instrument.size_precision();
2103
2104 let interval =
2105 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
2106
2107 let now = chrono::Utc::now();
2109 let end_time = end.unwrap_or(now).timestamp_millis() as u64;
2110 let start_time = if let Some(start) = start {
2111 start.timestamp_millis() as u64
2112 } else {
2113 let spec = bar_type.spec();
2115 let step_ms = match spec.aggregation {
2116 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
2117 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
2118 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
2119 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
2120 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
2121 _ => 60_000,
2122 };
2123 end_time.saturating_sub(1000 * step_ms)
2124 };
2125
2126 let candles = self
2127 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
2128 .await?;
2129
2130 let now_ms = now.timestamp_millis() as u64;
2132
2133 let mut bars: Vec<Bar> = candles
2134 .iter()
2135 .filter(|candle| candle.end_timestamp < now_ms)
2136 .enumerate()
2137 .filter_map(|(i, candle)| {
2138 candle_to_bar(candle, bar_type, price_precision, size_precision)
2139 .map_err(|e| {
2140 log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
2141 e
2142 })
2143 .ok()
2144 })
2145 .collect();
2146
2147 if let Some(limit) = limit
2149 && limit > 0
2150 && bars.len() > limit as usize
2151 {
2152 bars.truncate(limit as usize);
2153 }
2154
2155 log::debug!(
2156 "Received {} bars for {} (filtered {} incomplete)",
2157 bars.len(),
2158 bar_type,
2159 candles.len() - bars.len()
2160 );
2161 Ok(bars)
2162 }
2163
2164 #[allow(clippy::too_many_arguments)]
2171 pub async fn submit_order(
2172 &self,
2173 instrument_id: InstrumentId,
2174 client_order_id: ClientOrderId,
2175 order_side: OrderSide,
2176 order_type: OrderType,
2177 quantity: Quantity,
2178 time_in_force: TimeInForce,
2179 price: Option<Price>,
2180 trigger_price: Option<Price>,
2181 post_only: bool,
2182 reduce_only: bool,
2183 ) -> Result<OrderStatusReport> {
2184 let symbol = instrument_id.symbol.as_str();
2185 let asset = self.get_asset_index(symbol).ok_or_else(|| {
2186 Error::bad_request(format!(
2187 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2188 ))
2189 })?;
2190
2191 let is_buy = matches!(order_side, OrderSide::Buy);
2192 let price_precision = self.get_price_precision(symbol).unwrap_or(2);
2193
2194 let price_decimal = match price {
2195 Some(px) if self.normalize_prices => {
2196 normalize_price(px.as_decimal(), price_precision).normalize()
2197 }
2198 Some(px) => px.as_decimal().normalize(),
2199 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
2200 None if matches!(
2201 order_type,
2202 OrderType::StopMarket | OrderType::MarketIfTouched
2203 ) =>
2204 {
2205 match trigger_price {
2206 Some(tp) => {
2207 let derived =
2208 derive_limit_from_trigger(tp.as_decimal().normalize(), is_buy);
2209 let sig_rounded = round_to_sig_figs(derived, 5);
2210 clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
2211 }
2212 None => Decimal::ZERO,
2213 }
2214 }
2215 None => return Err(Error::bad_request("Limit orders require a price")),
2216 };
2217
2218 let size_decimal = quantity.as_decimal().normalize();
2219
2220 let kind = match order_type {
2221 OrderType::Market => HyperliquidExecOrderKind::Limit {
2222 limit: HyperliquidExecLimitParams {
2223 tif: HyperliquidExecTif::Ioc,
2224 },
2225 },
2226 OrderType::Limit => {
2227 let tif = if post_only {
2228 HyperliquidExecTif::Alo
2229 } else {
2230 match time_in_force {
2231 TimeInForce::Gtc => HyperliquidExecTif::Gtc,
2232 TimeInForce::Ioc => HyperliquidExecTif::Ioc,
2233 TimeInForce::Fok
2234 | TimeInForce::Day
2235 | TimeInForce::Gtd
2236 | TimeInForce::AtTheOpen
2237 | TimeInForce::AtTheClose => {
2238 return Err(Error::bad_request(format!(
2239 "Time in force {time_in_force:?} not supported"
2240 )));
2241 }
2242 }
2243 };
2244 HyperliquidExecOrderKind::Limit {
2245 limit: HyperliquidExecLimitParams { tif },
2246 }
2247 }
2248 OrderType::StopMarket
2249 | OrderType::StopLimit
2250 | OrderType::MarketIfTouched
2251 | OrderType::LimitIfTouched => {
2252 if let Some(trig_px) = trigger_price {
2253 let trigger_price_decimal = if self.normalize_prices {
2254 normalize_price(trig_px.as_decimal(), price_precision).normalize()
2255 } else {
2256 trig_px.as_decimal().normalize()
2257 };
2258
2259 let tpsl = match order_type {
2263 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
2264 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2265 HyperliquidExecTpSl::Tp
2266 }
2267 _ => unreachable!(),
2268 };
2269
2270 let is_market = matches!(
2271 order_type,
2272 OrderType::StopMarket | OrderType::MarketIfTouched
2273 );
2274
2275 HyperliquidExecOrderKind::Trigger {
2276 trigger: HyperliquidExecTriggerParams {
2277 is_market,
2278 trigger_px: trigger_price_decimal,
2279 tpsl,
2280 },
2281 }
2282 } else {
2283 return Err(Error::bad_request("Trigger orders require a trigger price"));
2284 }
2285 }
2286 _ => {
2287 return Err(Error::bad_request(format!(
2288 "Order type {order_type:?} not supported"
2289 )));
2290 }
2291 };
2292
2293 let hyperliquid_order = HyperliquidExecPlaceOrderRequest {
2294 asset,
2295 is_buy,
2296 price: price_decimal,
2297 size: size_decimal,
2298 reduce_only,
2299 kind,
2300 cloid: Some(Cloid::from_client_order_id(client_order_id)),
2301 };
2302
2303 let builder = if self.has_vault_address() {
2304 None
2305 } else {
2306 Some(HyperliquidExecBuilderFee {
2307 address: NAUTILUS_BUILDER_ADDRESS.to_string(),
2308 fee_tenths_bp: 0,
2309 })
2310 };
2311
2312 let action = HyperliquidExecAction::Order {
2313 orders: vec![hyperliquid_order],
2314 grouping: HyperliquidExecGrouping::Na,
2315 builder,
2316 };
2317
2318 let response = self.inner.post_action_exec(&action).await?;
2319
2320 match response {
2321 HyperliquidExchangeResponse::Status {
2322 status,
2323 response: response_data,
2324 } if status == RESPONSE_STATUS_OK => {
2325 let data_value = if let Some(data) = response_data.get("data") {
2326 data.clone()
2327 } else {
2328 response_data
2329 };
2330
2331 let order_response: HyperliquidExecOrderResponseData =
2332 serde_json::from_value(data_value).map_err(|e| {
2333 Error::bad_request(format!("Failed to parse order response: {e}"))
2334 })?;
2335
2336 let order_status = order_response
2337 .statuses
2338 .first()
2339 .ok_or_else(|| Error::bad_request("No order status in response"))?;
2340
2341 let symbol_str = instrument_id.symbol.as_str();
2342 let product_type = HyperliquidProductType::from_symbol(symbol_str).ok();
2343
2344 let asset_str = symbol_str.split('-').next().unwrap_or(symbol_str);
2346 let instrument = self
2347 .get_or_create_instrument(&Ustr::from(asset_str), product_type)
2348 .ok_or_else(|| {
2349 Error::bad_request(format!("Instrument not found for {asset_str}"))
2350 })?;
2351
2352 let account_id = self
2353 .account_id
2354 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2355 let ts_init = self.clock.get_time_ns();
2356
2357 match order_status {
2358 HyperliquidExecOrderStatus::Resting { resting } => Ok(self
2359 .create_order_status_report(
2360 instrument_id,
2361 Some(client_order_id),
2362 VenueOrderId::new(resting.oid.to_string()),
2363 order_side,
2364 order_type,
2365 quantity,
2366 time_in_force,
2367 price,
2368 trigger_price,
2369 OrderStatus::Accepted,
2370 Quantity::new(0.0, instrument.size_precision()),
2371 &instrument,
2372 account_id,
2373 ts_init,
2374 )),
2375 HyperliquidExecOrderStatus::Filled { filled } => {
2376 let filled_qty = Quantity::new(
2377 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
2378 instrument.size_precision(),
2379 );
2380 Ok(self.create_order_status_report(
2381 instrument_id,
2382 Some(client_order_id),
2383 VenueOrderId::new(filled.oid.to_string()),
2384 order_side,
2385 order_type,
2386 quantity,
2387 time_in_force,
2388 price,
2389 trigger_price,
2390 OrderStatus::Filled,
2391 filled_qty,
2392 &instrument,
2393 account_id,
2394 ts_init,
2395 ))
2396 }
2397 HyperliquidExecOrderStatus::Error { error } => {
2398 Err(Error::bad_request(format!("Order rejected: {error}")))
2399 }
2400 }
2401 }
2402 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
2403 "Order submission failed: {error}"
2404 ))),
2405 _ => Err(Error::bad_request("Unexpected response format")),
2406 }
2407 }
2408
2409 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
2413 self.submit_order(
2414 order.instrument_id(),
2415 order.client_order_id(),
2416 order.order_side(),
2417 order.order_type(),
2418 order.quantity(),
2419 order.time_in_force(),
2420 order.price(),
2421 order.trigger_price(),
2422 order.is_post_only(),
2423 order.is_reduce_only(),
2424 )
2425 .await
2426 }
2427
2428 #[allow(clippy::too_many_arguments)]
2429 fn create_order_status_report(
2430 &self,
2431 instrument_id: InstrumentId,
2432 client_order_id: Option<ClientOrderId>,
2433 venue_order_id: VenueOrderId,
2434 order_side: OrderSide,
2435 order_type: OrderType,
2436 quantity: Quantity,
2437 time_in_force: TimeInForce,
2438 price: Option<Price>,
2439 trigger_price: Option<Price>,
2440 order_status: OrderStatus,
2441 filled_qty: Quantity,
2442 _instrument: &InstrumentAny,
2443 account_id: AccountId,
2444 ts_init: UnixNanos,
2445 ) -> OrderStatusReport {
2446 let ts_accepted = self.clock.get_time_ns();
2447 let ts_last = ts_accepted;
2448 let report_id = UUID4::new();
2449
2450 let mut report = OrderStatusReport::new(
2451 account_id,
2452 instrument_id,
2453 client_order_id,
2454 venue_order_id,
2455 order_side,
2456 order_type,
2457 time_in_force,
2458 order_status,
2459 quantity,
2460 filled_qty,
2461 ts_accepted,
2462 ts_last,
2463 ts_init,
2464 Some(report_id),
2465 );
2466
2467 if let Some(px) = price {
2468 report = report.with_price(px);
2469 }
2470
2471 if let Some(trig_px) = trigger_price {
2472 report = report
2473 .with_trigger_price(trig_px)
2474 .with_trigger_type(TriggerType::Default);
2475 }
2476
2477 report
2478 }
2479
2480 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
2487 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
2489
2490 for order in orders {
2491 let instrument_id = order.instrument_id();
2492 let symbol = instrument_id.symbol.as_str();
2493 let asset = self.get_asset_index(symbol).ok_or_else(|| {
2494 Error::bad_request(format!(
2495 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2496 ))
2497 })?;
2498 let price_decimals = self.get_price_precision(symbol).unwrap_or(2);
2499 let request = order_to_hyperliquid_request_with_asset(
2500 order,
2501 asset,
2502 price_decimals,
2503 self.normalize_prices,
2504 )
2505 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
2506 hyperliquid_orders.push(request);
2507 }
2508
2509 let builder = if self.has_vault_address() {
2510 None
2511 } else {
2512 Some(HyperliquidExecBuilderFee {
2513 address: NAUTILUS_BUILDER_ADDRESS.to_string(),
2514 fee_tenths_bp: 0,
2515 })
2516 };
2517
2518 let action = HyperliquidExecAction::Order {
2519 orders: hyperliquid_orders,
2520 grouping: HyperliquidExecGrouping::Na,
2521 builder,
2522 };
2523
2524 let response = self.inner.post_action_exec(&action).await?;
2526
2527 match response {
2529 HyperliquidExchangeResponse::Status {
2530 status,
2531 response: response_data,
2532 } if status == RESPONSE_STATUS_OK => {
2533 let data_value = if let Some(data) = response_data.get("data") {
2536 data.clone()
2537 } else {
2538 response_data
2539 };
2540
2541 let order_response: HyperliquidExecOrderResponseData =
2543 serde_json::from_value(data_value).map_err(|e| {
2544 Error::bad_request(format!("Failed to parse order response: {e}"))
2545 })?;
2546
2547 let account_id = self
2548 .account_id
2549 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2550 let ts_init = self.clock.get_time_ns();
2551
2552 if order_response.statuses.len() != orders.len() {
2554 return Err(Error::bad_request(format!(
2555 "Mismatch between submitted orders ({}) and response statuses ({})",
2556 orders.len(),
2557 order_response.statuses.len()
2558 )));
2559 }
2560
2561 let mut reports = Vec::new();
2562
2563 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
2565 let instrument_id = order.instrument_id();
2567 let symbol = instrument_id.symbol.as_str();
2568 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
2569
2570 let asset = symbol.split('-').next().unwrap_or(symbol);
2572 let instrument = self
2573 .get_or_create_instrument(&Ustr::from(asset), product_type)
2574 .ok_or_else(|| {
2575 Error::bad_request(format!("Instrument not found for {asset}"))
2576 })?;
2577
2578 let report = match order_status {
2580 HyperliquidExecOrderStatus::Resting { resting } => {
2581 self.create_order_status_report(
2583 order.instrument_id(),
2584 Some(order.client_order_id()),
2585 VenueOrderId::new(resting.oid.to_string()),
2586 order.order_side(),
2587 order.order_type(),
2588 order.quantity(),
2589 order.time_in_force(),
2590 order.price(),
2591 order.trigger_price(),
2592 OrderStatus::Accepted,
2593 Quantity::new(0.0, instrument.size_precision()),
2594 &instrument,
2595 account_id,
2596 ts_init,
2597 )
2598 }
2599 HyperliquidExecOrderStatus::Filled { filled } => {
2600 let filled_qty = Quantity::new(
2602 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
2603 instrument.size_precision(),
2604 );
2605 self.create_order_status_report(
2606 order.instrument_id(),
2607 Some(order.client_order_id()),
2608 VenueOrderId::new(filled.oid.to_string()),
2609 order.order_side(),
2610 order.order_type(),
2611 order.quantity(),
2612 order.time_in_force(),
2613 order.price(),
2614 order.trigger_price(),
2615 OrderStatus::Filled,
2616 filled_qty,
2617 &instrument,
2618 account_id,
2619 ts_init,
2620 )
2621 }
2622 HyperliquidExecOrderStatus::Error { error } => {
2623 return Err(Error::bad_request(format!(
2624 "Order {} rejected: {error}",
2625 order.client_order_id()
2626 )));
2627 }
2628 };
2629
2630 reports.push(report);
2631 }
2632
2633 Ok(reports)
2634 }
2635 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
2636 "Order submission failed: {error}"
2637 ))),
2638 _ => Err(Error::bad_request("Unexpected response format")),
2639 }
2640 }
2641}
2642
2643fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
2648 if dex_index == 0 {
2649 0
2650 } else {
2651 100_000 + dex_index as u32 * 10_000
2652 }
2653}
2654
2655#[cfg(test)]
2656mod tests {
2657 use nautilus_core::{MUTEX_POISONED, time::get_atomic_clock_realtime};
2658 use nautilus_model::{
2659 currencies::CURRENCY_MAP,
2660 enums::CurrencyType,
2661 identifiers::{InstrumentId, Symbol},
2662 instruments::{CurrencyPair, Instrument, InstrumentAny},
2663 types::{Currency, Price, Quantity},
2664 };
2665 use rstest::rstest;
2666 use ustr::Ustr;
2667
2668 use super::HyperliquidHttpClient;
2669 use crate::{
2670 common::{consts::HYPERLIQUID_VENUE, enums::HyperliquidProductType},
2671 http::query::InfoRequest,
2672 };
2673
2674 #[rstest]
2675 fn stable_json_roundtrips() {
2676 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
2677 let s = serde_json::to_string(&v).unwrap();
2678 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
2680 assert_eq!(parsed["type"], "l2Book");
2681 assert_eq!(parsed["coin"], "BTC");
2682 assert_eq!(parsed, v);
2683 }
2684
2685 #[rstest]
2686 fn info_pretty_shape() {
2687 let r = InfoRequest::l2_book("BTC");
2688 let val = serde_json::to_value(&r).unwrap();
2689 let pretty = serde_json::to_string_pretty(&val).unwrap();
2690 assert!(pretty.contains("\"type\": \"l2Book\""));
2691 assert!(pretty.contains("\"coin\": \"BTC\""));
2692 }
2693
2694 #[rstest]
2695 fn test_cache_instrument_by_raw_symbol() {
2696 let client = HyperliquidHttpClient::new(true, 60, None).unwrap();
2697
2698 let base_code = "vntls:vCURSOR";
2700 let quote_code = "USDC";
2701
2702 {
2704 let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
2705 if !currency_map.contains_key(base_code) {
2706 currency_map.insert(
2707 base_code.to_string(),
2708 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
2709 );
2710 }
2711 }
2712
2713 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
2714 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
2715
2716 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
2718 let venue = *HYPERLIQUID_VENUE;
2719 let instrument_id = InstrumentId::new(symbol, venue);
2720
2721 let raw_symbol = Symbol::new(base_code);
2723
2724 let clock = get_atomic_clock_realtime();
2725 let ts = clock.get_time_ns();
2726
2727 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
2728 instrument_id,
2729 raw_symbol,
2730 base_currency,
2731 quote_currency,
2732 8,
2733 8,
2734 Price::from("0.00000001"),
2735 Quantity::from("0.00000001"),
2736 None,
2737 None,
2738 None,
2739 None,
2740 None,
2741 None,
2742 None,
2743 None,
2744 None,
2745 None,
2746 None,
2747 None, None, ts,
2750 ts,
2751 ));
2752
2753 client.cache_instrument(&instrument);
2755
2756 let instruments = client.instruments.load();
2758 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
2759 assert!(
2760 by_full_symbol.is_some(),
2761 "Instrument should be accessible by full symbol"
2762 );
2763 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
2764
2765 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
2767 assert!(
2768 by_raw_symbol.is_some(),
2769 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
2770 );
2771 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
2772 drop(instruments);
2773
2774 let instruments_by_coin = client.instruments_by_coin.load();
2776 let by_coin =
2777 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
2778 assert!(
2779 by_coin.is_some(),
2780 "Instrument should be accessible by coin and product type"
2781 );
2782 assert_eq!(by_coin.unwrap().id(), instrument.id());
2783 drop(instruments_by_coin);
2784
2785 let retrieved_with_type = client.get_or_create_instrument(
2787 &Ustr::from("vntls:vCURSOR"),
2788 Some(HyperliquidProductType::Spot),
2789 );
2790 assert!(retrieved_with_type.is_some());
2791 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
2792
2793 let retrieved_without_type =
2795 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
2796 assert!(retrieved_without_type.is_some());
2797 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
2798 }
2799}