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, 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, HyperliquidEnvironment,
65 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
66 },
67 parse::{
68 bar_type_to_interval, clamp_price_to_precision, derive_limit_from_trigger,
69 determine_order_list_grouping, extract_inner_error, normalize_price,
70 order_to_hyperliquid_request_with_asset, parse_combined_account_balances_and_margins,
71 parse_spot_account_balances, round_to_sig_figs, time_in_force_to_hyperliquid_tif,
72 },
73 },
74 data::candle_to_bar,
75 http::{
76 error::{Error, Result},
77 models::{
78 ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeRequest,
79 HyperliquidExchangeResponse, HyperliquidExecAction, HyperliquidExecBuilderFee,
80 HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
81 HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecModifyOrderRequest,
82 HyperliquidExecOrderKind, HyperliquidExecOrderResponseData, HyperliquidExecOrderStatus,
83 HyperliquidExecPlaceOrderRequest, HyperliquidExecTif, HyperliquidExecTpSl,
84 HyperliquidExecTriggerParams, HyperliquidFills, HyperliquidFundingHistoryEntry,
85 HyperliquidL2Book, HyperliquidMeta, HyperliquidOrderStatus, PerpMeta, PerpMetaAndCtxs,
86 RESPONSE_STATUS_OK, SpotClearinghouseState, SpotMeta, SpotMetaAndCtxs,
87 },
88 parse::{
89 HyperliquidInstrumentDef, instruments_from_defs_owned, parse_fill_report,
90 parse_order_status_report_from_basic, parse_perp_instruments,
91 parse_position_status_report, parse_spot_instruments,
92 parse_spot_position_status_report,
93 },
94 query::{ExchangeAction, InfoRequest},
95 rate_limits::{
96 RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
97 info_base_weight, info_extra_weight,
98 },
99 },
100 signing::{
101 HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
102 },
103 websocket::messages::WsBasicOrderData,
104};
105
106pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
108 LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
109
110#[derive(Debug, Clone)]
115#[cfg_attr(
116 feature = "python",
117 pyo3::pyclass(
118 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
119 from_py_object
120 )
121)]
122#[cfg_attr(
123 feature = "python",
124 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.hyperliquid")
125)]
126pub struct HyperliquidRawHttpClient {
127 client: HttpClient,
128 environment: HyperliquidEnvironment,
129 base_info: String,
130 base_exchange: String,
131 signer: Option<HyperliquidEip712Signer>,
132 nonce_manager: Option<Arc<NonceManager>>,
133 vault_address: Option<VaultAddress>,
134 rest_limiter: Arc<WeightedLimiter>,
135 rate_limit_backoff_base: Duration,
136 rate_limit_backoff_cap: Duration,
137 rate_limit_max_attempts_info: u32,
138}
139
140impl HyperliquidRawHttpClient {
141 pub fn new(
147 environment: HyperliquidEnvironment,
148 timeout_secs: u64,
149 proxy_url: Option<String>,
150 ) -> std::result::Result<Self, HttpClientError> {
151 Ok(Self {
152 client: HttpClient::new(
153 Self::default_headers(),
154 vec![],
155 vec![],
156 Some(*HYPERLIQUID_REST_QUOTA),
157 Some(timeout_secs),
158 proxy_url,
159 )?,
160 environment,
161 base_info: info_url(environment).to_string(),
162 base_exchange: exchange_url(environment).to_string(),
163 signer: None,
164 nonce_manager: None,
165 vault_address: None,
166 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
167 rate_limit_backoff_base: Duration::from_millis(125),
168 rate_limit_backoff_cap: Duration::from_secs(5),
169 rate_limit_max_attempts_info: 3,
170 })
171 }
172
173 pub fn with_credentials(
180 secrets: &Secrets,
181 timeout_secs: u64,
182 proxy_url: Option<String>,
183 ) -> std::result::Result<Self, HttpClientError> {
184 let signer = HyperliquidEip712Signer::new(&secrets.private_key)
185 .map_err(|e| HttpClientError::from(e.to_string()))?;
186 let nonce_manager = Arc::new(NonceManager::new());
187
188 Ok(Self {
189 client: HttpClient::new(
190 Self::default_headers(),
191 vec![],
192 vec![],
193 Some(*HYPERLIQUID_REST_QUOTA),
194 Some(timeout_secs),
195 proxy_url,
196 )?,
197 environment: secrets.environment,
198 base_info: info_url(secrets.environment).to_string(),
199 base_exchange: exchange_url(secrets.environment).to_string(),
200 signer: Some(signer),
201 nonce_manager: Some(nonce_manager),
202 vault_address: secrets.vault_address,
203 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
204 rate_limit_backoff_base: Duration::from_millis(125),
205 rate_limit_backoff_cap: Duration::from_secs(5),
206 rate_limit_max_attempts_info: 3,
207 })
208 }
209
210 pub fn set_base_info_url(&mut self, url: String) {
212 self.base_info = url;
213 }
214
215 pub fn set_base_exchange_url(&mut self, url: String) {
217 self.base_exchange = url;
218 }
219
220 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
226 let secrets = Secrets::from_env(environment)
227 .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
228 Self::with_credentials(&secrets, 60, None)
229 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
230 }
231
232 pub fn from_credentials(
238 private_key: &str,
239 vault_address: Option<&str>,
240 environment: HyperliquidEnvironment,
241 timeout_secs: u64,
242 proxy_url: Option<String>,
243 ) -> Result<Self> {
244 let secrets = Secrets::from_private_key(private_key, vault_address, environment)
245 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
246 Self::with_credentials(&secrets, timeout_secs, proxy_url)
247 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
248 }
249
250 #[must_use]
252 pub fn with_rate_limits(mut self) -> Self {
253 self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
254 self.rate_limit_backoff_base = Duration::from_millis(125);
255 self.rate_limit_backoff_cap = Duration::from_secs(5);
256 self.rate_limit_max_attempts_info = 3;
257 self
258 }
259
260 #[must_use]
262 pub fn environment(&self) -> HyperliquidEnvironment {
263 self.environment
264 }
265
266 #[must_use]
268 pub fn is_testnet(&self) -> bool {
269 self.environment == HyperliquidEnvironment::Testnet
270 }
271
272 pub fn get_user_address(&self) -> Result<String> {
278 self.signer
279 .as_ref()
280 .ok_or_else(|| Error::auth("No signer configured"))?
281 .address()
282 }
283
284 #[must_use]
286 pub fn has_vault_address(&self) -> bool {
287 self.vault_address.is_some()
288 }
289
290 pub fn get_account_address(&self) -> Result<String> {
297 if let Some(vault) = &self.vault_address {
298 Ok(vault.to_hex())
299 } else {
300 self.get_user_address()
301 }
302 }
303
304 fn default_headers() -> HashMap<String, String> {
305 HashMap::from([
306 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
307 ("Content-Type".to_string(), "application/json".to_string()),
308 ])
309 }
310
311 fn signer_id(&self) -> SignerId {
312 SignerId("hyperliquid:default".into())
313 }
314
315 fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
316 let retry_after = headers.get("retry-after")?;
317 retry_after.parse::<u64>().ok().map(|s| s * 1000) }
319
320 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
322 let request = InfoRequest::meta();
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 get_spot_meta(&self) -> Result<SpotMeta> {
329 let request = InfoRequest::spot_meta();
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 get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
336 let request = InfoRequest::meta_and_asset_ctxs();
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 get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
343 let request = InfoRequest::spot_meta_and_asset_ctxs();
344 let response = self.send_info_request(&request).await?;
345 serde_json::from_value(response).map_err(Error::Serde)
346 }
347
348 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
349 let request = InfoRequest::meta();
350 let response = self.send_info_request(&request).await?;
351 serde_json::from_value(response).map_err(Error::Serde)
352 }
353
354 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
356 let request = InfoRequest::all_perp_metas();
357 let response = self.send_info_request(&request).await?;
358 serde_json::from_value(response).map_err(Error::Serde)
359 }
360
361 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
363 let request = InfoRequest::l2_book(coin);
364 let response = self.send_info_request(&request).await?;
365 serde_json::from_value(response).map_err(Error::Serde)
366 }
367
368 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
370 let request = InfoRequest::user_fills(user);
371 let response = self.send_info_request(&request).await?;
372 serde_json::from_value(response).map_err(Error::Serde)
373 }
374
375 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
377 let request = InfoRequest::order_status(user, oid);
378 let response = self.send_info_request(&request).await?;
379 serde_json::from_value(response).map_err(Error::Serde)
380 }
381
382 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
384 let request = InfoRequest::open_orders(user);
385 self.send_info_request(&request).await
386 }
387
388 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
390 let request = InfoRequest::frontend_open_orders(user);
391 self.send_info_request(&request).await
392 }
393
394 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
396 let request = InfoRequest::clearinghouse_state(user);
397 self.send_info_request(&request).await
398 }
399
400 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
402 let request = InfoRequest::spot_clearinghouse_state(user);
403 self.send_info_request(&request).await
404 }
405
406 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
408 let request = InfoRequest::user_fees(user);
409 self.send_info_request(&request).await
410 }
411
412 pub async fn info_candle_snapshot(
414 &self,
415 coin: &str,
416 interval: HyperliquidBarInterval,
417 start_time: u64,
418 end_time: u64,
419 ) -> Result<HyperliquidCandleSnapshot> {
420 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
421 let response = self.send_info_request(&request).await?;
422
423 log::trace!(
424 "Candle snapshot raw response (len={}): {:?}",
425 response.as_array().map_or(0, |a| a.len()),
426 response
427 );
428
429 serde_json::from_value(response).map_err(Error::Serde)
430 }
431
432 pub async fn info_funding_history(
437 &self,
438 coin: &str,
439 start_time: u64,
440 end_time: Option<u64>,
441 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
442 let request = InfoRequest::funding_history(coin, start_time, end_time);
443 let response = self.send_info_request(&request).await?;
444 serde_json::from_value(response).map_err(Error::Serde)
445 }
446
447 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
449 self.send_info_request(request).await
450 }
451
452 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
453 let base_w = info_base_weight(request);
454 self.rest_limiter.acquire(base_w).await;
455
456 let mut attempt = 0u32;
457
458 loop {
459 let response = self.http_roundtrip_info(request).await?;
460
461 if response.status.is_success() {
462 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
464 let extra = info_extra_weight(request, &val);
465 if extra > 0 {
466 self.rest_limiter.debit_extra(extra).await;
467 log::debug!(
468 "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
469 );
470 }
471 return Ok(val);
472 }
473
474 if response.status.as_u16() == 429 {
476 if attempt >= self.rate_limit_max_attempts_info {
477 let ra = self.parse_retry_after_simple(&response.headers);
478 return Err(Error::rate_limit("info", base_w, ra));
479 }
480 let delay = self
481 .parse_retry_after_simple(&response.headers)
482 .map_or_else(
483 || {
484 backoff_full_jitter(
485 attempt,
486 self.rate_limit_backoff_base,
487 self.rate_limit_backoff_cap,
488 )
489 },
490 Duration::from_millis,
491 );
492 log::warn!(
493 "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
494 delay.as_millis()
495 );
496 attempt += 1;
497 tokio::time::sleep(delay).await;
498 self.rest_limiter.acquire(1).await;
500 continue;
501 }
502
503 if (response.status.is_server_error() || response.status.as_u16() == 408)
505 && attempt < self.rate_limit_max_attempts_info
506 {
507 let delay = backoff_full_jitter(
508 attempt,
509 self.rate_limit_backoff_base,
510 self.rate_limit_backoff_cap,
511 );
512 log::warn!(
513 "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
514 response.status.as_u16(),
515 delay.as_millis()
516 );
517 attempt += 1;
518 tokio::time::sleep(delay).await;
519 continue;
520 }
521
522 let error_body = String::from_utf8_lossy(&response.body);
524 return Err(Error::http(
525 response.status.as_u16(),
526 error_body.to_string(),
527 ));
528 }
529 }
530
531 async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
532 let url = &self.base_info;
533 let body = serde_json::to_value(request).map_err(Error::Serde)?;
534 let body_bytes = serde_json::to_string(&body)
535 .map_err(Error::Serde)?
536 .into_bytes();
537
538 self.client
539 .request(
540 Method::POST,
541 url.clone(),
542 None,
543 None,
544 Some(body_bytes),
545 None,
546 None,
547 )
548 .await
549 .map_err(Error::from_http_client)
550 }
551
552 pub async fn post_action(
554 &self,
555 action: &ExchangeAction,
556 ) -> Result<HyperliquidExchangeResponse> {
557 let w = exchange_weight(action);
558 self.rest_limiter.acquire(w).await;
559
560 let signer = self
561 .signer
562 .as_ref()
563 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
564
565 let nonce_manager = self
566 .nonce_manager
567 .as_ref()
568 .ok_or_else(|| Error::auth("nonce manager missing"))?;
569
570 let signer_id = self.signer_id();
571 let time_nonce = nonce_manager.next(signer_id)?;
572
573 let action_value = serde_json::to_value(action)
574 .context("serialize exchange action")
575 .map_err(|e| Error::bad_request(e.to_string()))?;
576
577 let action_bytes = rmp_serde::to_vec_named(action)
579 .context("serialize action with MessagePack")
580 .map_err(|e| Error::bad_request(e.to_string()))?;
581
582 let sign_request = SignRequest {
583 action: action_value,
584 action_bytes: Some(action_bytes),
585 time_nonce,
586 action_type: HyperliquidActionType::L1,
587 is_testnet: self.is_testnet(),
588 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
589 };
590
591 let sig = signer.sign(&sign_request)?.signature;
592
593 let nonce_u64 = time_nonce.as_millis() as u64;
594
595 let request = if let Some(vault) = self.vault_address {
596 HyperliquidExchangeRequest::with_vault(
597 action.clone(),
598 nonce_u64,
599 sig,
600 vault.to_string(),
601 )
602 } else {
603 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
604 };
605
606 let response = self.http_roundtrip_exchange(&request).await?;
607
608 if response.status.is_success() {
609 let parsed_response: HyperliquidExchangeResponse =
610 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
611
612 match &parsed_response {
614 HyperliquidExchangeResponse::Status {
615 status,
616 response: response_data,
617 } if status == "err" => {
618 let error_msg = response_data
619 .as_str()
620 .map_or_else(|| response_data.to_string(), |s| s.to_string());
621 log::error!("Hyperliquid API returned error: {error_msg}");
622 Err(Error::bad_request(format!("API error: {error_msg}")))
623 }
624 HyperliquidExchangeResponse::Error { error } => {
625 log::error!("Hyperliquid API returned error: {error}");
626 Err(Error::bad_request(format!("API error: {error}")))
627 }
628 _ => Ok(parsed_response),
629 }
630 } else if response.status.as_u16() == 429 {
631 let ra = self.parse_retry_after_simple(&response.headers);
632 Err(Error::rate_limit("exchange", w, ra))
633 } else {
634 let error_body = String::from_utf8_lossy(&response.body);
635 log::error!(
636 "Exchange API error (status {}): {}",
637 response.status.as_u16(),
638 error_body
639 );
640 Err(Error::http(
641 response.status.as_u16(),
642 error_body.to_string(),
643 ))
644 }
645 }
646
647 pub async fn post_action_exec(
652 &self,
653 action: &HyperliquidExecAction,
654 ) -> Result<HyperliquidExchangeResponse> {
655 let w = match action {
656 HyperliquidExecAction::Order { orders, .. } => 1 + (orders.len() as u32 / 40),
657 HyperliquidExecAction::Cancel { cancels } => 1 + (cancels.len() as u32 / 40),
658 HyperliquidExecAction::CancelByCloid { cancels } => 1 + (cancels.len() as u32 / 40),
659 HyperliquidExecAction::BatchModify { modifies } => 1 + (modifies.len() as u32 / 40),
660 _ => 1,
661 };
662 self.rest_limiter.acquire(w).await;
663
664 let signer = self
665 .signer
666 .as_ref()
667 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
668
669 let nonce_manager = self
670 .nonce_manager
671 .as_ref()
672 .ok_or_else(|| Error::auth("nonce manager missing"))?;
673
674 let signer_id = self.signer_id();
675 let time_nonce = nonce_manager.next(signer_id)?;
676 let action_value = serde_json::to_value(action)
679 .context("serialize exchange action")
680 .map_err(|e| Error::bad_request(e.to_string()))?;
681
682 let action_bytes = rmp_serde::to_vec_named(action)
684 .context("serialize action with MessagePack")
685 .map_err(|e| Error::bad_request(e.to_string()))?;
686
687 let sig = signer
688 .sign(&SignRequest {
689 action: action_value,
690 action_bytes: Some(action_bytes),
691 time_nonce,
692 action_type: HyperliquidActionType::L1,
693 is_testnet: self.is_testnet(),
694 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
695 })?
696 .signature;
697
698 let request = if let Some(vault) = self.vault_address {
699 HyperliquidExchangeRequest::with_vault(
700 action.clone(),
701 time_nonce.as_millis() as u64,
702 sig,
703 vault.to_string(),
704 )
705 } else {
706 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
707 };
708
709 let response = self.http_roundtrip_exchange(&request).await?;
710
711 if response.status.is_success() {
712 let parsed_response: HyperliquidExchangeResponse =
713 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
714
715 match &parsed_response {
717 HyperliquidExchangeResponse::Status {
718 status,
719 response: response_data,
720 } if status == "err" => {
721 let error_msg = response_data
722 .as_str()
723 .map_or_else(|| response_data.to_string(), |s| s.to_string());
724 log::error!("Hyperliquid API returned error: {error_msg}");
725 Err(Error::bad_request(format!("API error: {error_msg}")))
726 }
727 HyperliquidExchangeResponse::Error { error } => {
728 log::error!("Hyperliquid API returned error: {error}");
729 Err(Error::bad_request(format!("API error: {error}")))
730 }
731 _ => Ok(parsed_response),
732 }
733 } else if response.status.as_u16() == 429 {
734 let ra = self.parse_retry_after_simple(&response.headers);
735 Err(Error::rate_limit("exchange", w, ra))
736 } else {
737 let error_body = String::from_utf8_lossy(&response.body);
738 Err(Error::http(
739 response.status.as_u16(),
740 error_body.to_string(),
741 ))
742 }
743 }
744
745 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
748 self.rest_limiter.snapshot().await
749 }
750 async fn http_roundtrip_exchange<T>(
751 &self,
752 request: &HyperliquidExchangeRequest<T>,
753 ) -> Result<HttpResponse>
754 where
755 T: serde::Serialize,
756 {
757 let url = &self.base_exchange;
758 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
759 let body_bytes = body.into_bytes();
760
761 let response = self
762 .client
763 .request(
764 Method::POST,
765 url.clone(),
766 None,
767 None,
768 Some(body_bytes),
769 None,
770 None,
771 )
772 .await
773 .map_err(Error::from_http_client)?;
774
775 Ok(response)
776 }
777}
778
779#[derive(Debug, Clone)]
785#[cfg_attr(
786 feature = "python",
787 pyo3::pyclass(
788 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
789 from_py_object
790 )
791)]
792#[cfg_attr(
793 feature = "python",
794 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.hyperliquid")
795)]
796pub struct HyperliquidHttpClient {
797 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
798 clock: &'static AtomicTime,
799 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
800 instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
801 asset_indices: Arc<AtomicMap<Ustr, u32>>,
803 spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
805 account_id: Option<AccountId>,
806 account_address: Option<String>,
810 normalize_prices: bool,
811 market_order_slippage_bps: u32,
812}
813
814impl Default for HyperliquidHttpClient {
815 fn default() -> Self {
816 Self::new(HyperliquidEnvironment::Mainnet, 60, None)
817 .expect("Failed to create default Hyperliquid HTTP client")
818 }
819}
820
821impl HyperliquidHttpClient {
822 pub fn new(
828 environment: HyperliquidEnvironment,
829 timeout_secs: u64,
830 proxy_url: Option<String>,
831 ) -> std::result::Result<Self, HttpClientError> {
832 let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
833 Ok(Self::from_raw(raw_client))
834 }
835
836 pub fn with_secrets(
842 secrets: &Secrets,
843 timeout_secs: u64,
844 proxy_url: Option<String>,
845 ) -> std::result::Result<Self, HttpClientError> {
846 let raw_client =
847 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
848 Ok(Self::from_raw(raw_client))
849 }
850
851 fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
852 Self {
853 inner: Arc::new(raw_client),
854 clock: get_atomic_clock_realtime(),
855 instruments: Arc::new(AtomicMap::new()),
856 instruments_by_coin: Arc::new(AtomicMap::new()),
857 asset_indices: Arc::new(AtomicMap::new()),
858 spot_fill_coins: Arc::new(AtomicMap::new()),
859 account_id: None,
860 account_address: None,
861 normalize_prices: true,
862 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
863 }
864 }
865
866 pub fn set_base_info_url(&mut self, url: String) {
872 Arc::get_mut(&mut self.inner)
873 .expect("cannot override URL: Arc has multiple references")
874 .set_base_info_url(url);
875 }
876
877 pub fn set_base_exchange_url(&mut self, url: String) {
883 Arc::get_mut(&mut self.inner)
884 .expect("cannot override URL: Arc has multiple references")
885 .set_base_exchange_url(url);
886 }
887
888 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
894 let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
895 Ok(Self {
896 inner: Arc::new(raw_client),
897 clock: get_atomic_clock_realtime(),
898 instruments: Arc::new(AtomicMap::new()),
899 instruments_by_coin: Arc::new(AtomicMap::new()),
900 asset_indices: Arc::new(AtomicMap::new()),
901 spot_fill_coins: Arc::new(AtomicMap::new()),
902 account_id: None,
903 account_address: None,
904 normalize_prices: true,
905 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
906 })
907 }
908
909 pub fn with_credentials(
922 private_key: Option<String>,
923 vault_address: Option<String>,
924 account_address: Option<String>,
925 environment: HyperliquidEnvironment,
926 timeout_secs: u64,
927 proxy_url: Option<String>,
928 ) -> Result<Self> {
929 let (pk_env_var, vault_env_var) =
930 crate::common::credential::credential_env_vars(environment);
931
932 let resolved_pk = match private_key {
934 Some(pk) => Some(pk),
935 None => env::var(pk_env_var).ok(),
936 };
937
938 let resolved_vault = match vault_address {
940 Some(vault) => Some(vault),
941 None => env::var(vault_env_var).ok(),
942 };
943
944 let resolved_account_address = match account_address {
946 Some(addr) => Some(addr),
947 None => env::var("HYPERLIQUID_ACCOUNT_ADDRESS").ok(),
948 };
949
950 match resolved_pk {
951 Some(pk) => {
952 let raw_client = HyperliquidRawHttpClient::from_credentials(
953 &pk,
954 resolved_vault.as_deref(),
955 environment,
956 timeout_secs,
957 proxy_url,
958 )?;
959 Ok(Self {
960 inner: Arc::new(raw_client),
961 clock: get_atomic_clock_realtime(),
962 instruments: Arc::new(AtomicMap::new()),
963 instruments_by_coin: Arc::new(AtomicMap::new()),
964 asset_indices: Arc::new(AtomicMap::new()),
965 spot_fill_coins: Arc::new(AtomicMap::new()),
966 account_id: None,
967 account_address: resolved_account_address,
968 normalize_prices: true,
969 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
970 })
971 }
972 None => {
973 Self::new(environment, timeout_secs, proxy_url)
975 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
976 }
977 }
978 }
979
980 pub fn from_credentials(
986 private_key: &str,
987 vault_address: Option<&str>,
988 environment: HyperliquidEnvironment,
989 timeout_secs: u64,
990 proxy_url: Option<String>,
991 ) -> Result<Self> {
992 let raw_client = HyperliquidRawHttpClient::from_credentials(
993 private_key,
994 vault_address,
995 environment,
996 timeout_secs,
997 proxy_url,
998 )?;
999 Ok(Self {
1000 inner: Arc::new(raw_client),
1001 clock: get_atomic_clock_realtime(),
1002 instruments: Arc::new(AtomicMap::new()),
1003 instruments_by_coin: Arc::new(AtomicMap::new()),
1004 asset_indices: Arc::new(AtomicMap::new()),
1005 spot_fill_coins: Arc::new(AtomicMap::new()),
1006 account_id: None,
1007 account_address: None,
1008 normalize_prices: true,
1009 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1010 })
1011 }
1012
1013 #[must_use]
1015 pub fn is_testnet(&self) -> bool {
1016 self.inner.is_testnet()
1017 }
1018
1019 #[must_use]
1021 pub fn normalize_prices(&self) -> bool {
1022 self.normalize_prices
1023 }
1024
1025 pub fn set_normalize_prices(&mut self, value: bool) {
1027 self.normalize_prices = value;
1028 }
1029
1030 #[must_use]
1032 pub fn market_order_slippage_bps(&self) -> u32 {
1033 self.market_order_slippage_bps
1034 }
1035
1036 pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1038 self.market_order_slippage_bps = value;
1039 }
1040
1041 pub fn get_user_address(&self) -> Result<String> {
1047 self.inner.get_user_address()
1048 }
1049
1050 #[must_use]
1052 pub fn has_vault_address(&self) -> bool {
1053 self.inner.has_vault_address()
1054 }
1055
1056 pub fn get_account_address(&self) -> Result<String> {
1064 if let Some(addr) = &self.account_address {
1065 return Ok(addr.clone());
1066 }
1067 self.inner.get_account_address()
1068 }
1069
1070 pub fn set_account_address(&mut self, address: Option<String>) {
1072 self.account_address = address;
1073 }
1074
1075 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1080 let full_symbol = instrument.symbol().inner();
1081 let coin = instrument.raw_symbol().inner();
1082
1083 self.instruments.rcu(|m| {
1084 m.insert(full_symbol, instrument.clone());
1085 m.insert(coin, instrument.clone());
1087 });
1088
1089 if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
1091 self.instruments_by_coin.rcu(|m| {
1092 m.insert((coin, product_type), instrument.clone());
1093
1094 if let Some(base) = full_symbol.as_str().split('-').next() {
1114 let base_ustr = Ustr::from(base);
1115 let key = (base_ustr, product_type);
1116 if base_ustr != coin && !m.contains_key(&key) {
1117 m.insert(key, instrument.clone());
1118 }
1119 }
1120 });
1121 } else {
1122 log::warn!("Unable to determine product type for symbol: {full_symbol}");
1123 }
1124 }
1125
1126 fn get_or_create_instrument(
1127 &self,
1128 coin: &Ustr,
1129 product_type: Option<HyperliquidProductType>,
1130 ) -> Option<InstrumentAny> {
1131 if let Some(pt) = product_type
1132 && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1133 {
1134 return Some(instrument.clone());
1135 }
1136
1137 if product_type.is_none() {
1139 let guard = self.instruments_by_coin.load();
1140
1141 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1142 return Some(instrument.clone());
1143 }
1144
1145 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1146 return Some(instrument.clone());
1147 }
1148 }
1149
1150 if coin.as_str().starts_with('@')
1152 && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1153 {
1154 if let Some(instrument) = self.instruments.load().get(symbol) {
1157 return Some(instrument.clone());
1158 }
1159 }
1160
1161 if coin.as_str().starts_with("vntls:") {
1163 log::info!("Creating synthetic instrument for vault token: {coin}");
1164
1165 let ts_event = self.clock.get_time_ns();
1166
1167 let symbol_str = format!("{coin}-USDC-SPOT");
1169 let symbol = Symbol::new(&symbol_str);
1170 let venue = *HYPERLIQUID_VENUE;
1171 let instrument_id = InstrumentId::new(symbol, venue);
1172
1173 let base_currency = Currency::new(
1175 coin.as_str(),
1176 8, 0, coin.as_str(),
1179 CurrencyType::Crypto,
1180 );
1181
1182 let quote_currency = Currency::new(
1183 "USDC",
1184 6, 0,
1186 "USDC",
1187 CurrencyType::Crypto,
1188 );
1189
1190 let price_increment = Price::from("0.00000001");
1191 let size_increment = Quantity::from("0.00000001");
1192
1193 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1194 instrument_id,
1195 symbol,
1196 base_currency,
1197 quote_currency,
1198 8, 8, price_increment,
1201 size_increment,
1202 None, None, None, None, None, None, None, None, None, None, None, None, None, ts_event,
1216 ts_event,
1217 ));
1218
1219 self.cache_instrument(&instrument);
1220
1221 Some(instrument)
1222 } else {
1223 log::warn!("Instrument not found in cache: {coin}");
1225 None
1226 }
1227 }
1228
1229 pub fn set_account_id(&mut self, account_id: AccountId) {
1233 self.account_id = Some(account_id);
1234 }
1235
1236 pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1238 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1239
1240 match self.inner.load_all_perp_metas().await {
1242 Ok(all_metas) => {
1243 for (dex_index, meta) in all_metas.iter().enumerate() {
1244 let base = perp_dex_asset_index_base(dex_index);
1245
1246 match parse_perp_instruments(meta, base) {
1247 Ok(perp_defs) => {
1248 log::debug!(
1249 "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1250 perp_defs.len(),
1251 );
1252 defs.extend(perp_defs);
1253 }
1254 Err(e) => {
1255 log::warn!("Failed to parse perp instruments for dex {dex_index}: {e}");
1256 }
1257 }
1258 }
1259 }
1260 Err(e) => {
1261 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1262
1263 match self.inner.load_perp_meta().await {
1264 Ok(perp_meta) => match parse_perp_instruments(&perp_meta, 0) {
1265 Ok(perp_defs) => {
1266 log::debug!(
1267 "Loaded Hyperliquid perp defs via fallback: count={}",
1268 perp_defs.len(),
1269 );
1270 defs.extend(perp_defs);
1271 }
1272 Err(e) => {
1273 log::warn!("Failed to parse perp instruments: {e}");
1274 }
1275 },
1276 Err(e) => {
1277 log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1278 }
1279 }
1280 }
1281 }
1282
1283 match self.inner.get_spot_meta().await {
1284 Ok(spot_meta) => match parse_spot_instruments(&spot_meta) {
1285 Ok(spot_defs) => {
1286 log::debug!(
1287 "Loaded Hyperliquid spot definitions: count={}",
1288 spot_defs.len(),
1289 );
1290 defs.extend(spot_defs);
1291 }
1292 Err(e) => {
1293 log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1294 }
1295 },
1296 Err(e) => {
1297 log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1298 }
1299 }
1300
1301 let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1308 let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1309 for def in defs {
1310 if seen_symbols.insert(def.symbol) {
1311 deduped.push(def);
1312 } else {
1313 log::warn!(
1314 "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1315 def.symbol,
1316 def.raw_symbol,
1317 );
1318 }
1319 }
1320 let defs = deduped;
1321
1322 self.asset_indices.rcu(|m| {
1324 for def in &defs {
1325 m.insert(def.symbol, def.asset_index);
1326 }
1327 });
1328 log::debug!(
1329 "Populated asset indices map (count={})",
1330 self.asset_indices.len()
1331 );
1332
1333 Ok(defs)
1334 }
1335
1336 pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1338 let ts_init = self.clock.get_time_ns();
1339 instruments_from_defs_owned(defs, ts_init)
1340 }
1341
1342 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1344 let defs = self.request_instrument_defs().await?;
1345 Ok(self.convert_defs(defs))
1346 }
1347
1348 pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1356 self.asset_indices.load().get(&Ustr::from(symbol)).copied()
1357 }
1358
1359 pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1361 self.instruments
1362 .load()
1363 .get(&Ustr::from(symbol))
1364 .map(|inst| inst.price_precision())
1365 }
1366
1367 #[must_use]
1375 pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1376 const SPOT_INDEX_OFFSET: u32 = 10_000;
1377 const BUILDER_PERP_OFFSET: u32 = 100_000;
1378
1379 let guard = self.asset_indices.load();
1380
1381 let mut mapping = AHashMap::new();
1382
1383 for (symbol, &asset_index) in guard.iter() {
1384 if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1386 let pair_index = asset_index - SPOT_INDEX_OFFSET;
1387 let fill_coin = Ustr::from(&format!("@{pair_index}"));
1388 mapping.insert(fill_coin, *symbol);
1389 }
1390 }
1391
1392 self.spot_fill_coins.store(mapping.clone());
1394
1395 mapping
1396 }
1397
1398 #[allow(dead_code)]
1400 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1401 self.inner.load_perp_meta().await
1402 }
1403
1404 #[allow(dead_code)]
1406 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1407 self.inner.load_all_perp_metas().await
1408 }
1409
1410 #[allow(dead_code)]
1412 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1413 self.inner.get_spot_meta().await
1414 }
1415
1416 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1418 self.inner.info_l2_book(coin).await
1419 }
1420
1421 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1423 self.inner.info_user_fills(user).await
1424 }
1425
1426 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1428 self.inner.info_order_status(user, oid).await
1429 }
1430
1431 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1433 self.inner.info_open_orders(user).await
1434 }
1435
1436 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1438 self.inner.info_frontend_open_orders(user).await
1439 }
1440
1441 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1443 self.inner.info_clearinghouse_state(user).await
1444 }
1445
1446 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
1448 self.inner.info_spot_clearinghouse_state(user).await
1449 }
1450
1451 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
1453 self.inner.info_user_fees(user).await
1454 }
1455
1456 pub async fn info_candle_snapshot(
1458 &self,
1459 coin: &str,
1460 interval: HyperliquidBarInterval,
1461 start_time: u64,
1462 end_time: u64,
1463 ) -> Result<HyperliquidCandleSnapshot> {
1464 self.inner
1465 .info_candle_snapshot(coin, interval, start_time, end_time)
1466 .await
1467 }
1468
1469 pub async fn info_funding_history(
1471 &self,
1472 coin: &str,
1473 start_time: u64,
1474 end_time: Option<u64>,
1475 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
1476 self.inner
1477 .info_funding_history(coin, start_time, end_time)
1478 .await
1479 }
1480
1481 pub async fn post_action(
1483 &self,
1484 action: &ExchangeAction,
1485 ) -> Result<HyperliquidExchangeResponse> {
1486 self.inner.post_action(action).await
1487 }
1488
1489 pub async fn post_action_exec(
1491 &self,
1492 action: &HyperliquidExecAction,
1493 ) -> Result<HyperliquidExchangeResponse> {
1494 self.inner.post_action_exec(action).await
1495 }
1496
1497 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1499 self.inner.info_meta().await
1500 }
1501
1502 pub async fn cancel_order(
1512 &self,
1513 instrument_id: InstrumentId,
1514 client_order_id: Option<ClientOrderId>,
1515 venue_order_id: Option<VenueOrderId>,
1516 ) -> Result<()> {
1517 let symbol = instrument_id.symbol.as_str();
1519 let asset_id = self.get_asset_index(symbol).ok_or_else(|| {
1520 Error::bad_request(format!(
1521 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1522 ))
1523 })?;
1524
1525 let action = if let Some(cloid) = client_order_id {
1527 let cloid_hash = Cloid::from_client_order_id(cloid);
1529 let cancel_req = HyperliquidExecCancelByCloidRequest {
1530 asset: asset_id,
1531 cloid: cloid_hash,
1532 };
1533 HyperliquidExecAction::CancelByCloid {
1534 cancels: vec![cancel_req],
1535 }
1536 } else if let Some(oid) = venue_order_id {
1537 let oid_u64 = oid
1538 .as_str()
1539 .parse::<u64>()
1540 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1541 let cancel_req = HyperliquidExecCancelOrderRequest {
1542 asset: asset_id,
1543 oid: oid_u64,
1544 };
1545 HyperliquidExecAction::Cancel {
1546 cancels: vec![cancel_req],
1547 }
1548 } else {
1549 return Err(Error::bad_request(
1550 "Either client_order_id or venue_order_id must be provided",
1551 ));
1552 };
1553
1554 let response = self.inner.post_action_exec(&action).await?;
1556
1557 match response {
1559 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
1560 HyperliquidExchangeResponse::Status {
1561 status,
1562 response: error_data,
1563 } => Err(Error::bad_request(format!(
1564 "Cancel order failed: status={status}, error={error_data}"
1565 ))),
1566 HyperliquidExchangeResponse::Error { error } => {
1567 Err(Error::bad_request(format!("Cancel order error: {error}")))
1568 }
1569 }
1570 }
1571
1572 #[expect(clippy::too_many_arguments)]
1582 pub async fn modify_order(
1583 &self,
1584 instrument_id: InstrumentId,
1585 venue_order_id: VenueOrderId,
1586 order_side: OrderSide,
1587 order_type: OrderType,
1588 price: Price,
1589 quantity: Quantity,
1590 trigger_price: Option<Price>,
1591 reduce_only: bool,
1592 post_only: bool,
1593 time_in_force: TimeInForce,
1594 client_order_id: Option<ClientOrderId>,
1595 ) -> Result<()> {
1596 let symbol = instrument_id.symbol.as_str();
1597 let asset_id = self.get_asset_index(symbol).ok_or_else(|| {
1598 Error::bad_request(format!(
1599 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1600 ))
1601 })?;
1602
1603 let oid: u64 = venue_order_id
1604 .as_str()
1605 .parse()
1606 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1607
1608 let is_buy = matches!(order_side, OrderSide::Buy);
1609 let decimals = self.get_price_precision(symbol).unwrap_or(2);
1610
1611 let normalized_price = if self.normalize_prices {
1612 normalize_price(price.as_decimal(), decimals).normalize()
1613 } else {
1614 price.as_decimal().normalize()
1615 };
1616
1617 let size = quantity.as_decimal().normalize();
1618 let cloid = client_order_id.map(Cloid::from_client_order_id);
1619
1620 let kind = match order_type {
1621 OrderType::Market => HyperliquidExecOrderKind::Limit {
1622 limit: HyperliquidExecLimitParams {
1623 tif: HyperliquidExecTif::Ioc,
1624 },
1625 },
1626 OrderType::Limit => {
1627 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
1628 .map_err(|e| Error::bad_request(format!("{e}")))?;
1629 HyperliquidExecOrderKind::Limit {
1630 limit: HyperliquidExecLimitParams { tif },
1631 }
1632 }
1633 OrderType::StopMarket
1634 | OrderType::StopLimit
1635 | OrderType::MarketIfTouched
1636 | OrderType::LimitIfTouched => {
1637 if let Some(trig_px) = trigger_price {
1638 let trigger_price_decimal = if self.normalize_prices {
1639 normalize_price(trig_px.as_decimal(), decimals).normalize()
1640 } else {
1641 trig_px.as_decimal().normalize()
1642 };
1643 let tpsl = match order_type {
1644 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1645 _ => HyperliquidExecTpSl::Tp,
1646 };
1647 let is_market = matches!(
1648 order_type,
1649 OrderType::StopMarket | OrderType::MarketIfTouched
1650 );
1651 HyperliquidExecOrderKind::Trigger {
1652 trigger: HyperliquidExecTriggerParams {
1653 is_market,
1654 trigger_px: trigger_price_decimal,
1655 tpsl,
1656 },
1657 }
1658 } else {
1659 return Err(Error::bad_request("Trigger orders require a trigger price"));
1660 }
1661 }
1662 _ => {
1663 return Err(Error::bad_request(format!(
1664 "Order type {order_type:?} not supported for modify"
1665 )));
1666 }
1667 };
1668
1669 let order = HyperliquidExecPlaceOrderRequest {
1670 asset: asset_id,
1671 is_buy,
1672 price: normalized_price,
1673 size,
1674 reduce_only,
1675 kind,
1676 cloid,
1677 };
1678
1679 let action = HyperliquidExecAction::Modify {
1680 modify: HyperliquidExecModifyOrderRequest { oid, order },
1681 };
1682
1683 let response = self.inner.post_action_exec(&action).await?;
1684
1685 match response {
1686 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
1687 if let Some(inner_error) = extract_inner_error(&response) {
1688 Err(Error::bad_request(format!(
1689 "Modify order rejected: {inner_error}",
1690 )))
1691 } else {
1692 Ok(())
1693 }
1694 }
1695 HyperliquidExchangeResponse::Status {
1696 status,
1697 response: error_data,
1698 } => Err(Error::bad_request(format!(
1699 "Modify order failed: status={status}, error={error_data}"
1700 ))),
1701 HyperliquidExchangeResponse::Error { error } => {
1702 Err(Error::bad_request(format!("Modify order error: {error}")))
1703 }
1704 }
1705 }
1706
1707 pub async fn request_order_status_reports(
1719 &self,
1720 user: &str,
1721 instrument_id: Option<InstrumentId>,
1722 ) -> Result<Vec<OrderStatusReport>> {
1723 let account_id = self
1724 .account_id
1725 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1726 let response = self.info_frontend_open_orders(user).await?;
1727
1728 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1730 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1731
1732 let mut reports = Vec::new();
1733 let ts_init = self.clock.get_time_ns();
1734
1735 for order_value in orders {
1736 let order: WsBasicOrderData = match serde_json::from_value(order_value.clone()) {
1738 Ok(o) => o,
1739 Err(e) => {
1740 log::warn!("Failed to parse order: {e}");
1741 continue;
1742 }
1743 };
1744
1745 let instrument = match self.get_or_create_instrument(&order.coin, None) {
1747 Some(inst) => inst,
1748 None => continue, };
1750
1751 if let Some(filter_id) = instrument_id
1753 && instrument.id() != filter_id
1754 {
1755 continue;
1756 }
1757
1758 let status = HyperliquidOrderStatusEnum::Open;
1760
1761 match parse_order_status_report_from_basic(
1763 &order,
1764 &status,
1765 &instrument,
1766 account_id,
1767 ts_init,
1768 ) {
1769 Ok(report) => reports.push(report),
1770 Err(e) => log::error!("Failed to parse order status report: {e}"),
1771 }
1772 }
1773
1774 Ok(reports)
1775 }
1776
1777 pub async fn request_order_status_report(
1787 &self,
1788 user: &str,
1789 oid: u64,
1790 ) -> Result<Option<OrderStatusReport>> {
1791 let account_id = self
1792 .account_id
1793 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1794
1795 let ts_init = self.clock.get_time_ns();
1796
1797 let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
1802 Ok(response) => match serde_json::from_value(response) {
1803 Ok(v) => v,
1804 Err(e) => {
1805 log::warn!("Failed to parse frontend open orders response: {e}");
1806 Vec::new()
1807 }
1808 },
1809 Err(e) => {
1810 log::warn!(
1811 "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
1812 );
1813 Vec::new()
1814 }
1815 };
1816
1817 if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
1818 let instrument = match self.get_or_create_instrument(&order.coin, None) {
1819 Some(inst) => inst,
1820 None => return Ok(None),
1821 };
1822
1823 let status = if order.trigger_activated == Some(true) {
1824 HyperliquidOrderStatusEnum::Triggered
1825 } else {
1826 HyperliquidOrderStatusEnum::Open
1827 };
1828
1829 return match parse_order_status_report_from_basic(
1830 &order,
1831 &status,
1832 &instrument,
1833 account_id,
1834 ts_init,
1835 ) {
1836 Ok(report) => Ok(Some(report)),
1837 Err(e) => {
1838 log::error!("Failed to parse order status report for oid {oid}: {e}");
1839 Ok(None)
1840 }
1841 };
1842 }
1843
1844 let response = self.info_order_status(user, oid).await?;
1846 let entry = match response.into_order() {
1847 Some(e) => e,
1848 None => return Ok(None),
1849 };
1850
1851 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
1852 Some(inst) => inst,
1853 None => return Ok(None),
1854 };
1855
1856 let basic = WsBasicOrderData {
1861 coin: entry.order.coin,
1862 side: entry.order.side,
1863 limit_px: entry.order.limit_px,
1864 sz: entry.order.sz,
1865 oid: entry.order.oid,
1866 timestamp: entry.order.timestamp,
1867 orig_sz: entry.order.orig_sz,
1868 cloid: entry.order.cloid,
1869 trigger_px: None,
1870 is_market: None,
1871 tpsl: None,
1872 trigger_activated: None,
1873 trailing_stop: None,
1874 };
1875
1876 match parse_order_status_report_from_basic(
1877 &basic,
1878 &entry.status,
1879 &instrument,
1880 account_id,
1881 ts_init,
1882 ) {
1883 Ok(mut report) => {
1884 if entry.status_timestamp > 0 {
1887 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
1888 }
1889 Ok(Some(report))
1890 }
1891 Err(e) => {
1892 log::error!("Failed to parse order status report for oid {oid}: {e}");
1893 Ok(None)
1894 }
1895 }
1896 }
1897
1898 pub async fn request_order_status_report_by_client_order_id(
1907 &self,
1908 user: &str,
1909 client_order_id: &ClientOrderId,
1910 ) -> Result<Option<OrderStatusReport>> {
1911 let account_id = self
1912 .account_id
1913 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1914
1915 let ts_init = self.clock.get_time_ns();
1916
1917 let cloid_hex = Cloid::from_client_order_id(*client_order_id).to_hex();
1918
1919 let response = self.info_frontend_open_orders(user).await?;
1920 let orders: Vec<WsBasicOrderData> = match serde_json::from_value(response) {
1921 Ok(v) => v,
1922 Err(e) => {
1923 log::warn!("Failed to parse frontend open orders response: {e}");
1924 return Ok(None);
1925 }
1926 };
1927
1928 let order = match orders
1929 .into_iter()
1930 .find(|o| o.cloid.as_ref().is_some_and(|c| c == &cloid_hex))
1931 {
1932 Some(o) => o,
1933 None => return Ok(None),
1934 };
1935
1936 let instrument = match self.get_or_create_instrument(&order.coin, None) {
1937 Some(inst) => inst,
1938 None => return Ok(None),
1939 };
1940
1941 let status = if order.trigger_activated == Some(true) {
1942 HyperliquidOrderStatusEnum::Triggered
1943 } else {
1944 HyperliquidOrderStatusEnum::Open
1945 };
1946
1947 match parse_order_status_report_from_basic(
1948 &order,
1949 &status,
1950 &instrument,
1951 account_id,
1952 ts_init,
1953 ) {
1954 Ok(mut report) => {
1955 report.client_order_id = Some(*client_order_id);
1956 Ok(Some(report))
1957 }
1958 Err(e) => {
1959 log::error!("Failed to parse order status report for cloid {cloid_hex}: {e}");
1960 Ok(None)
1961 }
1962 }
1963 }
1964
1965 pub async fn request_fill_reports(
1979 &self,
1980 user: &str,
1981 instrument_id: Option<InstrumentId>,
1982 ) -> Result<Vec<FillReport>> {
1983 let account_id = self
1984 .account_id
1985 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1986 let fills_response = self.info_user_fills(user).await?;
1987
1988 let mut reports = Vec::new();
1989 let ts_init = self.clock.get_time_ns();
1990
1991 for fill in fills_response {
1992 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
1994 Some(inst) => inst,
1995 None => continue, };
1997
1998 if let Some(filter_id) = instrument_id
2000 && instrument.id() != filter_id
2001 {
2002 continue;
2003 }
2004
2005 match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2007 Ok(report) => reports.push(report),
2008 Err(e) => log::error!("Failed to parse fill report: {e}"),
2009 }
2010 }
2011
2012 Ok(reports)
2013 }
2014
2015 pub async fn request_position_status_reports(
2037 &self,
2038 user: &str,
2039 instrument_id: Option<InstrumentId>,
2040 ) -> Result<Vec<PositionStatusReport>> {
2041 let account_id = self
2042 .account_id
2043 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2044
2045 let filter_product = instrument_id
2046 .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
2047 let fetch_perp = filter_product != Some(HyperliquidProductType::Spot);
2048 let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
2049
2050 let mut reports = Vec::new();
2051 let ts_init = self.clock.get_time_ns();
2052
2053 if !fetch_perp {
2054 let spot_reports = self
2055 .request_spot_position_status_reports(user, instrument_id)
2056 .await?;
2057 reports.extend(spot_reports);
2058 return Ok(reports);
2059 }
2060
2061 let state_response = self.info_clearinghouse_state(user).await?;
2062
2063 let asset_positions: Vec<serde_json::Value> = state_response
2065 .get("assetPositions")
2066 .and_then(|v| v.as_array())
2067 .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
2068 .clone();
2069
2070 for position_value in asset_positions {
2071 let coin = position_value
2073 .get("position")
2074 .and_then(|p| p.get("coin"))
2075 .and_then(|c| c.as_str())
2076 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
2077
2078 let coin_ustr = Ustr::from(coin);
2080 let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
2081 Some(inst) => inst,
2082 None => continue, };
2084
2085 if let Some(filter_id) = instrument_id
2087 && instrument.id() != filter_id
2088 {
2089 continue;
2090 }
2091
2092 match parse_position_status_report(&position_value, &instrument, account_id, ts_init) {
2094 Ok(report) => reports.push(report),
2095 Err(e) => log::error!("Failed to parse position status report: {e}"),
2096 }
2097 }
2098
2099 if fetch_spot {
2102 let spot_reports = self
2103 .request_spot_position_status_reports(user, instrument_id)
2104 .await?;
2105 reports.extend(spot_reports);
2106 }
2107
2108 Ok(reports)
2109 }
2110
2111 pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
2124 let account_id = self
2125 .account_id
2126 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2127 let state_response = self.info_clearinghouse_state(user).await?;
2128 let ts_init = self.clock.get_time_ns();
2129
2130 log::trace!("Clearinghouse state response: {state_response}");
2131
2132 let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
2133 .map_err(|e| {
2134 log::error!("Failed to parse clearinghouse state: {e}");
2135 log::debug!("Raw response: {state_response}");
2136 Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
2137 })?;
2138
2139 let spot_response = self.info_spot_clearinghouse_state(user).await?;
2142 let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
2143 .map_err(|e| {
2144 log::error!("Failed to parse spot clearinghouse state: {e}");
2145 log::debug!("Raw spot response: {spot_response}");
2146 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2147 })?;
2148
2149 let (balances, margins) =
2150 parse_combined_account_balances_and_margins(&perp_state, &spot_state)
2151 .map_err(|e| Error::decode(e.to_string()))?;
2152
2153 Ok(AccountState::new(
2154 account_id,
2155 AccountType::Margin,
2156 balances,
2157 margins,
2158 true, UUID4::new(),
2160 ts_init,
2161 ts_init,
2162 None,
2163 ))
2164 }
2165
2166 pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
2177 let response = self.info_spot_clearinghouse_state(user).await?;
2178
2179 log::trace!("Spot clearinghouse state response: {response}");
2180
2181 let state: SpotClearinghouseState =
2182 serde_json::from_value(response.clone()).map_err(|e| {
2183 log::error!("Failed to parse spot clearinghouse state: {e}");
2184 log::debug!("Raw response: {response}");
2185 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2186 })?;
2187
2188 parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
2189 }
2190
2191 pub async fn request_spot_position_status_reports(
2203 &self,
2204 user: &str,
2205 instrument_id: Option<InstrumentId>,
2206 ) -> Result<Vec<PositionStatusReport>> {
2207 let account_id = self
2208 .account_id
2209 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2210 let response = self.info_spot_clearinghouse_state(user).await?;
2211
2212 let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
2213 log::error!("Failed to parse spot clearinghouse state: {e}");
2214 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2215 })?;
2216
2217 let ts_init = self.clock.get_time_ns();
2218 let mut reports = Vec::with_capacity(state.balances.len());
2219
2220 for balance in &state.balances {
2221 if balance.total.is_zero() {
2222 continue;
2223 }
2224
2225 if balance.coin.as_str() == "USDC" {
2230 continue;
2231 }
2232
2233 let instrument = match self
2234 .get_or_create_instrument(&balance.coin, Some(HyperliquidProductType::Spot))
2235 {
2236 Some(inst) => inst,
2237 None => continue,
2238 };
2239
2240 if let Some(filter_id) = instrument_id
2241 && instrument.id() != filter_id
2242 {
2243 continue;
2244 }
2245
2246 match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
2247 Ok(report) => reports.push(report),
2248 Err(e) => log::error!(
2249 "Failed to parse spot position status report for {}: {e}",
2250 balance.coin,
2251 ),
2252 }
2253 }
2254
2255 Ok(reports)
2256 }
2257
2258 pub async fn request_bars(
2275 &self,
2276 bar_type: BarType,
2277 start: Option<chrono::DateTime<chrono::Utc>>,
2278 end: Option<chrono::DateTime<chrono::Utc>>,
2279 limit: Option<u32>,
2280 ) -> Result<Vec<Bar>> {
2281 let instrument_id = bar_type.instrument_id();
2282 let symbol = instrument_id.symbol;
2283
2284 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
2285
2286 let base = Ustr::from(
2288 symbol
2289 .as_str()
2290 .split('-')
2291 .next()
2292 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?,
2293 );
2294
2295 let instrument = self
2296 .get_or_create_instrument(&base, product_type)
2297 .ok_or_else(|| {
2298 Error::bad_request(format!("Instrument not found in cache: {instrument_id}"))
2299 })?;
2300
2301 let coin = instrument.raw_symbol().inner();
2306
2307 let price_precision = instrument.price_precision();
2308 let size_precision = instrument.size_precision();
2309
2310 let interval =
2311 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
2312
2313 let now = chrono::Utc::now();
2315 let end_time = end.unwrap_or(now).timestamp_millis() as u64;
2316 let start_time = if let Some(start) = start {
2317 start.timestamp_millis() as u64
2318 } else {
2319 let spec = bar_type.spec();
2321 let step_ms = match spec.aggregation {
2322 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
2323 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
2324 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
2325 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
2326 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
2327 _ => 60_000,
2328 };
2329 end_time.saturating_sub(1000 * step_ms)
2330 };
2331
2332 let candles = self
2333 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
2334 .await?;
2335
2336 let now_ms = now.timestamp_millis() as u64;
2338
2339 let mut bars: Vec<Bar> = candles
2340 .iter()
2341 .filter(|candle| candle.end_timestamp < now_ms)
2342 .enumerate()
2343 .filter_map(|(i, candle)| {
2344 candle_to_bar(candle, bar_type, price_precision, size_precision)
2345 .map_err(|e| {
2346 log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
2347 e
2348 })
2349 .ok()
2350 })
2351 .collect();
2352
2353 if let Some(limit) = limit
2355 && limit > 0
2356 && bars.len() > limit as usize
2357 {
2358 bars.truncate(limit as usize);
2359 }
2360
2361 log::debug!(
2362 "Received {} bars for {} (filtered {} incomplete)",
2363 bars.len(),
2364 bar_type,
2365 candles.len() - bars.len()
2366 );
2367 Ok(bars)
2368 }
2369
2370 #[expect(clippy::too_many_arguments)]
2377 pub async fn submit_order(
2378 &self,
2379 instrument_id: InstrumentId,
2380 client_order_id: ClientOrderId,
2381 order_side: OrderSide,
2382 order_type: OrderType,
2383 quantity: Quantity,
2384 time_in_force: TimeInForce,
2385 price: Option<Price>,
2386 trigger_price: Option<Price>,
2387 post_only: bool,
2388 reduce_only: bool,
2389 ) -> Result<OrderStatusReport> {
2390 let symbol = instrument_id.symbol.as_str();
2391 let asset = self.get_asset_index(symbol).ok_or_else(|| {
2392 Error::bad_request(format!(
2393 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2394 ))
2395 })?;
2396
2397 let is_buy = matches!(order_side, OrderSide::Buy);
2398 let price_precision = self.get_price_precision(symbol).unwrap_or(2);
2399
2400 let price_decimal = match price {
2401 Some(px) if self.normalize_prices => {
2402 normalize_price(px.as_decimal(), price_precision).normalize()
2403 }
2404 Some(px) => px.as_decimal().normalize(),
2405 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
2406 None if matches!(
2407 order_type,
2408 OrderType::StopMarket | OrderType::MarketIfTouched
2409 ) =>
2410 {
2411 match trigger_price {
2412 Some(tp) => {
2413 let derived = derive_limit_from_trigger(
2414 tp.as_decimal().normalize(),
2415 is_buy,
2416 self.market_order_slippage_bps,
2417 );
2418 let sig_rounded = round_to_sig_figs(derived, 5);
2419 clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
2420 }
2421 None => Decimal::ZERO,
2422 }
2423 }
2424 None => return Err(Error::bad_request("Limit orders require a price")),
2425 };
2426
2427 let size_decimal = quantity.as_decimal().normalize();
2428
2429 let kind = match order_type {
2430 OrderType::Market => HyperliquidExecOrderKind::Limit {
2431 limit: HyperliquidExecLimitParams {
2432 tif: HyperliquidExecTif::Ioc,
2433 },
2434 },
2435 OrderType::Limit => {
2436 let tif = if post_only {
2437 HyperliquidExecTif::Alo
2438 } else {
2439 match time_in_force {
2440 TimeInForce::Gtc => HyperliquidExecTif::Gtc,
2441 TimeInForce::Ioc => HyperliquidExecTif::Ioc,
2442 TimeInForce::Fok
2443 | TimeInForce::Day
2444 | TimeInForce::Gtd
2445 | TimeInForce::AtTheOpen
2446 | TimeInForce::AtTheClose => {
2447 return Err(Error::bad_request(format!(
2448 "Time in force {time_in_force:?} not supported"
2449 )));
2450 }
2451 }
2452 };
2453 HyperliquidExecOrderKind::Limit {
2454 limit: HyperliquidExecLimitParams { tif },
2455 }
2456 }
2457 OrderType::StopMarket
2458 | OrderType::StopLimit
2459 | OrderType::MarketIfTouched
2460 | OrderType::LimitIfTouched => {
2461 if let Some(trig_px) = trigger_price {
2462 let trigger_price_decimal = if self.normalize_prices {
2463 normalize_price(trig_px.as_decimal(), price_precision).normalize()
2464 } else {
2465 trig_px.as_decimal().normalize()
2466 };
2467
2468 let tpsl = match order_type {
2472 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
2473 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2474 HyperliquidExecTpSl::Tp
2475 }
2476 _ => unreachable!(),
2477 };
2478
2479 let is_market = matches!(
2480 order_type,
2481 OrderType::StopMarket | OrderType::MarketIfTouched
2482 );
2483
2484 HyperliquidExecOrderKind::Trigger {
2485 trigger: HyperliquidExecTriggerParams {
2486 is_market,
2487 trigger_px: trigger_price_decimal,
2488 tpsl,
2489 },
2490 }
2491 } else {
2492 return Err(Error::bad_request("Trigger orders require a trigger price"));
2493 }
2494 }
2495 _ => {
2496 return Err(Error::bad_request(format!(
2497 "Order type {order_type:?} not supported"
2498 )));
2499 }
2500 };
2501
2502 let hyperliquid_order = HyperliquidExecPlaceOrderRequest {
2503 asset,
2504 is_buy,
2505 price: price_decimal,
2506 size: size_decimal,
2507 reduce_only,
2508 kind,
2509 cloid: Some(Cloid::from_client_order_id(client_order_id)),
2510 };
2511
2512 let builder = if self.has_vault_address() {
2513 None
2514 } else {
2515 Some(HyperliquidExecBuilderFee {
2516 address: NAUTILUS_BUILDER_ADDRESS.to_string(),
2517 fee_tenths_bp: 0,
2518 })
2519 };
2520
2521 let action = HyperliquidExecAction::Order {
2522 orders: vec![hyperliquid_order],
2523 grouping: HyperliquidExecGrouping::Na,
2524 builder,
2525 };
2526
2527 let response = self.inner.post_action_exec(&action).await?;
2528
2529 match response {
2530 HyperliquidExchangeResponse::Status {
2531 status,
2532 response: response_data,
2533 } if status == RESPONSE_STATUS_OK => {
2534 let data_value = if let Some(data) = response_data.get("data") {
2535 data.clone()
2536 } else {
2537 response_data
2538 };
2539
2540 let order_response: HyperliquidExecOrderResponseData =
2541 serde_json::from_value(data_value).map_err(|e| {
2542 Error::bad_request(format!("Failed to parse order response: {e}"))
2543 })?;
2544
2545 let order_status = order_response
2546 .statuses
2547 .first()
2548 .ok_or_else(|| Error::bad_request("No order status in response"))?;
2549
2550 let symbol_str = instrument_id.symbol.as_str();
2551 let product_type = HyperliquidProductType::from_symbol(symbol_str).ok();
2552
2553 let asset_str = symbol_str.split('-').next().unwrap_or(symbol_str);
2555 let instrument = self
2556 .get_or_create_instrument(&Ustr::from(asset_str), product_type)
2557 .ok_or_else(|| {
2558 Error::bad_request(format!("Instrument not found for {asset_str}"))
2559 })?;
2560
2561 let account_id = self
2562 .account_id
2563 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2564 let ts_init = self.clock.get_time_ns();
2565
2566 match order_status {
2567 HyperliquidExecOrderStatus::Resting { resting } => Ok(self
2568 .create_order_status_report(
2569 instrument_id,
2570 Some(client_order_id),
2571 VenueOrderId::new(resting.oid.to_string()),
2572 order_side,
2573 order_type,
2574 quantity,
2575 time_in_force,
2576 price,
2577 trigger_price,
2578 OrderStatus::Accepted,
2579 Quantity::new(0.0, instrument.size_precision()),
2580 &instrument,
2581 account_id,
2582 ts_init,
2583 )),
2584 HyperliquidExecOrderStatus::Filled { filled } => {
2585 let filled_qty = Quantity::new(
2586 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
2587 instrument.size_precision(),
2588 );
2589 Ok(self.create_order_status_report(
2590 instrument_id,
2591 Some(client_order_id),
2592 VenueOrderId::new(filled.oid.to_string()),
2593 order_side,
2594 order_type,
2595 quantity,
2596 time_in_force,
2597 price,
2598 trigger_price,
2599 OrderStatus::Filled,
2600 filled_qty,
2601 &instrument,
2602 account_id,
2603 ts_init,
2604 ))
2605 }
2606 HyperliquidExecOrderStatus::Error { error } => {
2607 Err(Error::bad_request(format!("Order rejected: {error}")))
2608 }
2609 }
2610 }
2611 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
2612 "Order submission failed: {error}"
2613 ))),
2614 _ => Err(Error::bad_request("Unexpected response format")),
2615 }
2616 }
2617
2618 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
2622 self.submit_order(
2623 order.instrument_id(),
2624 order.client_order_id(),
2625 order.order_side(),
2626 order.order_type(),
2627 order.quantity(),
2628 order.time_in_force(),
2629 order.price(),
2630 order.trigger_price(),
2631 order.is_post_only(),
2632 order.is_reduce_only(),
2633 )
2634 .await
2635 }
2636
2637 #[expect(clippy::too_many_arguments)]
2638 fn create_order_status_report(
2639 &self,
2640 instrument_id: InstrumentId,
2641 client_order_id: Option<ClientOrderId>,
2642 venue_order_id: VenueOrderId,
2643 order_side: OrderSide,
2644 order_type: OrderType,
2645 quantity: Quantity,
2646 time_in_force: TimeInForce,
2647 price: Option<Price>,
2648 trigger_price: Option<Price>,
2649 order_status: OrderStatus,
2650 filled_qty: Quantity,
2651 _instrument: &InstrumentAny,
2652 account_id: AccountId,
2653 ts_init: UnixNanos,
2654 ) -> OrderStatusReport {
2655 let ts_accepted = self.clock.get_time_ns();
2656 let ts_last = ts_accepted;
2657 let report_id = UUID4::new();
2658
2659 let mut report = OrderStatusReport::new(
2660 account_id,
2661 instrument_id,
2662 client_order_id,
2663 venue_order_id,
2664 order_side,
2665 order_type,
2666 time_in_force,
2667 order_status,
2668 quantity,
2669 filled_qty,
2670 ts_accepted,
2671 ts_last,
2672 ts_init,
2673 Some(report_id),
2674 );
2675
2676 if let Some(px) = price {
2677 report = report.with_price(px);
2678 }
2679
2680 if let Some(trig_px) = trigger_price {
2681 report = report
2682 .with_trigger_price(trig_px)
2683 .with_trigger_type(TriggerType::Default);
2684 }
2685
2686 report
2687 }
2688
2689 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
2696 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
2698
2699 for order in orders {
2700 let instrument_id = order.instrument_id();
2701 let symbol = instrument_id.symbol.as_str();
2702 let asset = self.get_asset_index(symbol).ok_or_else(|| {
2703 Error::bad_request(format!(
2704 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2705 ))
2706 })?;
2707 let price_decimals = self.get_price_precision(symbol).unwrap_or(2);
2708 let request = order_to_hyperliquid_request_with_asset(
2709 order,
2710 asset,
2711 price_decimals,
2712 self.normalize_prices,
2713 self.market_order_slippage_bps,
2714 )
2715 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
2716 hyperliquid_orders.push(request);
2717 }
2718
2719 let builder = if self.has_vault_address() {
2720 None
2721 } else {
2722 Some(HyperliquidExecBuilderFee {
2723 address: NAUTILUS_BUILDER_ADDRESS.to_string(),
2724 fee_tenths_bp: 0,
2725 })
2726 };
2727
2728 let grouping =
2729 determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
2730
2731 let action = HyperliquidExecAction::Order {
2732 orders: hyperliquid_orders,
2733 grouping,
2734 builder,
2735 };
2736
2737 let response = self.inner.post_action_exec(&action).await?;
2739
2740 match response {
2742 HyperliquidExchangeResponse::Status {
2743 status,
2744 response: response_data,
2745 } if status == RESPONSE_STATUS_OK => {
2746 let data_value = if let Some(data) = response_data.get("data") {
2749 data.clone()
2750 } else {
2751 response_data
2752 };
2753
2754 let order_response: HyperliquidExecOrderResponseData =
2756 serde_json::from_value(data_value).map_err(|e| {
2757 Error::bad_request(format!("Failed to parse order response: {e}"))
2758 })?;
2759
2760 let account_id = self
2761 .account_id
2762 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2763 let ts_init = self.clock.get_time_ns();
2764
2765 if grouping == HyperliquidExecGrouping::Na
2769 && order_response.statuses.len() != orders.len()
2770 {
2771 return Err(Error::bad_request(format!(
2772 "Mismatch between submitted orders ({}) and response statuses ({})",
2773 orders.len(),
2774 order_response.statuses.len()
2775 )));
2776 }
2777
2778 let mut reports = Vec::new();
2779
2780 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
2784 let instrument_id = order.instrument_id();
2786 let symbol = instrument_id.symbol.as_str();
2787 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
2788
2789 let asset = symbol.split('-').next().unwrap_or(symbol);
2791 let instrument = self
2792 .get_or_create_instrument(&Ustr::from(asset), product_type)
2793 .ok_or_else(|| {
2794 Error::bad_request(format!("Instrument not found for {asset}"))
2795 })?;
2796
2797 let report = match order_status {
2799 HyperliquidExecOrderStatus::Resting { resting } => {
2800 self.create_order_status_report(
2802 order.instrument_id(),
2803 Some(order.client_order_id()),
2804 VenueOrderId::new(resting.oid.to_string()),
2805 order.order_side(),
2806 order.order_type(),
2807 order.quantity(),
2808 order.time_in_force(),
2809 order.price(),
2810 order.trigger_price(),
2811 OrderStatus::Accepted,
2812 Quantity::new(0.0, instrument.size_precision()),
2813 &instrument,
2814 account_id,
2815 ts_init,
2816 )
2817 }
2818 HyperliquidExecOrderStatus::Filled { filled } => {
2819 let filled_qty = Quantity::new(
2821 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
2822 instrument.size_precision(),
2823 );
2824 self.create_order_status_report(
2825 order.instrument_id(),
2826 Some(order.client_order_id()),
2827 VenueOrderId::new(filled.oid.to_string()),
2828 order.order_side(),
2829 order.order_type(),
2830 order.quantity(),
2831 order.time_in_force(),
2832 order.price(),
2833 order.trigger_price(),
2834 OrderStatus::Filled,
2835 filled_qty,
2836 &instrument,
2837 account_id,
2838 ts_init,
2839 )
2840 }
2841 HyperliquidExecOrderStatus::Error { error } => {
2842 return Err(Error::bad_request(format!(
2843 "Order {} rejected: {error}",
2844 order.client_order_id()
2845 )));
2846 }
2847 };
2848
2849 reports.push(report);
2850 }
2851
2852 Ok(reports)
2853 }
2854 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
2855 "Order submission failed: {error}"
2856 ))),
2857 _ => Err(Error::bad_request("Unexpected response format")),
2858 }
2859 }
2860}
2861
2862fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
2867 if dex_index == 0 {
2868 0
2869 } else {
2870 100_000 + dex_index as u32 * 10_000
2871 }
2872}
2873
2874#[cfg(test)]
2875mod tests {
2876 use nautilus_core::{MUTEX_POISONED, time::get_atomic_clock_realtime};
2877 use nautilus_model::{
2878 currencies::CURRENCY_MAP,
2879 enums::CurrencyType,
2880 identifiers::{InstrumentId, Symbol},
2881 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
2882 types::{Currency, Price, Quantity},
2883 };
2884 use rstest::rstest;
2885 use ustr::Ustr;
2886
2887 use super::HyperliquidHttpClient;
2888 use crate::{
2889 common::{
2890 consts::HYPERLIQUID_VENUE,
2891 enums::{HyperliquidEnvironment, HyperliquidProductType},
2892 },
2893 http::query::InfoRequest,
2894 };
2895
2896 #[rstest]
2897 fn stable_json_roundtrips() {
2898 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
2899 let s = serde_json::to_string(&v).unwrap();
2900 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
2902 assert_eq!(parsed["type"], "l2Book");
2903 assert_eq!(parsed["coin"], "BTC");
2904 assert_eq!(parsed, v);
2905 }
2906
2907 #[rstest]
2908 fn info_pretty_shape() {
2909 let r = InfoRequest::l2_book("BTC");
2910 let val = serde_json::to_value(&r).unwrap();
2911 let pretty = serde_json::to_string_pretty(&val).unwrap();
2912 assert!(pretty.contains("\"type\": \"l2Book\""));
2913 assert!(pretty.contains("\"coin\": \"BTC\""));
2914 }
2915
2916 #[rstest]
2917 fn test_cache_instrument_by_raw_symbol() {
2918 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
2919
2920 let base_code = "vntls:vCURSOR";
2922 let quote_code = "USDC";
2923
2924 {
2926 let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
2927 if !currency_map.contains_key(base_code) {
2928 currency_map.insert(
2929 base_code.to_string(),
2930 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
2931 );
2932 }
2933 }
2934
2935 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
2936 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
2937
2938 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
2940 let venue = *HYPERLIQUID_VENUE;
2941 let instrument_id = InstrumentId::new(symbol, venue);
2942
2943 let raw_symbol = Symbol::new(base_code);
2945
2946 let clock = get_atomic_clock_realtime();
2947 let ts = clock.get_time_ns();
2948
2949 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
2950 instrument_id,
2951 raw_symbol,
2952 base_currency,
2953 quote_currency,
2954 8,
2955 8,
2956 Price::from("0.00000001"),
2957 Quantity::from("0.00000001"),
2958 None,
2959 None,
2960 None,
2961 None,
2962 None,
2963 None,
2964 None,
2965 None,
2966 None,
2967 None,
2968 None,
2969 None, None, ts,
2972 ts,
2973 ));
2974
2975 client.cache_instrument(&instrument);
2977
2978 let instruments = client.instruments.load();
2980 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
2981 assert!(
2982 by_full_symbol.is_some(),
2983 "Instrument should be accessible by full symbol"
2984 );
2985 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
2986
2987 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
2989 assert!(
2990 by_raw_symbol.is_some(),
2991 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
2992 );
2993 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
2994 drop(instruments);
2995
2996 let instruments_by_coin = client.instruments_by_coin.load();
2998 let by_coin =
2999 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
3000 assert!(
3001 by_coin.is_some(),
3002 "Instrument should be accessible by coin and product type"
3003 );
3004 assert_eq!(by_coin.unwrap().id(), instrument.id());
3005 drop(instruments_by_coin);
3006
3007 let retrieved_with_type = client.get_or_create_instrument(
3009 &Ustr::from("vntls:vCURSOR"),
3010 Some(HyperliquidProductType::Spot),
3011 );
3012 assert!(retrieved_with_type.is_some());
3013 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
3014
3015 let retrieved_without_type =
3017 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
3018 assert!(retrieved_without_type.is_some());
3019 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
3020 }
3021
3022 #[rstest]
3023 fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
3024 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3029
3030 let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
3031 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3032 let clock = get_atomic_clock_realtime();
3033 let ts = clock.get_time_ns();
3034
3035 let canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
3036 InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
3037 Symbol::new("@107"),
3038 hype,
3039 usdc,
3040 5,
3041 2,
3042 Price::from("0.00001"),
3043 Quantity::from("0.01"),
3044 None,
3045 None,
3046 None,
3047 None,
3048 None,
3049 None,
3050 None,
3051 None,
3052 None,
3053 None,
3054 None,
3055 None,
3056 None,
3057 ts,
3058 ts,
3059 ));
3060
3061 let non_canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
3062 InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
3063 Symbol::new("@999"),
3064 hype,
3065 usdc,
3066 5,
3067 2,
3068 Price::from("0.00001"),
3069 Quantity::from("0.01"),
3070 None,
3071 None,
3072 None,
3073 None,
3074 None,
3075 None,
3076 None,
3077 None,
3078 None,
3079 None,
3080 None,
3081 None,
3082 None,
3083 ts,
3084 ts,
3085 ));
3086
3087 client.cache_instrument(&canonical);
3088 client.cache_instrument(&non_canonical);
3089
3090 let instruments_by_coin = client.instruments_by_coin.load();
3091 let by_base = instruments_by_coin
3092 .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
3093 .expect("base alias must resolve");
3094 assert_eq!(
3095 by_base.raw_symbol().inner().as_str(),
3096 "@107",
3097 "base alias must point to the canonical pair, not the one cached later",
3098 );
3099 }
3100
3101 #[rstest]
3102 fn test_cache_instrument_perp_aliases_sanitized_base() {
3103 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3109
3110 let base_currency = Currency::new(
3111 "dex:STREAMABCD****",
3112 8,
3113 0,
3114 "dex:STREAMABCD****",
3115 CurrencyType::Crypto,
3116 );
3117 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
3118 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3119 let clock = get_atomic_clock_realtime();
3120 let ts = clock.get_time_ns();
3121
3122 let hip3 = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
3123 InstrumentId::new(
3124 Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
3125 *HYPERLIQUID_VENUE,
3126 ),
3127 Symbol::new("dex:STREAMABCD****"),
3128 base_currency,
3129 usd,
3130 usdc,
3131 false,
3132 6,
3133 3,
3134 Price::from("0.000001"),
3135 Quantity::from("0.001"),
3136 None,
3137 None,
3138 None,
3139 None,
3140 None,
3141 None,
3142 None,
3143 None,
3144 None,
3145 None,
3146 None,
3147 None,
3148 None,
3149 ts,
3150 ts,
3151 ));
3152
3153 client.cache_instrument(&hip3);
3154
3155 let instruments_by_coin = client.instruments_by_coin.load();
3156 let by_raw = instruments_by_coin
3157 .get(&(
3158 Ustr::from("dex:STREAMABCD****"),
3159 HyperliquidProductType::Perp,
3160 ))
3161 .expect("venue coin lookup must resolve");
3162 assert_eq!(by_raw.id(), hip3.id());
3163
3164 let by_sanitized = instruments_by_coin
3165 .get(&(
3166 Ustr::from("dex:STREAMABCDxxxx"),
3167 HyperliquidProductType::Perp,
3168 ))
3169 .expect("sanitized base lookup must resolve");
3170 assert_eq!(by_sanitized.id(), hip3.id());
3171 drop(instruments_by_coin);
3172
3173 let resolved = client
3175 .get_or_create_instrument(
3176 &Ustr::from("dex:STREAMABCDxxxx"),
3177 Some(HyperliquidProductType::Perp),
3178 )
3179 .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
3180 assert_eq!(resolved.id(), hip3.id());
3181 }
3182}