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_common::cache::InstrumentLookupError;
33use nautilus_core::{
34 AtomicMap, MUTEX_POISONED, UUID4, UnixNanos,
35 consts::NAUTILUS_USER_AGENT,
36 time::{AtomicTime, get_atomic_clock_realtime},
37};
38use nautilus_model::{
39 data::{Bar, BarType},
40 enums::{
41 AccountType, BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce,
42 TriggerType,
43 },
44 events::AccountState,
45 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
46 instruments::{CurrencyPair, Instrument, InstrumentAny},
47 orders::{Order, OrderAny},
48 reports::{FillReport, OrderStatusReport, PositionStatusReport},
49 types::{AccountBalance, Currency, Price, Quantity},
50};
51use nautilus_network::{
52 http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
53 ratelimiter::quota::Quota,
54};
55use rust_decimal::Decimal;
56use serde_json::Value;
57use ustr::Ustr;
58
59use crate::{
60 account::resolve_execution_account_address,
61 common::{
62 consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url},
63 credential::{Secrets, VaultAddress, credential_env_vars},
64 enums::{
65 HyperliquidBarInterval, HyperliquidEnvironment,
66 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
67 },
68 parse::{
69 bar_type_to_interval, cache_alias_for_symbol, clamp_price_to_precision,
70 derive_limit_from_trigger, determine_order_list_grouping, extract_inner_error,
71 normalize_price, order_to_hyperliquid_request_with_asset_and_cloid,
72 parse_combined_account_balances_and_margins, parse_spot_account_balances,
73 round_to_sig_figs, time_in_force_to_hyperliquid_tif,
74 },
75 },
76 data::candle_to_bar,
77 http::{
78 error::{Error, Result},
79 models::{
80 ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeRequest,
81 HyperliquidExchangeResponse, HyperliquidExecAction, HyperliquidExecBuilderFee,
82 HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
83 HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecMergeOutcomeParams,
84 HyperliquidExecMergeQuestionParams, HyperliquidExecModifyOrderRequest,
85 HyperliquidExecNegateOutcomeParams, HyperliquidExecOrderKind,
86 HyperliquidExecOrderResponseData, HyperliquidExecOrderStatus,
87 HyperliquidExecPlaceOrderRequest, HyperliquidExecSplitOutcomeParams,
88 HyperliquidExecTif, HyperliquidExecTpSl, HyperliquidExecTriggerParams,
89 HyperliquidExecUserOutcomeOp, HyperliquidFills, HyperliquidFundingHistoryEntry,
90 HyperliquidL2Book, HyperliquidMeta, HyperliquidOrderStatus, HyperliquidRecentTrade,
91 OutcomeMeta, PerpDex, PerpMeta, PerpMetaAndCtxs, RESPONSE_STATUS_OK,
92 SpotClearinghouseState, SpotMeta, SpotMetaAndCtxs,
93 },
94 parse::{
95 HyperliquidInstrumentDef, instruments_from_defs_owned, parse_fill_report,
96 parse_order_status_report_from_basic, parse_outcome_instruments,
97 parse_perp_instruments_with_settlement, parse_position_status_report,
98 parse_spot_instruments, parse_spot_position_status_report,
99 resolve_perp_settlement_currency,
100 },
101 query::{ExchangeAction, InfoRequest},
102 rate_limits::{
103 RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
104 exec_action_weight, info_base_weight, info_extra_weight,
105 },
106 },
107 signing::{
108 HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
109 },
110 websocket::messages::WsBasicOrderData,
111};
112
113pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
115 LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
116
117#[derive(Debug, Clone)]
122#[cfg_attr(
123 feature = "python",
124 pyo3::pyclass(
125 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
126 from_py_object
127 )
128)]
129#[cfg_attr(
130 feature = "python",
131 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
132)]
133pub struct HyperliquidRawHttpClient {
134 client: HttpClient,
135 environment: HyperliquidEnvironment,
136 base_info: String,
137 base_exchange: String,
138 signer: Option<HyperliquidEip712Signer>,
139 nonce_manager: Option<Arc<NonceManager>>,
140 vault_address: Option<VaultAddress>,
141 rest_limiter: Arc<WeightedLimiter>,
142 rate_limit_backoff_base: Duration,
143 rate_limit_backoff_cap: Duration,
144 rate_limit_max_attempts_info: u32,
145}
146
147impl HyperliquidRawHttpClient {
148 pub fn new(
154 environment: HyperliquidEnvironment,
155 timeout_secs: u64,
156 proxy_url: Option<String>,
157 ) -> std::result::Result<Self, HttpClientError> {
158 Ok(Self {
159 client: HttpClient::new(
160 Self::default_headers(),
161 vec![],
162 vec![],
163 Some(*HYPERLIQUID_REST_QUOTA),
164 Some(timeout_secs),
165 proxy_url,
166 )?,
167 environment,
168 base_info: info_url(environment).to_string(),
169 base_exchange: exchange_url(environment).to_string(),
170 signer: None,
171 nonce_manager: None,
172 vault_address: None,
173 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
174 rate_limit_backoff_base: Duration::from_millis(125),
175 rate_limit_backoff_cap: Duration::from_secs(5),
176 rate_limit_max_attempts_info: 3,
177 })
178 }
179
180 pub fn with_credentials(
187 secrets: &Secrets,
188 timeout_secs: u64,
189 proxy_url: Option<String>,
190 ) -> std::result::Result<Self, HttpClientError> {
191 let signer = HyperliquidEip712Signer::new(&secrets.private_key)
192 .map_err(|e| HttpClientError::from(e.to_string()))?;
193 let nonce_manager = Arc::new(NonceManager::new());
194
195 Ok(Self {
196 client: HttpClient::new(
197 Self::default_headers(),
198 vec![],
199 vec![],
200 Some(*HYPERLIQUID_REST_QUOTA),
201 Some(timeout_secs),
202 proxy_url,
203 )?,
204 environment: secrets.environment,
205 base_info: info_url(secrets.environment).to_string(),
206 base_exchange: exchange_url(secrets.environment).to_string(),
207 signer: Some(signer),
208 nonce_manager: Some(nonce_manager),
209 vault_address: secrets.vault_address,
210 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
211 rate_limit_backoff_base: Duration::from_millis(125),
212 rate_limit_backoff_cap: Duration::from_secs(5),
213 rate_limit_max_attempts_info: 3,
214 })
215 }
216
217 pub fn set_base_info_url(&mut self, url: String) {
219 self.base_info = url;
220 }
221
222 pub fn set_base_exchange_url(&mut self, url: String) {
224 self.base_exchange = url;
225 }
226
227 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
233 let secrets = Secrets::from_env(environment)
234 .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
235 Self::with_credentials(&secrets, 60, None)
236 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
237 }
238
239 pub fn from_credentials(
245 private_key: &str,
246 vault_address: Option<&str>,
247 environment: HyperliquidEnvironment,
248 timeout_secs: u64,
249 proxy_url: Option<String>,
250 ) -> Result<Self> {
251 let secrets = Secrets::from_private_key(private_key, vault_address, environment)
252 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
253 Self::with_credentials(&secrets, timeout_secs, proxy_url)
254 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
255 }
256
257 #[must_use]
259 pub fn with_rate_limits(mut self) -> Self {
260 self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
261 self.rate_limit_backoff_base = Duration::from_millis(125);
262 self.rate_limit_backoff_cap = Duration::from_secs(5);
263 self.rate_limit_max_attempts_info = 3;
264 self
265 }
266
267 #[must_use]
269 pub fn environment(&self) -> HyperliquidEnvironment {
270 self.environment
271 }
272
273 #[must_use]
275 pub fn is_testnet(&self) -> bool {
276 self.environment == HyperliquidEnvironment::Testnet
277 }
278
279 pub fn get_user_address(&self) -> Result<String> {
285 self.signer
286 .as_ref()
287 .ok_or_else(|| Error::auth("No signer configured"))?
288 .address()
289 }
290
291 #[must_use]
293 pub fn has_vault_address(&self) -> bool {
294 self.vault_address.is_some()
295 }
296
297 pub fn get_account_address(&self) -> Result<String> {
304 if let Some(vault) = &self.vault_address {
305 Ok(vault.to_hex())
306 } else {
307 self.get_user_address()
308 }
309 }
310
311 fn default_headers() -> HashMap<String, String> {
312 HashMap::from([
313 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
314 ("Content-Type".to_string(), "application/json".to_string()),
315 ])
316 }
317
318 fn signer_id(&self) -> SignerId {
319 SignerId("hyperliquid:default".into())
320 }
321
322 fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
323 let retry_after = headers.get("retry-after")?;
324 retry_after.parse::<u64>().ok().map(|s| s * 1000) }
326
327 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
329 let request = InfoRequest::meta();
330 let response = self.send_info_request(&request).await?;
331 serde_json::from_value(response).map_err(Error::Serde)
332 }
333
334 pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
336 let request = InfoRequest::spot_meta();
337 let response = self.send_info_request(&request).await?;
338 serde_json::from_value(response).map_err(Error::Serde)
339 }
340
341 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
343 let request = InfoRequest::meta_and_asset_ctxs();
344 let response = self.send_info_request(&request).await?;
345 serde_json::from_value(response).map_err(Error::Serde)
346 }
347
348 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
350 let request = InfoRequest::spot_meta_and_asset_ctxs();
351 let response = self.send_info_request(&request).await?;
352 serde_json::from_value(response).map_err(Error::Serde)
353 }
354
355 pub async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
357 let request = InfoRequest::outcome_meta();
358 let response = self.send_info_request(&request).await?;
359 serde_json::from_value(response).map_err(Error::Serde)
360 }
361
362 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
363 let request = InfoRequest::meta();
364 let response = self.send_info_request(&request).await?;
365 serde_json::from_value(response).map_err(Error::Serde)
366 }
367
368 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
370 let request = InfoRequest::all_perp_metas();
371 let response = self.send_info_request(&request).await?;
372 serde_json::from_value(response).map_err(Error::Serde)
373 }
374
375 pub(crate) async fn load_perp_dexs(&self) -> Result<Vec<Option<PerpDex>>> {
377 let request = InfoRequest::perp_dexs();
378 let response = self.send_info_request(&request).await?;
379 serde_json::from_value(response).map_err(Error::Serde)
380 }
381
382 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
384 let request = InfoRequest::l2_book(coin);
385 let response = self.send_info_request(&request).await?;
386 serde_json::from_value(response).map_err(Error::Serde)
387 }
388
389 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
394 let request = InfoRequest::recent_trades(coin);
395 let response = self.send_info_request(&request).await?;
396 serde_json::from_value(response).map_err(Error::Serde)
397 }
398
399 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
401 let request = InfoRequest::user_fills(user);
402 let response = self.send_info_request(&request).await?;
403 serde_json::from_value(response).map_err(Error::Serde)
404 }
405
406 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
408 let request = InfoRequest::order_status(user, oid);
409 let response = self.send_info_request(&request).await?;
410 serde_json::from_value(response).map_err(Error::Serde)
411 }
412
413 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
415 let request = InfoRequest::open_orders(user);
416 self.send_info_request(&request).await
417 }
418
419 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
421 let request = InfoRequest::frontend_open_orders(user);
422 self.send_info_request(&request).await
423 }
424
425 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
427 let request = InfoRequest::clearinghouse_state(user);
428 self.send_info_request(&request).await
429 }
430
431 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
433 let request = InfoRequest::spot_clearinghouse_state(user);
434 self.send_info_request(&request).await
435 }
436
437 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
439 let request = InfoRequest::user_fees(user);
440 self.send_info_request(&request).await
441 }
442
443 pub async fn info_candle_snapshot(
445 &self,
446 coin: &str,
447 interval: HyperliquidBarInterval,
448 start_time: u64,
449 end_time: u64,
450 ) -> Result<HyperliquidCandleSnapshot> {
451 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
452 let response = self.send_info_request(&request).await?;
453
454 log::trace!(
455 "Candle snapshot raw response (len={}): {:?}",
456 response.as_array().map_or(0, |a| a.len()),
457 response
458 );
459
460 serde_json::from_value(response).map_err(Error::Serde)
461 }
462
463 pub async fn info_funding_history(
468 &self,
469 coin: &str,
470 start_time: u64,
471 end_time: Option<u64>,
472 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
473 let request = InfoRequest::funding_history(coin, start_time, end_time);
474 let response = self.send_info_request(&request).await?;
475 serde_json::from_value(response).map_err(Error::Serde)
476 }
477
478 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
480 self.send_info_request(request).await
481 }
482
483 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
484 let base_w = info_base_weight(request);
485 self.rest_limiter.acquire(base_w).await;
486
487 let mut attempt = 0u32;
488
489 loop {
490 let response = self.http_roundtrip_info(request).await?;
491
492 if response.status.is_success() {
493 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
495 let extra = info_extra_weight(request, &val);
496 if extra > 0 {
497 self.rest_limiter.debit_extra(extra).await;
498 log::debug!(
499 "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
500 );
501 }
502 return Ok(val);
503 }
504
505 if response.status.as_u16() == 429 {
507 if attempt >= self.rate_limit_max_attempts_info {
508 let ra = self.parse_retry_after_simple(&response.headers);
509 return Err(Error::rate_limit("info", base_w, ra));
510 }
511 let delay = self
512 .parse_retry_after_simple(&response.headers)
513 .map_or_else(
514 || {
515 backoff_full_jitter(
516 attempt,
517 self.rate_limit_backoff_base,
518 self.rate_limit_backoff_cap,
519 )
520 },
521 Duration::from_millis,
522 );
523 log::warn!(
524 "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
525 delay.as_millis()
526 );
527 attempt += 1;
528 tokio::time::sleep(delay).await;
529 self.rest_limiter.acquire(1).await;
531 continue;
532 }
533
534 if (response.status.is_server_error() || response.status.as_u16() == 408)
536 && attempt < self.rate_limit_max_attempts_info
537 {
538 let delay = backoff_full_jitter(
539 attempt,
540 self.rate_limit_backoff_base,
541 self.rate_limit_backoff_cap,
542 );
543 log::warn!(
544 "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
545 response.status.as_u16(),
546 delay.as_millis()
547 );
548 attempt += 1;
549 tokio::time::sleep(delay).await;
550 continue;
551 }
552
553 let error_body = String::from_utf8_lossy(&response.body);
555 return Err(Error::http(
556 response.status.as_u16(),
557 error_body.to_string(),
558 ));
559 }
560 }
561
562 async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
563 let url = &self.base_info;
564 let body = serde_json::to_value(request).map_err(Error::Serde)?;
565 let body_bytes = serde_json::to_string(&body)
566 .map_err(Error::Serde)?
567 .into_bytes();
568
569 self.client
570 .request(
571 Method::POST,
572 url.clone(),
573 None,
574 None,
575 Some(body_bytes),
576 None,
577 None,
578 )
579 .await
580 .map_err(Error::from_http_client)
581 }
582
583 pub async fn post_action(
585 &self,
586 action: &ExchangeAction,
587 ) -> Result<HyperliquidExchangeResponse> {
588 let w = exchange_weight(action);
589 self.rest_limiter.acquire(w).await;
590
591 let signer = self
592 .signer
593 .as_ref()
594 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
595
596 let nonce_manager = self
597 .nonce_manager
598 .as_ref()
599 .ok_or_else(|| Error::auth("nonce manager missing"))?;
600
601 let signer_id = self.signer_id();
602 let time_nonce = nonce_manager.next(signer_id)?;
603
604 let action_bytes = rmp_serde::to_vec_named(action)
606 .context("serialize action with MessagePack")
607 .map_err(|e| Error::bad_request(e.to_string()))?;
608
609 let sign_request = SignRequest {
610 action: None,
611 action_bytes: Some(action_bytes),
612 time_nonce,
613 action_type: HyperliquidActionType::L1,
614 is_testnet: self.is_testnet(),
615 vault_address: self.vault_address,
616 expires_after: None,
617 };
618
619 let sig = signer.sign(&sign_request)?.signature;
620
621 let nonce_u64 = time_nonce.as_millis() as u64;
622
623 let request = if let Some(vault) = self.vault_address {
624 HyperliquidExchangeRequest::with_vault(
625 action.clone(),
626 nonce_u64,
627 sig,
628 vault.to_string(),
629 )
630 } else {
631 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
632 };
633
634 let response = self.http_roundtrip_exchange(&request).await?;
635
636 if response.status.is_success() {
637 let parsed_response: HyperliquidExchangeResponse =
638 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
639
640 match &parsed_response {
642 HyperliquidExchangeResponse::Status {
643 status,
644 response: response_data,
645 } if status == "err" => {
646 let error_msg = response_data
647 .as_str()
648 .map_or_else(|| response_data.to_string(), |s| s.to_string());
649 log::error!("Hyperliquid API returned error: {error_msg}");
650 Err(Error::bad_request(format!("API error: {error_msg}")))
651 }
652 HyperliquidExchangeResponse::Error { error } => {
653 log::error!("Hyperliquid API returned error: {error}");
654 Err(Error::bad_request(format!("API error: {error}")))
655 }
656 _ => Ok(parsed_response),
657 }
658 } else if response.status.as_u16() == 429 {
659 let ra = self.parse_retry_after_simple(&response.headers);
660 Err(Error::rate_limit("exchange", w, ra))
661 } else {
662 let error_body = String::from_utf8_lossy(&response.body);
663 log::error!(
664 "Exchange API error (status {}): {}",
665 response.status.as_u16(),
666 error_body
667 );
668 Err(Error::http(
669 response.status.as_u16(),
670 error_body.to_string(),
671 ))
672 }
673 }
674
675 pub fn sign_action_exec_request(
677 &self,
678 action: &HyperliquidExecAction,
679 expires_after: Option<u64>,
680 ) -> Result<HyperliquidExchangeRequest<HyperliquidExecAction>> {
681 let signer = self
682 .signer
683 .as_ref()
684 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
685
686 let nonce_manager = self
687 .nonce_manager
688 .as_ref()
689 .ok_or_else(|| Error::auth("nonce manager missing"))?;
690
691 let signer_id = self.signer_id();
692 let time_nonce = nonce_manager.next(signer_id)?;
693 let action_bytes = rmp_serde::to_vec_named(action)
697 .context("serialize action with MessagePack")
698 .map_err(|e| Error::bad_request(e.to_string()))?;
699
700 let sig = signer
701 .sign(&SignRequest {
702 action: None,
703 action_bytes: Some(action_bytes),
704 time_nonce,
705 action_type: HyperliquidActionType::L1,
706 is_testnet: self.is_testnet(),
707 vault_address: self.vault_address,
708 expires_after,
709 })?
710 .signature;
711
712 let mut request = if let Some(vault) = self.vault_address {
713 HyperliquidExchangeRequest::with_vault(
714 action.clone(),
715 time_nonce.as_millis() as u64,
716 sig,
717 vault.to_string(),
718 )
719 } else {
720 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
721 };
722 request.expires_after = expires_after;
723 Ok(request)
724 }
725
726 pub async fn post_action_exec(
731 &self,
732 action: &HyperliquidExecAction,
733 ) -> Result<HyperliquidExchangeResponse> {
734 let w = exec_action_weight(action);
735 self.rest_limiter.acquire(w).await;
736
737 let request = self.sign_action_exec_request(action, None)?;
738
739 let response = self.http_roundtrip_exchange(&request).await?;
740
741 if response.status.is_success() {
742 let parsed_response: HyperliquidExchangeResponse =
743 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
744
745 match &parsed_response {
747 HyperliquidExchangeResponse::Status {
748 status,
749 response: response_data,
750 } if status == "err" => {
751 let error_msg = response_data
752 .as_str()
753 .map_or_else(|| response_data.to_string(), |s| s.to_string());
754 log::error!("Hyperliquid API returned error: {error_msg}");
755 Err(Error::bad_request(format!("API error: {error_msg}")))
756 }
757 HyperliquidExchangeResponse::Error { error } => {
758 log::error!("Hyperliquid API returned error: {error}");
759 Err(Error::bad_request(format!("API error: {error}")))
760 }
761 _ => Ok(parsed_response),
762 }
763 } else if response.status.as_u16() == 429 {
764 let ra = self.parse_retry_after_simple(&response.headers);
765 Err(Error::rate_limit("exchange", w, ra))
766 } else {
767 let error_body = String::from_utf8_lossy(&response.body);
768 Err(Error::http(
769 response.status.as_u16(),
770 error_body.to_string(),
771 ))
772 }
773 }
774
775 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
778 self.rest_limiter.snapshot().await
779 }
780 async fn http_roundtrip_exchange<T>(
781 &self,
782 request: &HyperliquidExchangeRequest<T>,
783 ) -> Result<HttpResponse>
784 where
785 T: serde::Serialize,
786 {
787 let url = &self.base_exchange;
788 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
789 let body_bytes = body.into_bytes();
790
791 let response = self
792 .client
793 .request(
794 Method::POST,
795 url.clone(),
796 None,
797 None,
798 Some(body_bytes),
799 None,
800 None,
801 )
802 .await
803 .map_err(Error::from_http_client)?;
804
805 Ok(response)
806 }
807}
808
809#[derive(Debug, Clone)]
815#[cfg_attr(
816 feature = "python",
817 pyo3::pyclass(
818 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
819 from_py_object
820 )
821)]
822#[cfg_attr(
823 feature = "python",
824 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
825)]
826pub struct HyperliquidHttpClient {
827 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
828 clock: &'static AtomicTime,
829 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
830 instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
831 asset_indices: Arc<AtomicMap<Ustr, u32>>,
833 spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
835 client_order_id_cloids: Arc<Mutex<AHashMap<ClientOrderId, Cloid>>>,
836 account_id: Option<AccountId>,
837 account_address: Option<String>,
841 normalize_prices: bool,
842 market_order_slippage_bps: u32,
843 include_builder_attribution: bool,
844}
845
846impl Default for HyperliquidHttpClient {
847 fn default() -> Self {
848 Self::new(HyperliquidEnvironment::Mainnet, 60, None)
849 .expect("Failed to create default Hyperliquid HTTP client")
850 }
851}
852
853impl HyperliquidHttpClient {
854 pub fn new(
860 environment: HyperliquidEnvironment,
861 timeout_secs: u64,
862 proxy_url: Option<String>,
863 ) -> std::result::Result<Self, HttpClientError> {
864 let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
865 Ok(Self::from_raw(raw_client))
866 }
867
868 pub fn with_secrets(
874 secrets: &Secrets,
875 timeout_secs: u64,
876 proxy_url: Option<String>,
877 ) -> std::result::Result<Self, HttpClientError> {
878 let raw_client =
879 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
880 Ok(Self::from_raw(raw_client))
881 }
882
883 fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
884 Self {
885 inner: Arc::new(raw_client),
886 clock: get_atomic_clock_realtime(),
887 instruments: Arc::new(AtomicMap::new()),
888 instruments_by_coin: Arc::new(AtomicMap::new()),
889 asset_indices: Arc::new(AtomicMap::new()),
890 spot_fill_coins: Arc::new(AtomicMap::new()),
891 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
892 account_id: None,
893 account_address: None,
894 normalize_prices: true,
895 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
896 include_builder_attribution: true,
897 }
898 }
899
900 #[allow(
902 clippy::missing_panics_doc,
903 reason = "cloid cache mutex poisoning is not expected"
904 )]
905 #[must_use]
906 pub fn get_or_generate_client_order_id_cloid(&self, client_order_id: ClientOrderId) -> Cloid {
907 let mut cloids = self.client_order_id_cloids.lock().expect(MUTEX_POISONED);
908 *cloids
909 .entry(client_order_id)
910 .or_insert_with(|| Cloid::from_client_order_id(client_order_id))
911 }
912
913 #[allow(
915 clippy::missing_panics_doc,
916 reason = "cloid cache mutex poisoning is not expected"
917 )]
918 pub fn cache_client_order_id_cloid(&self, client_order_id: ClientOrderId, cloid: Cloid) {
919 self.client_order_id_cloids
920 .lock()
921 .expect(MUTEX_POISONED)
922 .entry(client_order_id)
923 .or_insert(cloid);
924 }
925
926 #[allow(
928 clippy::missing_panics_doc,
929 reason = "cloid cache mutex poisoning is not expected"
930 )]
931 #[must_use]
932 pub fn cached_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
933 self.client_order_id_cloids
934 .lock()
935 .expect(MUTEX_POISONED)
936 .get(client_order_id)
937 .copied()
938 }
939
940 #[allow(
942 clippy::missing_panics_doc,
943 reason = "cloid cache mutex poisoning is not expected"
944 )]
945 pub fn remove_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
946 self.client_order_id_cloids
947 .lock()
948 .expect(MUTEX_POISONED)
949 .remove(client_order_id)
950 }
951
952 pub fn set_base_info_url(&mut self, url: String) {
958 Arc::get_mut(&mut self.inner)
959 .expect("cannot override URL: Arc has multiple references")
960 .set_base_info_url(url);
961 }
962
963 pub fn set_base_exchange_url(&mut self, url: String) {
969 Arc::get_mut(&mut self.inner)
970 .expect("cannot override URL: Arc has multiple references")
971 .set_base_exchange_url(url);
972 }
973
974 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
980 let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
981 Ok(Self {
982 inner: Arc::new(raw_client),
983 clock: get_atomic_clock_realtime(),
984 instruments: Arc::new(AtomicMap::new()),
985 instruments_by_coin: Arc::new(AtomicMap::new()),
986 asset_indices: Arc::new(AtomicMap::new()),
987 spot_fill_coins: Arc::new(AtomicMap::new()),
988 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
989 account_id: None,
990 account_address: None,
991 normalize_prices: true,
992 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
993 include_builder_attribution: true,
994 })
995 }
996
997 pub fn with_credentials(
1010 private_key: Option<String>,
1011 vault_address: Option<String>,
1012 account_address: Option<&str>,
1013 environment: HyperliquidEnvironment,
1014 timeout_secs: u64,
1015 proxy_url: Option<String>,
1016 ) -> Result<Self> {
1017 let (pk_env_var, vault_env_var) = credential_env_vars(environment);
1018
1019 let resolved_account_address = resolve_execution_account_address(
1020 private_key.as_deref(),
1021 vault_address.as_deref(),
1022 account_address,
1023 environment,
1024 )?;
1025
1026 let resolved_pk = private_key.or_else(|| std::env::var(pk_env_var).ok());
1028
1029 let resolved_vault = vault_address.or_else(|| std::env::var(vault_env_var).ok());
1031
1032 Self::from_resolved_credentials(
1033 resolved_pk,
1034 resolved_vault.as_deref(),
1035 resolved_account_address,
1036 environment,
1037 timeout_secs,
1038 proxy_url,
1039 )
1040 }
1041
1042 fn from_resolved_credentials(
1043 private_key: Option<String>,
1044 vault_address: Option<&str>,
1045 account_address: Option<String>,
1046 environment: HyperliquidEnvironment,
1047 timeout_secs: u64,
1048 proxy_url: Option<String>,
1049 ) -> Result<Self> {
1050 match private_key {
1051 Some(pk) => {
1052 let raw_client = HyperliquidRawHttpClient::from_credentials(
1053 &pk,
1054 vault_address,
1055 environment,
1056 timeout_secs,
1057 proxy_url,
1058 )?;
1059 Ok(Self {
1060 inner: Arc::new(raw_client),
1061 clock: get_atomic_clock_realtime(),
1062 instruments: Arc::new(AtomicMap::new()),
1063 instruments_by_coin: Arc::new(AtomicMap::new()),
1064 asset_indices: Arc::new(AtomicMap::new()),
1065 spot_fill_coins: Arc::new(AtomicMap::new()),
1066 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1067 account_id: None,
1068 account_address,
1069 normalize_prices: true,
1070 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1071 include_builder_attribution: true,
1072 })
1073 }
1074 None => {
1075 let mut client = Self::new(environment, timeout_secs, proxy_url)
1077 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))?;
1078 client.set_account_address(account_address);
1079 Ok(client)
1080 }
1081 }
1082 }
1083
1084 pub fn from_credentials(
1090 private_key: &str,
1091 vault_address: Option<&str>,
1092 environment: HyperliquidEnvironment,
1093 timeout_secs: u64,
1094 proxy_url: Option<String>,
1095 ) -> Result<Self> {
1096 let raw_client = HyperliquidRawHttpClient::from_credentials(
1097 private_key,
1098 vault_address,
1099 environment,
1100 timeout_secs,
1101 proxy_url,
1102 )?;
1103 Ok(Self {
1104 inner: Arc::new(raw_client),
1105 clock: get_atomic_clock_realtime(),
1106 instruments: Arc::new(AtomicMap::new()),
1107 instruments_by_coin: Arc::new(AtomicMap::new()),
1108 asset_indices: Arc::new(AtomicMap::new()),
1109 spot_fill_coins: Arc::new(AtomicMap::new()),
1110 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1111 account_id: None,
1112 account_address: None,
1113 normalize_prices: true,
1114 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1115 include_builder_attribution: true,
1116 })
1117 }
1118
1119 #[must_use]
1121 pub fn is_testnet(&self) -> bool {
1122 self.inner.is_testnet()
1123 }
1124
1125 #[must_use]
1127 pub fn normalize_prices(&self) -> bool {
1128 self.normalize_prices
1129 }
1130
1131 pub fn set_normalize_prices(&mut self, value: bool) {
1133 self.normalize_prices = value;
1134 }
1135
1136 #[must_use]
1138 pub fn market_order_slippage_bps(&self) -> u32 {
1139 self.market_order_slippage_bps
1140 }
1141
1142 pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1144 self.market_order_slippage_bps = value;
1145 }
1146
1147 #[must_use]
1149 pub fn include_builder_attribution(&self) -> bool {
1150 self.include_builder_attribution
1151 }
1152
1153 pub fn set_include_builder_attribution(&mut self, value: bool) {
1155 self.include_builder_attribution = value;
1156 }
1157
1158 pub fn get_user_address(&self) -> Result<String> {
1164 self.inner.get_user_address()
1165 }
1166
1167 #[must_use]
1169 pub fn has_vault_address(&self) -> bool {
1170 self.inner.has_vault_address()
1171 }
1172
1173 #[must_use]
1178 pub fn builder_attribution(&self) -> Option<HyperliquidExecBuilderFee> {
1179 if !self.include_builder_attribution || self.has_vault_address() || self.is_testnet() {
1180 None
1181 } else {
1182 Some(HyperliquidExecBuilderFee {
1183 address: NAUTILUS_BUILDER_ADDRESS.to_string(),
1184 fee_tenths_bp: 0,
1185 })
1186 }
1187 }
1188
1189 pub fn get_account_address(&self) -> Result<String> {
1197 if let Some(addr) = &self.account_address {
1198 return Ok(addr.clone());
1199 }
1200 self.inner.get_account_address()
1201 }
1202
1203 pub fn set_account_address(&mut self, address: Option<String>) {
1205 self.account_address = address;
1206 }
1207
1208 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1213 let full_symbol = instrument.symbol().inner();
1214 let coin = instrument.raw_symbol().inner();
1215
1216 self.instruments.rcu(|m| {
1217 m.insert(full_symbol, instrument.clone());
1218 m.insert(coin, instrument.clone());
1220 });
1221
1222 if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
1224 self.instruments_by_coin.rcu(|m| {
1225 m.insert((coin, product_type), instrument.clone());
1226
1227 if let Some(alias_ustr) = cache_alias_for_symbol(full_symbol.as_str())
1252 .map(|alias| Ustr::from(alias.as_str()))
1253 {
1254 let key = (alias_ustr, product_type);
1255 if alias_ustr != coin && !m.contains_key(&key) {
1256 m.insert(key, instrument.clone());
1257 }
1258 }
1259 });
1260 } else {
1261 log::warn!("Unable to determine product type for symbol: {full_symbol}");
1262 }
1263 }
1264
1265 fn get_or_create_instrument(
1266 &self,
1267 coin: &Ustr,
1268 product_type: Option<HyperliquidProductType>,
1269 ) -> Option<InstrumentAny> {
1270 if let Some(pt) = product_type
1271 && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1272 {
1273 return Some(instrument.clone());
1274 }
1275
1276 if product_type.is_none() {
1280 let guard = self.instruments_by_coin.load();
1281
1282 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Outcome)) {
1283 return Some(instrument.clone());
1284 }
1285
1286 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1287 return Some(instrument.clone());
1288 }
1289
1290 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1291 return Some(instrument.clone());
1292 }
1293 }
1294
1295 if coin.as_str().starts_with('@')
1297 && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1298 {
1299 if let Some(instrument) = self.instruments.load().get(symbol) {
1302 return Some(instrument.clone());
1303 }
1304 }
1305
1306 if coin.as_str().starts_with("vntls:") {
1308 log::debug!("Creating synthetic instrument for vault token: {coin}");
1309
1310 let ts_event = self.clock.get_time_ns();
1311
1312 let symbol_str = format!("{coin}-USDC-SPOT");
1314 let symbol = Symbol::new(&symbol_str);
1315 let venue = *HYPERLIQUID_VENUE;
1316 let instrument_id = InstrumentId::new(symbol, venue);
1317
1318 let base_currency = Currency::new(
1320 coin.as_str(),
1321 8, 0, coin.as_str(),
1324 CurrencyType::Crypto,
1325 );
1326
1327 let quote_currency = Currency::new(
1328 "USDC",
1329 6, 0,
1331 "USDC",
1332 CurrencyType::Crypto,
1333 );
1334
1335 let price_increment = Price::from("0.00000001");
1336 let size_increment = Quantity::from("0.00000001");
1337
1338 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1339 instrument_id,
1340 symbol,
1341 base_currency,
1342 quote_currency,
1343 8, 8, price_increment,
1346 size_increment,
1347 None, None, None, None, None, None, None, None, None, None, None, None, None, None, ts_event,
1362 ts_event,
1363 ));
1364
1365 self.cache_instrument(&instrument);
1366
1367 Some(instrument)
1368 } else {
1369 log::warn!("Instrument not found in cache: {coin}");
1371 None
1372 }
1373 }
1374
1375 pub fn set_account_id(&mut self, account_id: AccountId) {
1379 self.account_id = Some(account_id);
1380 }
1381
1382 pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1384 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1385 let spot_meta = match self.inner.get_spot_meta().await {
1386 Ok(spot_meta) => Some(spot_meta),
1387 Err(e) => {
1388 log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1389 None
1390 }
1391 };
1392
1393 match self.inner.load_all_perp_metas().await {
1395 Ok(all_metas) => {
1396 for (dex_index, meta) in all_metas.iter().enumerate() {
1397 let base = perp_dex_asset_index_base(dex_index);
1398 let settlement_currency = match resolve_perp_settlement_currency(
1399 meta,
1400 spot_meta.as_ref(),
1401 ) {
1402 Ok(settlement_currency) => settlement_currency,
1403 Err(e) => {
1404 return Err(Error::decode(format!(
1405 "failed to resolve perp settlement currency for dex {dex_index}: {e}",
1406 )));
1407 }
1408 };
1409
1410 let perp_defs = parse_perp_instruments_with_settlement(
1411 meta,
1412 base,
1413 settlement_currency.as_str(),
1414 );
1415 log::debug!(
1416 "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1417 perp_defs.len(),
1418 );
1419 defs.extend(perp_defs);
1420 }
1421 }
1422 Err(e) => {
1423 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1424
1425 match self.inner.load_perp_meta().await {
1426 Ok(perp_meta) => {
1427 match resolve_perp_settlement_currency(&perp_meta, spot_meta.as_ref()) {
1428 Ok(settlement_currency) => {
1429 let perp_defs = parse_perp_instruments_with_settlement(
1430 &perp_meta,
1431 0,
1432 settlement_currency.as_str(),
1433 );
1434 log::debug!(
1435 "Loaded Hyperliquid perp defs via fallback: count={}",
1436 perp_defs.len(),
1437 );
1438 defs.extend(perp_defs);
1439 }
1440 Err(e) => {
1441 return Err(Error::decode(format!(
1442 "failed to resolve fallback perp settlement currency: {e}",
1443 )));
1444 }
1445 }
1446 }
1447 Err(e) => {
1448 log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1449 }
1450 }
1451 }
1452 }
1453
1454 if let Some(spot_meta) = spot_meta.as_ref() {
1455 match parse_spot_instruments(spot_meta) {
1456 Ok(spot_defs) => {
1457 log::debug!(
1458 "Loaded Hyperliquid spot definitions: count={}",
1459 spot_defs.len(),
1460 );
1461 defs.extend(spot_defs);
1462 }
1463 Err(e) => {
1464 log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1465 }
1466 }
1467 }
1468
1469 match self.inner.get_outcome_meta().await {
1473 Ok(outcome_meta) => match parse_outcome_instruments(&outcome_meta) {
1474 Ok(outcome_defs) => {
1475 log::debug!(
1476 "Loaded Hyperliquid outcome definitions: count={}",
1477 outcome_defs.len(),
1478 );
1479 defs.extend(outcome_defs);
1480 }
1481 Err(e) => {
1482 log::warn!("Failed to parse Hyperliquid outcome instruments: {e}");
1483 }
1484 },
1485 Err(e) => {
1486 log::debug!("Skipping Hyperliquid outcome metadata: {e}");
1487 }
1488 }
1489
1490 let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1497 let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1498 for def in defs {
1499 if seen_symbols.insert(def.symbol) {
1500 deduped.push(def);
1501 } else {
1502 log::warn!(
1503 "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1504 def.symbol,
1505 def.raw_symbol,
1506 );
1507 }
1508 }
1509 let defs = deduped;
1510
1511 self.asset_indices.rcu(|m| {
1513 for def in &defs {
1514 m.insert(def.symbol, def.asset_index);
1515 }
1516 });
1517 log::debug!(
1518 "Populated asset indices map (count={})",
1519 self.asset_indices.len()
1520 );
1521
1522 Ok(defs)
1523 }
1524
1525 pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1527 let ts_init = self.clock.get_time_ns();
1528 instruments_from_defs_owned(defs, ts_init)
1529 }
1530
1531 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1533 let defs = self.request_instrument_defs().await?;
1534 Ok(self.convert_defs(defs))
1535 }
1536
1537 pub async fn build_all_dex_asset_ctxs_instrument_ids(
1542 &self,
1543 ) -> Result<AHashMap<String, Vec<Option<InstrumentId>>>> {
1544 let all_metas = match self.inner.load_all_perp_metas().await {
1545 Ok(all_metas) => all_metas,
1546 Err(e) => {
1547 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1548 vec![self.inner.load_perp_meta().await?]
1549 }
1550 };
1551
1552 let perp_dexs = match self.inner.load_perp_dexs().await {
1553 Ok(dexs) => Some(dexs),
1554 Err(e) => {
1555 log::warn!("Failed to load perpDexs, inferring dex names from metadata: {e}");
1556 None
1557 }
1558 };
1559
1560 let raw_symbol_to_id =
1561 self.instruments
1562 .load()
1563 .values()
1564 .fold(AHashMap::new(), |mut acc, instrument| {
1565 acc.insert(instrument.raw_symbol().to_string(), instrument.id());
1566 acc
1567 });
1568
1569 let mut mapping = AHashMap::new();
1570
1571 for (dex_index, meta) in all_metas.iter().enumerate() {
1572 let dex_name = resolve_perp_dex_name(dex_index, meta, perp_dexs.as_deref());
1573 let mut instrument_ids = Vec::with_capacity(meta.universe.len());
1574
1575 for asset in &meta.universe {
1576 if let Some(instrument_id) = raw_symbol_to_id.get(&asset.name) {
1577 instrument_ids.push(Some(*instrument_id));
1578 } else {
1579 log::warn!(
1580 "Missing cached Hyperliquid instrument for dex='{}' raw_symbol='{}'",
1581 dex_name,
1582 asset.name
1583 );
1584 instrument_ids.push(None);
1585 }
1586 }
1587
1588 mapping.insert(dex_name, instrument_ids);
1589 }
1590
1591 Ok(mapping)
1592 }
1593
1594 pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1602 self.get_asset_index_for_symbol(Ustr::from(symbol))
1603 }
1604
1605 pub(crate) fn get_asset_index_for_symbol(&self, symbol: Ustr) -> Option<u32> {
1609 self.asset_indices.load().get(&symbol).copied()
1610 }
1611
1612 pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1614 self.get_price_precision_for_symbol(Ustr::from(symbol))
1615 }
1616
1617 pub(crate) fn get_price_precision_for_symbol(&self, symbol: Ustr) -> Option<u8> {
1619 self.instruments
1620 .load()
1621 .get(&symbol)
1622 .map(|inst| inst.price_precision())
1623 }
1624
1625 #[must_use]
1633 pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1634 const SPOT_INDEX_OFFSET: u32 = 10_000;
1635 const BUILDER_PERP_OFFSET: u32 = 100_000;
1636
1637 let guard = self.asset_indices.load();
1638
1639 let mut mapping = AHashMap::new();
1640
1641 for (symbol, &asset_index) in guard.iter() {
1642 if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1644 let pair_index = asset_index - SPOT_INDEX_OFFSET;
1645 let fill_coin = Ustr::from(&format!("@{pair_index}"));
1646 mapping.insert(fill_coin, *symbol);
1647 }
1648 }
1649
1650 self.spot_fill_coins.store(mapping.clone());
1652
1653 mapping
1654 }
1655
1656 #[allow(dead_code)]
1658 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1659 self.inner.load_perp_meta().await
1660 }
1661
1662 #[allow(dead_code)]
1664 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1665 self.inner.load_all_perp_metas().await
1666 }
1667
1668 #[allow(dead_code)]
1670 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1671 self.inner.get_spot_meta().await
1672 }
1673
1674 pub(crate) async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
1676 self.inner.get_outcome_meta().await
1677 }
1678
1679 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1681 self.inner.info_l2_book(coin).await
1682 }
1683
1684 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
1686 self.inner.info_recent_trades(coin).await
1687 }
1688
1689 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1691 self.inner.info_user_fills(user).await
1692 }
1693
1694 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1696 self.inner.info_order_status(user, oid).await
1697 }
1698
1699 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1701 self.inner.info_open_orders(user).await
1702 }
1703
1704 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1706 self.inner.info_frontend_open_orders(user).await
1707 }
1708
1709 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1711 self.inner.info_clearinghouse_state(user).await
1712 }
1713
1714 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
1716 self.inner.info_spot_clearinghouse_state(user).await
1717 }
1718
1719 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
1721 self.inner.info_user_fees(user).await
1722 }
1723
1724 pub async fn info_candle_snapshot(
1726 &self,
1727 coin: &str,
1728 interval: HyperliquidBarInterval,
1729 start_time: u64,
1730 end_time: u64,
1731 ) -> Result<HyperliquidCandleSnapshot> {
1732 self.inner
1733 .info_candle_snapshot(coin, interval, start_time, end_time)
1734 .await
1735 }
1736
1737 pub async fn info_funding_history(
1739 &self,
1740 coin: &str,
1741 start_time: u64,
1742 end_time: Option<u64>,
1743 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
1744 self.inner
1745 .info_funding_history(coin, start_time, end_time)
1746 .await
1747 }
1748
1749 pub async fn post_action(
1751 &self,
1752 action: &ExchangeAction,
1753 ) -> Result<HyperliquidExchangeResponse> {
1754 self.inner.post_action(action).await
1755 }
1756
1757 pub async fn post_action_exec(
1759 &self,
1760 action: &HyperliquidExecAction,
1761 ) -> Result<HyperliquidExchangeResponse> {
1762 self.inner.post_action_exec(action).await
1763 }
1764
1765 pub fn sign_action_exec_request(
1767 &self,
1768 action: &HyperliquidExecAction,
1769 expires_after: Option<u64>,
1770 ) -> Result<HyperliquidExchangeRequest<HyperliquidExecAction>> {
1771 self.inner.sign_action_exec_request(action, expires_after)
1772 }
1773
1774 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1776 self.inner.info_meta().await
1777 }
1778
1779 pub async fn cancel_order(
1789 &self,
1790 instrument_id: InstrumentId,
1791 client_order_id: Option<ClientOrderId>,
1792 venue_order_id: Option<VenueOrderId>,
1793 ) -> Result<()> {
1794 let symbol = instrument_id.symbol.inner();
1796 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1797 Error::bad_request(format!(
1798 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1799 ))
1800 })?;
1801
1802 let action = if let Some(client_order_id) = client_order_id {
1803 if let Some(cloid) = self.cached_client_order_id_cloid(&client_order_id) {
1804 HyperliquidExecAction::CancelByCloid {
1805 cancels: vec![HyperliquidExecCancelByCloidRequest {
1806 asset: asset_id,
1807 cloid,
1808 }],
1809 }
1810 } else if let Some(oid) = venue_order_id {
1811 let oid_u64 = oid
1812 .as_str()
1813 .parse::<u64>()
1814 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1815 HyperliquidExecAction::Cancel {
1816 cancels: vec![HyperliquidExecCancelOrderRequest {
1817 asset: asset_id,
1818 oid: oid_u64,
1819 }],
1820 }
1821 } else {
1822 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
1823 HyperliquidExecAction::CancelByCloid {
1824 cancels: vec![HyperliquidExecCancelByCloidRequest {
1825 asset: asset_id,
1826 cloid,
1827 }],
1828 }
1829 }
1830 } else if let Some(oid) = venue_order_id {
1831 let oid_u64 = oid
1832 .as_str()
1833 .parse::<u64>()
1834 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1835 HyperliquidExecAction::Cancel {
1836 cancels: vec![HyperliquidExecCancelOrderRequest {
1837 asset: asset_id,
1838 oid: oid_u64,
1839 }],
1840 }
1841 } else {
1842 return Err(Error::bad_request(
1843 "Either client_order_id or venue_order_id must be provided",
1844 ));
1845 };
1846
1847 let response = self.inner.post_action_exec(&action).await?;
1849
1850 match response {
1852 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
1853 HyperliquidExchangeResponse::Status {
1854 status,
1855 response: error_data,
1856 } => Err(Error::bad_request(format!(
1857 "Cancel order failed: status={status}, error={error_data}"
1858 ))),
1859 HyperliquidExchangeResponse::Error { error } => {
1860 Err(Error::bad_request(format!("Cancel order error: {error}")))
1861 }
1862 }
1863 }
1864
1865 #[expect(clippy::too_many_arguments)]
1875 pub async fn modify_order(
1876 &self,
1877 instrument_id: InstrumentId,
1878 venue_order_id: VenueOrderId,
1879 order_side: OrderSide,
1880 order_type: OrderType,
1881 price: Price,
1882 quantity: Quantity,
1883 trigger_price: Option<Price>,
1884 reduce_only: bool,
1885 post_only: bool,
1886 time_in_force: TimeInForce,
1887 client_order_id: Option<ClientOrderId>,
1888 ) -> Result<()> {
1889 let symbol = instrument_id.symbol.inner();
1890 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1891 Error::bad_request(format!(
1892 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1893 ))
1894 })?;
1895
1896 let oid: u64 = venue_order_id
1897 .as_str()
1898 .parse()
1899 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1900
1901 let is_buy = matches!(order_side, OrderSide::Buy);
1902 let decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
1903
1904 let normalized_price = if self.normalize_prices {
1905 normalize_price(price.as_decimal(), decimals).normalize()
1906 } else {
1907 price.as_decimal().normalize()
1908 };
1909
1910 let size = quantity.as_decimal().normalize();
1911
1912 let kind = match order_type {
1913 OrderType::Market => HyperliquidExecOrderKind::Limit {
1914 limit: HyperliquidExecLimitParams {
1915 tif: HyperliquidExecTif::Ioc,
1916 },
1917 },
1918 OrderType::Limit => {
1919 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
1920 .map_err(|e| Error::bad_request(format!("{e}")))?;
1921 HyperliquidExecOrderKind::Limit {
1922 limit: HyperliquidExecLimitParams { tif },
1923 }
1924 }
1925 OrderType::StopMarket
1926 | OrderType::StopLimit
1927 | OrderType::MarketIfTouched
1928 | OrderType::LimitIfTouched => {
1929 if let Some(trig_px) = trigger_price {
1930 let trigger_price_decimal = if self.normalize_prices {
1931 normalize_price(trig_px.as_decimal(), decimals).normalize()
1932 } else {
1933 trig_px.as_decimal().normalize()
1934 };
1935 let tpsl = match order_type {
1936 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1937 _ => HyperliquidExecTpSl::Tp,
1938 };
1939 let is_market = matches!(
1940 order_type,
1941 OrderType::StopMarket | OrderType::MarketIfTouched
1942 );
1943 HyperliquidExecOrderKind::Trigger {
1944 trigger: HyperliquidExecTriggerParams {
1945 is_market,
1946 trigger_px: trigger_price_decimal,
1947 tpsl,
1948 },
1949 }
1950 } else {
1951 return Err(Error::bad_request("Trigger orders require a trigger price"));
1952 }
1953 }
1954 _ => {
1955 return Err(Error::bad_request(format!(
1956 "Order type {order_type:?} not supported for modify"
1957 )));
1958 }
1959 };
1960 let cloid = client_order_id.map(|id| self.get_or_generate_client_order_id_cloid(id));
1961
1962 let order = HyperliquidExecPlaceOrderRequest {
1963 asset: asset_id,
1964 is_buy,
1965 price: normalized_price,
1966 size,
1967 reduce_only,
1968 kind,
1969 cloid,
1970 };
1971
1972 let action = HyperliquidExecAction::Modify {
1973 modify: HyperliquidExecModifyOrderRequest { oid, order },
1974 };
1975
1976 let response = self.inner.post_action_exec(&action).await?;
1977
1978 match response {
1979 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
1980 if let Some(inner_error) = extract_inner_error(&response) {
1981 Err(Error::bad_request(format!(
1982 "Modify order rejected: {inner_error}",
1983 )))
1984 } else {
1985 Ok(())
1986 }
1987 }
1988 HyperliquidExchangeResponse::Status {
1989 status,
1990 response: error_data,
1991 } => Err(Error::bad_request(format!(
1992 "Modify order failed: status={status}, error={error_data}"
1993 ))),
1994 HyperliquidExchangeResponse::Error { error } => {
1995 Err(Error::bad_request(format!("Modify order error: {error}")))
1996 }
1997 }
1998 }
1999
2000 pub async fn submit_split_outcome(
2014 &self,
2015 outcome: u32,
2016 amount: Decimal,
2017 ) -> Result<HyperliquidExchangeResponse> {
2018 let action = HyperliquidExecAction::UserOutcome {
2019 op: HyperliquidExecUserOutcomeOp::SplitOutcome(HyperliquidExecSplitOutcomeParams {
2020 outcome,
2021 amount,
2022 }),
2023 };
2024 self.inner.post_action_exec(&action).await
2025 }
2026
2027 pub async fn submit_merge_outcome(
2038 &self,
2039 outcome: u32,
2040 amount: Option<Decimal>,
2041 ) -> Result<HyperliquidExchangeResponse> {
2042 let action = HyperliquidExecAction::UserOutcome {
2043 op: HyperliquidExecUserOutcomeOp::MergeOutcome(HyperliquidExecMergeOutcomeParams {
2044 outcome,
2045 amount,
2046 }),
2047 };
2048 self.inner.post_action_exec(&action).await
2049 }
2050
2051 pub async fn submit_merge_question(
2061 &self,
2062 question: u32,
2063 amount: Option<Decimal>,
2064 ) -> Result<HyperliquidExchangeResponse> {
2065 let action = HyperliquidExecAction::UserOutcome {
2066 op: HyperliquidExecUserOutcomeOp::MergeQuestion(HyperliquidExecMergeQuestionParams {
2067 question,
2068 amount,
2069 }),
2070 };
2071 self.inner.post_action_exec(&action).await
2072 }
2073
2074 pub async fn submit_negate_outcome(
2084 &self,
2085 question: u32,
2086 outcome: u32,
2087 amount: Decimal,
2088 ) -> Result<HyperliquidExchangeResponse> {
2089 let action = HyperliquidExecAction::UserOutcome {
2090 op: HyperliquidExecUserOutcomeOp::NegateOutcome(HyperliquidExecNegateOutcomeParams {
2091 question,
2092 outcome,
2093 amount,
2094 }),
2095 };
2096 self.inner.post_action_exec(&action).await
2097 }
2098
2099 pub async fn request_order_status_reports(
2111 &self,
2112 user: &str,
2113 instrument_id: Option<InstrumentId>,
2114 ) -> Result<Vec<OrderStatusReport>> {
2115 let account_id = self
2116 .account_id
2117 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2118 let response = self.info_frontend_open_orders(user).await?;
2119
2120 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
2122 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
2123
2124 let mut reports = Vec::new();
2125 let ts_init = self.clock.get_time_ns();
2126
2127 for order_value in orders {
2128 let order: WsBasicOrderData = match serde_json::from_value(order_value.clone()) {
2130 Ok(o) => o,
2131 Err(e) => {
2132 log::warn!("Failed to parse order: {e}");
2133 continue;
2134 }
2135 };
2136
2137 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2139 Some(inst) => inst,
2140 None => continue, };
2142
2143 if let Some(filter_id) = instrument_id
2145 && instrument.id() != filter_id
2146 {
2147 continue;
2148 }
2149
2150 let status = HyperliquidOrderStatusEnum::Open;
2152
2153 match parse_order_status_report_from_basic(
2155 &order,
2156 &status,
2157 &instrument,
2158 account_id,
2159 ts_init,
2160 ) {
2161 Ok(report) => reports.push(report),
2162 Err(e) => log::error!("Failed to parse order status report: {e}"),
2163 }
2164 }
2165
2166 Ok(reports)
2167 }
2168
2169 pub async fn request_order_status_report(
2179 &self,
2180 user: &str,
2181 oid: u64,
2182 ) -> Result<Option<OrderStatusReport>> {
2183 let account_id = self
2184 .account_id
2185 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2186
2187 let ts_init = self.clock.get_time_ns();
2188
2189 let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
2194 Ok(response) => match serde_json::from_value(response) {
2195 Ok(v) => v,
2196 Err(e) => {
2197 log::warn!("Failed to parse frontend open orders response: {e}");
2198 Vec::new()
2199 }
2200 },
2201 Err(e) => {
2202 log::warn!(
2203 "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
2204 );
2205 Vec::new()
2206 }
2207 };
2208
2209 if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
2210 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2211 Some(inst) => inst,
2212 None => return Ok(None),
2213 };
2214
2215 let status = if order.trigger_activated == Some(true) {
2216 HyperliquidOrderStatusEnum::Triggered
2217 } else {
2218 HyperliquidOrderStatusEnum::Open
2219 };
2220
2221 return match parse_order_status_report_from_basic(
2222 &order,
2223 &status,
2224 &instrument,
2225 account_id,
2226 ts_init,
2227 ) {
2228 Ok(report) => Ok(Some(report)),
2229 Err(e) => {
2230 log::error!("Failed to parse order status report for oid {oid}: {e}");
2231 Ok(None)
2232 }
2233 };
2234 }
2235
2236 let response = self.info_order_status(user, oid).await?;
2238 let entry = match response.into_order() {
2239 Some(e) => e,
2240 None => return Ok(None),
2241 };
2242
2243 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2244 Some(inst) => inst,
2245 None => return Ok(None),
2246 };
2247
2248 let basic = WsBasicOrderData {
2253 coin: entry.order.coin,
2254 side: entry.order.side,
2255 limit_px: entry.order.limit_px,
2256 sz: entry.order.sz,
2257 oid: entry.order.oid,
2258 timestamp: entry.order.timestamp,
2259 orig_sz: entry.order.orig_sz,
2260 cloid: entry.order.cloid,
2261 tif: None,
2262 reduce_only: None,
2263 trigger_px: None,
2264 is_market: None,
2265 tpsl: None,
2266 trigger_activated: None,
2267 trailing_stop: None,
2268 };
2269
2270 match parse_order_status_report_from_basic(
2271 &basic,
2272 &entry.status,
2273 &instrument,
2274 account_id,
2275 ts_init,
2276 ) {
2277 Ok(mut report) => {
2278 if entry.status_timestamp > 0 {
2281 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2282 }
2283 Ok(Some(report))
2284 }
2285 Err(e) => {
2286 log::error!("Failed to parse order status report for oid {oid}: {e}");
2287 Ok(None)
2288 }
2289 }
2290 }
2291
2292 pub async fn request_order_status_report_by_client_order_id(
2301 &self,
2302 user: &str,
2303 client_order_id: &ClientOrderId,
2304 ) -> Result<Option<OrderStatusReport>> {
2305 let account_id = self
2306 .account_id
2307 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2308
2309 let ts_init = self.clock.get_time_ns();
2310
2311 let cached_cloid_hex = self
2312 .cached_client_order_id_cloid(client_order_id)
2313 .map(|cloid| cloid.to_hex());
2314 let cloid = Cloid::from_client_order_id(*client_order_id);
2315 let cloid_hex = cloid.to_hex();
2316
2317 let response = self.info_frontend_open_orders(user).await?;
2318 let orders: Vec<WsBasicOrderData> = match serde_json::from_value(response) {
2319 Ok(v) => v,
2320 Err(e) => {
2321 log::warn!("Failed to parse frontend open orders response: {e}");
2322 return Ok(None);
2323 }
2324 };
2325
2326 let order = match orders.into_iter().find(|o| {
2327 o.cloid
2328 .as_ref()
2329 .is_some_and(|c| cached_cloid_hex.as_ref() == Some(c) || c == &cloid_hex)
2330 }) {
2331 Some(o) => o,
2332 None => return Ok(None),
2333 };
2334
2335 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2336 Some(inst) => inst,
2337 None => return Ok(None),
2338 };
2339
2340 let status = if order.trigger_activated == Some(true) {
2341 HyperliquidOrderStatusEnum::Triggered
2342 } else {
2343 HyperliquidOrderStatusEnum::Open
2344 };
2345
2346 match parse_order_status_report_from_basic(
2347 &order,
2348 &status,
2349 &instrument,
2350 account_id,
2351 ts_init,
2352 ) {
2353 Ok(mut report) => {
2354 report.client_order_id = Some(*client_order_id);
2355 Ok(Some(report))
2356 }
2357 Err(e) => {
2358 log::error!("Failed to parse order status report for cloid {cloid_hex}: {e}");
2359 Ok(None)
2360 }
2361 }
2362 }
2363
2364 pub async fn request_fill_reports(
2378 &self,
2379 user: &str,
2380 instrument_id: Option<InstrumentId>,
2381 ) -> Result<Vec<FillReport>> {
2382 let account_id = self
2383 .account_id
2384 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2385 let fills_response = self.info_user_fills(user).await?;
2386
2387 let mut reports = Vec::new();
2388 let ts_init = self.clock.get_time_ns();
2389
2390 for fill in fills_response {
2391 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
2393 Some(inst) => inst,
2394 None => continue, };
2396
2397 if let Some(filter_id) = instrument_id
2399 && instrument.id() != filter_id
2400 {
2401 continue;
2402 }
2403
2404 match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2406 Ok(report) => reports.push(report),
2407 Err(e) => log::error!("Failed to parse fill report: {e}"),
2408 }
2409 }
2410
2411 Ok(reports)
2412 }
2413
2414 pub async fn request_position_status_reports(
2438 &self,
2439 user: &str,
2440 instrument_id: Option<InstrumentId>,
2441 ) -> Result<Vec<PositionStatusReport>> {
2442 let account_id = self
2443 .account_id
2444 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2445
2446 let filter_product = instrument_id
2447 .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
2448
2449 let fetch_perp = !matches!(
2450 filter_product,
2451 Some(HyperliquidProductType::Spot | HyperliquidProductType::Outcome)
2452 );
2453 let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
2454
2455 let mut reports = Vec::new();
2456 let ts_init = self.clock.get_time_ns();
2457
2458 if !fetch_perp {
2459 let spot_reports = self
2460 .request_spot_position_status_reports(user, instrument_id)
2461 .await?;
2462 reports.extend(spot_reports);
2463 return Ok(reports);
2464 }
2465
2466 let state_response = self.info_clearinghouse_state(user).await?;
2467
2468 let asset_positions: Vec<serde_json::Value> = state_response
2470 .get("assetPositions")
2471 .and_then(|v| v.as_array())
2472 .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
2473 .clone();
2474
2475 for position_value in asset_positions {
2476 let coin = position_value
2478 .get("position")
2479 .and_then(|p| p.get("coin"))
2480 .and_then(|c| c.as_str())
2481 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
2482
2483 let coin_ustr = Ustr::from(coin);
2485 let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
2486 Some(inst) => inst,
2487 None => continue, };
2489
2490 if let Some(filter_id) = instrument_id
2492 && instrument.id() != filter_id
2493 {
2494 continue;
2495 }
2496
2497 match parse_position_status_report(&position_value, &instrument, account_id, ts_init) {
2499 Ok(report) => reports.push(report),
2500 Err(e) => log::error!("Failed to parse position status report: {e}"),
2501 }
2502 }
2503
2504 if fetch_spot {
2507 let spot_reports = self
2508 .request_spot_position_status_reports(user, instrument_id)
2509 .await?;
2510 reports.extend(spot_reports);
2511 }
2512
2513 Ok(reports)
2514 }
2515
2516 pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
2529 let account_id = self
2530 .account_id
2531 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2532 let state_response = self.info_clearinghouse_state(user).await?;
2533 let ts_init = self.clock.get_time_ns();
2534
2535 log::trace!("Clearinghouse state response: {state_response}");
2536
2537 let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
2538 .map_err(|e| {
2539 log::error!("Failed to parse clearinghouse state: {e}");
2540 log::debug!("Raw response: {state_response}");
2541 Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
2542 })?;
2543
2544 let spot_response = self.info_spot_clearinghouse_state(user).await?;
2547 let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
2548 .map_err(|e| {
2549 log::error!("Failed to parse spot clearinghouse state: {e}");
2550 log::debug!("Raw spot response: {spot_response}");
2551 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2552 })?;
2553
2554 let (balances, margins) =
2555 parse_combined_account_balances_and_margins(&perp_state, &spot_state)
2556 .map_err(|e| Error::decode(e.to_string()))?;
2557
2558 Ok(AccountState::new(
2559 account_id,
2560 AccountType::Margin,
2561 balances,
2562 margins,
2563 true, UUID4::new(),
2565 ts_init,
2566 ts_init,
2567 None,
2568 ))
2569 }
2570
2571 pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
2582 let response = self.info_spot_clearinghouse_state(user).await?;
2583
2584 log::trace!("Spot clearinghouse state response: {response}");
2585
2586 let state: SpotClearinghouseState =
2587 serde_json::from_value(response.clone()).map_err(|e| {
2588 log::error!("Failed to parse spot clearinghouse state: {e}");
2589 log::debug!("Raw response: {response}");
2590 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2591 })?;
2592
2593 parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
2594 }
2595
2596 pub async fn request_spot_position_status_reports(
2611 &self,
2612 user: &str,
2613 instrument_id: Option<InstrumentId>,
2614 ) -> Result<Vec<PositionStatusReport>> {
2615 let account_id = self
2616 .account_id
2617 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2618 let response = self.info_spot_clearinghouse_state(user).await?;
2619
2620 let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
2621 log::error!("Failed to parse spot clearinghouse state: {e}");
2622 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2623 })?;
2624
2625 let ts_init = self.clock.get_time_ns();
2626 let mut reports = Vec::with_capacity(state.balances.len());
2627
2628 for balance in &state.balances {
2629 if balance.total.is_zero() {
2630 continue;
2631 }
2632
2633 if balance.coin.as_str() == "USDC" {
2638 continue;
2639 }
2640
2641 let product_type = match HyperliquidProductType::from_symbol(balance.coin.as_str()) {
2642 Ok(HyperliquidProductType::Outcome) => HyperliquidProductType::Outcome,
2643 _ => HyperliquidProductType::Spot,
2644 };
2645
2646 let instrument = match self.get_or_create_instrument(&balance.coin, Some(product_type))
2647 {
2648 Some(inst) => inst,
2649 None => continue,
2650 };
2651
2652 if let Some(filter_id) = instrument_id
2653 && instrument.id() != filter_id
2654 {
2655 continue;
2656 }
2657
2658 match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
2659 Ok(report) => reports.push(report),
2660 Err(e) => log::error!(
2661 "Failed to parse spot position status report for {}: {e}",
2662 balance.coin,
2663 ),
2664 }
2665 }
2666
2667 Ok(reports)
2668 }
2669
2670 pub async fn request_bars(
2687 &self,
2688 bar_type: BarType,
2689 start: Option<chrono::DateTime<chrono::Utc>>,
2690 end: Option<chrono::DateTime<chrono::Utc>>,
2691 limit: Option<u32>,
2692 ) -> Result<Vec<Bar>> {
2693 let instrument_id = bar_type.instrument_id();
2694 let symbol = instrument_id.symbol;
2695
2696 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
2697
2698 let alias = cache_alias_for_symbol(symbol.as_str())
2702 .map(|alias| Ustr::from(alias.as_str()))
2703 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
2704
2705 let instrument = self
2706 .get_or_create_instrument(&alias, product_type)
2707 .ok_or_else(|| {
2708 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
2709 })?;
2710
2711 let coin = instrument.raw_symbol().inner();
2716
2717 let price_precision = instrument.price_precision();
2718 let size_precision = instrument.size_precision();
2719
2720 let interval =
2721 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
2722
2723 let now = chrono::Utc::now();
2725 let end_time = end.unwrap_or(now).timestamp_millis() as u64;
2726 let start_time = if let Some(start) = start {
2727 start.timestamp_millis() as u64
2728 } else {
2729 let spec = bar_type.spec();
2731 let step_ms = match spec.aggregation {
2732 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
2733 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
2734 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
2735 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
2736 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
2737 _ => 60_000,
2738 };
2739 end_time.saturating_sub(1000 * step_ms)
2740 };
2741
2742 let candles = self
2743 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
2744 .await?;
2745
2746 let now_ms = now.timestamp_millis() as u64;
2748
2749 let mut bars: Vec<Bar> = candles
2750 .iter()
2751 .filter(|candle| candle.end_timestamp < now_ms)
2752 .enumerate()
2753 .filter_map(|(i, candle)| {
2754 candle_to_bar(candle, bar_type, price_precision, size_precision)
2755 .map_err(|e| {
2756 log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
2757 e
2758 })
2759 .ok()
2760 })
2761 .collect();
2762
2763 if let Some(limit) = limit
2765 && limit > 0
2766 && bars.len() > limit as usize
2767 {
2768 bars.truncate(limit as usize);
2769 }
2770
2771 log::debug!(
2772 "Received {} bars for {} (filtered {} incomplete)",
2773 bars.len(),
2774 bar_type,
2775 candles.len() - bars.len()
2776 );
2777 Ok(bars)
2778 }
2779
2780 #[expect(clippy::too_many_arguments)]
2787 pub async fn submit_order(
2788 &self,
2789 instrument_id: InstrumentId,
2790 client_order_id: ClientOrderId,
2791 order_side: OrderSide,
2792 order_type: OrderType,
2793 quantity: Quantity,
2794 time_in_force: TimeInForce,
2795 price: Option<Price>,
2796 trigger_price: Option<Price>,
2797 post_only: bool,
2798 reduce_only: bool,
2799 ) -> Result<OrderStatusReport> {
2800 let symbol = instrument_id.symbol.inner();
2801 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
2802 Error::bad_request(format!(
2803 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2804 ))
2805 })?;
2806
2807 let is_buy = matches!(order_side, OrderSide::Buy);
2808 let price_precision = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
2809
2810 let price_decimal = match price {
2811 Some(px) if self.normalize_prices => {
2812 normalize_price(px.as_decimal(), price_precision).normalize()
2813 }
2814 Some(px) => px.as_decimal().normalize(),
2815 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
2816 None if matches!(
2817 order_type,
2818 OrderType::StopMarket | OrderType::MarketIfTouched
2819 ) =>
2820 {
2821 match trigger_price {
2822 Some(tp) => {
2823 let derived = derive_limit_from_trigger(
2824 tp.as_decimal().normalize(),
2825 is_buy,
2826 self.market_order_slippage_bps,
2827 );
2828 let sig_rounded = round_to_sig_figs(derived, 5);
2829 clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
2830 }
2831 None => Decimal::ZERO,
2832 }
2833 }
2834 None => return Err(Error::bad_request("Limit orders require a price")),
2835 };
2836
2837 let size_decimal = quantity.as_decimal().normalize();
2838
2839 let kind = match order_type {
2840 OrderType::Market => HyperliquidExecOrderKind::Limit {
2841 limit: HyperliquidExecLimitParams {
2842 tif: HyperliquidExecTif::Ioc,
2843 },
2844 },
2845 OrderType::Limit => {
2846 let tif = if post_only {
2847 HyperliquidExecTif::Alo
2848 } else {
2849 match time_in_force {
2850 TimeInForce::Gtc => HyperliquidExecTif::Gtc,
2851 TimeInForce::Ioc => HyperliquidExecTif::Ioc,
2852 TimeInForce::Fok
2853 | TimeInForce::Day
2854 | TimeInForce::Gtd
2855 | TimeInForce::AtTheOpen
2856 | TimeInForce::AtTheClose => {
2857 return Err(Error::bad_request(format!(
2858 "Time in force {time_in_force:?} not supported"
2859 )));
2860 }
2861 }
2862 };
2863 HyperliquidExecOrderKind::Limit {
2864 limit: HyperliquidExecLimitParams { tif },
2865 }
2866 }
2867 OrderType::StopMarket
2868 | OrderType::StopLimit
2869 | OrderType::MarketIfTouched
2870 | OrderType::LimitIfTouched => {
2871 if let Some(trig_px) = trigger_price {
2872 let trigger_price_decimal = if self.normalize_prices {
2873 normalize_price(trig_px.as_decimal(), price_precision).normalize()
2874 } else {
2875 trig_px.as_decimal().normalize()
2876 };
2877
2878 let tpsl = match order_type {
2882 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
2883 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2884 HyperliquidExecTpSl::Tp
2885 }
2886 _ => unreachable!(),
2887 };
2888
2889 let is_market = matches!(
2890 order_type,
2891 OrderType::StopMarket | OrderType::MarketIfTouched
2892 );
2893
2894 HyperliquidExecOrderKind::Trigger {
2895 trigger: HyperliquidExecTriggerParams {
2896 is_market,
2897 trigger_px: trigger_price_decimal,
2898 tpsl,
2899 },
2900 }
2901 } else {
2902 return Err(Error::bad_request("Trigger orders require a trigger price"));
2903 }
2904 }
2905 _ => {
2906 return Err(Error::bad_request(format!(
2907 "Order type {order_type:?} not supported"
2908 )));
2909 }
2910 };
2911
2912 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
2913 let hyperliquid_order = HyperliquidExecPlaceOrderRequest {
2914 asset,
2915 is_buy,
2916 price: price_decimal,
2917 size: size_decimal,
2918 reduce_only,
2919 kind,
2920 cloid: Some(cloid),
2921 };
2922
2923 let builder = self.builder_attribution();
2924
2925 let action = HyperliquidExecAction::Order {
2926 orders: vec![hyperliquid_order],
2927 grouping: HyperliquidExecGrouping::Na,
2928 builder,
2929 };
2930
2931 let response = self.inner.post_action_exec(&action).await?;
2932
2933 self.build_submit_order_report(
2936 instrument_id,
2937 client_order_id,
2938 order_side,
2939 order_type,
2940 quantity,
2941 time_in_force,
2942 price,
2943 trigger_price,
2944 response,
2945 )?
2946 .ok_or_else(|| {
2947 Error::bad_request(
2948 "Single-order submission returned no actionable status (deferred trigger child)",
2949 )
2950 })
2951 }
2952
2953 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
2957 self.submit_order(
2958 order.instrument_id(),
2959 order.client_order_id(),
2960 order.order_side(),
2961 order.order_type(),
2962 order.quantity(),
2963 order.time_in_force(),
2964 order.price(),
2965 order.trigger_price(),
2966 order.is_post_only(),
2967 order.is_reduce_only(),
2968 )
2969 .await
2970 }
2971
2972 #[expect(clippy::too_many_arguments)]
2973 fn create_order_status_report(
2974 &self,
2975 instrument_id: InstrumentId,
2976 client_order_id: Option<ClientOrderId>,
2977 venue_order_id: VenueOrderId,
2978 order_side: OrderSide,
2979 order_type: OrderType,
2980 quantity: Quantity,
2981 time_in_force: TimeInForce,
2982 price: Option<Price>,
2983 trigger_price: Option<Price>,
2984 order_status: OrderStatus,
2985 filled_qty: Quantity,
2986 _instrument: &InstrumentAny,
2987 account_id: AccountId,
2988 ts_init: UnixNanos,
2989 ) -> OrderStatusReport {
2990 let ts_accepted = self.clock.get_time_ns();
2991 let ts_last = ts_accepted;
2992 let report_id = UUID4::new();
2993
2994 let mut report = OrderStatusReport::new(
2995 account_id,
2996 instrument_id,
2997 client_order_id,
2998 venue_order_id,
2999 order_side,
3000 order_type,
3001 time_in_force,
3002 order_status,
3003 quantity,
3004 filled_qty,
3005 ts_accepted,
3006 ts_last,
3007 ts_init,
3008 Some(report_id),
3009 );
3010
3011 if let Some(px) = price {
3012 report = report.with_price(px);
3013 }
3014
3015 if let Some(trig_px) = trigger_price {
3016 report = report
3017 .with_trigger_price(trig_px)
3018 .with_trigger_type(TriggerType::Default);
3019 }
3020
3021 report
3022 }
3023
3024 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
3031 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
3033 let mut client_order_ids = Vec::with_capacity(orders.len());
3034
3035 for order in orders {
3036 let instrument_id = order.instrument_id();
3037 let symbol = instrument_id.symbol.inner();
3038 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3039 Error::bad_request(format!(
3040 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3041 ))
3042 })?;
3043 let price_decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
3044 let request = order_to_hyperliquid_request_with_asset_and_cloid(
3045 order,
3046 asset,
3047 price_decimals,
3048 self.normalize_prices,
3049 self.market_order_slippage_bps,
3050 None,
3051 )
3052 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
3053 client_order_ids.push(order.client_order_id());
3054 hyperliquid_orders.push(request);
3055 }
3056
3057 for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
3058 request.cloid = Some(self.get_or_generate_client_order_id_cloid(client_order_id));
3059 }
3060
3061 let builder = self.builder_attribution();
3062
3063 let grouping =
3064 determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
3065
3066 let action = HyperliquidExecAction::Order {
3067 orders: hyperliquid_orders,
3068 grouping,
3069 builder,
3070 };
3071
3072 let response = self.inner.post_action_exec(&action).await?;
3074
3075 self.build_submit_orders_reports(orders, grouping, response)
3076 }
3077
3078 #[expect(clippy::too_many_arguments)]
3094 pub fn build_submit_order_report(
3095 &self,
3096 instrument_id: InstrumentId,
3097 client_order_id: ClientOrderId,
3098 order_side: OrderSide,
3099 order_type: OrderType,
3100 quantity: Quantity,
3101 time_in_force: TimeInForce,
3102 price: Option<Price>,
3103 trigger_price: Option<Price>,
3104 response: HyperliquidExchangeResponse,
3105 ) -> Result<Option<OrderStatusReport>> {
3106 let order_response = parse_order_response(response)?;
3107
3108 let Some(order_status) = order_response.statuses.first() else {
3109 return Ok(None);
3110 };
3111
3112 let account_id = self
3113 .account_id
3114 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3115 let ts_init = self.clock.get_time_ns();
3116
3117 self.build_status_report(
3118 instrument_id,
3119 client_order_id,
3120 order_side,
3121 order_type,
3122 quantity,
3123 time_in_force,
3124 price,
3125 trigger_price,
3126 order_status,
3127 account_id,
3128 ts_init,
3129 )
3130 }
3131
3132 pub fn build_submit_orders_reports(
3147 &self,
3148 orders: &[&OrderAny],
3149 grouping: HyperliquidExecGrouping,
3150 response: HyperliquidExchangeResponse,
3151 ) -> Result<Vec<OrderStatusReport>> {
3152 let order_response = parse_order_response(response)?;
3153
3154 let account_id = self
3155 .account_id
3156 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3157 let ts_init = self.clock.get_time_ns();
3158
3159 if grouping == HyperliquidExecGrouping::Na && order_response.statuses.len() != orders.len()
3163 {
3164 return Err(Error::bad_request(format!(
3165 "Mismatch between submitted orders ({}) and response statuses ({})",
3166 orders.len(),
3167 order_response.statuses.len()
3168 )));
3169 }
3170
3171 let mut reports = Vec::with_capacity(order_response.statuses.len());
3174 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
3175 if let Some(report) = self.build_status_report(
3176 order.instrument_id(),
3177 order.client_order_id(),
3178 order.order_side(),
3179 order.order_type(),
3180 order.quantity(),
3181 order.time_in_force(),
3182 order.price(),
3183 order.trigger_price(),
3184 order_status,
3185 account_id,
3186 ts_init,
3187 )? {
3188 reports.push(report);
3189 }
3190 }
3191
3192 Ok(reports)
3193 }
3194
3195 #[expect(clippy::too_many_arguments)]
3203 fn build_status_report(
3204 &self,
3205 instrument_id: InstrumentId,
3206 client_order_id: ClientOrderId,
3207 order_side: OrderSide,
3208 order_type: OrderType,
3209 quantity: Quantity,
3210 time_in_force: TimeInForce,
3211 price: Option<Price>,
3212 trigger_price: Option<Price>,
3213 order_status: &HyperliquidExecOrderStatus,
3214 account_id: AccountId,
3215 ts_init: UnixNanos,
3216 ) -> Result<Option<OrderStatusReport>> {
3217 if matches!(order_status, HyperliquidExecOrderStatus::Tag(_)) {
3218 return Ok(None);
3219 }
3220
3221 let symbol = instrument_id.symbol.as_str();
3222 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
3223
3224 let asset = cache_alias_for_symbol(symbol).unwrap_or_else(|| symbol.to_string());
3227 let instrument = self
3228 .get_or_create_instrument(&Ustr::from(asset.as_str()), product_type)
3229 .ok_or_else(|| {
3230 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3231 })?;
3232
3233 let report = match order_status {
3234 HyperliquidExecOrderStatus::Resting { resting } => self.create_order_status_report(
3235 instrument_id,
3236 Some(client_order_id),
3237 VenueOrderId::new(resting.oid.to_string()),
3238 order_side,
3239 order_type,
3240 quantity,
3241 time_in_force,
3242 price,
3243 trigger_price,
3244 OrderStatus::Accepted,
3245 Quantity::zero(instrument.size_precision()),
3246 &instrument,
3247 account_id,
3248 ts_init,
3249 ),
3250 HyperliquidExecOrderStatus::Filled { filled } => {
3251 let filled_qty =
3252 Quantity::from_decimal_dp(filled.total_sz, instrument.size_precision())
3253 .map_err(|e| {
3254 Error::bad_request(format!(
3255 "Invalid filled size {}: {e}",
3256 filled.total_sz
3257 ))
3258 })?;
3259 self.create_order_status_report(
3260 instrument_id,
3261 Some(client_order_id),
3262 VenueOrderId::new(filled.oid.to_string()),
3263 order_side,
3264 order_type,
3265 quantity,
3266 time_in_force,
3267 price,
3268 trigger_price,
3269 OrderStatus::Filled,
3270 filled_qty,
3271 &instrument,
3272 account_id,
3273 ts_init,
3274 )
3275 }
3276 HyperliquidExecOrderStatus::Error { error } => {
3277 return Err(Error::bad_request(format!(
3278 "Order {client_order_id} rejected: {error}"
3279 )));
3280 }
3281 HyperliquidExecOrderStatus::Tag(_) => unreachable!("handled above"),
3282 };
3283
3284 Ok(Some(report))
3285 }
3286}
3287
3288fn parse_order_response(
3293 response: HyperliquidExchangeResponse,
3294) -> Result<HyperliquidExecOrderResponseData> {
3295 let response_data = match response {
3296 HyperliquidExchangeResponse::Status {
3297 status,
3298 response: response_data,
3299 } if status == RESPONSE_STATUS_OK => response_data,
3300 HyperliquidExchangeResponse::Error { error } => {
3301 return Err(Error::bad_request(format!(
3302 "Order submission failed: {error}"
3303 )));
3304 }
3305 _ => return Err(Error::bad_request("Unexpected response format")),
3306 };
3307
3308 let data_value = if let Some(data) = response_data.get("data") {
3309 data.clone()
3310 } else {
3311 response_data
3312 };
3313
3314 serde_json::from_value(data_value)
3315 .map_err(|e| Error::bad_request(format!("Failed to parse order response: {e}")))
3316}
3317
3318fn resolve_perp_dex_name(
3319 dex_index: usize,
3320 meta: &PerpMeta,
3321 perp_dexs: Option<&[Option<PerpDex>]>,
3322) -> String {
3323 if dex_index == 0 {
3324 return String::new();
3325 }
3326
3327 if let Some(dex_name) = perp_dexs
3328 .and_then(|dexs| dexs.get(dex_index))
3329 .and_then(|dex| dex.as_ref())
3330 .map(|dex| dex.name.clone())
3331 {
3332 return dex_name;
3333 }
3334
3335 meta.universe
3336 .iter()
3337 .find_map(|asset| asset.name.split_once(':').map(|(dex, _)| dex.to_string()))
3338 .unwrap_or_default()
3339}
3340
3341fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
3346 if dex_index == 0 {
3347 0
3348 } else {
3349 100_000 + dex_index as u32 * 10_000
3350 }
3351}
3352
3353#[cfg(test)]
3354mod tests {
3355 use std::{net::SocketAddr, sync::Arc};
3356
3357 use axum::{
3358 Router,
3359 extract::State,
3360 http::StatusCode,
3361 response::{IntoResponse, Json, Response},
3362 routing::post,
3363 };
3364 use nautilus_core::{MUTEX_POISONED, time::get_atomic_clock_realtime};
3365 use nautilus_model::{
3366 currencies::CURRENCY_MAP,
3367 enums::{CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce},
3368 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol},
3369 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
3370 types::{Currency, Price, Quantity},
3371 };
3372 use rstest::rstest;
3373 use rust_decimal_macros::dec;
3374 use serde_json::{Value, json};
3375 use ustr::Ustr;
3376
3377 use super::{HyperliquidHttpClient, resolve_perp_dex_name};
3378 use crate::{
3379 common::{
3380 consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS},
3381 enums::{HyperliquidEnvironment, HyperliquidProductType},
3382 },
3383 http::{
3384 models::{Cloid, HyperliquidExchangeResponse, PerpAsset, PerpDex, PerpMeta},
3385 query::InfoRequest,
3386 },
3387 };
3388
3389 const TEST_PRIVATE_KEY: &str =
3390 "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
3391
3392 fn perp_meta_with_assets(names: &[&str]) -> PerpMeta {
3393 PerpMeta {
3394 universe: names
3395 .iter()
3396 .map(|name| PerpAsset {
3397 name: (*name).to_string(),
3398 ..Default::default()
3399 })
3400 .collect(),
3401 margin_tables: Vec::new(),
3402 collateral_token: None,
3403 }
3404 }
3405
3406 #[rstest]
3407 fn resolve_perp_dex_name_uses_empty_string_for_default_dex() {
3408 let meta = perp_meta_with_assets(&["BTC", "ETH"]);
3409 assert_eq!(resolve_perp_dex_name(0, &meta, None), "");
3410 }
3411
3412 #[rstest]
3413 fn resolve_perp_dex_name_prefers_perp_dexs_entry() {
3414 let meta = perp_meta_with_assets(&["xyz:TSLA"]);
3415 let perp_dexs = vec![
3416 None,
3417 Some(PerpDex {
3418 name: "xyz".to_string(),
3419 }),
3420 ];
3421 assert_eq!(resolve_perp_dex_name(1, &meta, Some(&perp_dexs)), "xyz");
3422 }
3423
3424 #[rstest]
3425 fn resolve_perp_dex_name_infers_from_asset_name_when_perp_dexs_missing() {
3426 let meta = perp_meta_with_assets(&["abc:TSLA", "abc:NVDA"]);
3427 assert_eq!(resolve_perp_dex_name(1, &meta, None), "abc");
3428 }
3429
3430 #[rstest]
3431 fn test_build_submit_order_report_elides_waiting_for_fill_tag() {
3432 let mut client =
3436 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3437 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
3438
3439 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
3440 "status": "ok",
3441 "response": {
3442 "type": "order",
3443 "data": {
3444 "statuses": ["waitingForFill"]
3445 }
3446 }
3447 }))
3448 .unwrap();
3449
3450 let result = client
3451 .build_submit_order_report(
3452 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
3453 ClientOrderId::from("O-WAITING-CHILD"),
3454 OrderSide::Buy,
3455 OrderType::StopMarket,
3456 Quantity::from("100"),
3457 TimeInForce::Gtc,
3458 None,
3459 Some(Price::from("0.16136")),
3460 response,
3461 )
3462 .unwrap();
3463
3464 assert!(
3465 result.is_none(),
3466 "Tag status must elide so the order stays SUBMITTED, was {result:?}"
3467 );
3468 }
3469
3470 #[rstest]
3471 fn test_build_submit_order_report_filled_uses_total_sz_decimal() {
3472 let mut client =
3476 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3477 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
3478
3479 let base = Currency::new("ARB", 8, 0, "ARB", CurrencyType::Crypto);
3480 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
3481 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3482 let clock = get_atomic_clock_realtime();
3483 let ts = clock.get_time_ns();
3484 let perp = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
3485 InstrumentId::new(Symbol::new("ARB-USD-PERP"), *HYPERLIQUID_VENUE),
3486 Symbol::new("ARB"),
3487 base,
3488 usd,
3489 usdc,
3490 false,
3491 5,
3492 2,
3493 Price::from("0.00001"),
3494 Quantity::from("0.01"),
3495 None,
3496 None,
3497 None,
3498 None,
3499 None,
3500 None,
3501 None,
3502 None,
3503 None,
3504 None,
3505 None,
3506 None,
3507 None,
3508 None,
3509 ts,
3510 ts,
3511 ));
3512 client.cache_instrument(&perp);
3513
3514 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
3515 "status": "ok",
3516 "response": {
3517 "type": "order",
3518 "data": {
3519 "statuses": [{
3520 "filled": {"totalSz": "0.5", "avgPx": "1.2345", "oid": 778899}
3521 }]
3522 }
3523 }
3524 }))
3525 .unwrap();
3526
3527 let report = client
3528 .build_submit_order_report(
3529 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
3530 ClientOrderId::from("O-FILLED-001"),
3531 OrderSide::Buy,
3532 OrderType::Market,
3533 Quantity::from("0.5"),
3534 TimeInForce::Ioc,
3535 None,
3536 None,
3537 response,
3538 )
3539 .unwrap()
3540 .expect("filled status must produce a report");
3541
3542 assert_eq!(report.order_status, OrderStatus::Filled);
3543 assert_eq!(report.venue_order_id.as_str(), "778899");
3544 assert_eq!(report.filled_qty.as_decimal(), dec!(0.5));
3545 }
3546
3547 #[derive(Clone, Default)]
3548 struct OutcomeMetaServerState {
3549 last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
3550 }
3551
3552 async fn handle_outcome_meta_info(
3553 State(state): State<OutcomeMetaServerState>,
3554 body: axum::body::Bytes,
3555 ) -> Response {
3556 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
3557 return (
3558 StatusCode::BAD_REQUEST,
3559 Json(json!({"error": "Invalid JSON body"})),
3560 )
3561 .into_response();
3562 };
3563
3564 *state.last_request_body.lock().await = Some(request_body.clone());
3565
3566 if request_body.get("type").and_then(|value| value.as_str()) != Some("outcomeMeta") {
3567 return (
3568 StatusCode::BAD_REQUEST,
3569 Json(json!({"error": "Expected outcomeMeta request"})),
3570 )
3571 .into_response();
3572 }
3573
3574 Json(json!({
3575 "outcomes": [
3576 {
3577 "outcome": 123,
3578 "name": "Recurring",
3579 "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m",
3580 "sideSpecs": [
3581 {"name": "Yes"},
3582 {"name": "No"}
3583 ]
3584 }
3585 ]
3586 }))
3587 .into_response()
3588 }
3589
3590 async fn start_outcome_meta_server(state: OutcomeMetaServerState) -> SocketAddr {
3591 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3592 let addr = listener.local_addr().unwrap();
3593 let router = Router::new()
3594 .route("/info", post(handle_outcome_meta_info))
3595 .with_state(state);
3596
3597 tokio::spawn(async move {
3598 axum::serve(listener, router).await.unwrap();
3599 });
3600
3601 addr
3602 }
3603
3604 async fn handle_unresolved_collateral_info(body: axum::body::Bytes) -> Response {
3605 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
3606 return (
3607 StatusCode::BAD_REQUEST,
3608 Json(json!({"error": "Invalid JSON body"})),
3609 )
3610 .into_response();
3611 };
3612
3613 match request_body.get("type").and_then(|value| value.as_str()) {
3614 Some("spotMeta") => (
3615 StatusCode::INTERNAL_SERVER_ERROR,
3616 Json(json!({"error": "spot metadata unavailable"})),
3617 )
3618 .into_response(),
3619 Some("allPerpMetas") => Json(json!([
3620 {
3621 "collateralToken": 360,
3622 "marginTables": [],
3623 "universe": [
3624 {
3625 "maxLeverage": 20,
3626 "name": "km:US500",
3627 "szDecimals": 3
3628 }
3629 ]
3630 }
3631 ]))
3632 .into_response(),
3633 _ => Json(json!({"universe": [], "marginTables": []})).into_response(),
3634 }
3635 }
3636
3637 async fn start_unresolved_collateral_server() -> SocketAddr {
3638 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3639 let addr = listener.local_addr().unwrap();
3640 let router = Router::new().route("/info", post(handle_unresolved_collateral_info));
3641
3642 tokio::spawn(async move {
3643 axum::serve(listener, router).await.unwrap();
3644 });
3645
3646 addr
3647 }
3648
3649 #[rstest]
3650 fn stable_json_roundtrips() {
3651 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
3652 let s = serde_json::to_string(&v).unwrap();
3653 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
3655 assert_eq!(parsed["type"], "l2Book");
3656 assert_eq!(parsed["coin"], "BTC");
3657 assert_eq!(parsed, v);
3658 }
3659
3660 #[rstest]
3661 fn info_pretty_shape() {
3662 let r = InfoRequest::l2_book("BTC");
3663 let val = serde_json::to_value(&r).unwrap();
3664 let pretty = serde_json::to_string_pretty(&val).unwrap();
3665 assert!(pretty.contains("\"type\": \"l2Book\""));
3666 assert!(pretty.contains("\"coin\": \"BTC\""));
3667 }
3668
3669 #[rstest]
3670 fn test_client_order_id_cloid_cache_is_stable_and_first_write_wins() {
3671 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3672 let client_order_id = ClientOrderId::new("O-CLOID-CACHE");
3673 let other_client_order_id = ClientOrderId::new("O-CLOID-CACHE-OTHER");
3674 let explicit_cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
3675
3676 let first = client.get_or_generate_client_order_id_cloid(client_order_id);
3677 let second = client.get_or_generate_client_order_id_cloid(client_order_id);
3678 client.cache_client_order_id_cloid(client_order_id, explicit_cloid);
3679 client.cache_client_order_id_cloid(other_client_order_id, explicit_cloid);
3680
3681 assert_eq!(first, Cloid::from_client_order_id(client_order_id));
3682 assert_eq!(first, second);
3683 assert_eq!(
3684 client.cached_client_order_id_cloid(&client_order_id),
3685 Some(first),
3686 "cache insert must not overwrite an existing generated CLOID",
3687 );
3688 assert_eq!(
3689 client.cached_client_order_id_cloid(&other_client_order_id),
3690 Some(explicit_cloid),
3691 );
3692 assert_eq!(
3693 client.remove_client_order_id_cloid(&client_order_id),
3694 Some(first),
3695 );
3696 assert_eq!(client.cached_client_order_id_cloid(&client_order_id), None);
3697 }
3698
3699 #[rstest]
3700 fn test_builder_attribution_defaults_to_mainnet_builder() {
3701 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3702 let builder = client
3703 .builder_attribution()
3704 .expect("mainnet client should include builder attribution by default");
3705
3706 assert!(client.include_builder_attribution());
3707 assert_eq!(builder.address, NAUTILUS_BUILDER_ADDRESS);
3708 assert_eq!(builder.fee_tenths_bp, 0);
3709 }
3710
3711 #[rstest]
3712 fn test_builder_attribution_disabled_returns_none() {
3713 let mut client =
3714 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3715 client.set_include_builder_attribution(false);
3716
3717 assert!(!client.include_builder_attribution());
3718 assert!(client.builder_attribution().is_none());
3719 }
3720
3721 #[rstest]
3722 fn test_builder_attribution_omitted_on_testnet() {
3723 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
3724
3725 assert!(client.include_builder_attribution());
3726 assert!(client.builder_attribution().is_none());
3727 }
3728
3729 #[rstest]
3730 #[tokio::test]
3731 async fn test_production_client_get_outcome_meta_uses_outcome_meta_request() {
3732 let state = OutcomeMetaServerState::default();
3733 let addr = start_outcome_meta_server(state.clone()).await;
3734 let mut client =
3735 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3736 client.set_base_info_url(format!("http://{addr}/info"));
3737
3738 let meta = client.get_outcome_meta().await.unwrap();
3739 let request_body = state.last_request_body.lock().await.clone().unwrap();
3740
3741 assert_eq!(request_body, json!({"type": "outcomeMeta"}));
3742 assert_eq!(meta.outcomes.len(), 1);
3743 assert_eq!(meta.outcomes[0].outcome, 123);
3744 assert_eq!(meta.outcomes[0].name, "Recurring");
3745 assert_eq!(meta.outcomes[0].side_specs.len(), 2);
3746 assert_eq!(meta.outcomes[0].side_specs[0].name, "Yes");
3747 assert_eq!(meta.outcomes[0].side_specs[1].name, "No");
3748 }
3749
3750 #[rstest]
3751 #[tokio::test]
3752 async fn test_request_instrument_defs_errors_when_non_usdc_collateral_unresolved() {
3753 let addr = start_unresolved_collateral_server().await;
3754 let mut client =
3755 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3756 client.set_base_info_url(format!("http://{addr}/info"));
3757
3758 let err = client.request_instrument_defs().await.unwrap_err();
3759
3760 assert_eq!(
3761 err.to_string(),
3762 "decode error: failed to resolve perp settlement currency for dex 0: \
3763 Spot metadata required to resolve perp collateral token 360",
3764 );
3765 }
3766
3767 #[rstest]
3768 fn test_with_credentials_preserves_explicit_account_address() {
3769 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
3770 let client = HyperliquidHttpClient::with_credentials(
3771 Some(TEST_PRIVATE_KEY.to_string()),
3772 None,
3773 Some(account_address),
3774 HyperliquidEnvironment::Mainnet,
3775 60,
3776 None,
3777 )
3778 .unwrap();
3779
3780 assert_eq!(client.get_account_address().unwrap(), account_address);
3781 }
3782
3783 #[rstest]
3784 fn test_from_resolved_credentials_preserves_account_address_without_private_key() {
3785 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
3786 let client = HyperliquidHttpClient::from_resolved_credentials(
3787 None,
3788 None,
3789 Some(account_address.to_string()),
3790 HyperliquidEnvironment::Mainnet,
3791 60,
3792 None,
3793 )
3794 .unwrap();
3795
3796 assert_eq!(client.get_account_address().unwrap(), account_address);
3797 }
3798
3799 #[rstest]
3800 fn test_cache_instrument_by_raw_symbol() {
3801 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3802
3803 let base_code = "vntls:vCURSOR";
3805 let quote_code = "USDC";
3806
3807 {
3809 let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
3810 if !currency_map.contains_key(base_code) {
3811 currency_map.insert(
3812 base_code.to_string(),
3813 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
3814 );
3815 }
3816 }
3817
3818 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
3819 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
3820
3821 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
3823 let venue = *HYPERLIQUID_VENUE;
3824 let instrument_id = InstrumentId::new(symbol, venue);
3825
3826 let raw_symbol = Symbol::new(base_code);
3828
3829 let clock = get_atomic_clock_realtime();
3830 let ts = clock.get_time_ns();
3831
3832 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
3833 instrument_id,
3834 raw_symbol,
3835 base_currency,
3836 quote_currency,
3837 8,
3838 8,
3839 Price::from("0.00000001"),
3840 Quantity::from("0.00000001"),
3841 None,
3842 None,
3843 None,
3844 None,
3845 None,
3846 None,
3847 None,
3848 None,
3849 None,
3850 None,
3851 None, None, None, None, ts,
3856 ts,
3857 ));
3858
3859 client.cache_instrument(&instrument);
3861
3862 let instruments = client.instruments.load();
3864 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
3865 assert!(
3866 by_full_symbol.is_some(),
3867 "Instrument should be accessible by full symbol"
3868 );
3869 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
3870
3871 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
3873 assert!(
3874 by_raw_symbol.is_some(),
3875 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
3876 );
3877 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
3878 drop(instruments);
3879
3880 let instruments_by_coin = client.instruments_by_coin.load();
3882 let by_coin =
3883 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
3884 assert!(
3885 by_coin.is_some(),
3886 "Instrument should be accessible by coin and product type"
3887 );
3888 assert_eq!(by_coin.unwrap().id(), instrument.id());
3889 drop(instruments_by_coin);
3890
3891 let retrieved_with_type = client.get_or_create_instrument(
3893 &Ustr::from("vntls:vCURSOR"),
3894 Some(HyperliquidProductType::Spot),
3895 );
3896 assert!(retrieved_with_type.is_some());
3897 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
3898
3899 let retrieved_without_type =
3901 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
3902 assert!(retrieved_without_type.is_some());
3903 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
3904 }
3905
3906 #[rstest]
3907 fn test_get_or_create_instrument_outcome_fallback_no_product_type() {
3908 use nautilus_core::time::get_atomic_clock_realtime;
3914 use nautilus_model::{
3915 enums::AssetClass,
3916 identifiers::{InstrumentId, Symbol},
3917 instruments::{BinaryOption, InstrumentAny},
3918 types::{Currency, Price, Quantity},
3919 };
3920
3921 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3922 let coin = "#500";
3923 let token = "+500";
3924
3925 let usdh = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
3926 let symbol = Symbol::new(token);
3927 let raw_symbol = Symbol::new(coin);
3928 let venue = *HYPERLIQUID_VENUE;
3929 let instrument_id = InstrumentId::new(symbol, venue);
3930
3931 let clock = get_atomic_clock_realtime();
3932 let ts = clock.get_time_ns();
3933
3934 let binary = InstrumentAny::BinaryOption(BinaryOption::new(
3935 instrument_id,
3936 raw_symbol,
3937 AssetClass::Alternative,
3938 usdh,
3939 Default::default(),
3940 Default::default(),
3941 4,
3942 2,
3943 Price::from("0.0001"),
3944 Quantity::from("0.01"),
3945 None,
3946 None,
3947 None,
3948 None,
3949 None,
3950 None,
3951 None,
3952 None,
3953 None,
3954 None,
3955 None,
3956 None,
3957 None,
3958 None,
3959 ts,
3960 ts,
3961 ));
3962
3963 client.cache_instrument(&binary);
3964
3965 let with_type = client
3966 .get_or_create_instrument(&Ustr::from(coin), Some(HyperliquidProductType::Outcome));
3967 assert!(with_type.is_some());
3968 assert_eq!(with_type.unwrap().id(), instrument_id);
3969
3970 let no_type = client.get_or_create_instrument(&Ustr::from(coin), None);
3971 assert!(
3972 no_type.is_some(),
3973 "Outcome coin must resolve through the no-product fallback",
3974 );
3975 assert_eq!(no_type.unwrap().id(), instrument_id);
3976
3977 let missing = client.get_or_create_instrument(&Ustr::from("#9999"), None);
3978 assert!(missing.is_none());
3979 }
3980
3981 #[rstest]
3982 fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
3983 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3988
3989 let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
3990 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3991 let clock = get_atomic_clock_realtime();
3992 let ts = clock.get_time_ns();
3993
3994 let canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
3995 InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
3996 Symbol::new("@107"),
3997 hype,
3998 usdc,
3999 5,
4000 2,
4001 Price::from("0.00001"),
4002 Quantity::from("0.01"),
4003 None,
4004 None,
4005 None,
4006 None,
4007 None,
4008 None,
4009 None,
4010 None,
4011 None,
4012 None,
4013 None,
4014 None,
4015 None,
4016 None,
4017 ts,
4018 ts,
4019 ));
4020
4021 let non_canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
4022 InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
4023 Symbol::new("@999"),
4024 hype,
4025 usdc,
4026 5,
4027 2,
4028 Price::from("0.00001"),
4029 Quantity::from("0.01"),
4030 None,
4031 None,
4032 None,
4033 None,
4034 None,
4035 None,
4036 None,
4037 None,
4038 None,
4039 None,
4040 None,
4041 None,
4042 None,
4043 None,
4044 ts,
4045 ts,
4046 ));
4047
4048 client.cache_instrument(&canonical);
4049 client.cache_instrument(&non_canonical);
4050
4051 let instruments_by_coin = client.instruments_by_coin.load();
4052 let by_base = instruments_by_coin
4053 .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
4054 .expect("base alias must resolve");
4055 assert_eq!(
4056 by_base.raw_symbol().inner().as_str(),
4057 "@107",
4058 "base alias must point to the canonical pair, not the one cached later",
4059 );
4060 }
4061
4062 #[rstest]
4063 fn test_cache_instrument_perp_aliases_sanitized_base() {
4064 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4070
4071 let base_currency = Currency::new(
4072 "dex:STREAMABCD****",
4073 8,
4074 0,
4075 "dex:STREAMABCD****",
4076 CurrencyType::Crypto,
4077 );
4078 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4079 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4080 let clock = get_atomic_clock_realtime();
4081 let ts = clock.get_time_ns();
4082
4083 let hip3 = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
4084 InstrumentId::new(
4085 Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
4086 *HYPERLIQUID_VENUE,
4087 ),
4088 Symbol::new("dex:STREAMABCD****"),
4089 base_currency,
4090 usd,
4091 usdc,
4092 false,
4093 6,
4094 3,
4095 Price::from("0.000001"),
4096 Quantity::from("0.001"),
4097 None,
4098 None,
4099 None,
4100 None,
4101 None,
4102 None,
4103 None,
4104 None,
4105 None,
4106 None,
4107 None,
4108 None,
4109 None,
4110 None,
4111 ts,
4112 ts,
4113 ));
4114
4115 client.cache_instrument(&hip3);
4116
4117 let instruments_by_coin = client.instruments_by_coin.load();
4118 let by_raw = instruments_by_coin
4119 .get(&(
4120 Ustr::from("dex:STREAMABCD****"),
4121 HyperliquidProductType::Perp,
4122 ))
4123 .expect("venue coin lookup must resolve");
4124 assert_eq!(by_raw.id(), hip3.id());
4125
4126 let by_sanitized = instruments_by_coin
4127 .get(&(
4128 Ustr::from("dex:STREAMABCDxxxx"),
4129 HyperliquidProductType::Perp,
4130 ))
4131 .expect("sanitized base lookup must resolve");
4132 assert_eq!(by_sanitized.id(), hip3.id());
4133 drop(instruments_by_coin);
4134
4135 let resolved = client
4137 .get_or_create_instrument(
4138 &Ustr::from("dex:STREAMABCDxxxx"),
4139 Some(HyperliquidProductType::Perp),
4140 )
4141 .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
4142 assert_eq!(resolved.id(), hip3.id());
4143 }
4144}