1use std::{
24 collections::HashMap,
25 env,
26 num::NonZeroU32,
27 sync::{Arc, LazyLock},
28 time::Duration,
29};
30
31use ahash::AHashMap;
32use anyhow::Context;
33use nautilus_core::{
34 AtomicMap, UUID4, UnixNanos,
35 consts::NAUTILUS_USER_AGENT,
36 time::{AtomicTime, get_atomic_clock_realtime},
37};
38use nautilus_model::{
39 data::{Bar, BarType},
40 enums::{
41 AccountType, BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce,
42 TriggerType,
43 },
44 events::AccountState,
45 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
46 instruments::{CurrencyPair, Instrument, InstrumentAny},
47 orders::{Order, OrderAny},
48 reports::{FillReport, OrderStatusReport, PositionStatusReport},
49 types::{AccountBalance, Currency, Price, Quantity},
50};
51use nautilus_network::{
52 http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
53 ratelimiter::quota::Quota,
54};
55use rust_decimal::Decimal;
56use serde_json::Value;
57use ustr::Ustr;
58
59use crate::{
60 common::{
61 consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url},
62 credential::{Secrets, VaultAddress},
63 enums::{
64 HyperliquidBarInterval, HyperliquidEnvironment,
65 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
66 },
67 parse::{
68 bar_type_to_interval, clamp_price_to_precision, derive_limit_from_trigger,
69 determine_order_list_grouping, extract_inner_error, normalize_price,
70 order_to_hyperliquid_request_with_asset, parse_combined_account_balances_and_margins,
71 parse_spot_account_balances, round_to_sig_figs, time_in_force_to_hyperliquid_tif,
72 },
73 },
74 data::candle_to_bar,
75 http::{
76 error::{Error, Result},
77 models::{
78 ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeRequest,
79 HyperliquidExchangeResponse, HyperliquidExecAction, HyperliquidExecBuilderFee,
80 HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
81 HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecMergeOutcomeParams,
82 HyperliquidExecMergeQuestionParams, HyperliquidExecModifyOrderRequest,
83 HyperliquidExecNegateOutcomeParams, HyperliquidExecOrderKind,
84 HyperliquidExecOrderResponseData, HyperliquidExecOrderStatus,
85 HyperliquidExecPlaceOrderRequest, HyperliquidExecSplitOutcomeParams,
86 HyperliquidExecTif, HyperliquidExecTpSl, HyperliquidExecTriggerParams,
87 HyperliquidExecUserOutcomeOp, HyperliquidFills, HyperliquidFundingHistoryEntry,
88 HyperliquidL2Book, HyperliquidMeta, HyperliquidOrderStatus, OutcomeMeta, PerpMeta,
89 PerpMetaAndCtxs, RESPONSE_STATUS_OK, SpotClearinghouseState, SpotMeta, SpotMetaAndCtxs,
90 },
91 parse::{
92 HyperliquidInstrumentDef, instruments_from_defs_owned, parse_fill_report,
93 parse_order_status_report_from_basic, parse_outcome_instruments,
94 parse_perp_instruments, parse_position_status_report, parse_spot_instruments,
95 parse_spot_position_status_report,
96 },
97 query::{ExchangeAction, InfoRequest},
98 rate_limits::{
99 RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
100 info_base_weight, info_extra_weight,
101 },
102 },
103 signing::{
104 HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
105 },
106 websocket::messages::WsBasicOrderData,
107};
108
109pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
111 LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
112
113#[derive(Debug, Clone)]
118#[cfg_attr(
119 feature = "python",
120 pyo3::pyclass(
121 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
122 from_py_object
123 )
124)]
125#[cfg_attr(
126 feature = "python",
127 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.hyperliquid")
128)]
129pub struct HyperliquidRawHttpClient {
130 client: HttpClient,
131 environment: HyperliquidEnvironment,
132 base_info: String,
133 base_exchange: String,
134 signer: Option<HyperliquidEip712Signer>,
135 nonce_manager: Option<Arc<NonceManager>>,
136 vault_address: Option<VaultAddress>,
137 rest_limiter: Arc<WeightedLimiter>,
138 rate_limit_backoff_base: Duration,
139 rate_limit_backoff_cap: Duration,
140 rate_limit_max_attempts_info: u32,
141}
142
143impl HyperliquidRawHttpClient {
144 pub fn new(
150 environment: HyperliquidEnvironment,
151 timeout_secs: u64,
152 proxy_url: Option<String>,
153 ) -> std::result::Result<Self, HttpClientError> {
154 Ok(Self {
155 client: HttpClient::new(
156 Self::default_headers(),
157 vec![],
158 vec![],
159 Some(*HYPERLIQUID_REST_QUOTA),
160 Some(timeout_secs),
161 proxy_url,
162 )?,
163 environment,
164 base_info: info_url(environment).to_string(),
165 base_exchange: exchange_url(environment).to_string(),
166 signer: None,
167 nonce_manager: None,
168 vault_address: None,
169 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
170 rate_limit_backoff_base: Duration::from_millis(125),
171 rate_limit_backoff_cap: Duration::from_secs(5),
172 rate_limit_max_attempts_info: 3,
173 })
174 }
175
176 pub fn with_credentials(
183 secrets: &Secrets,
184 timeout_secs: u64,
185 proxy_url: Option<String>,
186 ) -> std::result::Result<Self, HttpClientError> {
187 let signer = HyperliquidEip712Signer::new(&secrets.private_key)
188 .map_err(|e| HttpClientError::from(e.to_string()))?;
189 let nonce_manager = Arc::new(NonceManager::new());
190
191 Ok(Self {
192 client: HttpClient::new(
193 Self::default_headers(),
194 vec![],
195 vec![],
196 Some(*HYPERLIQUID_REST_QUOTA),
197 Some(timeout_secs),
198 proxy_url,
199 )?,
200 environment: secrets.environment,
201 base_info: info_url(secrets.environment).to_string(),
202 base_exchange: exchange_url(secrets.environment).to_string(),
203 signer: Some(signer),
204 nonce_manager: Some(nonce_manager),
205 vault_address: secrets.vault_address,
206 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
207 rate_limit_backoff_base: Duration::from_millis(125),
208 rate_limit_backoff_cap: Duration::from_secs(5),
209 rate_limit_max_attempts_info: 3,
210 })
211 }
212
213 pub fn set_base_info_url(&mut self, url: String) {
215 self.base_info = url;
216 }
217
218 pub fn set_base_exchange_url(&mut self, url: String) {
220 self.base_exchange = url;
221 }
222
223 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
229 let secrets = Secrets::from_env(environment)
230 .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
231 Self::with_credentials(&secrets, 60, None)
232 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
233 }
234
235 pub fn from_credentials(
241 private_key: &str,
242 vault_address: Option<&str>,
243 environment: HyperliquidEnvironment,
244 timeout_secs: u64,
245 proxy_url: Option<String>,
246 ) -> Result<Self> {
247 let secrets = Secrets::from_private_key(private_key, vault_address, environment)
248 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
249 Self::with_credentials(&secrets, timeout_secs, proxy_url)
250 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
251 }
252
253 #[must_use]
255 pub fn with_rate_limits(mut self) -> Self {
256 self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
257 self.rate_limit_backoff_base = Duration::from_millis(125);
258 self.rate_limit_backoff_cap = Duration::from_secs(5);
259 self.rate_limit_max_attempts_info = 3;
260 self
261 }
262
263 #[must_use]
265 pub fn environment(&self) -> HyperliquidEnvironment {
266 self.environment
267 }
268
269 #[must_use]
271 pub fn is_testnet(&self) -> bool {
272 self.environment == HyperliquidEnvironment::Testnet
273 }
274
275 pub fn get_user_address(&self) -> Result<String> {
281 self.signer
282 .as_ref()
283 .ok_or_else(|| Error::auth("No signer configured"))?
284 .address()
285 }
286
287 #[must_use]
289 pub fn has_vault_address(&self) -> bool {
290 self.vault_address.is_some()
291 }
292
293 pub fn get_account_address(&self) -> Result<String> {
300 if let Some(vault) = &self.vault_address {
301 Ok(vault.to_hex())
302 } else {
303 self.get_user_address()
304 }
305 }
306
307 fn default_headers() -> HashMap<String, String> {
308 HashMap::from([
309 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
310 ("Content-Type".to_string(), "application/json".to_string()),
311 ])
312 }
313
314 fn signer_id(&self) -> SignerId {
315 SignerId("hyperliquid:default".into())
316 }
317
318 fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
319 let retry_after = headers.get("retry-after")?;
320 retry_after.parse::<u64>().ok().map(|s| s * 1000) }
322
323 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
325 let request = InfoRequest::meta();
326 let response = self.send_info_request(&request).await?;
327 serde_json::from_value(response).map_err(Error::Serde)
328 }
329
330 pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
332 let request = InfoRequest::spot_meta();
333 let response = self.send_info_request(&request).await?;
334 serde_json::from_value(response).map_err(Error::Serde)
335 }
336
337 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
339 let request = InfoRequest::meta_and_asset_ctxs();
340 let response = self.send_info_request(&request).await?;
341 serde_json::from_value(response).map_err(Error::Serde)
342 }
343
344 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
346 let request = InfoRequest::spot_meta_and_asset_ctxs();
347 let response = self.send_info_request(&request).await?;
348 serde_json::from_value(response).map_err(Error::Serde)
349 }
350
351 pub async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
353 let request = InfoRequest::outcome_meta();
354 let response = self.send_info_request(&request).await?;
355 serde_json::from_value(response).map_err(Error::Serde)
356 }
357
358 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
359 let request = InfoRequest::meta();
360 let response = self.send_info_request(&request).await?;
361 serde_json::from_value(response).map_err(Error::Serde)
362 }
363
364 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
366 let request = InfoRequest::all_perp_metas();
367 let response = self.send_info_request(&request).await?;
368 serde_json::from_value(response).map_err(Error::Serde)
369 }
370
371 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
373 let request = InfoRequest::l2_book(coin);
374 let response = self.send_info_request(&request).await?;
375 serde_json::from_value(response).map_err(Error::Serde)
376 }
377
378 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
380 let request = InfoRequest::user_fills(user);
381 let response = self.send_info_request(&request).await?;
382 serde_json::from_value(response).map_err(Error::Serde)
383 }
384
385 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
387 let request = InfoRequest::order_status(user, oid);
388 let response = self.send_info_request(&request).await?;
389 serde_json::from_value(response).map_err(Error::Serde)
390 }
391
392 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
394 let request = InfoRequest::open_orders(user);
395 self.send_info_request(&request).await
396 }
397
398 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
400 let request = InfoRequest::frontend_open_orders(user);
401 self.send_info_request(&request).await
402 }
403
404 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
406 let request = InfoRequest::clearinghouse_state(user);
407 self.send_info_request(&request).await
408 }
409
410 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
412 let request = InfoRequest::spot_clearinghouse_state(user);
413 self.send_info_request(&request).await
414 }
415
416 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
418 let request = InfoRequest::user_fees(user);
419 self.send_info_request(&request).await
420 }
421
422 pub async fn info_candle_snapshot(
424 &self,
425 coin: &str,
426 interval: HyperliquidBarInterval,
427 start_time: u64,
428 end_time: u64,
429 ) -> Result<HyperliquidCandleSnapshot> {
430 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
431 let response = self.send_info_request(&request).await?;
432
433 log::trace!(
434 "Candle snapshot raw response (len={}): {:?}",
435 response.as_array().map_or(0, |a| a.len()),
436 response
437 );
438
439 serde_json::from_value(response).map_err(Error::Serde)
440 }
441
442 pub async fn info_funding_history(
447 &self,
448 coin: &str,
449 start_time: u64,
450 end_time: Option<u64>,
451 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
452 let request = InfoRequest::funding_history(coin, start_time, end_time);
453 let response = self.send_info_request(&request).await?;
454 serde_json::from_value(response).map_err(Error::Serde)
455 }
456
457 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
459 self.send_info_request(request).await
460 }
461
462 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
463 let base_w = info_base_weight(request);
464 self.rest_limiter.acquire(base_w).await;
465
466 let mut attempt = 0u32;
467
468 loop {
469 let response = self.http_roundtrip_info(request).await?;
470
471 if response.status.is_success() {
472 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
474 let extra = info_extra_weight(request, &val);
475 if extra > 0 {
476 self.rest_limiter.debit_extra(extra).await;
477 log::debug!(
478 "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
479 );
480 }
481 return Ok(val);
482 }
483
484 if response.status.as_u16() == 429 {
486 if attempt >= self.rate_limit_max_attempts_info {
487 let ra = self.parse_retry_after_simple(&response.headers);
488 return Err(Error::rate_limit("info", base_w, ra));
489 }
490 let delay = self
491 .parse_retry_after_simple(&response.headers)
492 .map_or_else(
493 || {
494 backoff_full_jitter(
495 attempt,
496 self.rate_limit_backoff_base,
497 self.rate_limit_backoff_cap,
498 )
499 },
500 Duration::from_millis,
501 );
502 log::warn!(
503 "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
504 delay.as_millis()
505 );
506 attempt += 1;
507 tokio::time::sleep(delay).await;
508 self.rest_limiter.acquire(1).await;
510 continue;
511 }
512
513 if (response.status.is_server_error() || response.status.as_u16() == 408)
515 && attempt < self.rate_limit_max_attempts_info
516 {
517 let delay = backoff_full_jitter(
518 attempt,
519 self.rate_limit_backoff_base,
520 self.rate_limit_backoff_cap,
521 );
522 log::warn!(
523 "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
524 response.status.as_u16(),
525 delay.as_millis()
526 );
527 attempt += 1;
528 tokio::time::sleep(delay).await;
529 continue;
530 }
531
532 let error_body = String::from_utf8_lossy(&response.body);
534 return Err(Error::http(
535 response.status.as_u16(),
536 error_body.to_string(),
537 ));
538 }
539 }
540
541 async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
542 let url = &self.base_info;
543 let body = serde_json::to_value(request).map_err(Error::Serde)?;
544 let body_bytes = serde_json::to_string(&body)
545 .map_err(Error::Serde)?
546 .into_bytes();
547
548 self.client
549 .request(
550 Method::POST,
551 url.clone(),
552 None,
553 None,
554 Some(body_bytes),
555 None,
556 None,
557 )
558 .await
559 .map_err(Error::from_http_client)
560 }
561
562 pub async fn post_action(
564 &self,
565 action: &ExchangeAction,
566 ) -> Result<HyperliquidExchangeResponse> {
567 let w = exchange_weight(action);
568 self.rest_limiter.acquire(w).await;
569
570 let signer = self
571 .signer
572 .as_ref()
573 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
574
575 let nonce_manager = self
576 .nonce_manager
577 .as_ref()
578 .ok_or_else(|| Error::auth("nonce manager missing"))?;
579
580 let signer_id = self.signer_id();
581 let time_nonce = nonce_manager.next(signer_id)?;
582
583 let action_value = serde_json::to_value(action)
584 .context("serialize exchange action")
585 .map_err(|e| Error::bad_request(e.to_string()))?;
586
587 let action_bytes = rmp_serde::to_vec_named(action)
589 .context("serialize action with MessagePack")
590 .map_err(|e| Error::bad_request(e.to_string()))?;
591
592 let sign_request = SignRequest {
593 action: action_value,
594 action_bytes: Some(action_bytes),
595 time_nonce,
596 action_type: HyperliquidActionType::L1,
597 is_testnet: self.is_testnet(),
598 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
599 };
600
601 let sig = signer.sign(&sign_request)?.signature;
602
603 let nonce_u64 = time_nonce.as_millis() as u64;
604
605 let request = if let Some(vault) = self.vault_address {
606 HyperliquidExchangeRequest::with_vault(
607 action.clone(),
608 nonce_u64,
609 sig,
610 vault.to_string(),
611 )
612 } else {
613 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
614 };
615
616 let response = self.http_roundtrip_exchange(&request).await?;
617
618 if response.status.is_success() {
619 let parsed_response: HyperliquidExchangeResponse =
620 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
621
622 match &parsed_response {
624 HyperliquidExchangeResponse::Status {
625 status,
626 response: response_data,
627 } if status == "err" => {
628 let error_msg = response_data
629 .as_str()
630 .map_or_else(|| response_data.to_string(), |s| s.to_string());
631 log::error!("Hyperliquid API returned error: {error_msg}");
632 Err(Error::bad_request(format!("API error: {error_msg}")))
633 }
634 HyperliquidExchangeResponse::Error { error } => {
635 log::error!("Hyperliquid API returned error: {error}");
636 Err(Error::bad_request(format!("API error: {error}")))
637 }
638 _ => Ok(parsed_response),
639 }
640 } else if response.status.as_u16() == 429 {
641 let ra = self.parse_retry_after_simple(&response.headers);
642 Err(Error::rate_limit("exchange", w, ra))
643 } else {
644 let error_body = String::from_utf8_lossy(&response.body);
645 log::error!(
646 "Exchange API error (status {}): {}",
647 response.status.as_u16(),
648 error_body
649 );
650 Err(Error::http(
651 response.status.as_u16(),
652 error_body.to_string(),
653 ))
654 }
655 }
656
657 pub async fn post_action_exec(
662 &self,
663 action: &HyperliquidExecAction,
664 ) -> Result<HyperliquidExchangeResponse> {
665 let w = match action {
666 HyperliquidExecAction::Order { orders, .. } => 1 + (orders.len() as u32 / 40),
667 HyperliquidExecAction::Cancel { cancels } => 1 + (cancels.len() as u32 / 40),
668 HyperliquidExecAction::CancelByCloid { cancels } => 1 + (cancels.len() as u32 / 40),
669 HyperliquidExecAction::BatchModify { modifies } => 1 + (modifies.len() as u32 / 40),
670 _ => 1,
671 };
672 self.rest_limiter.acquire(w).await;
673
674 let signer = self
675 .signer
676 .as_ref()
677 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
678
679 let nonce_manager = self
680 .nonce_manager
681 .as_ref()
682 .ok_or_else(|| Error::auth("nonce manager missing"))?;
683
684 let signer_id = self.signer_id();
685 let time_nonce = nonce_manager.next(signer_id)?;
686 let action_value = serde_json::to_value(action)
689 .context("serialize exchange action")
690 .map_err(|e| Error::bad_request(e.to_string()))?;
691
692 let action_bytes = rmp_serde::to_vec_named(action)
694 .context("serialize action with MessagePack")
695 .map_err(|e| Error::bad_request(e.to_string()))?;
696
697 let sig = signer
698 .sign(&SignRequest {
699 action: action_value,
700 action_bytes: Some(action_bytes),
701 time_nonce,
702 action_type: HyperliquidActionType::L1,
703 is_testnet: self.is_testnet(),
704 vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
705 })?
706 .signature;
707
708 let request = if let Some(vault) = self.vault_address {
709 HyperliquidExchangeRequest::with_vault(
710 action.clone(),
711 time_nonce.as_millis() as u64,
712 sig,
713 vault.to_string(),
714 )
715 } else {
716 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
717 };
718
719 let response = self.http_roundtrip_exchange(&request).await?;
720
721 if response.status.is_success() {
722 let parsed_response: HyperliquidExchangeResponse =
723 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
724
725 match &parsed_response {
727 HyperliquidExchangeResponse::Status {
728 status,
729 response: response_data,
730 } if status == "err" => {
731 let error_msg = response_data
732 .as_str()
733 .map_or_else(|| response_data.to_string(), |s| s.to_string());
734 log::error!("Hyperliquid API returned error: {error_msg}");
735 Err(Error::bad_request(format!("API error: {error_msg}")))
736 }
737 HyperliquidExchangeResponse::Error { error } => {
738 log::error!("Hyperliquid API returned error: {error}");
739 Err(Error::bad_request(format!("API error: {error}")))
740 }
741 _ => Ok(parsed_response),
742 }
743 } else if response.status.as_u16() == 429 {
744 let ra = self.parse_retry_after_simple(&response.headers);
745 Err(Error::rate_limit("exchange", w, ra))
746 } else {
747 let error_body = String::from_utf8_lossy(&response.body);
748 Err(Error::http(
749 response.status.as_u16(),
750 error_body.to_string(),
751 ))
752 }
753 }
754
755 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
758 self.rest_limiter.snapshot().await
759 }
760 async fn http_roundtrip_exchange<T>(
761 &self,
762 request: &HyperliquidExchangeRequest<T>,
763 ) -> Result<HttpResponse>
764 where
765 T: serde::Serialize,
766 {
767 let url = &self.base_exchange;
768 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
769 let body_bytes = body.into_bytes();
770
771 let response = self
772 .client
773 .request(
774 Method::POST,
775 url.clone(),
776 None,
777 None,
778 Some(body_bytes),
779 None,
780 None,
781 )
782 .await
783 .map_err(Error::from_http_client)?;
784
785 Ok(response)
786 }
787}
788
789#[derive(Debug, Clone)]
795#[cfg_attr(
796 feature = "python",
797 pyo3::pyclass(
798 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
799 from_py_object
800 )
801)]
802#[cfg_attr(
803 feature = "python",
804 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.hyperliquid")
805)]
806pub struct HyperliquidHttpClient {
807 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
808 clock: &'static AtomicTime,
809 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
810 instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
811 asset_indices: Arc<AtomicMap<Ustr, u32>>,
813 spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
815 account_id: Option<AccountId>,
816 account_address: Option<String>,
820 normalize_prices: bool,
821 market_order_slippage_bps: u32,
822}
823
824impl Default for HyperliquidHttpClient {
825 fn default() -> Self {
826 Self::new(HyperliquidEnvironment::Mainnet, 60, None)
827 .expect("Failed to create default Hyperliquid HTTP client")
828 }
829}
830
831impl HyperliquidHttpClient {
832 pub fn new(
838 environment: HyperliquidEnvironment,
839 timeout_secs: u64,
840 proxy_url: Option<String>,
841 ) -> std::result::Result<Self, HttpClientError> {
842 let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
843 Ok(Self::from_raw(raw_client))
844 }
845
846 pub fn with_secrets(
852 secrets: &Secrets,
853 timeout_secs: u64,
854 proxy_url: Option<String>,
855 ) -> std::result::Result<Self, HttpClientError> {
856 let raw_client =
857 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
858 Ok(Self::from_raw(raw_client))
859 }
860
861 fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
862 Self {
863 inner: Arc::new(raw_client),
864 clock: get_atomic_clock_realtime(),
865 instruments: Arc::new(AtomicMap::new()),
866 instruments_by_coin: Arc::new(AtomicMap::new()),
867 asset_indices: Arc::new(AtomicMap::new()),
868 spot_fill_coins: Arc::new(AtomicMap::new()),
869 account_id: None,
870 account_address: None,
871 normalize_prices: true,
872 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
873 }
874 }
875
876 pub fn set_base_info_url(&mut self, url: String) {
882 Arc::get_mut(&mut self.inner)
883 .expect("cannot override URL: Arc has multiple references")
884 .set_base_info_url(url);
885 }
886
887 pub fn set_base_exchange_url(&mut self, url: String) {
893 Arc::get_mut(&mut self.inner)
894 .expect("cannot override URL: Arc has multiple references")
895 .set_base_exchange_url(url);
896 }
897
898 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
904 let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
905 Ok(Self {
906 inner: Arc::new(raw_client),
907 clock: get_atomic_clock_realtime(),
908 instruments: Arc::new(AtomicMap::new()),
909 instruments_by_coin: Arc::new(AtomicMap::new()),
910 asset_indices: Arc::new(AtomicMap::new()),
911 spot_fill_coins: Arc::new(AtomicMap::new()),
912 account_id: None,
913 account_address: None,
914 normalize_prices: true,
915 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
916 })
917 }
918
919 pub fn with_credentials(
932 private_key: Option<String>,
933 vault_address: Option<String>,
934 account_address: Option<String>,
935 environment: HyperliquidEnvironment,
936 timeout_secs: u64,
937 proxy_url: Option<String>,
938 ) -> Result<Self> {
939 let (pk_env_var, vault_env_var) =
940 crate::common::credential::credential_env_vars(environment);
941
942 let resolved_pk = match private_key {
944 Some(pk) => Some(pk),
945 None => env::var(pk_env_var).ok(),
946 };
947
948 let resolved_vault = match vault_address {
950 Some(vault) => Some(vault),
951 None => env::var(vault_env_var).ok(),
952 };
953
954 let resolved_account_address = match account_address {
956 Some(addr) => Some(addr),
957 None => env::var("HYPERLIQUID_ACCOUNT_ADDRESS").ok(),
958 };
959
960 match resolved_pk {
961 Some(pk) => {
962 let raw_client = HyperliquidRawHttpClient::from_credentials(
963 &pk,
964 resolved_vault.as_deref(),
965 environment,
966 timeout_secs,
967 proxy_url,
968 )?;
969 Ok(Self {
970 inner: Arc::new(raw_client),
971 clock: get_atomic_clock_realtime(),
972 instruments: Arc::new(AtomicMap::new()),
973 instruments_by_coin: Arc::new(AtomicMap::new()),
974 asset_indices: Arc::new(AtomicMap::new()),
975 spot_fill_coins: Arc::new(AtomicMap::new()),
976 account_id: None,
977 account_address: resolved_account_address,
978 normalize_prices: true,
979 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
980 })
981 }
982 None => {
983 Self::new(environment, timeout_secs, proxy_url)
985 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
986 }
987 }
988 }
989
990 pub fn from_credentials(
996 private_key: &str,
997 vault_address: Option<&str>,
998 environment: HyperliquidEnvironment,
999 timeout_secs: u64,
1000 proxy_url: Option<String>,
1001 ) -> Result<Self> {
1002 let raw_client = HyperliquidRawHttpClient::from_credentials(
1003 private_key,
1004 vault_address,
1005 environment,
1006 timeout_secs,
1007 proxy_url,
1008 )?;
1009 Ok(Self {
1010 inner: Arc::new(raw_client),
1011 clock: get_atomic_clock_realtime(),
1012 instruments: Arc::new(AtomicMap::new()),
1013 instruments_by_coin: Arc::new(AtomicMap::new()),
1014 asset_indices: Arc::new(AtomicMap::new()),
1015 spot_fill_coins: Arc::new(AtomicMap::new()),
1016 account_id: None,
1017 account_address: None,
1018 normalize_prices: true,
1019 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1020 })
1021 }
1022
1023 #[must_use]
1025 pub fn is_testnet(&self) -> bool {
1026 self.inner.is_testnet()
1027 }
1028
1029 #[must_use]
1031 pub fn normalize_prices(&self) -> bool {
1032 self.normalize_prices
1033 }
1034
1035 pub fn set_normalize_prices(&mut self, value: bool) {
1037 self.normalize_prices = value;
1038 }
1039
1040 #[must_use]
1042 pub fn market_order_slippage_bps(&self) -> u32 {
1043 self.market_order_slippage_bps
1044 }
1045
1046 pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1048 self.market_order_slippage_bps = value;
1049 }
1050
1051 pub fn get_user_address(&self) -> Result<String> {
1057 self.inner.get_user_address()
1058 }
1059
1060 #[must_use]
1062 pub fn has_vault_address(&self) -> bool {
1063 self.inner.has_vault_address()
1064 }
1065
1066 #[must_use]
1069 pub fn builder_attribution(&self) -> Option<HyperliquidExecBuilderFee> {
1070 if self.has_vault_address() || self.is_testnet() {
1071 None
1072 } else {
1073 Some(HyperliquidExecBuilderFee {
1074 address: NAUTILUS_BUILDER_ADDRESS.to_string(),
1075 fee_tenths_bp: 0,
1076 })
1077 }
1078 }
1079
1080 pub fn get_account_address(&self) -> Result<String> {
1088 if let Some(addr) = &self.account_address {
1089 return Ok(addr.clone());
1090 }
1091 self.inner.get_account_address()
1092 }
1093
1094 pub fn set_account_address(&mut self, address: Option<String>) {
1096 self.account_address = address;
1097 }
1098
1099 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1104 let full_symbol = instrument.symbol().inner();
1105 let coin = instrument.raw_symbol().inner();
1106
1107 self.instruments.rcu(|m| {
1108 m.insert(full_symbol, instrument.clone());
1109 m.insert(coin, instrument.clone());
1111 });
1112
1113 if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
1115 self.instruments_by_coin.rcu(|m| {
1116 m.insert((coin, product_type), instrument.clone());
1117
1118 if let Some(base) = full_symbol.as_str().split('-').next() {
1138 let base_ustr = Ustr::from(base);
1139 let key = (base_ustr, product_type);
1140 if base_ustr != coin && !m.contains_key(&key) {
1141 m.insert(key, instrument.clone());
1142 }
1143 }
1144 });
1145 } else {
1146 log::warn!("Unable to determine product type for symbol: {full_symbol}");
1147 }
1148 }
1149
1150 fn get_or_create_instrument(
1151 &self,
1152 coin: &Ustr,
1153 product_type: Option<HyperliquidProductType>,
1154 ) -> Option<InstrumentAny> {
1155 if let Some(pt) = product_type
1156 && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1157 {
1158 return Some(instrument.clone());
1159 }
1160
1161 if product_type.is_none() {
1165 let guard = self.instruments_by_coin.load();
1166
1167 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Outcome)) {
1168 return Some(instrument.clone());
1169 }
1170
1171 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1172 return Some(instrument.clone());
1173 }
1174
1175 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1176 return Some(instrument.clone());
1177 }
1178 }
1179
1180 if coin.as_str().starts_with('@')
1182 && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1183 {
1184 if let Some(instrument) = self.instruments.load().get(symbol) {
1187 return Some(instrument.clone());
1188 }
1189 }
1190
1191 if coin.as_str().starts_with("vntls:") {
1193 log::info!("Creating synthetic instrument for vault token: {coin}");
1194
1195 let ts_event = self.clock.get_time_ns();
1196
1197 let symbol_str = format!("{coin}-USDC-SPOT");
1199 let symbol = Symbol::new(&symbol_str);
1200 let venue = *HYPERLIQUID_VENUE;
1201 let instrument_id = InstrumentId::new(symbol, venue);
1202
1203 let base_currency = Currency::new(
1205 coin.as_str(),
1206 8, 0, coin.as_str(),
1209 CurrencyType::Crypto,
1210 );
1211
1212 let quote_currency = Currency::new(
1213 "USDC",
1214 6, 0,
1216 "USDC",
1217 CurrencyType::Crypto,
1218 );
1219
1220 let price_increment = Price::from("0.00000001");
1221 let size_increment = Quantity::from("0.00000001");
1222
1223 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1224 instrument_id,
1225 symbol,
1226 base_currency,
1227 quote_currency,
1228 8, 8, price_increment,
1231 size_increment,
1232 None, None, None, None, None, None, None, None, None, None, None, None, None, ts_event,
1246 ts_event,
1247 ));
1248
1249 self.cache_instrument(&instrument);
1250
1251 Some(instrument)
1252 } else {
1253 log::warn!("Instrument not found in cache: {coin}");
1255 None
1256 }
1257 }
1258
1259 pub fn set_account_id(&mut self, account_id: AccountId) {
1263 self.account_id = Some(account_id);
1264 }
1265
1266 pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1268 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1269
1270 match self.inner.load_all_perp_metas().await {
1272 Ok(all_metas) => {
1273 for (dex_index, meta) in all_metas.iter().enumerate() {
1274 let base = perp_dex_asset_index_base(dex_index);
1275
1276 match parse_perp_instruments(meta, base) {
1277 Ok(perp_defs) => {
1278 log::debug!(
1279 "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1280 perp_defs.len(),
1281 );
1282 defs.extend(perp_defs);
1283 }
1284 Err(e) => {
1285 log::warn!("Failed to parse perp instruments for dex {dex_index}: {e}");
1286 }
1287 }
1288 }
1289 }
1290 Err(e) => {
1291 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1292
1293 match self.inner.load_perp_meta().await {
1294 Ok(perp_meta) => match parse_perp_instruments(&perp_meta, 0) {
1295 Ok(perp_defs) => {
1296 log::debug!(
1297 "Loaded Hyperliquid perp defs via fallback: count={}",
1298 perp_defs.len(),
1299 );
1300 defs.extend(perp_defs);
1301 }
1302 Err(e) => {
1303 log::warn!("Failed to parse perp instruments: {e}");
1304 }
1305 },
1306 Err(e) => {
1307 log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1308 }
1309 }
1310 }
1311 }
1312
1313 match self.inner.get_spot_meta().await {
1314 Ok(spot_meta) => match parse_spot_instruments(&spot_meta) {
1315 Ok(spot_defs) => {
1316 log::debug!(
1317 "Loaded Hyperliquid spot definitions: count={}",
1318 spot_defs.len(),
1319 );
1320 defs.extend(spot_defs);
1321 }
1322 Err(e) => {
1323 log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1324 }
1325 },
1326 Err(e) => {
1327 log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1328 }
1329 }
1330
1331 match self.inner.get_outcome_meta().await {
1335 Ok(outcome_meta) => match parse_outcome_instruments(&outcome_meta) {
1336 Ok(outcome_defs) => {
1337 log::debug!(
1338 "Loaded Hyperliquid outcome definitions: count={}",
1339 outcome_defs.len(),
1340 );
1341 defs.extend(outcome_defs);
1342 }
1343 Err(e) => {
1344 log::warn!("Failed to parse Hyperliquid outcome instruments: {e}");
1345 }
1346 },
1347 Err(e) => {
1348 log::debug!("Skipping Hyperliquid outcome metadata: {e}");
1349 }
1350 }
1351
1352 let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1359 let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1360 for def in defs {
1361 if seen_symbols.insert(def.symbol) {
1362 deduped.push(def);
1363 } else {
1364 log::warn!(
1365 "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1366 def.symbol,
1367 def.raw_symbol,
1368 );
1369 }
1370 }
1371 let defs = deduped;
1372
1373 self.asset_indices.rcu(|m| {
1375 for def in &defs {
1376 m.insert(def.symbol, def.asset_index);
1377 }
1378 });
1379 log::debug!(
1380 "Populated asset indices map (count={})",
1381 self.asset_indices.len()
1382 );
1383
1384 Ok(defs)
1385 }
1386
1387 pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1389 let ts_init = self.clock.get_time_ns();
1390 instruments_from_defs_owned(defs, ts_init)
1391 }
1392
1393 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1395 let defs = self.request_instrument_defs().await?;
1396 Ok(self.convert_defs(defs))
1397 }
1398
1399 pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1407 self.asset_indices.load().get(&Ustr::from(symbol)).copied()
1408 }
1409
1410 pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1412 self.instruments
1413 .load()
1414 .get(&Ustr::from(symbol))
1415 .map(|inst| inst.price_precision())
1416 }
1417
1418 #[must_use]
1426 pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1427 const SPOT_INDEX_OFFSET: u32 = 10_000;
1428 const BUILDER_PERP_OFFSET: u32 = 100_000;
1429
1430 let guard = self.asset_indices.load();
1431
1432 let mut mapping = AHashMap::new();
1433
1434 for (symbol, &asset_index) in guard.iter() {
1435 if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1437 let pair_index = asset_index - SPOT_INDEX_OFFSET;
1438 let fill_coin = Ustr::from(&format!("@{pair_index}"));
1439 mapping.insert(fill_coin, *symbol);
1440 }
1441 }
1442
1443 self.spot_fill_coins.store(mapping.clone());
1445
1446 mapping
1447 }
1448
1449 #[allow(dead_code)]
1451 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1452 self.inner.load_perp_meta().await
1453 }
1454
1455 #[allow(dead_code)]
1457 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1458 self.inner.load_all_perp_metas().await
1459 }
1460
1461 #[allow(dead_code)]
1463 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1464 self.inner.get_spot_meta().await
1465 }
1466
1467 pub(crate) async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
1469 self.inner.get_outcome_meta().await
1470 }
1471
1472 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1474 self.inner.info_l2_book(coin).await
1475 }
1476
1477 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1479 self.inner.info_user_fills(user).await
1480 }
1481
1482 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1484 self.inner.info_order_status(user, oid).await
1485 }
1486
1487 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1489 self.inner.info_open_orders(user).await
1490 }
1491
1492 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1494 self.inner.info_frontend_open_orders(user).await
1495 }
1496
1497 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1499 self.inner.info_clearinghouse_state(user).await
1500 }
1501
1502 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
1504 self.inner.info_spot_clearinghouse_state(user).await
1505 }
1506
1507 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
1509 self.inner.info_user_fees(user).await
1510 }
1511
1512 pub async fn info_candle_snapshot(
1514 &self,
1515 coin: &str,
1516 interval: HyperliquidBarInterval,
1517 start_time: u64,
1518 end_time: u64,
1519 ) -> Result<HyperliquidCandleSnapshot> {
1520 self.inner
1521 .info_candle_snapshot(coin, interval, start_time, end_time)
1522 .await
1523 }
1524
1525 pub async fn info_funding_history(
1527 &self,
1528 coin: &str,
1529 start_time: u64,
1530 end_time: Option<u64>,
1531 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
1532 self.inner
1533 .info_funding_history(coin, start_time, end_time)
1534 .await
1535 }
1536
1537 pub async fn post_action(
1539 &self,
1540 action: &ExchangeAction,
1541 ) -> Result<HyperliquidExchangeResponse> {
1542 self.inner.post_action(action).await
1543 }
1544
1545 pub async fn post_action_exec(
1547 &self,
1548 action: &HyperliquidExecAction,
1549 ) -> Result<HyperliquidExchangeResponse> {
1550 self.inner.post_action_exec(action).await
1551 }
1552
1553 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1555 self.inner.info_meta().await
1556 }
1557
1558 pub async fn cancel_order(
1568 &self,
1569 instrument_id: InstrumentId,
1570 client_order_id: Option<ClientOrderId>,
1571 venue_order_id: Option<VenueOrderId>,
1572 ) -> Result<()> {
1573 let symbol = instrument_id.symbol.as_str();
1575 let asset_id = self.get_asset_index(symbol).ok_or_else(|| {
1576 Error::bad_request(format!(
1577 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1578 ))
1579 })?;
1580
1581 let action = if let Some(cloid) = client_order_id {
1583 let cloid_hash = Cloid::from_client_order_id(cloid);
1585 let cancel_req = HyperliquidExecCancelByCloidRequest {
1586 asset: asset_id,
1587 cloid: cloid_hash,
1588 };
1589 HyperliquidExecAction::CancelByCloid {
1590 cancels: vec![cancel_req],
1591 }
1592 } else if let Some(oid) = venue_order_id {
1593 let oid_u64 = oid
1594 .as_str()
1595 .parse::<u64>()
1596 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1597 let cancel_req = HyperliquidExecCancelOrderRequest {
1598 asset: asset_id,
1599 oid: oid_u64,
1600 };
1601 HyperliquidExecAction::Cancel {
1602 cancels: vec![cancel_req],
1603 }
1604 } else {
1605 return Err(Error::bad_request(
1606 "Either client_order_id or venue_order_id must be provided",
1607 ));
1608 };
1609
1610 let response = self.inner.post_action_exec(&action).await?;
1612
1613 match response {
1615 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
1616 HyperliquidExchangeResponse::Status {
1617 status,
1618 response: error_data,
1619 } => Err(Error::bad_request(format!(
1620 "Cancel order failed: status={status}, error={error_data}"
1621 ))),
1622 HyperliquidExchangeResponse::Error { error } => {
1623 Err(Error::bad_request(format!("Cancel order error: {error}")))
1624 }
1625 }
1626 }
1627
1628 #[expect(clippy::too_many_arguments)]
1638 pub async fn modify_order(
1639 &self,
1640 instrument_id: InstrumentId,
1641 venue_order_id: VenueOrderId,
1642 order_side: OrderSide,
1643 order_type: OrderType,
1644 price: Price,
1645 quantity: Quantity,
1646 trigger_price: Option<Price>,
1647 reduce_only: bool,
1648 post_only: bool,
1649 time_in_force: TimeInForce,
1650 client_order_id: Option<ClientOrderId>,
1651 ) -> Result<()> {
1652 let symbol = instrument_id.symbol.as_str();
1653 let asset_id = self.get_asset_index(symbol).ok_or_else(|| {
1654 Error::bad_request(format!(
1655 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1656 ))
1657 })?;
1658
1659 let oid: u64 = venue_order_id
1660 .as_str()
1661 .parse()
1662 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1663
1664 let is_buy = matches!(order_side, OrderSide::Buy);
1665 let decimals = self.get_price_precision(symbol).unwrap_or(2);
1666
1667 let normalized_price = if self.normalize_prices {
1668 normalize_price(price.as_decimal(), decimals).normalize()
1669 } else {
1670 price.as_decimal().normalize()
1671 };
1672
1673 let size = quantity.as_decimal().normalize();
1674 let cloid = client_order_id.map(Cloid::from_client_order_id);
1675
1676 let kind = match order_type {
1677 OrderType::Market => HyperliquidExecOrderKind::Limit {
1678 limit: HyperliquidExecLimitParams {
1679 tif: HyperliquidExecTif::Ioc,
1680 },
1681 },
1682 OrderType::Limit => {
1683 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
1684 .map_err(|e| Error::bad_request(format!("{e}")))?;
1685 HyperliquidExecOrderKind::Limit {
1686 limit: HyperliquidExecLimitParams { tif },
1687 }
1688 }
1689 OrderType::StopMarket
1690 | OrderType::StopLimit
1691 | OrderType::MarketIfTouched
1692 | OrderType::LimitIfTouched => {
1693 if let Some(trig_px) = trigger_price {
1694 let trigger_price_decimal = if self.normalize_prices {
1695 normalize_price(trig_px.as_decimal(), decimals).normalize()
1696 } else {
1697 trig_px.as_decimal().normalize()
1698 };
1699 let tpsl = match order_type {
1700 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1701 _ => HyperliquidExecTpSl::Tp,
1702 };
1703 let is_market = matches!(
1704 order_type,
1705 OrderType::StopMarket | OrderType::MarketIfTouched
1706 );
1707 HyperliquidExecOrderKind::Trigger {
1708 trigger: HyperliquidExecTriggerParams {
1709 is_market,
1710 trigger_px: trigger_price_decimal,
1711 tpsl,
1712 },
1713 }
1714 } else {
1715 return Err(Error::bad_request("Trigger orders require a trigger price"));
1716 }
1717 }
1718 _ => {
1719 return Err(Error::bad_request(format!(
1720 "Order type {order_type:?} not supported for modify"
1721 )));
1722 }
1723 };
1724
1725 let order = HyperliquidExecPlaceOrderRequest {
1726 asset: asset_id,
1727 is_buy,
1728 price: normalized_price,
1729 size,
1730 reduce_only,
1731 kind,
1732 cloid,
1733 };
1734
1735 let action = HyperliquidExecAction::Modify {
1736 modify: HyperliquidExecModifyOrderRequest { oid, order },
1737 };
1738
1739 let response = self.inner.post_action_exec(&action).await?;
1740
1741 match response {
1742 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
1743 if let Some(inner_error) = extract_inner_error(&response) {
1744 Err(Error::bad_request(format!(
1745 "Modify order rejected: {inner_error}",
1746 )))
1747 } else {
1748 Ok(())
1749 }
1750 }
1751 HyperliquidExchangeResponse::Status {
1752 status,
1753 response: error_data,
1754 } => Err(Error::bad_request(format!(
1755 "Modify order failed: status={status}, error={error_data}"
1756 ))),
1757 HyperliquidExchangeResponse::Error { error } => {
1758 Err(Error::bad_request(format!("Modify order error: {error}")))
1759 }
1760 }
1761 }
1762
1763 pub async fn submit_split_outcome(
1777 &self,
1778 outcome: u32,
1779 amount: Decimal,
1780 ) -> Result<HyperliquidExchangeResponse> {
1781 let action = HyperliquidExecAction::UserOutcome {
1782 op: HyperliquidExecUserOutcomeOp::SplitOutcome(HyperliquidExecSplitOutcomeParams {
1783 outcome,
1784 amount,
1785 }),
1786 };
1787 self.inner.post_action_exec(&action).await
1788 }
1789
1790 pub async fn submit_merge_outcome(
1801 &self,
1802 outcome: u32,
1803 amount: Option<Decimal>,
1804 ) -> Result<HyperliquidExchangeResponse> {
1805 let action = HyperliquidExecAction::UserOutcome {
1806 op: HyperliquidExecUserOutcomeOp::MergeOutcome(HyperliquidExecMergeOutcomeParams {
1807 outcome,
1808 amount,
1809 }),
1810 };
1811 self.inner.post_action_exec(&action).await
1812 }
1813
1814 pub async fn submit_merge_question(
1824 &self,
1825 question: u32,
1826 amount: Option<Decimal>,
1827 ) -> Result<HyperliquidExchangeResponse> {
1828 let action = HyperliquidExecAction::UserOutcome {
1829 op: HyperliquidExecUserOutcomeOp::MergeQuestion(HyperliquidExecMergeQuestionParams {
1830 question,
1831 amount,
1832 }),
1833 };
1834 self.inner.post_action_exec(&action).await
1835 }
1836
1837 pub async fn submit_negate_outcome(
1847 &self,
1848 question: u32,
1849 outcome: u32,
1850 amount: Decimal,
1851 ) -> Result<HyperliquidExchangeResponse> {
1852 let action = HyperliquidExecAction::UserOutcome {
1853 op: HyperliquidExecUserOutcomeOp::NegateOutcome(HyperliquidExecNegateOutcomeParams {
1854 question,
1855 outcome,
1856 amount,
1857 }),
1858 };
1859 self.inner.post_action_exec(&action).await
1860 }
1861
1862 pub async fn request_order_status_reports(
1874 &self,
1875 user: &str,
1876 instrument_id: Option<InstrumentId>,
1877 ) -> Result<Vec<OrderStatusReport>> {
1878 let account_id = self
1879 .account_id
1880 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1881 let response = self.info_frontend_open_orders(user).await?;
1882
1883 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1885 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1886
1887 let mut reports = Vec::new();
1888 let ts_init = self.clock.get_time_ns();
1889
1890 for order_value in orders {
1891 let order: WsBasicOrderData = match serde_json::from_value(order_value.clone()) {
1893 Ok(o) => o,
1894 Err(e) => {
1895 log::warn!("Failed to parse order: {e}");
1896 continue;
1897 }
1898 };
1899
1900 let instrument = match self.get_or_create_instrument(&order.coin, None) {
1902 Some(inst) => inst,
1903 None => continue, };
1905
1906 if let Some(filter_id) = instrument_id
1908 && instrument.id() != filter_id
1909 {
1910 continue;
1911 }
1912
1913 let status = HyperliquidOrderStatusEnum::Open;
1915
1916 match parse_order_status_report_from_basic(
1918 &order,
1919 &status,
1920 &instrument,
1921 account_id,
1922 ts_init,
1923 ) {
1924 Ok(report) => reports.push(report),
1925 Err(e) => log::error!("Failed to parse order status report: {e}"),
1926 }
1927 }
1928
1929 Ok(reports)
1930 }
1931
1932 pub async fn request_order_status_report(
1942 &self,
1943 user: &str,
1944 oid: u64,
1945 ) -> Result<Option<OrderStatusReport>> {
1946 let account_id = self
1947 .account_id
1948 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1949
1950 let ts_init = self.clock.get_time_ns();
1951
1952 let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
1957 Ok(response) => match serde_json::from_value(response) {
1958 Ok(v) => v,
1959 Err(e) => {
1960 log::warn!("Failed to parse frontend open orders response: {e}");
1961 Vec::new()
1962 }
1963 },
1964 Err(e) => {
1965 log::warn!(
1966 "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
1967 );
1968 Vec::new()
1969 }
1970 };
1971
1972 if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
1973 let instrument = match self.get_or_create_instrument(&order.coin, None) {
1974 Some(inst) => inst,
1975 None => return Ok(None),
1976 };
1977
1978 let status = if order.trigger_activated == Some(true) {
1979 HyperliquidOrderStatusEnum::Triggered
1980 } else {
1981 HyperliquidOrderStatusEnum::Open
1982 };
1983
1984 return match parse_order_status_report_from_basic(
1985 &order,
1986 &status,
1987 &instrument,
1988 account_id,
1989 ts_init,
1990 ) {
1991 Ok(report) => Ok(Some(report)),
1992 Err(e) => {
1993 log::error!("Failed to parse order status report for oid {oid}: {e}");
1994 Ok(None)
1995 }
1996 };
1997 }
1998
1999 let response = self.info_order_status(user, oid).await?;
2001 let entry = match response.into_order() {
2002 Some(e) => e,
2003 None => return Ok(None),
2004 };
2005
2006 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2007 Some(inst) => inst,
2008 None => return Ok(None),
2009 };
2010
2011 let basic = WsBasicOrderData {
2016 coin: entry.order.coin,
2017 side: entry.order.side,
2018 limit_px: entry.order.limit_px,
2019 sz: entry.order.sz,
2020 oid: entry.order.oid,
2021 timestamp: entry.order.timestamp,
2022 orig_sz: entry.order.orig_sz,
2023 cloid: entry.order.cloid,
2024 trigger_px: None,
2025 is_market: None,
2026 tpsl: None,
2027 trigger_activated: None,
2028 trailing_stop: None,
2029 };
2030
2031 match parse_order_status_report_from_basic(
2032 &basic,
2033 &entry.status,
2034 &instrument,
2035 account_id,
2036 ts_init,
2037 ) {
2038 Ok(mut report) => {
2039 if entry.status_timestamp > 0 {
2042 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2043 }
2044 Ok(Some(report))
2045 }
2046 Err(e) => {
2047 log::error!("Failed to parse order status report for oid {oid}: {e}");
2048 Ok(None)
2049 }
2050 }
2051 }
2052
2053 pub async fn request_order_status_report_by_client_order_id(
2062 &self,
2063 user: &str,
2064 client_order_id: &ClientOrderId,
2065 ) -> Result<Option<OrderStatusReport>> {
2066 let account_id = self
2067 .account_id
2068 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2069
2070 let ts_init = self.clock.get_time_ns();
2071
2072 let cloid_hex = Cloid::from_client_order_id(*client_order_id).to_hex();
2073
2074 let response = self.info_frontend_open_orders(user).await?;
2075 let orders: Vec<WsBasicOrderData> = match serde_json::from_value(response) {
2076 Ok(v) => v,
2077 Err(e) => {
2078 log::warn!("Failed to parse frontend open orders response: {e}");
2079 return Ok(None);
2080 }
2081 };
2082
2083 let order = match orders
2084 .into_iter()
2085 .find(|o| o.cloid.as_ref().is_some_and(|c| c == &cloid_hex))
2086 {
2087 Some(o) => o,
2088 None => return Ok(None),
2089 };
2090
2091 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2092 Some(inst) => inst,
2093 None => return Ok(None),
2094 };
2095
2096 let status = if order.trigger_activated == Some(true) {
2097 HyperliquidOrderStatusEnum::Triggered
2098 } else {
2099 HyperliquidOrderStatusEnum::Open
2100 };
2101
2102 match parse_order_status_report_from_basic(
2103 &order,
2104 &status,
2105 &instrument,
2106 account_id,
2107 ts_init,
2108 ) {
2109 Ok(mut report) => {
2110 report.client_order_id = Some(*client_order_id);
2111 Ok(Some(report))
2112 }
2113 Err(e) => {
2114 log::error!("Failed to parse order status report for cloid {cloid_hex}: {e}");
2115 Ok(None)
2116 }
2117 }
2118 }
2119
2120 pub async fn request_fill_reports(
2134 &self,
2135 user: &str,
2136 instrument_id: Option<InstrumentId>,
2137 ) -> Result<Vec<FillReport>> {
2138 let account_id = self
2139 .account_id
2140 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2141 let fills_response = self.info_user_fills(user).await?;
2142
2143 let mut reports = Vec::new();
2144 let ts_init = self.clock.get_time_ns();
2145
2146 for fill in fills_response {
2147 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
2149 Some(inst) => inst,
2150 None => continue, };
2152
2153 if let Some(filter_id) = instrument_id
2155 && instrument.id() != filter_id
2156 {
2157 continue;
2158 }
2159
2160 match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2162 Ok(report) => reports.push(report),
2163 Err(e) => log::error!("Failed to parse fill report: {e}"),
2164 }
2165 }
2166
2167 Ok(reports)
2168 }
2169
2170 pub async fn request_position_status_reports(
2194 &self,
2195 user: &str,
2196 instrument_id: Option<InstrumentId>,
2197 ) -> Result<Vec<PositionStatusReport>> {
2198 let account_id = self
2199 .account_id
2200 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2201
2202 let filter_product = instrument_id
2203 .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
2204
2205 let fetch_perp = !matches!(
2206 filter_product,
2207 Some(HyperliquidProductType::Spot | HyperliquidProductType::Outcome)
2208 );
2209 let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
2210
2211 let mut reports = Vec::new();
2212 let ts_init = self.clock.get_time_ns();
2213
2214 if !fetch_perp {
2215 let spot_reports = self
2216 .request_spot_position_status_reports(user, instrument_id)
2217 .await?;
2218 reports.extend(spot_reports);
2219 return Ok(reports);
2220 }
2221
2222 let state_response = self.info_clearinghouse_state(user).await?;
2223
2224 let asset_positions: Vec<serde_json::Value> = state_response
2226 .get("assetPositions")
2227 .and_then(|v| v.as_array())
2228 .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
2229 .clone();
2230
2231 for position_value in asset_positions {
2232 let coin = position_value
2234 .get("position")
2235 .and_then(|p| p.get("coin"))
2236 .and_then(|c| c.as_str())
2237 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
2238
2239 let coin_ustr = Ustr::from(coin);
2241 let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
2242 Some(inst) => inst,
2243 None => continue, };
2245
2246 if let Some(filter_id) = instrument_id
2248 && instrument.id() != filter_id
2249 {
2250 continue;
2251 }
2252
2253 match parse_position_status_report(&position_value, &instrument, account_id, ts_init) {
2255 Ok(report) => reports.push(report),
2256 Err(e) => log::error!("Failed to parse position status report: {e}"),
2257 }
2258 }
2259
2260 if fetch_spot {
2263 let spot_reports = self
2264 .request_spot_position_status_reports(user, instrument_id)
2265 .await?;
2266 reports.extend(spot_reports);
2267 }
2268
2269 Ok(reports)
2270 }
2271
2272 pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
2285 let account_id = self
2286 .account_id
2287 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2288 let state_response = self.info_clearinghouse_state(user).await?;
2289 let ts_init = self.clock.get_time_ns();
2290
2291 log::trace!("Clearinghouse state response: {state_response}");
2292
2293 let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
2294 .map_err(|e| {
2295 log::error!("Failed to parse clearinghouse state: {e}");
2296 log::debug!("Raw response: {state_response}");
2297 Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
2298 })?;
2299
2300 let spot_response = self.info_spot_clearinghouse_state(user).await?;
2303 let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
2304 .map_err(|e| {
2305 log::error!("Failed to parse spot clearinghouse state: {e}");
2306 log::debug!("Raw spot response: {spot_response}");
2307 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2308 })?;
2309
2310 let (balances, margins) =
2311 parse_combined_account_balances_and_margins(&perp_state, &spot_state)
2312 .map_err(|e| Error::decode(e.to_string()))?;
2313
2314 Ok(AccountState::new(
2315 account_id,
2316 AccountType::Margin,
2317 balances,
2318 margins,
2319 true, UUID4::new(),
2321 ts_init,
2322 ts_init,
2323 None,
2324 ))
2325 }
2326
2327 pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
2338 let response = self.info_spot_clearinghouse_state(user).await?;
2339
2340 log::trace!("Spot clearinghouse state response: {response}");
2341
2342 let state: SpotClearinghouseState =
2343 serde_json::from_value(response.clone()).map_err(|e| {
2344 log::error!("Failed to parse spot clearinghouse state: {e}");
2345 log::debug!("Raw response: {response}");
2346 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2347 })?;
2348
2349 parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
2350 }
2351
2352 pub async fn request_spot_position_status_reports(
2367 &self,
2368 user: &str,
2369 instrument_id: Option<InstrumentId>,
2370 ) -> Result<Vec<PositionStatusReport>> {
2371 let account_id = self
2372 .account_id
2373 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2374 let response = self.info_spot_clearinghouse_state(user).await?;
2375
2376 let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
2377 log::error!("Failed to parse spot clearinghouse state: {e}");
2378 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2379 })?;
2380
2381 let ts_init = self.clock.get_time_ns();
2382 let mut reports = Vec::with_capacity(state.balances.len());
2383
2384 for balance in &state.balances {
2385 if balance.total.is_zero() {
2386 continue;
2387 }
2388
2389 if balance.coin.as_str() == "USDC" {
2394 continue;
2395 }
2396
2397 let product_type = match HyperliquidProductType::from_symbol(balance.coin.as_str()) {
2398 Ok(HyperliquidProductType::Outcome) => HyperliquidProductType::Outcome,
2399 _ => HyperliquidProductType::Spot,
2400 };
2401
2402 let instrument = match self.get_or_create_instrument(&balance.coin, Some(product_type))
2403 {
2404 Some(inst) => inst,
2405 None => continue,
2406 };
2407
2408 if let Some(filter_id) = instrument_id
2409 && instrument.id() != filter_id
2410 {
2411 continue;
2412 }
2413
2414 match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
2415 Ok(report) => reports.push(report),
2416 Err(e) => log::error!(
2417 "Failed to parse spot position status report for {}: {e}",
2418 balance.coin,
2419 ),
2420 }
2421 }
2422
2423 Ok(reports)
2424 }
2425
2426 pub async fn request_bars(
2443 &self,
2444 bar_type: BarType,
2445 start: Option<chrono::DateTime<chrono::Utc>>,
2446 end: Option<chrono::DateTime<chrono::Utc>>,
2447 limit: Option<u32>,
2448 ) -> Result<Vec<Bar>> {
2449 let instrument_id = bar_type.instrument_id();
2450 let symbol = instrument_id.symbol;
2451
2452 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
2453
2454 let base = Ustr::from(
2456 symbol
2457 .as_str()
2458 .split('-')
2459 .next()
2460 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?,
2461 );
2462
2463 let instrument = self
2464 .get_or_create_instrument(&base, product_type)
2465 .ok_or_else(|| {
2466 Error::bad_request(format!("Instrument not found in cache: {instrument_id}"))
2467 })?;
2468
2469 let coin = instrument.raw_symbol().inner();
2474
2475 let price_precision = instrument.price_precision();
2476 let size_precision = instrument.size_precision();
2477
2478 let interval =
2479 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
2480
2481 let now = chrono::Utc::now();
2483 let end_time = end.unwrap_or(now).timestamp_millis() as u64;
2484 let start_time = if let Some(start) = start {
2485 start.timestamp_millis() as u64
2486 } else {
2487 let spec = bar_type.spec();
2489 let step_ms = match spec.aggregation {
2490 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
2491 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
2492 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
2493 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
2494 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
2495 _ => 60_000,
2496 };
2497 end_time.saturating_sub(1000 * step_ms)
2498 };
2499
2500 let candles = self
2501 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
2502 .await?;
2503
2504 let now_ms = now.timestamp_millis() as u64;
2506
2507 let mut bars: Vec<Bar> = candles
2508 .iter()
2509 .filter(|candle| candle.end_timestamp < now_ms)
2510 .enumerate()
2511 .filter_map(|(i, candle)| {
2512 candle_to_bar(candle, bar_type, price_precision, size_precision)
2513 .map_err(|e| {
2514 log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
2515 e
2516 })
2517 .ok()
2518 })
2519 .collect();
2520
2521 if let Some(limit) = limit
2523 && limit > 0
2524 && bars.len() > limit as usize
2525 {
2526 bars.truncate(limit as usize);
2527 }
2528
2529 log::debug!(
2530 "Received {} bars for {} (filtered {} incomplete)",
2531 bars.len(),
2532 bar_type,
2533 candles.len() - bars.len()
2534 );
2535 Ok(bars)
2536 }
2537
2538 #[expect(clippy::too_many_arguments)]
2545 pub async fn submit_order(
2546 &self,
2547 instrument_id: InstrumentId,
2548 client_order_id: ClientOrderId,
2549 order_side: OrderSide,
2550 order_type: OrderType,
2551 quantity: Quantity,
2552 time_in_force: TimeInForce,
2553 price: Option<Price>,
2554 trigger_price: Option<Price>,
2555 post_only: bool,
2556 reduce_only: bool,
2557 ) -> Result<OrderStatusReport> {
2558 let symbol = instrument_id.symbol.as_str();
2559 let asset = self.get_asset_index(symbol).ok_or_else(|| {
2560 Error::bad_request(format!(
2561 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2562 ))
2563 })?;
2564
2565 let is_buy = matches!(order_side, OrderSide::Buy);
2566 let price_precision = self.get_price_precision(symbol).unwrap_or(2);
2567
2568 let price_decimal = match price {
2569 Some(px) if self.normalize_prices => {
2570 normalize_price(px.as_decimal(), price_precision).normalize()
2571 }
2572 Some(px) => px.as_decimal().normalize(),
2573 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
2574 None if matches!(
2575 order_type,
2576 OrderType::StopMarket | OrderType::MarketIfTouched
2577 ) =>
2578 {
2579 match trigger_price {
2580 Some(tp) => {
2581 let derived = derive_limit_from_trigger(
2582 tp.as_decimal().normalize(),
2583 is_buy,
2584 self.market_order_slippage_bps,
2585 );
2586 let sig_rounded = round_to_sig_figs(derived, 5);
2587 clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
2588 }
2589 None => Decimal::ZERO,
2590 }
2591 }
2592 None => return Err(Error::bad_request("Limit orders require a price")),
2593 };
2594
2595 let size_decimal = quantity.as_decimal().normalize();
2596
2597 let kind = match order_type {
2598 OrderType::Market => HyperliquidExecOrderKind::Limit {
2599 limit: HyperliquidExecLimitParams {
2600 tif: HyperliquidExecTif::Ioc,
2601 },
2602 },
2603 OrderType::Limit => {
2604 let tif = if post_only {
2605 HyperliquidExecTif::Alo
2606 } else {
2607 match time_in_force {
2608 TimeInForce::Gtc => HyperliquidExecTif::Gtc,
2609 TimeInForce::Ioc => HyperliquidExecTif::Ioc,
2610 TimeInForce::Fok
2611 | TimeInForce::Day
2612 | TimeInForce::Gtd
2613 | TimeInForce::AtTheOpen
2614 | TimeInForce::AtTheClose => {
2615 return Err(Error::bad_request(format!(
2616 "Time in force {time_in_force:?} not supported"
2617 )));
2618 }
2619 }
2620 };
2621 HyperliquidExecOrderKind::Limit {
2622 limit: HyperliquidExecLimitParams { tif },
2623 }
2624 }
2625 OrderType::StopMarket
2626 | OrderType::StopLimit
2627 | OrderType::MarketIfTouched
2628 | OrderType::LimitIfTouched => {
2629 if let Some(trig_px) = trigger_price {
2630 let trigger_price_decimal = if self.normalize_prices {
2631 normalize_price(trig_px.as_decimal(), price_precision).normalize()
2632 } else {
2633 trig_px.as_decimal().normalize()
2634 };
2635
2636 let tpsl = match order_type {
2640 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
2641 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2642 HyperliquidExecTpSl::Tp
2643 }
2644 _ => unreachable!(),
2645 };
2646
2647 let is_market = matches!(
2648 order_type,
2649 OrderType::StopMarket | OrderType::MarketIfTouched
2650 );
2651
2652 HyperliquidExecOrderKind::Trigger {
2653 trigger: HyperliquidExecTriggerParams {
2654 is_market,
2655 trigger_px: trigger_price_decimal,
2656 tpsl,
2657 },
2658 }
2659 } else {
2660 return Err(Error::bad_request("Trigger orders require a trigger price"));
2661 }
2662 }
2663 _ => {
2664 return Err(Error::bad_request(format!(
2665 "Order type {order_type:?} not supported"
2666 )));
2667 }
2668 };
2669
2670 let hyperliquid_order = HyperliquidExecPlaceOrderRequest {
2671 asset,
2672 is_buy,
2673 price: price_decimal,
2674 size: size_decimal,
2675 reduce_only,
2676 kind,
2677 cloid: Some(Cloid::from_client_order_id(client_order_id)),
2678 };
2679
2680 let builder = self.builder_attribution();
2681
2682 let action = HyperliquidExecAction::Order {
2683 orders: vec![hyperliquid_order],
2684 grouping: HyperliquidExecGrouping::Na,
2685 builder,
2686 };
2687
2688 let response = self.inner.post_action_exec(&action).await?;
2689
2690 match response {
2691 HyperliquidExchangeResponse::Status {
2692 status,
2693 response: response_data,
2694 } if status == RESPONSE_STATUS_OK => {
2695 let data_value = if let Some(data) = response_data.get("data") {
2696 data.clone()
2697 } else {
2698 response_data
2699 };
2700
2701 let order_response: HyperliquidExecOrderResponseData =
2702 serde_json::from_value(data_value).map_err(|e| {
2703 Error::bad_request(format!("Failed to parse order response: {e}"))
2704 })?;
2705
2706 let order_status = order_response
2707 .statuses
2708 .first()
2709 .ok_or_else(|| Error::bad_request("No order status in response"))?;
2710
2711 let symbol_str = instrument_id.symbol.as_str();
2712 let product_type = HyperliquidProductType::from_symbol(symbol_str).ok();
2713
2714 let asset_str = symbol_str.split('-').next().unwrap_or(symbol_str);
2716 let instrument = self
2717 .get_or_create_instrument(&Ustr::from(asset_str), product_type)
2718 .ok_or_else(|| {
2719 Error::bad_request(format!("Instrument not found for {asset_str}"))
2720 })?;
2721
2722 let account_id = self
2723 .account_id
2724 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2725 let ts_init = self.clock.get_time_ns();
2726
2727 match order_status {
2728 HyperliquidExecOrderStatus::Resting { resting } => Ok(self
2729 .create_order_status_report(
2730 instrument_id,
2731 Some(client_order_id),
2732 VenueOrderId::new(resting.oid.to_string()),
2733 order_side,
2734 order_type,
2735 quantity,
2736 time_in_force,
2737 price,
2738 trigger_price,
2739 OrderStatus::Accepted,
2740 Quantity::new(0.0, instrument.size_precision()),
2741 &instrument,
2742 account_id,
2743 ts_init,
2744 )),
2745 HyperliquidExecOrderStatus::Filled { filled } => {
2746 let filled_qty = Quantity::new(
2747 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
2748 instrument.size_precision(),
2749 );
2750 Ok(self.create_order_status_report(
2751 instrument_id,
2752 Some(client_order_id),
2753 VenueOrderId::new(filled.oid.to_string()),
2754 order_side,
2755 order_type,
2756 quantity,
2757 time_in_force,
2758 price,
2759 trigger_price,
2760 OrderStatus::Filled,
2761 filled_qty,
2762 &instrument,
2763 account_id,
2764 ts_init,
2765 ))
2766 }
2767 HyperliquidExecOrderStatus::Error { error } => {
2768 Err(Error::bad_request(format!("Order rejected: {error}")))
2769 }
2770 }
2771 }
2772 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
2773 "Order submission failed: {error}"
2774 ))),
2775 _ => Err(Error::bad_request("Unexpected response format")),
2776 }
2777 }
2778
2779 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
2783 self.submit_order(
2784 order.instrument_id(),
2785 order.client_order_id(),
2786 order.order_side(),
2787 order.order_type(),
2788 order.quantity(),
2789 order.time_in_force(),
2790 order.price(),
2791 order.trigger_price(),
2792 order.is_post_only(),
2793 order.is_reduce_only(),
2794 )
2795 .await
2796 }
2797
2798 #[expect(clippy::too_many_arguments)]
2799 fn create_order_status_report(
2800 &self,
2801 instrument_id: InstrumentId,
2802 client_order_id: Option<ClientOrderId>,
2803 venue_order_id: VenueOrderId,
2804 order_side: OrderSide,
2805 order_type: OrderType,
2806 quantity: Quantity,
2807 time_in_force: TimeInForce,
2808 price: Option<Price>,
2809 trigger_price: Option<Price>,
2810 order_status: OrderStatus,
2811 filled_qty: Quantity,
2812 _instrument: &InstrumentAny,
2813 account_id: AccountId,
2814 ts_init: UnixNanos,
2815 ) -> OrderStatusReport {
2816 let ts_accepted = self.clock.get_time_ns();
2817 let ts_last = ts_accepted;
2818 let report_id = UUID4::new();
2819
2820 let mut report = OrderStatusReport::new(
2821 account_id,
2822 instrument_id,
2823 client_order_id,
2824 venue_order_id,
2825 order_side,
2826 order_type,
2827 time_in_force,
2828 order_status,
2829 quantity,
2830 filled_qty,
2831 ts_accepted,
2832 ts_last,
2833 ts_init,
2834 Some(report_id),
2835 );
2836
2837 if let Some(px) = price {
2838 report = report.with_price(px);
2839 }
2840
2841 if let Some(trig_px) = trigger_price {
2842 report = report
2843 .with_trigger_price(trig_px)
2844 .with_trigger_type(TriggerType::Default);
2845 }
2846
2847 report
2848 }
2849
2850 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
2857 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
2859
2860 for order in orders {
2861 let instrument_id = order.instrument_id();
2862 let symbol = instrument_id.symbol.as_str();
2863 let asset = self.get_asset_index(symbol).ok_or_else(|| {
2864 Error::bad_request(format!(
2865 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2866 ))
2867 })?;
2868 let price_decimals = self.get_price_precision(symbol).unwrap_or(2);
2869 let request = order_to_hyperliquid_request_with_asset(
2870 order,
2871 asset,
2872 price_decimals,
2873 self.normalize_prices,
2874 self.market_order_slippage_bps,
2875 )
2876 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
2877 hyperliquid_orders.push(request);
2878 }
2879
2880 let builder = self.builder_attribution();
2881
2882 let grouping =
2883 determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
2884
2885 let action = HyperliquidExecAction::Order {
2886 orders: hyperliquid_orders,
2887 grouping,
2888 builder,
2889 };
2890
2891 let response = self.inner.post_action_exec(&action).await?;
2893
2894 match response {
2896 HyperliquidExchangeResponse::Status {
2897 status,
2898 response: response_data,
2899 } if status == RESPONSE_STATUS_OK => {
2900 let data_value = if let Some(data) = response_data.get("data") {
2903 data.clone()
2904 } else {
2905 response_data
2906 };
2907
2908 let order_response: HyperliquidExecOrderResponseData =
2910 serde_json::from_value(data_value).map_err(|e| {
2911 Error::bad_request(format!("Failed to parse order response: {e}"))
2912 })?;
2913
2914 let account_id = self
2915 .account_id
2916 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2917 let ts_init = self.clock.get_time_ns();
2918
2919 if grouping == HyperliquidExecGrouping::Na
2923 && order_response.statuses.len() != orders.len()
2924 {
2925 return Err(Error::bad_request(format!(
2926 "Mismatch between submitted orders ({}) and response statuses ({})",
2927 orders.len(),
2928 order_response.statuses.len()
2929 )));
2930 }
2931
2932 let mut reports = Vec::new();
2933
2934 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
2938 let instrument_id = order.instrument_id();
2940 let symbol = instrument_id.symbol.as_str();
2941 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
2942
2943 let asset = symbol.split('-').next().unwrap_or(symbol);
2945 let instrument = self
2946 .get_or_create_instrument(&Ustr::from(asset), product_type)
2947 .ok_or_else(|| {
2948 Error::bad_request(format!("Instrument not found for {asset}"))
2949 })?;
2950
2951 let report = match order_status {
2953 HyperliquidExecOrderStatus::Resting { resting } => {
2954 self.create_order_status_report(
2956 order.instrument_id(),
2957 Some(order.client_order_id()),
2958 VenueOrderId::new(resting.oid.to_string()),
2959 order.order_side(),
2960 order.order_type(),
2961 order.quantity(),
2962 order.time_in_force(),
2963 order.price(),
2964 order.trigger_price(),
2965 OrderStatus::Accepted,
2966 Quantity::new(0.0, instrument.size_precision()),
2967 &instrument,
2968 account_id,
2969 ts_init,
2970 )
2971 }
2972 HyperliquidExecOrderStatus::Filled { filled } => {
2973 let filled_qty = Quantity::new(
2975 filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
2976 instrument.size_precision(),
2977 );
2978 self.create_order_status_report(
2979 order.instrument_id(),
2980 Some(order.client_order_id()),
2981 VenueOrderId::new(filled.oid.to_string()),
2982 order.order_side(),
2983 order.order_type(),
2984 order.quantity(),
2985 order.time_in_force(),
2986 order.price(),
2987 order.trigger_price(),
2988 OrderStatus::Filled,
2989 filled_qty,
2990 &instrument,
2991 account_id,
2992 ts_init,
2993 )
2994 }
2995 HyperliquidExecOrderStatus::Error { error } => {
2996 return Err(Error::bad_request(format!(
2997 "Order {} rejected: {error}",
2998 order.client_order_id()
2999 )));
3000 }
3001 };
3002
3003 reports.push(report);
3004 }
3005
3006 Ok(reports)
3007 }
3008 HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
3009 "Order submission failed: {error}"
3010 ))),
3011 _ => Err(Error::bad_request("Unexpected response format")),
3012 }
3013 }
3014}
3015
3016fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
3021 if dex_index == 0 {
3022 0
3023 } else {
3024 100_000 + dex_index as u32 * 10_000
3025 }
3026}
3027
3028#[cfg(test)]
3029mod tests {
3030 use std::{net::SocketAddr, sync::Arc};
3031
3032 use axum::{
3033 Router,
3034 extract::State,
3035 http::StatusCode,
3036 response::{IntoResponse, Json, Response},
3037 routing::post,
3038 };
3039 use nautilus_core::{MUTEX_POISONED, time::get_atomic_clock_realtime};
3040 use nautilus_model::{
3041 currencies::CURRENCY_MAP,
3042 enums::CurrencyType,
3043 identifiers::{InstrumentId, Symbol},
3044 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
3045 types::{Currency, Price, Quantity},
3046 };
3047 use rstest::rstest;
3048 use serde_json::{Value, json};
3049 use ustr::Ustr;
3050
3051 use super::HyperliquidHttpClient;
3052 use crate::{
3053 common::{
3054 consts::HYPERLIQUID_VENUE,
3055 enums::{HyperliquidEnvironment, HyperliquidProductType},
3056 },
3057 http::query::InfoRequest,
3058 };
3059
3060 #[derive(Clone, Default)]
3061 struct OutcomeMetaServerState {
3062 last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
3063 }
3064
3065 async fn handle_outcome_meta_info(
3066 State(state): State<OutcomeMetaServerState>,
3067 body: axum::body::Bytes,
3068 ) -> Response {
3069 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
3070 return (
3071 StatusCode::BAD_REQUEST,
3072 Json(json!({"error": "Invalid JSON body"})),
3073 )
3074 .into_response();
3075 };
3076
3077 *state.last_request_body.lock().await = Some(request_body.clone());
3078
3079 if request_body.get("type").and_then(|value| value.as_str()) != Some("outcomeMeta") {
3080 return (
3081 StatusCode::BAD_REQUEST,
3082 Json(json!({"error": "Expected outcomeMeta request"})),
3083 )
3084 .into_response();
3085 }
3086
3087 Json(json!({
3088 "outcomes": [
3089 {
3090 "outcome": 123,
3091 "name": "Recurring",
3092 "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m",
3093 "sideSpecs": [
3094 {"name": "Yes"},
3095 {"name": "No"}
3096 ]
3097 }
3098 ]
3099 }))
3100 .into_response()
3101 }
3102
3103 async fn start_outcome_meta_server(state: OutcomeMetaServerState) -> SocketAddr {
3104 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3105 let addr = listener.local_addr().unwrap();
3106 let router = Router::new()
3107 .route("/info", post(handle_outcome_meta_info))
3108 .with_state(state);
3109
3110 tokio::spawn(async move {
3111 axum::serve(listener, router).await.unwrap();
3112 });
3113
3114 addr
3115 }
3116
3117 #[rstest]
3118 fn stable_json_roundtrips() {
3119 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
3120 let s = serde_json::to_string(&v).unwrap();
3121 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
3123 assert_eq!(parsed["type"], "l2Book");
3124 assert_eq!(parsed["coin"], "BTC");
3125 assert_eq!(parsed, v);
3126 }
3127
3128 #[rstest]
3129 fn info_pretty_shape() {
3130 let r = InfoRequest::l2_book("BTC");
3131 let val = serde_json::to_value(&r).unwrap();
3132 let pretty = serde_json::to_string_pretty(&val).unwrap();
3133 assert!(pretty.contains("\"type\": \"l2Book\""));
3134 assert!(pretty.contains("\"coin\": \"BTC\""));
3135 }
3136
3137 #[rstest]
3138 #[tokio::test]
3139 async fn test_production_client_get_outcome_meta_uses_outcome_meta_request() {
3140 let state = OutcomeMetaServerState::default();
3141 let addr = start_outcome_meta_server(state.clone()).await;
3142 let mut client =
3143 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3144 client.set_base_info_url(format!("http://{addr}/info"));
3145
3146 let meta = client.get_outcome_meta().await.unwrap();
3147 let request_body = state.last_request_body.lock().await.clone().unwrap();
3148
3149 assert_eq!(request_body, json!({"type": "outcomeMeta"}));
3150 assert_eq!(meta.outcomes.len(), 1);
3151 assert_eq!(meta.outcomes[0].outcome, 123);
3152 assert_eq!(meta.outcomes[0].name, "Recurring");
3153 assert_eq!(meta.outcomes[0].side_specs.len(), 2);
3154 assert_eq!(meta.outcomes[0].side_specs[0].name, "Yes");
3155 assert_eq!(meta.outcomes[0].side_specs[1].name, "No");
3156 }
3157
3158 #[rstest]
3159 fn test_cache_instrument_by_raw_symbol() {
3160 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3161
3162 let base_code = "vntls:vCURSOR";
3164 let quote_code = "USDC";
3165
3166 {
3168 let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
3169 if !currency_map.contains_key(base_code) {
3170 currency_map.insert(
3171 base_code.to_string(),
3172 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
3173 );
3174 }
3175 }
3176
3177 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
3178 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
3179
3180 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
3182 let venue = *HYPERLIQUID_VENUE;
3183 let instrument_id = InstrumentId::new(symbol, venue);
3184
3185 let raw_symbol = Symbol::new(base_code);
3187
3188 let clock = get_atomic_clock_realtime();
3189 let ts = clock.get_time_ns();
3190
3191 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
3192 instrument_id,
3193 raw_symbol,
3194 base_currency,
3195 quote_currency,
3196 8,
3197 8,
3198 Price::from("0.00000001"),
3199 Quantity::from("0.00000001"),
3200 None,
3201 None,
3202 None,
3203 None,
3204 None,
3205 None,
3206 None,
3207 None,
3208 None,
3209 None,
3210 None,
3211 None, None, ts,
3214 ts,
3215 ));
3216
3217 client.cache_instrument(&instrument);
3219
3220 let instruments = client.instruments.load();
3222 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
3223 assert!(
3224 by_full_symbol.is_some(),
3225 "Instrument should be accessible by full symbol"
3226 );
3227 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
3228
3229 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
3231 assert!(
3232 by_raw_symbol.is_some(),
3233 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
3234 );
3235 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
3236 drop(instruments);
3237
3238 let instruments_by_coin = client.instruments_by_coin.load();
3240 let by_coin =
3241 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
3242 assert!(
3243 by_coin.is_some(),
3244 "Instrument should be accessible by coin and product type"
3245 );
3246 assert_eq!(by_coin.unwrap().id(), instrument.id());
3247 drop(instruments_by_coin);
3248
3249 let retrieved_with_type = client.get_or_create_instrument(
3251 &Ustr::from("vntls:vCURSOR"),
3252 Some(HyperliquidProductType::Spot),
3253 );
3254 assert!(retrieved_with_type.is_some());
3255 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
3256
3257 let retrieved_without_type =
3259 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
3260 assert!(retrieved_without_type.is_some());
3261 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
3262 }
3263
3264 #[rstest]
3265 fn test_get_or_create_instrument_outcome_fallback_no_product_type() {
3266 use nautilus_core::time::get_atomic_clock_realtime;
3272 use nautilus_model::{
3273 enums::AssetClass,
3274 identifiers::{InstrumentId, Symbol},
3275 instruments::{BinaryOption, InstrumentAny},
3276 types::{Currency, Price, Quantity},
3277 };
3278
3279 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3280 let coin = "#500";
3281 let token = "+500";
3282
3283 let usdh = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
3284 let symbol = Symbol::new(token);
3285 let raw_symbol = Symbol::new(coin);
3286 let venue = *HYPERLIQUID_VENUE;
3287 let instrument_id = InstrumentId::new(symbol, venue);
3288
3289 let clock = get_atomic_clock_realtime();
3290 let ts = clock.get_time_ns();
3291
3292 let binary = InstrumentAny::BinaryOption(BinaryOption::new(
3293 instrument_id,
3294 raw_symbol,
3295 AssetClass::Alternative,
3296 usdh,
3297 Default::default(),
3298 Default::default(),
3299 4,
3300 2,
3301 Price::from("0.0001"),
3302 Quantity::from("0.01"),
3303 None,
3304 None,
3305 None,
3306 None,
3307 None,
3308 None,
3309 None,
3310 None,
3311 None,
3312 None,
3313 None,
3314 None,
3315 None,
3316 ts,
3317 ts,
3318 ));
3319
3320 client.cache_instrument(&binary);
3321
3322 let with_type = client
3323 .get_or_create_instrument(&Ustr::from(coin), Some(HyperliquidProductType::Outcome));
3324 assert!(with_type.is_some());
3325 assert_eq!(with_type.unwrap().id(), instrument_id);
3326
3327 let no_type = client.get_or_create_instrument(&Ustr::from(coin), None);
3328 assert!(
3329 no_type.is_some(),
3330 "Outcome coin must resolve through the no-product fallback",
3331 );
3332 assert_eq!(no_type.unwrap().id(), instrument_id);
3333
3334 let missing = client.get_or_create_instrument(&Ustr::from("#9999"), None);
3335 assert!(missing.is_none());
3336 }
3337
3338 #[rstest]
3339 fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
3340 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3345
3346 let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
3347 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3348 let clock = get_atomic_clock_realtime();
3349 let ts = clock.get_time_ns();
3350
3351 let canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
3352 InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
3353 Symbol::new("@107"),
3354 hype,
3355 usdc,
3356 5,
3357 2,
3358 Price::from("0.00001"),
3359 Quantity::from("0.01"),
3360 None,
3361 None,
3362 None,
3363 None,
3364 None,
3365 None,
3366 None,
3367 None,
3368 None,
3369 None,
3370 None,
3371 None,
3372 None,
3373 ts,
3374 ts,
3375 ));
3376
3377 let non_canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
3378 InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
3379 Symbol::new("@999"),
3380 hype,
3381 usdc,
3382 5,
3383 2,
3384 Price::from("0.00001"),
3385 Quantity::from("0.01"),
3386 None,
3387 None,
3388 None,
3389 None,
3390 None,
3391 None,
3392 None,
3393 None,
3394 None,
3395 None,
3396 None,
3397 None,
3398 None,
3399 ts,
3400 ts,
3401 ));
3402
3403 client.cache_instrument(&canonical);
3404 client.cache_instrument(&non_canonical);
3405
3406 let instruments_by_coin = client.instruments_by_coin.load();
3407 let by_base = instruments_by_coin
3408 .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
3409 .expect("base alias must resolve");
3410 assert_eq!(
3411 by_base.raw_symbol().inner().as_str(),
3412 "@107",
3413 "base alias must point to the canonical pair, not the one cached later",
3414 );
3415 }
3416
3417 #[rstest]
3418 fn test_cache_instrument_perp_aliases_sanitized_base() {
3419 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3425
3426 let base_currency = Currency::new(
3427 "dex:STREAMABCD****",
3428 8,
3429 0,
3430 "dex:STREAMABCD****",
3431 CurrencyType::Crypto,
3432 );
3433 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
3434 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3435 let clock = get_atomic_clock_realtime();
3436 let ts = clock.get_time_ns();
3437
3438 let hip3 = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
3439 InstrumentId::new(
3440 Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
3441 *HYPERLIQUID_VENUE,
3442 ),
3443 Symbol::new("dex:STREAMABCD****"),
3444 base_currency,
3445 usd,
3446 usdc,
3447 false,
3448 6,
3449 3,
3450 Price::from("0.000001"),
3451 Quantity::from("0.001"),
3452 None,
3453 None,
3454 None,
3455 None,
3456 None,
3457 None,
3458 None,
3459 None,
3460 None,
3461 None,
3462 None,
3463 None,
3464 None,
3465 ts,
3466 ts,
3467 ));
3468
3469 client.cache_instrument(&hip3);
3470
3471 let instruments_by_coin = client.instruments_by_coin.load();
3472 let by_raw = instruments_by_coin
3473 .get(&(
3474 Ustr::from("dex:STREAMABCD****"),
3475 HyperliquidProductType::Perp,
3476 ))
3477 .expect("venue coin lookup must resolve");
3478 assert_eq!(by_raw.id(), hip3.id());
3479
3480 let by_sanitized = instruments_by_coin
3481 .get(&(
3482 Ustr::from("dex:STREAMABCDxxxx"),
3483 HyperliquidProductType::Perp,
3484 ))
3485 .expect("sanitized base lookup must resolve");
3486 assert_eq!(by_sanitized.id(), hip3.id());
3487 drop(instruments_by_coin);
3488
3489 let resolved = client
3491 .get_or_create_instrument(
3492 &Ustr::from("dex:STREAMABCDxxxx"),
3493 Some(HyperliquidProductType::Perp),
3494 )
3495 .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
3496 assert_eq!(resolved.id(), hip3.id());
3497 }
3498}