1use std::{
24 collections::HashMap,
25 num::NonZeroU32,
26 sync::{Arc, LazyLock, Mutex},
27 time::Duration,
28};
29
30use ahash::AHashMap;
31use anyhow::Context;
32use nautilus_core::{
33 AtomicMap, MUTEX_POISONED, UUID4, UnixNanos,
34 consts::NAUTILUS_USER_AGENT,
35 time::{AtomicTime, get_atomic_clock_realtime},
36};
37use nautilus_model::{
38 data::{Bar, BarType},
39 enums::{
40 AccountType, BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce,
41 TriggerType,
42 },
43 events::AccountState,
44 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
45 instruments::{CurrencyPair, Instrument, InstrumentAny},
46 orders::{Order, OrderAny},
47 reports::{FillReport, OrderStatusReport, PositionStatusReport},
48 types::{AccountBalance, Currency, Price, Quantity},
49};
50use nautilus_network::{
51 http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
52 ratelimiter::quota::Quota,
53};
54use rust_decimal::Decimal;
55use serde_json::Value;
56use ustr::Ustr;
57
58use crate::{
59 account::resolve_execution_account_address,
60 common::{
61 consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url},
62 credential::{Secrets, VaultAddress, credential_env_vars},
63 enums::{
64 HyperliquidBarInterval, HyperliquidEnvironment,
65 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
66 },
67 parse::{
68 bar_type_to_interval, cache_alias_for_symbol, clamp_price_to_precision,
69 derive_limit_from_trigger, determine_order_list_grouping, extract_inner_error,
70 normalize_price, order_to_hyperliquid_request_with_asset_and_cloid,
71 parse_combined_account_balances_and_margins, parse_spot_account_balances,
72 round_to_sig_figs, time_in_force_to_hyperliquid_tif,
73 },
74 },
75 data::candle_to_bar,
76 http::{
77 error::{Error, Result},
78 models::{
79 ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeRequest,
80 HyperliquidExchangeResponse, HyperliquidExecAction, HyperliquidExecBuilderFee,
81 HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
82 HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecMergeOutcomeParams,
83 HyperliquidExecMergeQuestionParams, HyperliquidExecModifyOrderRequest,
84 HyperliquidExecNegateOutcomeParams, HyperliquidExecOrderKind,
85 HyperliquidExecOrderResponseData, HyperliquidExecOrderStatus,
86 HyperliquidExecPlaceOrderRequest, HyperliquidExecSplitOutcomeParams,
87 HyperliquidExecTif, HyperliquidExecTpSl, HyperliquidExecTriggerParams,
88 HyperliquidExecUserOutcomeOp, HyperliquidFills, HyperliquidFundingHistoryEntry,
89 HyperliquidL2Book, HyperliquidMeta, HyperliquidOrderStatus, OutcomeMeta, PerpDex,
90 PerpMeta, PerpMetaAndCtxs, RESPONSE_STATUS_OK, SpotClearinghouseState, SpotMeta,
91 SpotMetaAndCtxs,
92 },
93 parse::{
94 HyperliquidInstrumentDef, instruments_from_defs_owned, parse_fill_report,
95 parse_order_status_report_from_basic, parse_outcome_instruments,
96 parse_perp_instruments, parse_position_status_report, parse_spot_instruments,
97 parse_spot_position_status_report,
98 },
99 query::{ExchangeAction, InfoRequest},
100 rate_limits::{
101 RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
102 exec_action_weight, info_base_weight, info_extra_weight,
103 },
104 },
105 signing::{
106 HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
107 },
108 websocket::messages::WsBasicOrderData,
109};
110
111pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
113 LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
114
115#[derive(Debug, Clone)]
120#[cfg_attr(
121 feature = "python",
122 pyo3::pyclass(
123 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
124 from_py_object
125 )
126)]
127#[cfg_attr(
128 feature = "python",
129 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
130)]
131pub struct HyperliquidRawHttpClient {
132 client: HttpClient,
133 environment: HyperliquidEnvironment,
134 base_info: String,
135 base_exchange: String,
136 signer: Option<HyperliquidEip712Signer>,
137 nonce_manager: Option<Arc<NonceManager>>,
138 vault_address: Option<VaultAddress>,
139 rest_limiter: Arc<WeightedLimiter>,
140 rate_limit_backoff_base: Duration,
141 rate_limit_backoff_cap: Duration,
142 rate_limit_max_attempts_info: u32,
143}
144
145impl HyperliquidRawHttpClient {
146 pub fn new(
152 environment: HyperliquidEnvironment,
153 timeout_secs: u64,
154 proxy_url: Option<String>,
155 ) -> std::result::Result<Self, HttpClientError> {
156 Ok(Self {
157 client: HttpClient::new(
158 Self::default_headers(),
159 vec![],
160 vec![],
161 Some(*HYPERLIQUID_REST_QUOTA),
162 Some(timeout_secs),
163 proxy_url,
164 )?,
165 environment,
166 base_info: info_url(environment).to_string(),
167 base_exchange: exchange_url(environment).to_string(),
168 signer: None,
169 nonce_manager: None,
170 vault_address: None,
171 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
172 rate_limit_backoff_base: Duration::from_millis(125),
173 rate_limit_backoff_cap: Duration::from_secs(5),
174 rate_limit_max_attempts_info: 3,
175 })
176 }
177
178 pub fn with_credentials(
185 secrets: &Secrets,
186 timeout_secs: u64,
187 proxy_url: Option<String>,
188 ) -> std::result::Result<Self, HttpClientError> {
189 let signer = HyperliquidEip712Signer::new(&secrets.private_key)
190 .map_err(|e| HttpClientError::from(e.to_string()))?;
191 let nonce_manager = Arc::new(NonceManager::new());
192
193 Ok(Self {
194 client: HttpClient::new(
195 Self::default_headers(),
196 vec![],
197 vec![],
198 Some(*HYPERLIQUID_REST_QUOTA),
199 Some(timeout_secs),
200 proxy_url,
201 )?,
202 environment: secrets.environment,
203 base_info: info_url(secrets.environment).to_string(),
204 base_exchange: exchange_url(secrets.environment).to_string(),
205 signer: Some(signer),
206 nonce_manager: Some(nonce_manager),
207 vault_address: secrets.vault_address,
208 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
209 rate_limit_backoff_base: Duration::from_millis(125),
210 rate_limit_backoff_cap: Duration::from_secs(5),
211 rate_limit_max_attempts_info: 3,
212 })
213 }
214
215 pub fn set_base_info_url(&mut self, url: String) {
217 self.base_info = url;
218 }
219
220 pub fn set_base_exchange_url(&mut self, url: String) {
222 self.base_exchange = url;
223 }
224
225 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
231 let secrets = Secrets::from_env(environment)
232 .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
233 Self::with_credentials(&secrets, 60, None)
234 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
235 }
236
237 pub fn from_credentials(
243 private_key: &str,
244 vault_address: Option<&str>,
245 environment: HyperliquidEnvironment,
246 timeout_secs: u64,
247 proxy_url: Option<String>,
248 ) -> Result<Self> {
249 let secrets = Secrets::from_private_key(private_key, vault_address, environment)
250 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
251 Self::with_credentials(&secrets, timeout_secs, proxy_url)
252 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
253 }
254
255 #[must_use]
257 pub fn with_rate_limits(mut self) -> Self {
258 self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
259 self.rate_limit_backoff_base = Duration::from_millis(125);
260 self.rate_limit_backoff_cap = Duration::from_secs(5);
261 self.rate_limit_max_attempts_info = 3;
262 self
263 }
264
265 #[must_use]
267 pub fn environment(&self) -> HyperliquidEnvironment {
268 self.environment
269 }
270
271 #[must_use]
273 pub fn is_testnet(&self) -> bool {
274 self.environment == HyperliquidEnvironment::Testnet
275 }
276
277 pub fn get_user_address(&self) -> Result<String> {
283 self.signer
284 .as_ref()
285 .ok_or_else(|| Error::auth("No signer configured"))?
286 .address()
287 }
288
289 #[must_use]
291 pub fn has_vault_address(&self) -> bool {
292 self.vault_address.is_some()
293 }
294
295 pub fn get_account_address(&self) -> Result<String> {
302 if let Some(vault) = &self.vault_address {
303 Ok(vault.to_hex())
304 } else {
305 self.get_user_address()
306 }
307 }
308
309 fn default_headers() -> HashMap<String, String> {
310 HashMap::from([
311 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
312 ("Content-Type".to_string(), "application/json".to_string()),
313 ])
314 }
315
316 fn signer_id(&self) -> SignerId {
317 SignerId("hyperliquid:default".into())
318 }
319
320 fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
321 let retry_after = headers.get("retry-after")?;
322 retry_after.parse::<u64>().ok().map(|s| s * 1000) }
324
325 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
327 let request = InfoRequest::meta();
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(&self) -> Result<SpotMeta> {
334 let request = InfoRequest::spot_meta();
335 let response = self.send_info_request(&request).await?;
336 serde_json::from_value(response).map_err(Error::Serde)
337 }
338
339 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
341 let request = InfoRequest::meta_and_asset_ctxs();
342 let response = self.send_info_request(&request).await?;
343 serde_json::from_value(response).map_err(Error::Serde)
344 }
345
346 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
348 let request = InfoRequest::spot_meta_and_asset_ctxs();
349 let response = self.send_info_request(&request).await?;
350 serde_json::from_value(response).map_err(Error::Serde)
351 }
352
353 pub async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
355 let request = InfoRequest::outcome_meta();
356 let response = self.send_info_request(&request).await?;
357 serde_json::from_value(response).map_err(Error::Serde)
358 }
359
360 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
361 let request = InfoRequest::meta();
362 let response = self.send_info_request(&request).await?;
363 serde_json::from_value(response).map_err(Error::Serde)
364 }
365
366 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
368 let request = InfoRequest::all_perp_metas();
369 let response = self.send_info_request(&request).await?;
370 serde_json::from_value(response).map_err(Error::Serde)
371 }
372
373 pub(crate) async fn load_perp_dexs(&self) -> Result<Vec<Option<PerpDex>>> {
375 let request = InfoRequest::perp_dexs();
376 let response = self.send_info_request(&request).await?;
377 serde_json::from_value(response).map_err(Error::Serde)
378 }
379
380 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
382 let request = InfoRequest::l2_book(coin);
383 let response = self.send_info_request(&request).await?;
384 serde_json::from_value(response).map_err(Error::Serde)
385 }
386
387 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
389 let request = InfoRequest::user_fills(user);
390 let response = self.send_info_request(&request).await?;
391 serde_json::from_value(response).map_err(Error::Serde)
392 }
393
394 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
396 let request = InfoRequest::order_status(user, oid);
397 let response = self.send_info_request(&request).await?;
398 serde_json::from_value(response).map_err(Error::Serde)
399 }
400
401 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
403 let request = InfoRequest::open_orders(user);
404 self.send_info_request(&request).await
405 }
406
407 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
409 let request = InfoRequest::frontend_open_orders(user);
410 self.send_info_request(&request).await
411 }
412
413 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
415 let request = InfoRequest::clearinghouse_state(user);
416 self.send_info_request(&request).await
417 }
418
419 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
421 let request = InfoRequest::spot_clearinghouse_state(user);
422 self.send_info_request(&request).await
423 }
424
425 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
427 let request = InfoRequest::user_fees(user);
428 self.send_info_request(&request).await
429 }
430
431 pub async fn info_candle_snapshot(
433 &self,
434 coin: &str,
435 interval: HyperliquidBarInterval,
436 start_time: u64,
437 end_time: u64,
438 ) -> Result<HyperliquidCandleSnapshot> {
439 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
440 let response = self.send_info_request(&request).await?;
441
442 log::trace!(
443 "Candle snapshot raw response (len={}): {:?}",
444 response.as_array().map_or(0, |a| a.len()),
445 response
446 );
447
448 serde_json::from_value(response).map_err(Error::Serde)
449 }
450
451 pub async fn info_funding_history(
456 &self,
457 coin: &str,
458 start_time: u64,
459 end_time: Option<u64>,
460 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
461 let request = InfoRequest::funding_history(coin, start_time, end_time);
462 let response = self.send_info_request(&request).await?;
463 serde_json::from_value(response).map_err(Error::Serde)
464 }
465
466 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
468 self.send_info_request(request).await
469 }
470
471 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
472 let base_w = info_base_weight(request);
473 self.rest_limiter.acquire(base_w).await;
474
475 let mut attempt = 0u32;
476
477 loop {
478 let response = self.http_roundtrip_info(request).await?;
479
480 if response.status.is_success() {
481 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
483 let extra = info_extra_weight(request, &val);
484 if extra > 0 {
485 self.rest_limiter.debit_extra(extra).await;
486 log::debug!(
487 "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
488 );
489 }
490 return Ok(val);
491 }
492
493 if response.status.as_u16() == 429 {
495 if attempt >= self.rate_limit_max_attempts_info {
496 let ra = self.parse_retry_after_simple(&response.headers);
497 return Err(Error::rate_limit("info", base_w, ra));
498 }
499 let delay = self
500 .parse_retry_after_simple(&response.headers)
501 .map_or_else(
502 || {
503 backoff_full_jitter(
504 attempt,
505 self.rate_limit_backoff_base,
506 self.rate_limit_backoff_cap,
507 )
508 },
509 Duration::from_millis,
510 );
511 log::warn!(
512 "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
513 delay.as_millis()
514 );
515 attempt += 1;
516 tokio::time::sleep(delay).await;
517 self.rest_limiter.acquire(1).await;
519 continue;
520 }
521
522 if (response.status.is_server_error() || response.status.as_u16() == 408)
524 && attempt < self.rate_limit_max_attempts_info
525 {
526 let delay = backoff_full_jitter(
527 attempt,
528 self.rate_limit_backoff_base,
529 self.rate_limit_backoff_cap,
530 );
531 log::warn!(
532 "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
533 response.status.as_u16(),
534 delay.as_millis()
535 );
536 attempt += 1;
537 tokio::time::sleep(delay).await;
538 continue;
539 }
540
541 let error_body = String::from_utf8_lossy(&response.body);
543 return Err(Error::http(
544 response.status.as_u16(),
545 error_body.to_string(),
546 ));
547 }
548 }
549
550 async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
551 let url = &self.base_info;
552 let body = serde_json::to_value(request).map_err(Error::Serde)?;
553 let body_bytes = serde_json::to_string(&body)
554 .map_err(Error::Serde)?
555 .into_bytes();
556
557 self.client
558 .request(
559 Method::POST,
560 url.clone(),
561 None,
562 None,
563 Some(body_bytes),
564 None,
565 None,
566 )
567 .await
568 .map_err(Error::from_http_client)
569 }
570
571 pub async fn post_action(
573 &self,
574 action: &ExchangeAction,
575 ) -> Result<HyperliquidExchangeResponse> {
576 let w = exchange_weight(action);
577 self.rest_limiter.acquire(w).await;
578
579 let signer = self
580 .signer
581 .as_ref()
582 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
583
584 let nonce_manager = self
585 .nonce_manager
586 .as_ref()
587 .ok_or_else(|| Error::auth("nonce manager missing"))?;
588
589 let signer_id = self.signer_id();
590 let time_nonce = nonce_manager.next(signer_id)?;
591
592 let action_bytes = rmp_serde::to_vec_named(action)
594 .context("serialize action with MessagePack")
595 .map_err(|e| Error::bad_request(e.to_string()))?;
596
597 let sign_request = SignRequest {
598 action: None,
599 action_bytes: Some(action_bytes),
600 time_nonce,
601 action_type: HyperliquidActionType::L1,
602 is_testnet: self.is_testnet(),
603 vault_address: self.vault_address,
604 expires_after: None,
605 };
606
607 let sig = signer.sign(&sign_request)?.signature;
608
609 let nonce_u64 = time_nonce.as_millis() as u64;
610
611 let request = if let Some(vault) = self.vault_address {
612 HyperliquidExchangeRequest::with_vault(
613 action.clone(),
614 nonce_u64,
615 sig,
616 vault.to_string(),
617 )
618 } else {
619 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
620 };
621
622 let response = self.http_roundtrip_exchange(&request).await?;
623
624 if response.status.is_success() {
625 let parsed_response: HyperliquidExchangeResponse =
626 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
627
628 match &parsed_response {
630 HyperliquidExchangeResponse::Status {
631 status,
632 response: response_data,
633 } if status == "err" => {
634 let error_msg = response_data
635 .as_str()
636 .map_or_else(|| response_data.to_string(), |s| s.to_string());
637 log::error!("Hyperliquid API returned error: {error_msg}");
638 Err(Error::bad_request(format!("API error: {error_msg}")))
639 }
640 HyperliquidExchangeResponse::Error { error } => {
641 log::error!("Hyperliquid API returned error: {error}");
642 Err(Error::bad_request(format!("API error: {error}")))
643 }
644 _ => Ok(parsed_response),
645 }
646 } else if response.status.as_u16() == 429 {
647 let ra = self.parse_retry_after_simple(&response.headers);
648 Err(Error::rate_limit("exchange", w, ra))
649 } else {
650 let error_body = String::from_utf8_lossy(&response.body);
651 log::error!(
652 "Exchange API error (status {}): {}",
653 response.status.as_u16(),
654 error_body
655 );
656 Err(Error::http(
657 response.status.as_u16(),
658 error_body.to_string(),
659 ))
660 }
661 }
662
663 pub fn sign_action_exec_request(
665 &self,
666 action: &HyperliquidExecAction,
667 expires_after: Option<u64>,
668 ) -> Result<HyperliquidExchangeRequest<HyperliquidExecAction>> {
669 let signer = self
670 .signer
671 .as_ref()
672 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
673
674 let nonce_manager = self
675 .nonce_manager
676 .as_ref()
677 .ok_or_else(|| Error::auth("nonce manager missing"))?;
678
679 let signer_id = self.signer_id();
680 let time_nonce = nonce_manager.next(signer_id)?;
681 let action_bytes = rmp_serde::to_vec_named(action)
685 .context("serialize action with MessagePack")
686 .map_err(|e| Error::bad_request(e.to_string()))?;
687
688 let sig = signer
689 .sign(&SignRequest {
690 action: None,
691 action_bytes: Some(action_bytes),
692 time_nonce,
693 action_type: HyperliquidActionType::L1,
694 is_testnet: self.is_testnet(),
695 vault_address: self.vault_address,
696 expires_after,
697 })?
698 .signature;
699
700 let mut request = if let Some(vault) = self.vault_address {
701 HyperliquidExchangeRequest::with_vault(
702 action.clone(),
703 time_nonce.as_millis() as u64,
704 sig,
705 vault.to_string(),
706 )
707 } else {
708 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
709 };
710 request.expires_after = expires_after;
711 Ok(request)
712 }
713
714 pub async fn post_action_exec(
719 &self,
720 action: &HyperliquidExecAction,
721 ) -> Result<HyperliquidExchangeResponse> {
722 let w = exec_action_weight(action);
723 self.rest_limiter.acquire(w).await;
724
725 let request = self.sign_action_exec_request(action, None)?;
726
727 let response = self.http_roundtrip_exchange(&request).await?;
728
729 if response.status.is_success() {
730 let parsed_response: HyperliquidExchangeResponse =
731 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
732
733 match &parsed_response {
735 HyperliquidExchangeResponse::Status {
736 status,
737 response: response_data,
738 } if status == "err" => {
739 let error_msg = response_data
740 .as_str()
741 .map_or_else(|| response_data.to_string(), |s| s.to_string());
742 log::error!("Hyperliquid API returned error: {error_msg}");
743 Err(Error::bad_request(format!("API error: {error_msg}")))
744 }
745 HyperliquidExchangeResponse::Error { error } => {
746 log::error!("Hyperliquid API returned error: {error}");
747 Err(Error::bad_request(format!("API error: {error}")))
748 }
749 _ => Ok(parsed_response),
750 }
751 } else if response.status.as_u16() == 429 {
752 let ra = self.parse_retry_after_simple(&response.headers);
753 Err(Error::rate_limit("exchange", w, ra))
754 } else {
755 let error_body = String::from_utf8_lossy(&response.body);
756 Err(Error::http(
757 response.status.as_u16(),
758 error_body.to_string(),
759 ))
760 }
761 }
762
763 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
766 self.rest_limiter.snapshot().await
767 }
768 async fn http_roundtrip_exchange<T>(
769 &self,
770 request: &HyperliquidExchangeRequest<T>,
771 ) -> Result<HttpResponse>
772 where
773 T: serde::Serialize,
774 {
775 let url = &self.base_exchange;
776 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
777 let body_bytes = body.into_bytes();
778
779 let response = self
780 .client
781 .request(
782 Method::POST,
783 url.clone(),
784 None,
785 None,
786 Some(body_bytes),
787 None,
788 None,
789 )
790 .await
791 .map_err(Error::from_http_client)?;
792
793 Ok(response)
794 }
795}
796
797#[derive(Debug, Clone)]
803#[cfg_attr(
804 feature = "python",
805 pyo3::pyclass(
806 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
807 from_py_object
808 )
809)]
810#[cfg_attr(
811 feature = "python",
812 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
813)]
814pub struct HyperliquidHttpClient {
815 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
816 clock: &'static AtomicTime,
817 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
818 instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
819 asset_indices: Arc<AtomicMap<Ustr, u32>>,
821 spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
823 client_order_id_cloids: Arc<Mutex<AHashMap<ClientOrderId, Cloid>>>,
824 account_id: Option<AccountId>,
825 account_address: Option<String>,
829 normalize_prices: bool,
830 market_order_slippage_bps: u32,
831}
832
833impl Default for HyperliquidHttpClient {
834 fn default() -> Self {
835 Self::new(HyperliquidEnvironment::Mainnet, 60, None)
836 .expect("Failed to create default Hyperliquid HTTP client")
837 }
838}
839
840impl HyperliquidHttpClient {
841 pub fn new(
847 environment: HyperliquidEnvironment,
848 timeout_secs: u64,
849 proxy_url: Option<String>,
850 ) -> std::result::Result<Self, HttpClientError> {
851 let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
852 Ok(Self::from_raw(raw_client))
853 }
854
855 pub fn with_secrets(
861 secrets: &Secrets,
862 timeout_secs: u64,
863 proxy_url: Option<String>,
864 ) -> std::result::Result<Self, HttpClientError> {
865 let raw_client =
866 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
867 Ok(Self::from_raw(raw_client))
868 }
869
870 fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
871 Self {
872 inner: Arc::new(raw_client),
873 clock: get_atomic_clock_realtime(),
874 instruments: Arc::new(AtomicMap::new()),
875 instruments_by_coin: Arc::new(AtomicMap::new()),
876 asset_indices: Arc::new(AtomicMap::new()),
877 spot_fill_coins: Arc::new(AtomicMap::new()),
878 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
879 account_id: None,
880 account_address: None,
881 normalize_prices: true,
882 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
883 }
884 }
885
886 #[allow(
888 clippy::missing_panics_doc,
889 reason = "cloid cache mutex poisoning is not expected"
890 )]
891 #[must_use]
892 pub fn get_or_generate_client_order_id_cloid(&self, client_order_id: ClientOrderId) -> Cloid {
893 let mut cloids = self.client_order_id_cloids.lock().expect(MUTEX_POISONED);
894 *cloids
895 .entry(client_order_id)
896 .or_insert_with(|| Cloid::from_client_order_id(client_order_id))
897 }
898
899 #[allow(
901 clippy::missing_panics_doc,
902 reason = "cloid cache mutex poisoning is not expected"
903 )]
904 pub fn cache_client_order_id_cloid(&self, client_order_id: ClientOrderId, cloid: Cloid) {
905 self.client_order_id_cloids
906 .lock()
907 .expect(MUTEX_POISONED)
908 .entry(client_order_id)
909 .or_insert(cloid);
910 }
911
912 fn replace_client_order_id_cloid(&self, client_order_id: ClientOrderId, cloid: Cloid) {
913 self.client_order_id_cloids
914 .lock()
915 .expect(MUTEX_POISONED)
916 .insert(client_order_id, cloid);
917 }
918
919 #[allow(
921 clippy::missing_panics_doc,
922 reason = "cloid cache mutex poisoning is not expected"
923 )]
924 #[must_use]
925 pub fn cached_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
926 self.client_order_id_cloids
927 .lock()
928 .expect(MUTEX_POISONED)
929 .get(client_order_id)
930 .copied()
931 }
932
933 #[allow(
935 clippy::missing_panics_doc,
936 reason = "cloid cache mutex poisoning is not expected"
937 )]
938 pub fn remove_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
939 self.client_order_id_cloids
940 .lock()
941 .expect(MUTEX_POISONED)
942 .remove(client_order_id)
943 }
944
945 pub fn set_base_info_url(&mut self, url: String) {
951 Arc::get_mut(&mut self.inner)
952 .expect("cannot override URL: Arc has multiple references")
953 .set_base_info_url(url);
954 }
955
956 pub fn set_base_exchange_url(&mut self, url: String) {
962 Arc::get_mut(&mut self.inner)
963 .expect("cannot override URL: Arc has multiple references")
964 .set_base_exchange_url(url);
965 }
966
967 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
973 let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
974 Ok(Self {
975 inner: Arc::new(raw_client),
976 clock: get_atomic_clock_realtime(),
977 instruments: Arc::new(AtomicMap::new()),
978 instruments_by_coin: Arc::new(AtomicMap::new()),
979 asset_indices: Arc::new(AtomicMap::new()),
980 spot_fill_coins: Arc::new(AtomicMap::new()),
981 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
982 account_id: None,
983 account_address: None,
984 normalize_prices: true,
985 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
986 })
987 }
988
989 pub fn with_credentials(
1002 private_key: Option<String>,
1003 vault_address: Option<String>,
1004 account_address: Option<&str>,
1005 environment: HyperliquidEnvironment,
1006 timeout_secs: u64,
1007 proxy_url: Option<String>,
1008 ) -> Result<Self> {
1009 let (pk_env_var, vault_env_var) = credential_env_vars(environment);
1010
1011 let resolved_account_address = resolve_execution_account_address(
1012 private_key.as_deref(),
1013 vault_address.as_deref(),
1014 account_address,
1015 environment,
1016 )?;
1017
1018 let resolved_pk = private_key.or_else(|| std::env::var(pk_env_var).ok());
1020
1021 let resolved_vault = vault_address.or_else(|| std::env::var(vault_env_var).ok());
1023
1024 Self::from_resolved_credentials(
1025 resolved_pk,
1026 resolved_vault.as_deref(),
1027 resolved_account_address,
1028 environment,
1029 timeout_secs,
1030 proxy_url,
1031 )
1032 }
1033
1034 fn from_resolved_credentials(
1035 private_key: Option<String>,
1036 vault_address: Option<&str>,
1037 account_address: Option<String>,
1038 environment: HyperliquidEnvironment,
1039 timeout_secs: u64,
1040 proxy_url: Option<String>,
1041 ) -> Result<Self> {
1042 match private_key {
1043 Some(pk) => {
1044 let raw_client = HyperliquidRawHttpClient::from_credentials(
1045 &pk,
1046 vault_address,
1047 environment,
1048 timeout_secs,
1049 proxy_url,
1050 )?;
1051 Ok(Self {
1052 inner: Arc::new(raw_client),
1053 clock: get_atomic_clock_realtime(),
1054 instruments: Arc::new(AtomicMap::new()),
1055 instruments_by_coin: Arc::new(AtomicMap::new()),
1056 asset_indices: Arc::new(AtomicMap::new()),
1057 spot_fill_coins: Arc::new(AtomicMap::new()),
1058 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1059 account_id: None,
1060 account_address,
1061 normalize_prices: true,
1062 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1063 })
1064 }
1065 None => {
1066 let mut client = Self::new(environment, timeout_secs, proxy_url)
1068 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))?;
1069 client.set_account_address(account_address);
1070 Ok(client)
1071 }
1072 }
1073 }
1074
1075 pub fn from_credentials(
1081 private_key: &str,
1082 vault_address: Option<&str>,
1083 environment: HyperliquidEnvironment,
1084 timeout_secs: u64,
1085 proxy_url: Option<String>,
1086 ) -> Result<Self> {
1087 let raw_client = HyperliquidRawHttpClient::from_credentials(
1088 private_key,
1089 vault_address,
1090 environment,
1091 timeout_secs,
1092 proxy_url,
1093 )?;
1094 Ok(Self {
1095 inner: Arc::new(raw_client),
1096 clock: get_atomic_clock_realtime(),
1097 instruments: Arc::new(AtomicMap::new()),
1098 instruments_by_coin: Arc::new(AtomicMap::new()),
1099 asset_indices: Arc::new(AtomicMap::new()),
1100 spot_fill_coins: Arc::new(AtomicMap::new()),
1101 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1102 account_id: None,
1103 account_address: None,
1104 normalize_prices: true,
1105 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1106 })
1107 }
1108
1109 #[must_use]
1111 pub fn is_testnet(&self) -> bool {
1112 self.inner.is_testnet()
1113 }
1114
1115 #[must_use]
1117 pub fn normalize_prices(&self) -> bool {
1118 self.normalize_prices
1119 }
1120
1121 pub fn set_normalize_prices(&mut self, value: bool) {
1123 self.normalize_prices = value;
1124 }
1125
1126 #[must_use]
1128 pub fn market_order_slippage_bps(&self) -> u32 {
1129 self.market_order_slippage_bps
1130 }
1131
1132 pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1134 self.market_order_slippage_bps = value;
1135 }
1136
1137 pub fn get_user_address(&self) -> Result<String> {
1143 self.inner.get_user_address()
1144 }
1145
1146 #[must_use]
1148 pub fn has_vault_address(&self) -> bool {
1149 self.inner.has_vault_address()
1150 }
1151
1152 #[must_use]
1155 pub fn builder_attribution(&self) -> Option<HyperliquidExecBuilderFee> {
1156 if self.has_vault_address() || self.is_testnet() {
1157 None
1158 } else {
1159 Some(HyperliquidExecBuilderFee {
1160 address: NAUTILUS_BUILDER_ADDRESS.to_string(),
1161 fee_tenths_bp: 0,
1162 })
1163 }
1164 }
1165
1166 pub fn get_account_address(&self) -> Result<String> {
1174 if let Some(addr) = &self.account_address {
1175 return Ok(addr.clone());
1176 }
1177 self.inner.get_account_address()
1178 }
1179
1180 pub fn set_account_address(&mut self, address: Option<String>) {
1182 self.account_address = address;
1183 }
1184
1185 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1190 let full_symbol = instrument.symbol().inner();
1191 let coin = instrument.raw_symbol().inner();
1192
1193 self.instruments.rcu(|m| {
1194 m.insert(full_symbol, instrument.clone());
1195 m.insert(coin, instrument.clone());
1197 });
1198
1199 if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
1201 self.instruments_by_coin.rcu(|m| {
1202 m.insert((coin, product_type), instrument.clone());
1203
1204 if let Some(alias_ustr) = cache_alias_for_symbol(full_symbol.as_str())
1229 .map(|alias| Ustr::from(alias.as_str()))
1230 {
1231 let key = (alias_ustr, product_type);
1232 if alias_ustr != coin && !m.contains_key(&key) {
1233 m.insert(key, instrument.clone());
1234 }
1235 }
1236 });
1237 } else {
1238 log::warn!("Unable to determine product type for symbol: {full_symbol}");
1239 }
1240 }
1241
1242 fn get_or_create_instrument(
1243 &self,
1244 coin: &Ustr,
1245 product_type: Option<HyperliquidProductType>,
1246 ) -> Option<InstrumentAny> {
1247 if let Some(pt) = product_type
1248 && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1249 {
1250 return Some(instrument.clone());
1251 }
1252
1253 if product_type.is_none() {
1257 let guard = self.instruments_by_coin.load();
1258
1259 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Outcome)) {
1260 return Some(instrument.clone());
1261 }
1262
1263 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1264 return Some(instrument.clone());
1265 }
1266
1267 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1268 return Some(instrument.clone());
1269 }
1270 }
1271
1272 if coin.as_str().starts_with('@')
1274 && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1275 {
1276 if let Some(instrument) = self.instruments.load().get(symbol) {
1279 return Some(instrument.clone());
1280 }
1281 }
1282
1283 if coin.as_str().starts_with("vntls:") {
1285 log::info!("Creating synthetic instrument for vault token: {coin}");
1286
1287 let ts_event = self.clock.get_time_ns();
1288
1289 let symbol_str = format!("{coin}-USDC-SPOT");
1291 let symbol = Symbol::new(&symbol_str);
1292 let venue = *HYPERLIQUID_VENUE;
1293 let instrument_id = InstrumentId::new(symbol, venue);
1294
1295 let base_currency = Currency::new(
1297 coin.as_str(),
1298 8, 0, coin.as_str(),
1301 CurrencyType::Crypto,
1302 );
1303
1304 let quote_currency = Currency::new(
1305 "USDC",
1306 6, 0,
1308 "USDC",
1309 CurrencyType::Crypto,
1310 );
1311
1312 let price_increment = Price::from("0.00000001");
1313 let size_increment = Quantity::from("0.00000001");
1314
1315 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1316 instrument_id,
1317 symbol,
1318 base_currency,
1319 quote_currency,
1320 8, 8, price_increment,
1323 size_increment,
1324 None, None, None, None, None, None, None, None, None, None, None, None, None, ts_event,
1338 ts_event,
1339 ));
1340
1341 self.cache_instrument(&instrument);
1342
1343 Some(instrument)
1344 } else {
1345 log::warn!("Instrument not found in cache: {coin}");
1347 None
1348 }
1349 }
1350
1351 pub fn set_account_id(&mut self, account_id: AccountId) {
1355 self.account_id = Some(account_id);
1356 }
1357
1358 pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1360 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1361
1362 match self.inner.load_all_perp_metas().await {
1364 Ok(all_metas) => {
1365 for (dex_index, meta) in all_metas.iter().enumerate() {
1366 let base = perp_dex_asset_index_base(dex_index);
1367
1368 match parse_perp_instruments(meta, base) {
1369 Ok(perp_defs) => {
1370 log::debug!(
1371 "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1372 perp_defs.len(),
1373 );
1374 defs.extend(perp_defs);
1375 }
1376 Err(e) => {
1377 log::warn!("Failed to parse perp instruments for dex {dex_index}: {e}");
1378 }
1379 }
1380 }
1381 }
1382 Err(e) => {
1383 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1384
1385 match self.inner.load_perp_meta().await {
1386 Ok(perp_meta) => match parse_perp_instruments(&perp_meta, 0) {
1387 Ok(perp_defs) => {
1388 log::debug!(
1389 "Loaded Hyperliquid perp defs via fallback: count={}",
1390 perp_defs.len(),
1391 );
1392 defs.extend(perp_defs);
1393 }
1394 Err(e) => {
1395 log::warn!("Failed to parse perp instruments: {e}");
1396 }
1397 },
1398 Err(e) => {
1399 log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1400 }
1401 }
1402 }
1403 }
1404
1405 match self.inner.get_spot_meta().await {
1406 Ok(spot_meta) => match parse_spot_instruments(&spot_meta) {
1407 Ok(spot_defs) => {
1408 log::debug!(
1409 "Loaded Hyperliquid spot definitions: count={}",
1410 spot_defs.len(),
1411 );
1412 defs.extend(spot_defs);
1413 }
1414 Err(e) => {
1415 log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1416 }
1417 },
1418 Err(e) => {
1419 log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1420 }
1421 }
1422
1423 match self.inner.get_outcome_meta().await {
1427 Ok(outcome_meta) => match parse_outcome_instruments(&outcome_meta) {
1428 Ok(outcome_defs) => {
1429 log::debug!(
1430 "Loaded Hyperliquid outcome definitions: count={}",
1431 outcome_defs.len(),
1432 );
1433 defs.extend(outcome_defs);
1434 }
1435 Err(e) => {
1436 log::warn!("Failed to parse Hyperliquid outcome instruments: {e}");
1437 }
1438 },
1439 Err(e) => {
1440 log::debug!("Skipping Hyperliquid outcome metadata: {e}");
1441 }
1442 }
1443
1444 let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1451 let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1452 for def in defs {
1453 if seen_symbols.insert(def.symbol) {
1454 deduped.push(def);
1455 } else {
1456 log::warn!(
1457 "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1458 def.symbol,
1459 def.raw_symbol,
1460 );
1461 }
1462 }
1463 let defs = deduped;
1464
1465 self.asset_indices.rcu(|m| {
1467 for def in &defs {
1468 m.insert(def.symbol, def.asset_index);
1469 }
1470 });
1471 log::debug!(
1472 "Populated asset indices map (count={})",
1473 self.asset_indices.len()
1474 );
1475
1476 Ok(defs)
1477 }
1478
1479 pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1481 let ts_init = self.clock.get_time_ns();
1482 instruments_from_defs_owned(defs, ts_init)
1483 }
1484
1485 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1487 let defs = self.request_instrument_defs().await?;
1488 Ok(self.convert_defs(defs))
1489 }
1490
1491 pub async fn build_all_dex_asset_ctxs_instrument_ids(
1496 &self,
1497 ) -> Result<AHashMap<String, Vec<Option<InstrumentId>>>> {
1498 let all_metas = match self.inner.load_all_perp_metas().await {
1499 Ok(all_metas) => all_metas,
1500 Err(e) => {
1501 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1502 vec![self.inner.load_perp_meta().await?]
1503 }
1504 };
1505
1506 let perp_dexs = match self.inner.load_perp_dexs().await {
1507 Ok(dexs) => Some(dexs),
1508 Err(e) => {
1509 log::warn!("Failed to load perpDexs, inferring dex names from metadata: {e}");
1510 None
1511 }
1512 };
1513
1514 let raw_symbol_to_id =
1515 self.instruments
1516 .load()
1517 .values()
1518 .fold(AHashMap::new(), |mut acc, instrument| {
1519 acc.insert(instrument.raw_symbol().to_string(), instrument.id());
1520 acc
1521 });
1522
1523 let mut mapping = AHashMap::new();
1524
1525 for (dex_index, meta) in all_metas.iter().enumerate() {
1526 let dex_name = resolve_perp_dex_name(dex_index, meta, perp_dexs.as_deref());
1527 let mut instrument_ids = Vec::with_capacity(meta.universe.len());
1528
1529 for asset in &meta.universe {
1530 if let Some(instrument_id) = raw_symbol_to_id.get(&asset.name) {
1531 instrument_ids.push(Some(*instrument_id));
1532 } else {
1533 log::warn!(
1534 "Missing cached Hyperliquid instrument for dex='{}' raw_symbol='{}'",
1535 dex_name,
1536 asset.name
1537 );
1538 instrument_ids.push(None);
1539 }
1540 }
1541
1542 mapping.insert(dex_name, instrument_ids);
1543 }
1544
1545 Ok(mapping)
1546 }
1547
1548 pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1556 self.get_asset_index_for_symbol(Ustr::from(symbol))
1557 }
1558
1559 pub(crate) fn get_asset_index_for_symbol(&self, symbol: Ustr) -> Option<u32> {
1563 self.asset_indices.load().get(&symbol).copied()
1564 }
1565
1566 pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1568 self.get_price_precision_for_symbol(Ustr::from(symbol))
1569 }
1570
1571 pub(crate) fn get_price_precision_for_symbol(&self, symbol: Ustr) -> Option<u8> {
1573 self.instruments
1574 .load()
1575 .get(&symbol)
1576 .map(|inst| inst.price_precision())
1577 }
1578
1579 #[must_use]
1587 pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1588 const SPOT_INDEX_OFFSET: u32 = 10_000;
1589 const BUILDER_PERP_OFFSET: u32 = 100_000;
1590
1591 let guard = self.asset_indices.load();
1592
1593 let mut mapping = AHashMap::new();
1594
1595 for (symbol, &asset_index) in guard.iter() {
1596 if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1598 let pair_index = asset_index - SPOT_INDEX_OFFSET;
1599 let fill_coin = Ustr::from(&format!("@{pair_index}"));
1600 mapping.insert(fill_coin, *symbol);
1601 }
1602 }
1603
1604 self.spot_fill_coins.store(mapping.clone());
1606
1607 mapping
1608 }
1609
1610 #[allow(dead_code)]
1612 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1613 self.inner.load_perp_meta().await
1614 }
1615
1616 #[allow(dead_code)]
1618 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1619 self.inner.load_all_perp_metas().await
1620 }
1621
1622 #[allow(dead_code)]
1624 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1625 self.inner.get_spot_meta().await
1626 }
1627
1628 pub(crate) async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
1630 self.inner.get_outcome_meta().await
1631 }
1632
1633 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1635 self.inner.info_l2_book(coin).await
1636 }
1637
1638 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1640 self.inner.info_user_fills(user).await
1641 }
1642
1643 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1645 self.inner.info_order_status(user, oid).await
1646 }
1647
1648 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1650 self.inner.info_open_orders(user).await
1651 }
1652
1653 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1655 self.inner.info_frontend_open_orders(user).await
1656 }
1657
1658 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1660 self.inner.info_clearinghouse_state(user).await
1661 }
1662
1663 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
1665 self.inner.info_spot_clearinghouse_state(user).await
1666 }
1667
1668 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
1670 self.inner.info_user_fees(user).await
1671 }
1672
1673 pub async fn info_candle_snapshot(
1675 &self,
1676 coin: &str,
1677 interval: HyperliquidBarInterval,
1678 start_time: u64,
1679 end_time: u64,
1680 ) -> Result<HyperliquidCandleSnapshot> {
1681 self.inner
1682 .info_candle_snapshot(coin, interval, start_time, end_time)
1683 .await
1684 }
1685
1686 pub async fn info_funding_history(
1688 &self,
1689 coin: &str,
1690 start_time: u64,
1691 end_time: Option<u64>,
1692 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
1693 self.inner
1694 .info_funding_history(coin, start_time, end_time)
1695 .await
1696 }
1697
1698 pub async fn post_action(
1700 &self,
1701 action: &ExchangeAction,
1702 ) -> Result<HyperliquidExchangeResponse> {
1703 self.inner.post_action(action).await
1704 }
1705
1706 pub async fn post_action_exec(
1708 &self,
1709 action: &HyperliquidExecAction,
1710 ) -> Result<HyperliquidExchangeResponse> {
1711 self.inner.post_action_exec(action).await
1712 }
1713
1714 pub fn sign_action_exec_request(
1716 &self,
1717 action: &HyperliquidExecAction,
1718 expires_after: Option<u64>,
1719 ) -> Result<HyperliquidExchangeRequest<HyperliquidExecAction>> {
1720 self.inner.sign_action_exec_request(action, expires_after)
1721 }
1722
1723 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1725 self.inner.info_meta().await
1726 }
1727
1728 pub async fn cancel_order(
1738 &self,
1739 instrument_id: InstrumentId,
1740 client_order_id: Option<ClientOrderId>,
1741 venue_order_id: Option<VenueOrderId>,
1742 ) -> Result<()> {
1743 let symbol = instrument_id.symbol.inner();
1745 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1746 Error::bad_request(format!(
1747 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1748 ))
1749 })?;
1750
1751 let action = if let Some(client_order_id) = client_order_id {
1752 if let Some(cloid) = self.cached_client_order_id_cloid(&client_order_id) {
1753 HyperliquidExecAction::CancelByCloid {
1754 cancels: vec![HyperliquidExecCancelByCloidRequest {
1755 asset: asset_id,
1756 cloid,
1757 }],
1758 }
1759 } else if let Some(oid) = venue_order_id {
1760 let oid_u64 = oid
1761 .as_str()
1762 .parse::<u64>()
1763 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1764 HyperliquidExecAction::Cancel {
1765 cancels: vec![HyperliquidExecCancelOrderRequest {
1766 asset: asset_id,
1767 oid: oid_u64,
1768 }],
1769 }
1770 } else {
1771 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
1772 HyperliquidExecAction::CancelByCloid {
1773 cancels: vec![HyperliquidExecCancelByCloidRequest {
1774 asset: asset_id,
1775 cloid,
1776 }],
1777 }
1778 }
1779 } else if let Some(oid) = venue_order_id {
1780 let oid_u64 = oid
1781 .as_str()
1782 .parse::<u64>()
1783 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1784 HyperliquidExecAction::Cancel {
1785 cancels: vec![HyperliquidExecCancelOrderRequest {
1786 asset: asset_id,
1787 oid: oid_u64,
1788 }],
1789 }
1790 } else {
1791 return Err(Error::bad_request(
1792 "Either client_order_id or venue_order_id must be provided",
1793 ));
1794 };
1795
1796 let response = self.inner.post_action_exec(&action).await?;
1798
1799 match response {
1801 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
1802 HyperliquidExchangeResponse::Status {
1803 status,
1804 response: error_data,
1805 } => Err(Error::bad_request(format!(
1806 "Cancel order failed: status={status}, error={error_data}"
1807 ))),
1808 HyperliquidExchangeResponse::Error { error } => {
1809 Err(Error::bad_request(format!("Cancel order error: {error}")))
1810 }
1811 }
1812 }
1813
1814 #[expect(clippy::too_many_arguments)]
1824 pub async fn modify_order(
1825 &self,
1826 instrument_id: InstrumentId,
1827 venue_order_id: VenueOrderId,
1828 order_side: OrderSide,
1829 order_type: OrderType,
1830 price: Price,
1831 quantity: Quantity,
1832 trigger_price: Option<Price>,
1833 reduce_only: bool,
1834 post_only: bool,
1835 time_in_force: TimeInForce,
1836 client_order_id: Option<ClientOrderId>,
1837 ) -> Result<()> {
1838 let symbol = instrument_id.symbol.inner();
1839 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1840 Error::bad_request(format!(
1841 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1842 ))
1843 })?;
1844
1845 let oid: u64 = venue_order_id
1846 .as_str()
1847 .parse()
1848 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1849
1850 let is_buy = matches!(order_side, OrderSide::Buy);
1851 let decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
1852
1853 let normalized_price = if self.normalize_prices {
1854 normalize_price(price.as_decimal(), decimals).normalize()
1855 } else {
1856 price.as_decimal().normalize()
1857 };
1858
1859 let size = quantity.as_decimal().normalize();
1860
1861 let kind = match order_type {
1862 OrderType::Market => HyperliquidExecOrderKind::Limit {
1863 limit: HyperliquidExecLimitParams {
1864 tif: HyperliquidExecTif::Ioc,
1865 },
1866 },
1867 OrderType::Limit => {
1868 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
1869 .map_err(|e| Error::bad_request(format!("{e}")))?;
1870 HyperliquidExecOrderKind::Limit {
1871 limit: HyperliquidExecLimitParams { tif },
1872 }
1873 }
1874 OrderType::StopMarket
1875 | OrderType::StopLimit
1876 | OrderType::MarketIfTouched
1877 | OrderType::LimitIfTouched => {
1878 if let Some(trig_px) = trigger_price {
1879 let trigger_price_decimal = if self.normalize_prices {
1880 normalize_price(trig_px.as_decimal(), decimals).normalize()
1881 } else {
1882 trig_px.as_decimal().normalize()
1883 };
1884 let tpsl = match order_type {
1885 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1886 _ => HyperliquidExecTpSl::Tp,
1887 };
1888 let is_market = matches!(
1889 order_type,
1890 OrderType::StopMarket | OrderType::MarketIfTouched
1891 );
1892 HyperliquidExecOrderKind::Trigger {
1893 trigger: HyperliquidExecTriggerParams {
1894 is_market,
1895 trigger_px: trigger_price_decimal,
1896 tpsl,
1897 },
1898 }
1899 } else {
1900 return Err(Error::bad_request("Trigger orders require a trigger price"));
1901 }
1902 }
1903 _ => {
1904 return Err(Error::bad_request(format!(
1905 "Order type {order_type:?} not supported for modify"
1906 )));
1907 }
1908 };
1909 let cloid = client_order_id.map(|id| self.get_or_generate_client_order_id_cloid(id));
1910
1911 let order = HyperliquidExecPlaceOrderRequest {
1912 asset: asset_id,
1913 is_buy,
1914 price: normalized_price,
1915 size,
1916 reduce_only,
1917 kind,
1918 cloid,
1919 };
1920
1921 let action = HyperliquidExecAction::Modify {
1922 modify: HyperliquidExecModifyOrderRequest { oid, order },
1923 };
1924
1925 let response = self.inner.post_action_exec(&action).await?;
1926
1927 match response {
1928 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
1929 if let Some(inner_error) = extract_inner_error(&response) {
1930 Err(Error::bad_request(format!(
1931 "Modify order rejected: {inner_error}",
1932 )))
1933 } else {
1934 Ok(())
1935 }
1936 }
1937 HyperliquidExchangeResponse::Status {
1938 status,
1939 response: error_data,
1940 } => Err(Error::bad_request(format!(
1941 "Modify order failed: status={status}, error={error_data}"
1942 ))),
1943 HyperliquidExchangeResponse::Error { error } => {
1944 Err(Error::bad_request(format!("Modify order error: {error}")))
1945 }
1946 }
1947 }
1948
1949 pub async fn submit_split_outcome(
1963 &self,
1964 outcome: u32,
1965 amount: Decimal,
1966 ) -> Result<HyperliquidExchangeResponse> {
1967 let action = HyperliquidExecAction::UserOutcome {
1968 op: HyperliquidExecUserOutcomeOp::SplitOutcome(HyperliquidExecSplitOutcomeParams {
1969 outcome,
1970 amount,
1971 }),
1972 };
1973 self.inner.post_action_exec(&action).await
1974 }
1975
1976 pub async fn submit_merge_outcome(
1987 &self,
1988 outcome: u32,
1989 amount: Option<Decimal>,
1990 ) -> Result<HyperliquidExchangeResponse> {
1991 let action = HyperliquidExecAction::UserOutcome {
1992 op: HyperliquidExecUserOutcomeOp::MergeOutcome(HyperliquidExecMergeOutcomeParams {
1993 outcome,
1994 amount,
1995 }),
1996 };
1997 self.inner.post_action_exec(&action).await
1998 }
1999
2000 pub async fn submit_merge_question(
2010 &self,
2011 question: u32,
2012 amount: Option<Decimal>,
2013 ) -> Result<HyperliquidExchangeResponse> {
2014 let action = HyperliquidExecAction::UserOutcome {
2015 op: HyperliquidExecUserOutcomeOp::MergeQuestion(HyperliquidExecMergeQuestionParams {
2016 question,
2017 amount,
2018 }),
2019 };
2020 self.inner.post_action_exec(&action).await
2021 }
2022
2023 pub async fn submit_negate_outcome(
2033 &self,
2034 question: u32,
2035 outcome: u32,
2036 amount: Decimal,
2037 ) -> Result<HyperliquidExchangeResponse> {
2038 let action = HyperliquidExecAction::UserOutcome {
2039 op: HyperliquidExecUserOutcomeOp::NegateOutcome(HyperliquidExecNegateOutcomeParams {
2040 question,
2041 outcome,
2042 amount,
2043 }),
2044 };
2045 self.inner.post_action_exec(&action).await
2046 }
2047
2048 pub async fn request_order_status_reports(
2060 &self,
2061 user: &str,
2062 instrument_id: Option<InstrumentId>,
2063 ) -> Result<Vec<OrderStatusReport>> {
2064 let account_id = self
2065 .account_id
2066 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2067 let response = self.info_frontend_open_orders(user).await?;
2068
2069 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
2071 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
2072
2073 let mut reports = Vec::new();
2074 let ts_init = self.clock.get_time_ns();
2075
2076 for order_value in orders {
2077 let order: WsBasicOrderData = match serde_json::from_value(order_value.clone()) {
2079 Ok(o) => o,
2080 Err(e) => {
2081 log::warn!("Failed to parse order: {e}");
2082 continue;
2083 }
2084 };
2085
2086 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2088 Some(inst) => inst,
2089 None => continue, };
2091
2092 if let Some(filter_id) = instrument_id
2094 && instrument.id() != filter_id
2095 {
2096 continue;
2097 }
2098
2099 let status = HyperliquidOrderStatusEnum::Open;
2101
2102 match parse_order_status_report_from_basic(
2104 &order,
2105 &status,
2106 &instrument,
2107 account_id,
2108 ts_init,
2109 ) {
2110 Ok(report) => reports.push(report),
2111 Err(e) => log::error!("Failed to parse order status report: {e}"),
2112 }
2113 }
2114
2115 Ok(reports)
2116 }
2117
2118 pub async fn request_order_status_report(
2128 &self,
2129 user: &str,
2130 oid: u64,
2131 ) -> Result<Option<OrderStatusReport>> {
2132 let account_id = self
2133 .account_id
2134 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2135
2136 let ts_init = self.clock.get_time_ns();
2137
2138 let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
2143 Ok(response) => match serde_json::from_value(response) {
2144 Ok(v) => v,
2145 Err(e) => {
2146 log::warn!("Failed to parse frontend open orders response: {e}");
2147 Vec::new()
2148 }
2149 },
2150 Err(e) => {
2151 log::warn!(
2152 "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
2153 );
2154 Vec::new()
2155 }
2156 };
2157
2158 if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
2159 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2160 Some(inst) => inst,
2161 None => return Ok(None),
2162 };
2163
2164 let status = if order.trigger_activated == Some(true) {
2165 HyperliquidOrderStatusEnum::Triggered
2166 } else {
2167 HyperliquidOrderStatusEnum::Open
2168 };
2169
2170 return match parse_order_status_report_from_basic(
2171 &order,
2172 &status,
2173 &instrument,
2174 account_id,
2175 ts_init,
2176 ) {
2177 Ok(report) => Ok(Some(report)),
2178 Err(e) => {
2179 log::error!("Failed to parse order status report for oid {oid}: {e}");
2180 Ok(None)
2181 }
2182 };
2183 }
2184
2185 let response = self.info_order_status(user, oid).await?;
2187 let entry = match response.into_order() {
2188 Some(e) => e,
2189 None => return Ok(None),
2190 };
2191
2192 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2193 Some(inst) => inst,
2194 None => return Ok(None),
2195 };
2196
2197 let basic = WsBasicOrderData {
2202 coin: entry.order.coin,
2203 side: entry.order.side,
2204 limit_px: entry.order.limit_px,
2205 sz: entry.order.sz,
2206 oid: entry.order.oid,
2207 timestamp: entry.order.timestamp,
2208 orig_sz: entry.order.orig_sz,
2209 cloid: entry.order.cloid,
2210 tif: None,
2211 reduce_only: None,
2212 trigger_px: None,
2213 is_market: None,
2214 tpsl: None,
2215 trigger_activated: None,
2216 trailing_stop: None,
2217 };
2218
2219 match parse_order_status_report_from_basic(
2220 &basic,
2221 &entry.status,
2222 &instrument,
2223 account_id,
2224 ts_init,
2225 ) {
2226 Ok(mut report) => {
2227 if entry.status_timestamp > 0 {
2230 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2231 }
2232 Ok(Some(report))
2233 }
2234 Err(e) => {
2235 log::error!("Failed to parse order status report for oid {oid}: {e}");
2236 Ok(None)
2237 }
2238 }
2239 }
2240
2241 pub async fn request_order_status_report_by_client_order_id(
2251 &self,
2252 user: &str,
2253 client_order_id: &ClientOrderId,
2254 ) -> Result<Option<OrderStatusReport>> {
2255 let account_id = self
2256 .account_id
2257 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2258
2259 let ts_init = self.clock.get_time_ns();
2260
2261 let cloid = self
2262 .cached_client_order_id_cloid(client_order_id)
2263 .unwrap_or_else(|| Cloid::from_client_order_id(*client_order_id));
2264 let cloid_hex = cloid.to_hex();
2265 let legacy_cloid = Cloid::from_legacy_client_order_id(*client_order_id);
2266 let legacy_cloid_hex = legacy_cloid.to_hex();
2267
2268 let response = self.info_frontend_open_orders(user).await?;
2269 let orders: Vec<WsBasicOrderData> = match serde_json::from_value(response) {
2270 Ok(v) => v,
2271 Err(e) => {
2272 log::warn!("Failed to parse frontend open orders response: {e}");
2273 return Ok(None);
2274 }
2275 };
2276
2277 let order = match orders.into_iter().find(|o| {
2278 o.cloid
2279 .as_ref()
2280 .is_some_and(|c| c == &cloid_hex || c == &legacy_cloid_hex)
2281 }) {
2282 Some(o) => o,
2283 None => return Ok(None),
2284 };
2285
2286 if order.cloid.as_ref() == Some(&legacy_cloid_hex) {
2287 self.replace_client_order_id_cloid(*client_order_id, legacy_cloid);
2288 }
2289
2290 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2291 Some(inst) => inst,
2292 None => return Ok(None),
2293 };
2294
2295 let status = if order.trigger_activated == Some(true) {
2296 HyperliquidOrderStatusEnum::Triggered
2297 } else {
2298 HyperliquidOrderStatusEnum::Open
2299 };
2300
2301 match parse_order_status_report_from_basic(
2302 &order,
2303 &status,
2304 &instrument,
2305 account_id,
2306 ts_init,
2307 ) {
2308 Ok(mut report) => {
2309 report.client_order_id = Some(*client_order_id);
2310 Ok(Some(report))
2311 }
2312 Err(e) => {
2313 log::error!("Failed to parse order status report for cloid {cloid_hex}: {e}");
2314 Ok(None)
2315 }
2316 }
2317 }
2318
2319 pub async fn request_fill_reports(
2333 &self,
2334 user: &str,
2335 instrument_id: Option<InstrumentId>,
2336 ) -> Result<Vec<FillReport>> {
2337 let account_id = self
2338 .account_id
2339 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2340 let fills_response = self.info_user_fills(user).await?;
2341
2342 let mut reports = Vec::new();
2343 let ts_init = self.clock.get_time_ns();
2344
2345 for fill in fills_response {
2346 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
2348 Some(inst) => inst,
2349 None => continue, };
2351
2352 if let Some(filter_id) = instrument_id
2354 && instrument.id() != filter_id
2355 {
2356 continue;
2357 }
2358
2359 match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2361 Ok(report) => reports.push(report),
2362 Err(e) => log::error!("Failed to parse fill report: {e}"),
2363 }
2364 }
2365
2366 Ok(reports)
2367 }
2368
2369 pub async fn request_position_status_reports(
2393 &self,
2394 user: &str,
2395 instrument_id: Option<InstrumentId>,
2396 ) -> Result<Vec<PositionStatusReport>> {
2397 let account_id = self
2398 .account_id
2399 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2400
2401 let filter_product = instrument_id
2402 .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
2403
2404 let fetch_perp = !matches!(
2405 filter_product,
2406 Some(HyperliquidProductType::Spot | HyperliquidProductType::Outcome)
2407 );
2408 let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
2409
2410 let mut reports = Vec::new();
2411 let ts_init = self.clock.get_time_ns();
2412
2413 if !fetch_perp {
2414 let spot_reports = self
2415 .request_spot_position_status_reports(user, instrument_id)
2416 .await?;
2417 reports.extend(spot_reports);
2418 return Ok(reports);
2419 }
2420
2421 let state_response = self.info_clearinghouse_state(user).await?;
2422
2423 let asset_positions: Vec<serde_json::Value> = state_response
2425 .get("assetPositions")
2426 .and_then(|v| v.as_array())
2427 .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
2428 .clone();
2429
2430 for position_value in asset_positions {
2431 let coin = position_value
2433 .get("position")
2434 .and_then(|p| p.get("coin"))
2435 .and_then(|c| c.as_str())
2436 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
2437
2438 let coin_ustr = Ustr::from(coin);
2440 let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
2441 Some(inst) => inst,
2442 None => continue, };
2444
2445 if let Some(filter_id) = instrument_id
2447 && instrument.id() != filter_id
2448 {
2449 continue;
2450 }
2451
2452 match parse_position_status_report(&position_value, &instrument, account_id, ts_init) {
2454 Ok(report) => reports.push(report),
2455 Err(e) => log::error!("Failed to parse position status report: {e}"),
2456 }
2457 }
2458
2459 if fetch_spot {
2462 let spot_reports = self
2463 .request_spot_position_status_reports(user, instrument_id)
2464 .await?;
2465 reports.extend(spot_reports);
2466 }
2467
2468 Ok(reports)
2469 }
2470
2471 pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
2484 let account_id = self
2485 .account_id
2486 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2487 let state_response = self.info_clearinghouse_state(user).await?;
2488 let ts_init = self.clock.get_time_ns();
2489
2490 log::trace!("Clearinghouse state response: {state_response}");
2491
2492 let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
2493 .map_err(|e| {
2494 log::error!("Failed to parse clearinghouse state: {e}");
2495 log::debug!("Raw response: {state_response}");
2496 Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
2497 })?;
2498
2499 let spot_response = self.info_spot_clearinghouse_state(user).await?;
2502 let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
2503 .map_err(|e| {
2504 log::error!("Failed to parse spot clearinghouse state: {e}");
2505 log::debug!("Raw spot response: {spot_response}");
2506 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2507 })?;
2508
2509 let (balances, margins) =
2510 parse_combined_account_balances_and_margins(&perp_state, &spot_state)
2511 .map_err(|e| Error::decode(e.to_string()))?;
2512
2513 Ok(AccountState::new(
2514 account_id,
2515 AccountType::Margin,
2516 balances,
2517 margins,
2518 true, UUID4::new(),
2520 ts_init,
2521 ts_init,
2522 None,
2523 ))
2524 }
2525
2526 pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
2537 let response = self.info_spot_clearinghouse_state(user).await?;
2538
2539 log::trace!("Spot clearinghouse state response: {response}");
2540
2541 let state: SpotClearinghouseState =
2542 serde_json::from_value(response.clone()).map_err(|e| {
2543 log::error!("Failed to parse spot clearinghouse state: {e}");
2544 log::debug!("Raw response: {response}");
2545 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2546 })?;
2547
2548 parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
2549 }
2550
2551 pub async fn request_spot_position_status_reports(
2566 &self,
2567 user: &str,
2568 instrument_id: Option<InstrumentId>,
2569 ) -> Result<Vec<PositionStatusReport>> {
2570 let account_id = self
2571 .account_id
2572 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2573 let response = self.info_spot_clearinghouse_state(user).await?;
2574
2575 let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
2576 log::error!("Failed to parse spot clearinghouse state: {e}");
2577 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2578 })?;
2579
2580 let ts_init = self.clock.get_time_ns();
2581 let mut reports = Vec::with_capacity(state.balances.len());
2582
2583 for balance in &state.balances {
2584 if balance.total.is_zero() {
2585 continue;
2586 }
2587
2588 if balance.coin.as_str() == "USDC" {
2593 continue;
2594 }
2595
2596 let product_type = match HyperliquidProductType::from_symbol(balance.coin.as_str()) {
2597 Ok(HyperliquidProductType::Outcome) => HyperliquidProductType::Outcome,
2598 _ => HyperliquidProductType::Spot,
2599 };
2600
2601 let instrument = match self.get_or_create_instrument(&balance.coin, Some(product_type))
2602 {
2603 Some(inst) => inst,
2604 None => continue,
2605 };
2606
2607 if let Some(filter_id) = instrument_id
2608 && instrument.id() != filter_id
2609 {
2610 continue;
2611 }
2612
2613 match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
2614 Ok(report) => reports.push(report),
2615 Err(e) => log::error!(
2616 "Failed to parse spot position status report for {}: {e}",
2617 balance.coin,
2618 ),
2619 }
2620 }
2621
2622 Ok(reports)
2623 }
2624
2625 pub async fn request_bars(
2642 &self,
2643 bar_type: BarType,
2644 start: Option<chrono::DateTime<chrono::Utc>>,
2645 end: Option<chrono::DateTime<chrono::Utc>>,
2646 limit: Option<u32>,
2647 ) -> Result<Vec<Bar>> {
2648 let instrument_id = bar_type.instrument_id();
2649 let symbol = instrument_id.symbol;
2650
2651 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
2652
2653 let alias = cache_alias_for_symbol(symbol.as_str())
2657 .map(|alias| Ustr::from(alias.as_str()))
2658 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
2659
2660 let instrument = self
2661 .get_or_create_instrument(&alias, product_type)
2662 .ok_or_else(|| {
2663 Error::bad_request(format!("Instrument not found in cache: {instrument_id}"))
2664 })?;
2665
2666 let coin = instrument.raw_symbol().inner();
2671
2672 let price_precision = instrument.price_precision();
2673 let size_precision = instrument.size_precision();
2674
2675 let interval =
2676 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
2677
2678 let now = chrono::Utc::now();
2680 let end_time = end.unwrap_or(now).timestamp_millis() as u64;
2681 let start_time = if let Some(start) = start {
2682 start.timestamp_millis() as u64
2683 } else {
2684 let spec = bar_type.spec();
2686 let step_ms = match spec.aggregation {
2687 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
2688 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
2689 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
2690 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
2691 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
2692 _ => 60_000,
2693 };
2694 end_time.saturating_sub(1000 * step_ms)
2695 };
2696
2697 let candles = self
2698 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
2699 .await?;
2700
2701 let now_ms = now.timestamp_millis() as u64;
2703
2704 let mut bars: Vec<Bar> = candles
2705 .iter()
2706 .filter(|candle| candle.end_timestamp < now_ms)
2707 .enumerate()
2708 .filter_map(|(i, candle)| {
2709 candle_to_bar(candle, bar_type, price_precision, size_precision)
2710 .map_err(|e| {
2711 log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
2712 e
2713 })
2714 .ok()
2715 })
2716 .collect();
2717
2718 if let Some(limit) = limit
2720 && limit > 0
2721 && bars.len() > limit as usize
2722 {
2723 bars.truncate(limit as usize);
2724 }
2725
2726 log::debug!(
2727 "Received {} bars for {} (filtered {} incomplete)",
2728 bars.len(),
2729 bar_type,
2730 candles.len() - bars.len()
2731 );
2732 Ok(bars)
2733 }
2734
2735 #[expect(clippy::too_many_arguments)]
2742 pub async fn submit_order(
2743 &self,
2744 instrument_id: InstrumentId,
2745 client_order_id: ClientOrderId,
2746 order_side: OrderSide,
2747 order_type: OrderType,
2748 quantity: Quantity,
2749 time_in_force: TimeInForce,
2750 price: Option<Price>,
2751 trigger_price: Option<Price>,
2752 post_only: bool,
2753 reduce_only: bool,
2754 ) -> Result<OrderStatusReport> {
2755 let symbol = instrument_id.symbol.inner();
2756 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
2757 Error::bad_request(format!(
2758 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2759 ))
2760 })?;
2761
2762 let is_buy = matches!(order_side, OrderSide::Buy);
2763 let price_precision = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
2764
2765 let price_decimal = match price {
2766 Some(px) if self.normalize_prices => {
2767 normalize_price(px.as_decimal(), price_precision).normalize()
2768 }
2769 Some(px) => px.as_decimal().normalize(),
2770 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
2771 None if matches!(
2772 order_type,
2773 OrderType::StopMarket | OrderType::MarketIfTouched
2774 ) =>
2775 {
2776 match trigger_price {
2777 Some(tp) => {
2778 let derived = derive_limit_from_trigger(
2779 tp.as_decimal().normalize(),
2780 is_buy,
2781 self.market_order_slippage_bps,
2782 );
2783 let sig_rounded = round_to_sig_figs(derived, 5);
2784 clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
2785 }
2786 None => Decimal::ZERO,
2787 }
2788 }
2789 None => return Err(Error::bad_request("Limit orders require a price")),
2790 };
2791
2792 let size_decimal = quantity.as_decimal().normalize();
2793
2794 let kind = match order_type {
2795 OrderType::Market => HyperliquidExecOrderKind::Limit {
2796 limit: HyperliquidExecLimitParams {
2797 tif: HyperliquidExecTif::Ioc,
2798 },
2799 },
2800 OrderType::Limit => {
2801 let tif = if post_only {
2802 HyperliquidExecTif::Alo
2803 } else {
2804 match time_in_force {
2805 TimeInForce::Gtc => HyperliquidExecTif::Gtc,
2806 TimeInForce::Ioc => HyperliquidExecTif::Ioc,
2807 TimeInForce::Fok
2808 | TimeInForce::Day
2809 | TimeInForce::Gtd
2810 | TimeInForce::AtTheOpen
2811 | TimeInForce::AtTheClose => {
2812 return Err(Error::bad_request(format!(
2813 "Time in force {time_in_force:?} not supported"
2814 )));
2815 }
2816 }
2817 };
2818 HyperliquidExecOrderKind::Limit {
2819 limit: HyperliquidExecLimitParams { tif },
2820 }
2821 }
2822 OrderType::StopMarket
2823 | OrderType::StopLimit
2824 | OrderType::MarketIfTouched
2825 | OrderType::LimitIfTouched => {
2826 if let Some(trig_px) = trigger_price {
2827 let trigger_price_decimal = if self.normalize_prices {
2828 normalize_price(trig_px.as_decimal(), price_precision).normalize()
2829 } else {
2830 trig_px.as_decimal().normalize()
2831 };
2832
2833 let tpsl = match order_type {
2837 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
2838 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2839 HyperliquidExecTpSl::Tp
2840 }
2841 _ => unreachable!(),
2842 };
2843
2844 let is_market = matches!(
2845 order_type,
2846 OrderType::StopMarket | OrderType::MarketIfTouched
2847 );
2848
2849 HyperliquidExecOrderKind::Trigger {
2850 trigger: HyperliquidExecTriggerParams {
2851 is_market,
2852 trigger_px: trigger_price_decimal,
2853 tpsl,
2854 },
2855 }
2856 } else {
2857 return Err(Error::bad_request("Trigger orders require a trigger price"));
2858 }
2859 }
2860 _ => {
2861 return Err(Error::bad_request(format!(
2862 "Order type {order_type:?} not supported"
2863 )));
2864 }
2865 };
2866
2867 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
2868 let hyperliquid_order = HyperliquidExecPlaceOrderRequest {
2869 asset,
2870 is_buy,
2871 price: price_decimal,
2872 size: size_decimal,
2873 reduce_only,
2874 kind,
2875 cloid: Some(cloid),
2876 };
2877
2878 let builder = self.builder_attribution();
2879
2880 let action = HyperliquidExecAction::Order {
2881 orders: vec![hyperliquid_order],
2882 grouping: HyperliquidExecGrouping::Na,
2883 builder,
2884 };
2885
2886 let response = self.inner.post_action_exec(&action).await?;
2887
2888 match response {
2889 HyperliquidExchangeResponse::Status {
2890 status,
2891 response: response_data,
2892 } if status == RESPONSE_STATUS_OK => {
2893 let data_value = if let Some(data) = response_data.get("data") {
2894 data.clone()
2895 } else {
2896 response_data
2897 };
2898
2899 let order_response: HyperliquidExecOrderResponseData =
2900 serde_json::from_value(data_value).map_err(|e| {
2901 Error::bad_request(format!("Failed to parse order response: {e}"))
2902 })?;
2903
2904 let order_status = order_response
2905 .statuses
2906 .first()
2907 .ok_or_else(|| Error::bad_request("No order status in response"))?;
2908
2909 let symbol_str = instrument_id.symbol.as_str();
2910 let product_type = HyperliquidProductType::from_symbol(symbol_str).ok();
2911
2912 let asset_alias =
2916 cache_alias_for_symbol(symbol_str).unwrap_or_else(|| symbol_str.to_string());
2917 let instrument = self
2918 .get_or_create_instrument(&Ustr::from(asset_alias.as_str()), product_type)
2919 .ok_or_else(|| {
2920 Error::bad_request(format!("Instrument not found for {asset_alias}"))
2921 })?;
2922
2923 let account_id = self
2924 .account_id
2925 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2926 let ts_init = self.clock.get_time_ns();
2927
2928 match order_status {
2929 HyperliquidExecOrderStatus::Resting { resting } => Ok(self
2930 .create_order_status_report(
2931 instrument_id,
2932 Some(client_order_id),
2933 VenueOrderId::new(resting.oid.to_string()),
2934 order_side,
2935 order_type,
2936 quantity,
2937 time_in_force,
2938 price,
2939 trigger_price,
2940 OrderStatus::Accepted,
2941 Quantity::new(0.0, instrument.size_precision()),
2942 &instrument,
2943 account_id,
2944 ts_init,
2945 )),
2946 HyperliquidExecOrderStatus::Filled { filled } => {
2947 let filled_qty = Quantity::new(
2948 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
2949 instrument.size_precision(),
2950 );
2951 Ok(self.create_order_status_report(
2952 instrument_id,
2953 Some(client_order_id),
2954 VenueOrderId::new(filled.oid.to_string()),
2955 order_side,
2956 order_type,
2957 quantity,
2958 time_in_force,
2959 price,
2960 trigger_price,
2961 OrderStatus::Filled,
2962 filled_qty,
2963 &instrument,
2964 account_id,
2965 ts_init,
2966 ))
2967 }
2968 HyperliquidExecOrderStatus::Error { error } => {
2969 Err(Error::bad_request(format!("Order rejected: {error}")))
2970 }
2971 }
2972 }
2973 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
2974 "Order submission failed: {error}"
2975 ))),
2976 _ => Err(Error::bad_request("Unexpected response format")),
2977 }
2978 }
2979
2980 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
2984 self.submit_order(
2985 order.instrument_id(),
2986 order.client_order_id(),
2987 order.order_side(),
2988 order.order_type(),
2989 order.quantity(),
2990 order.time_in_force(),
2991 order.price(),
2992 order.trigger_price(),
2993 order.is_post_only(),
2994 order.is_reduce_only(),
2995 )
2996 .await
2997 }
2998
2999 #[expect(clippy::too_many_arguments)]
3000 fn create_order_status_report(
3001 &self,
3002 instrument_id: InstrumentId,
3003 client_order_id: Option<ClientOrderId>,
3004 venue_order_id: VenueOrderId,
3005 order_side: OrderSide,
3006 order_type: OrderType,
3007 quantity: Quantity,
3008 time_in_force: TimeInForce,
3009 price: Option<Price>,
3010 trigger_price: Option<Price>,
3011 order_status: OrderStatus,
3012 filled_qty: Quantity,
3013 _instrument: &InstrumentAny,
3014 account_id: AccountId,
3015 ts_init: UnixNanos,
3016 ) -> OrderStatusReport {
3017 let ts_accepted = self.clock.get_time_ns();
3018 let ts_last = ts_accepted;
3019 let report_id = UUID4::new();
3020
3021 let mut report = OrderStatusReport::new(
3022 account_id,
3023 instrument_id,
3024 client_order_id,
3025 venue_order_id,
3026 order_side,
3027 order_type,
3028 time_in_force,
3029 order_status,
3030 quantity,
3031 filled_qty,
3032 ts_accepted,
3033 ts_last,
3034 ts_init,
3035 Some(report_id),
3036 );
3037
3038 if let Some(px) = price {
3039 report = report.with_price(px);
3040 }
3041
3042 if let Some(trig_px) = trigger_price {
3043 report = report
3044 .with_trigger_price(trig_px)
3045 .with_trigger_type(TriggerType::Default);
3046 }
3047
3048 report
3049 }
3050
3051 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
3058 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
3060 let mut client_order_ids = Vec::with_capacity(orders.len());
3061
3062 for order in orders {
3063 let instrument_id = order.instrument_id();
3064 let symbol = instrument_id.symbol.inner();
3065 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3066 Error::bad_request(format!(
3067 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3068 ))
3069 })?;
3070 let price_decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
3071 let request = order_to_hyperliquid_request_with_asset_and_cloid(
3072 order,
3073 asset,
3074 price_decimals,
3075 self.normalize_prices,
3076 self.market_order_slippage_bps,
3077 None,
3078 )
3079 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
3080 client_order_ids.push(order.client_order_id());
3081 hyperliquid_orders.push(request);
3082 }
3083
3084 for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
3085 request.cloid = Some(self.get_or_generate_client_order_id_cloid(client_order_id));
3086 }
3087
3088 let builder = self.builder_attribution();
3089
3090 let grouping =
3091 determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
3092
3093 let action = HyperliquidExecAction::Order {
3094 orders: hyperliquid_orders,
3095 grouping,
3096 builder,
3097 };
3098
3099 let response = self.inner.post_action_exec(&action).await?;
3101
3102 match response {
3104 HyperliquidExchangeResponse::Status {
3105 status,
3106 response: response_data,
3107 } if status == RESPONSE_STATUS_OK => {
3108 let data_value = if let Some(data) = response_data.get("data") {
3111 data.clone()
3112 } else {
3113 response_data
3114 };
3115
3116 let order_response: HyperliquidExecOrderResponseData =
3118 serde_json::from_value(data_value).map_err(|e| {
3119 Error::bad_request(format!("Failed to parse order response: {e}"))
3120 })?;
3121
3122 let account_id = self
3123 .account_id
3124 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3125 let ts_init = self.clock.get_time_ns();
3126
3127 if grouping == HyperliquidExecGrouping::Na
3131 && order_response.statuses.len() != orders.len()
3132 {
3133 return Err(Error::bad_request(format!(
3134 "Mismatch between submitted orders ({}) and response statuses ({})",
3135 orders.len(),
3136 order_response.statuses.len()
3137 )));
3138 }
3139
3140 let mut reports = Vec::new();
3141
3142 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
3146 let instrument_id = order.instrument_id();
3147 let symbol = instrument_id.symbol.as_str();
3148 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
3149
3150 let asset =
3153 cache_alias_for_symbol(symbol).unwrap_or_else(|| symbol.to_string());
3154 let instrument = self
3155 .get_or_create_instrument(&Ustr::from(asset.as_str()), product_type)
3156 .ok_or_else(|| {
3157 Error::bad_request(format!("Instrument not found for {asset}"))
3158 })?;
3159
3160 let report = match order_status {
3162 HyperliquidExecOrderStatus::Resting { resting } => {
3163 self.create_order_status_report(
3165 order.instrument_id(),
3166 Some(order.client_order_id()),
3167 VenueOrderId::new(resting.oid.to_string()),
3168 order.order_side(),
3169 order.order_type(),
3170 order.quantity(),
3171 order.time_in_force(),
3172 order.price(),
3173 order.trigger_price(),
3174 OrderStatus::Accepted,
3175 Quantity::new(0.0, instrument.size_precision()),
3176 &instrument,
3177 account_id,
3178 ts_init,
3179 )
3180 }
3181 HyperliquidExecOrderStatus::Filled { filled } => {
3182 let filled_qty = Quantity::new(
3184 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
3185 instrument.size_precision(),
3186 );
3187 self.create_order_status_report(
3188 order.instrument_id(),
3189 Some(order.client_order_id()),
3190 VenueOrderId::new(filled.oid.to_string()),
3191 order.order_side(),
3192 order.order_type(),
3193 order.quantity(),
3194 order.time_in_force(),
3195 order.price(),
3196 order.trigger_price(),
3197 OrderStatus::Filled,
3198 filled_qty,
3199 &instrument,
3200 account_id,
3201 ts_init,
3202 )
3203 }
3204 HyperliquidExecOrderStatus::Error { error } => {
3205 return Err(Error::bad_request(format!(
3206 "Order {} rejected: {error}",
3207 order.client_order_id()
3208 )));
3209 }
3210 };
3211
3212 reports.push(report);
3213 }
3214
3215 Ok(reports)
3216 }
3217 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
3218 "Order submission failed: {error}"
3219 ))),
3220 _ => Err(Error::bad_request("Unexpected response format")),
3221 }
3222 }
3223}
3224
3225fn resolve_perp_dex_name(
3226 dex_index: usize,
3227 meta: &PerpMeta,
3228 perp_dexs: Option<&[Option<PerpDex>]>,
3229) -> String {
3230 if dex_index == 0 {
3231 return String::new();
3232 }
3233
3234 if let Some(dex_name) = perp_dexs
3235 .and_then(|dexs| dexs.get(dex_index))
3236 .and_then(|dex| dex.as_ref())
3237 .map(|dex| dex.name.clone())
3238 {
3239 return dex_name;
3240 }
3241
3242 meta.universe
3243 .iter()
3244 .find_map(|asset| asset.name.split_once(':').map(|(dex, _)| dex.to_string()))
3245 .unwrap_or_default()
3246}
3247
3248fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
3253 if dex_index == 0 {
3254 0
3255 } else {
3256 100_000 + dex_index as u32 * 10_000
3257 }
3258}
3259
3260#[cfg(test)]
3261mod tests {
3262 use std::{net::SocketAddr, sync::Arc};
3263
3264 use axum::{
3265 Router,
3266 extract::State,
3267 http::StatusCode,
3268 response::{IntoResponse, Json, Response},
3269 routing::post,
3270 };
3271 use nautilus_core::{MUTEX_POISONED, time::get_atomic_clock_realtime};
3272 use nautilus_model::{
3273 currencies::CURRENCY_MAP,
3274 enums::CurrencyType,
3275 identifiers::{ClientOrderId, InstrumentId, Symbol},
3276 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
3277 types::{Currency, Price, Quantity},
3278 };
3279 use rstest::rstest;
3280 use serde_json::{Value, json};
3281 use ustr::Ustr;
3282
3283 use super::{HyperliquidHttpClient, resolve_perp_dex_name};
3284 use crate::{
3285 common::{
3286 consts::HYPERLIQUID_VENUE,
3287 enums::{HyperliquidEnvironment, HyperliquidProductType},
3288 },
3289 http::{
3290 models::{Cloid, PerpAsset, PerpDex, PerpMeta},
3291 query::InfoRequest,
3292 },
3293 };
3294
3295 const TEST_PRIVATE_KEY: &str =
3296 "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
3297
3298 fn perp_meta_with_assets(names: &[&str]) -> PerpMeta {
3299 PerpMeta {
3300 universe: names
3301 .iter()
3302 .map(|name| PerpAsset {
3303 name: (*name).to_string(),
3304 ..Default::default()
3305 })
3306 .collect(),
3307 margin_tables: Vec::new(),
3308 }
3309 }
3310
3311 #[rstest]
3312 fn resolve_perp_dex_name_uses_empty_string_for_default_dex() {
3313 let meta = perp_meta_with_assets(&["BTC", "ETH"]);
3314 assert_eq!(resolve_perp_dex_name(0, &meta, None), "");
3315 }
3316
3317 #[rstest]
3318 fn resolve_perp_dex_name_prefers_perp_dexs_entry() {
3319 let meta = perp_meta_with_assets(&["xyz:TSLA"]);
3320 let perp_dexs = vec![
3321 None,
3322 Some(PerpDex {
3323 name: "xyz".to_string(),
3324 }),
3325 ];
3326 assert_eq!(resolve_perp_dex_name(1, &meta, Some(&perp_dexs)), "xyz");
3327 }
3328
3329 #[rstest]
3330 fn resolve_perp_dex_name_infers_from_asset_name_when_perp_dexs_missing() {
3331 let meta = perp_meta_with_assets(&["abc:TSLA", "abc:NVDA"]);
3332 assert_eq!(resolve_perp_dex_name(1, &meta, None), "abc");
3333 }
3334
3335 #[derive(Clone, Default)]
3336 struct OutcomeMetaServerState {
3337 last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
3338 }
3339
3340 async fn handle_outcome_meta_info(
3341 State(state): State<OutcomeMetaServerState>,
3342 body: axum::body::Bytes,
3343 ) -> Response {
3344 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
3345 return (
3346 StatusCode::BAD_REQUEST,
3347 Json(json!({"error": "Invalid JSON body"})),
3348 )
3349 .into_response();
3350 };
3351
3352 *state.last_request_body.lock().await = Some(request_body.clone());
3353
3354 if request_body.get("type").and_then(|value| value.as_str()) != Some("outcomeMeta") {
3355 return (
3356 StatusCode::BAD_REQUEST,
3357 Json(json!({"error": "Expected outcomeMeta request"})),
3358 )
3359 .into_response();
3360 }
3361
3362 Json(json!({
3363 "outcomes": [
3364 {
3365 "outcome": 123,
3366 "name": "Recurring",
3367 "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m",
3368 "sideSpecs": [
3369 {"name": "Yes"},
3370 {"name": "No"}
3371 ]
3372 }
3373 ]
3374 }))
3375 .into_response()
3376 }
3377
3378 async fn start_outcome_meta_server(state: OutcomeMetaServerState) -> SocketAddr {
3379 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3380 let addr = listener.local_addr().unwrap();
3381 let router = Router::new()
3382 .route("/info", post(handle_outcome_meta_info))
3383 .with_state(state);
3384
3385 tokio::spawn(async move {
3386 axum::serve(listener, router).await.unwrap();
3387 });
3388
3389 addr
3390 }
3391
3392 #[rstest]
3393 fn stable_json_roundtrips() {
3394 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
3395 let s = serde_json::to_string(&v).unwrap();
3396 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
3398 assert_eq!(parsed["type"], "l2Book");
3399 assert_eq!(parsed["coin"], "BTC");
3400 assert_eq!(parsed, v);
3401 }
3402
3403 #[rstest]
3404 fn info_pretty_shape() {
3405 let r = InfoRequest::l2_book("BTC");
3406 let val = serde_json::to_value(&r).unwrap();
3407 let pretty = serde_json::to_string_pretty(&val).unwrap();
3408 assert!(pretty.contains("\"type\": \"l2Book\""));
3409 assert!(pretty.contains("\"coin\": \"BTC\""));
3410 }
3411
3412 #[rstest]
3413 fn test_client_order_id_cloid_cache_is_stable_and_first_write_wins() {
3414 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3415 let client_order_id = ClientOrderId::new("O-CLOID-CACHE");
3416 let other_client_order_id = ClientOrderId::new("O-CLOID-CACHE-OTHER");
3417 let explicit_cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
3418
3419 let first = client.get_or_generate_client_order_id_cloid(client_order_id);
3420 let second = client.get_or_generate_client_order_id_cloid(client_order_id);
3421 client.cache_client_order_id_cloid(client_order_id, explicit_cloid);
3422 client.cache_client_order_id_cloid(other_client_order_id, explicit_cloid);
3423
3424 assert_eq!(first, Cloid::from_client_order_id(client_order_id));
3425 assert_eq!(first, second);
3426 assert_eq!(
3427 client.cached_client_order_id_cloid(&client_order_id),
3428 Some(first),
3429 "cache insert must not overwrite an existing generated CLOID",
3430 );
3431 assert_eq!(
3432 client.cached_client_order_id_cloid(&other_client_order_id),
3433 Some(explicit_cloid),
3434 );
3435 assert_eq!(
3436 client.remove_client_order_id_cloid(&client_order_id),
3437 Some(first),
3438 );
3439 assert_eq!(client.cached_client_order_id_cloid(&client_order_id), None);
3440 }
3441
3442 #[rstest]
3443 #[tokio::test]
3444 async fn test_production_client_get_outcome_meta_uses_outcome_meta_request() {
3445 let state = OutcomeMetaServerState::default();
3446 let addr = start_outcome_meta_server(state.clone()).await;
3447 let mut client =
3448 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3449 client.set_base_info_url(format!("http://{addr}/info"));
3450
3451 let meta = client.get_outcome_meta().await.unwrap();
3452 let request_body = state.last_request_body.lock().await.clone().unwrap();
3453
3454 assert_eq!(request_body, json!({"type": "outcomeMeta"}));
3455 assert_eq!(meta.outcomes.len(), 1);
3456 assert_eq!(meta.outcomes[0].outcome, 123);
3457 assert_eq!(meta.outcomes[0].name, "Recurring");
3458 assert_eq!(meta.outcomes[0].side_specs.len(), 2);
3459 assert_eq!(meta.outcomes[0].side_specs[0].name, "Yes");
3460 assert_eq!(meta.outcomes[0].side_specs[1].name, "No");
3461 }
3462
3463 #[rstest]
3464 fn test_with_credentials_preserves_explicit_account_address() {
3465 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
3466 let client = HyperliquidHttpClient::with_credentials(
3467 Some(TEST_PRIVATE_KEY.to_string()),
3468 None,
3469 Some(account_address),
3470 HyperliquidEnvironment::Mainnet,
3471 60,
3472 None,
3473 )
3474 .unwrap();
3475
3476 assert_eq!(client.get_account_address().unwrap(), account_address);
3477 }
3478
3479 #[rstest]
3480 fn test_from_resolved_credentials_preserves_account_address_without_private_key() {
3481 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
3482 let client = HyperliquidHttpClient::from_resolved_credentials(
3483 None,
3484 None,
3485 Some(account_address.to_string()),
3486 HyperliquidEnvironment::Mainnet,
3487 60,
3488 None,
3489 )
3490 .unwrap();
3491
3492 assert_eq!(client.get_account_address().unwrap(), account_address);
3493 }
3494
3495 #[rstest]
3496 fn test_cache_instrument_by_raw_symbol() {
3497 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3498
3499 let base_code = "vntls:vCURSOR";
3501 let quote_code = "USDC";
3502
3503 {
3505 let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
3506 if !currency_map.contains_key(base_code) {
3507 currency_map.insert(
3508 base_code.to_string(),
3509 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
3510 );
3511 }
3512 }
3513
3514 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
3515 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
3516
3517 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
3519 let venue = *HYPERLIQUID_VENUE;
3520 let instrument_id = InstrumentId::new(symbol, venue);
3521
3522 let raw_symbol = Symbol::new(base_code);
3524
3525 let clock = get_atomic_clock_realtime();
3526 let ts = clock.get_time_ns();
3527
3528 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
3529 instrument_id,
3530 raw_symbol,
3531 base_currency,
3532 quote_currency,
3533 8,
3534 8,
3535 Price::from("0.00000001"),
3536 Quantity::from("0.00000001"),
3537 None,
3538 None,
3539 None,
3540 None,
3541 None,
3542 None,
3543 None,
3544 None,
3545 None,
3546 None,
3547 None,
3548 None, None, ts,
3551 ts,
3552 ));
3553
3554 client.cache_instrument(&instrument);
3556
3557 let instruments = client.instruments.load();
3559 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
3560 assert!(
3561 by_full_symbol.is_some(),
3562 "Instrument should be accessible by full symbol"
3563 );
3564 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
3565
3566 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
3568 assert!(
3569 by_raw_symbol.is_some(),
3570 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
3571 );
3572 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
3573 drop(instruments);
3574
3575 let instruments_by_coin = client.instruments_by_coin.load();
3577 let by_coin =
3578 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
3579 assert!(
3580 by_coin.is_some(),
3581 "Instrument should be accessible by coin and product type"
3582 );
3583 assert_eq!(by_coin.unwrap().id(), instrument.id());
3584 drop(instruments_by_coin);
3585
3586 let retrieved_with_type = client.get_or_create_instrument(
3588 &Ustr::from("vntls:vCURSOR"),
3589 Some(HyperliquidProductType::Spot),
3590 );
3591 assert!(retrieved_with_type.is_some());
3592 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
3593
3594 let retrieved_without_type =
3596 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
3597 assert!(retrieved_without_type.is_some());
3598 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
3599 }
3600
3601 #[rstest]
3602 fn test_get_or_create_instrument_outcome_fallback_no_product_type() {
3603 use nautilus_core::time::get_atomic_clock_realtime;
3609 use nautilus_model::{
3610 enums::AssetClass,
3611 identifiers::{InstrumentId, Symbol},
3612 instruments::{BinaryOption, InstrumentAny},
3613 types::{Currency, Price, Quantity},
3614 };
3615
3616 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3617 let coin = "#500";
3618 let token = "+500";
3619
3620 let usdh = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
3621 let symbol = Symbol::new(token);
3622 let raw_symbol = Symbol::new(coin);
3623 let venue = *HYPERLIQUID_VENUE;
3624 let instrument_id = InstrumentId::new(symbol, venue);
3625
3626 let clock = get_atomic_clock_realtime();
3627 let ts = clock.get_time_ns();
3628
3629 let binary = InstrumentAny::BinaryOption(BinaryOption::new(
3630 instrument_id,
3631 raw_symbol,
3632 AssetClass::Alternative,
3633 usdh,
3634 Default::default(),
3635 Default::default(),
3636 4,
3637 2,
3638 Price::from("0.0001"),
3639 Quantity::from("0.01"),
3640 None,
3641 None,
3642 None,
3643 None,
3644 None,
3645 None,
3646 None,
3647 None,
3648 None,
3649 None,
3650 None,
3651 None,
3652 None,
3653 ts,
3654 ts,
3655 ));
3656
3657 client.cache_instrument(&binary);
3658
3659 let with_type = client
3660 .get_or_create_instrument(&Ustr::from(coin), Some(HyperliquidProductType::Outcome));
3661 assert!(with_type.is_some());
3662 assert_eq!(with_type.unwrap().id(), instrument_id);
3663
3664 let no_type = client.get_or_create_instrument(&Ustr::from(coin), None);
3665 assert!(
3666 no_type.is_some(),
3667 "Outcome coin must resolve through the no-product fallback",
3668 );
3669 assert_eq!(no_type.unwrap().id(), instrument_id);
3670
3671 let missing = client.get_or_create_instrument(&Ustr::from("#9999"), None);
3672 assert!(missing.is_none());
3673 }
3674
3675 #[rstest]
3676 fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
3677 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3682
3683 let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
3684 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3685 let clock = get_atomic_clock_realtime();
3686 let ts = clock.get_time_ns();
3687
3688 let canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
3689 InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
3690 Symbol::new("@107"),
3691 hype,
3692 usdc,
3693 5,
3694 2,
3695 Price::from("0.00001"),
3696 Quantity::from("0.01"),
3697 None,
3698 None,
3699 None,
3700 None,
3701 None,
3702 None,
3703 None,
3704 None,
3705 None,
3706 None,
3707 None,
3708 None,
3709 None,
3710 ts,
3711 ts,
3712 ));
3713
3714 let non_canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
3715 InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
3716 Symbol::new("@999"),
3717 hype,
3718 usdc,
3719 5,
3720 2,
3721 Price::from("0.00001"),
3722 Quantity::from("0.01"),
3723 None,
3724 None,
3725 None,
3726 None,
3727 None,
3728 None,
3729 None,
3730 None,
3731 None,
3732 None,
3733 None,
3734 None,
3735 None,
3736 ts,
3737 ts,
3738 ));
3739
3740 client.cache_instrument(&canonical);
3741 client.cache_instrument(&non_canonical);
3742
3743 let instruments_by_coin = client.instruments_by_coin.load();
3744 let by_base = instruments_by_coin
3745 .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
3746 .expect("base alias must resolve");
3747 assert_eq!(
3748 by_base.raw_symbol().inner().as_str(),
3749 "@107",
3750 "base alias must point to the canonical pair, not the one cached later",
3751 );
3752 }
3753
3754 #[rstest]
3755 fn test_cache_instrument_perp_aliases_sanitized_base() {
3756 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3762
3763 let base_currency = Currency::new(
3764 "dex:STREAMABCD****",
3765 8,
3766 0,
3767 "dex:STREAMABCD****",
3768 CurrencyType::Crypto,
3769 );
3770 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
3771 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3772 let clock = get_atomic_clock_realtime();
3773 let ts = clock.get_time_ns();
3774
3775 let hip3 = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
3776 InstrumentId::new(
3777 Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
3778 *HYPERLIQUID_VENUE,
3779 ),
3780 Symbol::new("dex:STREAMABCD****"),
3781 base_currency,
3782 usd,
3783 usdc,
3784 false,
3785 6,
3786 3,
3787 Price::from("0.000001"),
3788 Quantity::from("0.001"),
3789 None,
3790 None,
3791 None,
3792 None,
3793 None,
3794 None,
3795 None,
3796 None,
3797 None,
3798 None,
3799 None,
3800 None,
3801 None,
3802 ts,
3803 ts,
3804 ));
3805
3806 client.cache_instrument(&hip3);
3807
3808 let instruments_by_coin = client.instruments_by_coin.load();
3809 let by_raw = instruments_by_coin
3810 .get(&(
3811 Ustr::from("dex:STREAMABCD****"),
3812 HyperliquidProductType::Perp,
3813 ))
3814 .expect("venue coin lookup must resolve");
3815 assert_eq!(by_raw.id(), hip3.id());
3816
3817 let by_sanitized = instruments_by_coin
3818 .get(&(
3819 Ustr::from("dex:STREAMABCDxxxx"),
3820 HyperliquidProductType::Perp,
3821 ))
3822 .expect("sanitized base lookup must resolve");
3823 assert_eq!(by_sanitized.id(), hip3.id());
3824 drop(instruments_by_coin);
3825
3826 let resolved = client
3828 .get_or_create_instrument(
3829 &Ustr::from("dex:STREAMABCDxxxx"),
3830 Some(HyperliquidProductType::Perp),
3831 )
3832 .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
3833 assert_eq!(resolved.id(), hip3.id());
3834 }
3835}