1use std::{
24 collections::HashMap,
25 num::NonZeroU32,
26 sync::{Arc, LazyLock},
27 time::Duration,
28};
29
30use ahash::AHashMap;
31use anyhow::Context;
32use nautilus_common::cache::InstrumentLookupError;
33use nautilus_core::{
34 AtomicMap, UUID4, UnixNanos,
35 datetime::datetime_to_unix_nanos,
36 string::secret::SecretString,
37 time::{AtomicTime, get_atomic_clock_realtime},
38};
39use nautilus_model::{
40 data::{Bar, BarType},
41 enums::{
42 AccountType, BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce,
43 TriggerType,
44 },
45 events::AccountState,
46 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
47 instruments::{CurrencyPair, Instrument, InstrumentAny},
48 orders::{Order, OrderAny},
49 reports::{FillReport, OrderStatusReport, PositionStatusReport},
50 types::{AccountBalance, Currency, Price, Quantity},
51};
52use nautilus_network::{
53 http::{
54 HttpClient, HttpClientError, HttpRedirectPolicy, HttpResponse, Method,
55 create_standard_nautilus_headers,
56 },
57 ratelimiter::quota::Quota,
58};
59use parking_lot::Mutex;
60use rust_decimal::Decimal;
61use serde_json::Value;
62use ustr::Ustr;
63
64use crate::{
65 account::resolve_execution_account_address,
66 common::{
67 consts::{
68 ASSET_INDEX_INFO_KEY, HYPERLIQUID_REST_WEIGHT_PER_MINUTE, HYPERLIQUID_VENUE,
69 NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url,
70 },
71 credential::{Secrets, VaultAddress, credential_env_vars},
72 enums::{
73 HyperliquidBarInterval, HyperliquidEnvironment,
74 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
75 },
76 parse::{
77 bar_type_to_interval, cache_alias_for_symbol, clamp_price_to_precision,
78 derive_limit_from_trigger, determine_order_list_grouping, extract_inner_error,
79 normalize_or_validate_wire_price, order_to_hyperliquid_request_with_optional_decimals,
80 parse_combined_account_balances_and_margins, parse_spot_account_balances,
81 parse_trigger_order_type, round_to_sig_figs, time_in_force_to_hyperliquid_tif,
82 },
83 },
84 data::candle_to_bar,
85 data_types::HyperliquidPublicTrade,
86 http::{
87 error::{Error, Result},
88 models::{
89 ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeAction,
90 HyperliquidExchangeBuilderFee, HyperliquidExchangeCancelByCloidRequest,
91 HyperliquidExchangeCancelOrderRequest, HyperliquidExchangeGrouping,
92 HyperliquidExchangeLimitParams, HyperliquidExchangeMergeOutcomeParams,
93 HyperliquidExchangeMergeQuestionParams, HyperliquidExchangeModifyOrderRequest,
94 HyperliquidExchangeModifyTarget, HyperliquidExchangeNegateOutcomeParams,
95 HyperliquidExchangeOrderKind, HyperliquidExchangeOrderResponseData,
96 HyperliquidExchangeOrderStatus, HyperliquidExchangePlaceOrderRequest,
97 HyperliquidExchangeRequest, HyperliquidExchangeResponse,
98 HyperliquidExchangeSplitOutcomeParams, HyperliquidExchangeTif, HyperliquidExchangeTpSl,
99 HyperliquidExchangeTriggerParams, HyperliquidExchangeUserOutcomeOp, HyperliquidFills,
100 HyperliquidFundingHistoryEntry, HyperliquidL2Book, HyperliquidMeta,
101 HyperliquidOrderStatus, HyperliquidOrderStatusEntry, HyperliquidRecentTrade,
102 OutcomeMeta, PerpDex, PerpMeta, PerpMetaAndCtxs, RESPONSE_STATUS_OK,
103 SpotClearinghouseState, SpotMeta, SpotMetaAndCtxs,
104 },
105 parse::{
106 HyperliquidInstrumentDef, filter_recent_public_trades, instruments_from_defs_owned,
107 parse_fill_report, parse_order_status_report_from_basic, parse_outcome_instruments,
108 parse_perp_instruments_with_settlement, parse_position_status_report,
109 parse_recent_public_trade, parse_spot_instruments, parse_spot_position_status_report,
110 resolve_perp_settlement_currency,
111 },
112 query::{ExchangeAction, InfoRequest},
113 rate_limits::{
114 RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
115 exec_action_weight, info_base_weight, info_extra_weight, shared_rest_limiter,
116 },
117 },
118 signing::{
119 HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
120 },
121 websocket::messages::WsBasicOrderData,
122};
123
124fn deduplicate_historical_order_reports(reports: Vec<OrderStatusReport>) -> Vec<OrderStatusReport> {
125 let mut best_by_venue_order_id = AHashMap::new();
126
127 for candidate in reports {
128 let Some(current) = best_by_venue_order_id.remove(&candidate.venue_order_id) else {
129 best_by_venue_order_id.insert(candidate.venue_order_id, candidate);
130 continue;
131 };
132 let (mut best, other) = if historical_report_is_more_advanced(&candidate, ¤t) {
133 (candidate, current)
134 } else {
135 (current, candidate)
136 };
137
138 if matches!(
139 best.order_type,
140 OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
141 ) {
142 best.price = best.price.or(other.price);
143 }
144 best.trigger_price = best.trigger_price.or(other.trigger_price);
145 best_by_venue_order_id.insert(best.venue_order_id, best);
146 }
147
148 best_by_venue_order_id.into_values().collect()
149}
150
151fn historical_report_is_more_advanced(
152 candidate: &OrderStatusReport,
153 current: &OrderStatusReport,
154) -> bool {
155 candidate.filled_qty > current.filled_qty
156 || (candidate.filled_qty == current.filled_qty
157 && (historical_status_priority(candidate.order_status)
158 > historical_status_priority(current.order_status)
159 || (candidate.order_status == current.order_status
160 && candidate.ts_last > current.ts_last)))
161}
162
163const fn historical_status_priority(status: OrderStatus) -> u8 {
164 match status {
165 OrderStatus::Initialized | OrderStatus::Submitted | OrderStatus::Emulated => 0,
166 OrderStatus::Released | OrderStatus::Denied => 1,
167 OrderStatus::Accepted | OrderStatus::PendingUpdate | OrderStatus::PendingCancel => 2,
168 OrderStatus::Triggered => 3,
169 OrderStatus::PartiallyFilled => 4,
170 OrderStatus::Canceled | OrderStatus::Expired | OrderStatus::Rejected => 5,
171 OrderStatus::Filled | OrderStatus::Voided => 6,
172 }
173}
174
175pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
180 Quota::per_minute(NonZeroU32::new(HYPERLIQUID_REST_WEIGHT_PER_MINUTE).unwrap())
181});
182
183pub(crate) const HYPERLIQUID_RECENT_HISTORY_LIMIT: usize = 2_000;
184const RATE_LIMIT_BACKOFF_BASE: Duration = Duration::from_millis(125);
185const RATE_LIMIT_BACKOFF_CAP: Duration = Duration::from_secs(5);
186const RATE_LIMIT_INFO_RETRIES_MAX: u32 = 3;
187const RETRY_AFTER_HEADER: &str = "retry-after";
188const VAULT_TOKEN_PREFIX: &str = "vntls:";
189
190#[derive(Debug, Clone)]
195#[cfg_attr(
196 feature = "python",
197 pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
198)]
199pub struct HyperliquidRawHttpClient {
200 client: HttpClient,
201 environment: HyperliquidEnvironment,
202 base_info: String,
203 base_exchange: String,
204 signer: Option<HyperliquidEip712Signer>,
205 nonce_manager: Option<Arc<NonceManager>>,
206 vault_address: Option<VaultAddress>,
207 proxy_url: Option<SecretString>,
208 info_limiter: Arc<WeightedLimiter>,
209 exchange_limiter: Arc<WeightedLimiter>,
210}
211
212impl HyperliquidRawHttpClient {
213 pub fn new(
219 environment: HyperliquidEnvironment,
220 timeout_secs: u64,
221 proxy_url: Option<String>,
222 ) -> std::result::Result<Self, HttpClientError> {
223 let base_info = info_url(environment).to_string();
224 let base_exchange = exchange_url(environment).to_string();
225 let info_limiter = shared_rest_limiter(environment, &base_info, proxy_url.as_deref());
226 let exchange_limiter =
227 shared_rest_limiter(environment, &base_exchange, proxy_url.as_deref());
228
229 Ok(Self {
230 client: Self::build_http_client(timeout_secs, proxy_url.clone())?,
231 environment,
232 base_info,
233 base_exchange,
234 signer: None,
235 nonce_manager: None,
236 vault_address: None,
237 proxy_url: proxy_url.map(SecretString::from),
238 info_limiter,
239 exchange_limiter,
240 })
241 }
242
243 pub fn with_credentials(
250 secrets: &Secrets,
251 timeout_secs: u64,
252 proxy_url: Option<String>,
253 ) -> std::result::Result<Self, HttpClientError> {
254 let signer = HyperliquidEip712Signer::new(&secrets.private_key)
255 .map_err(|e| HttpClientError::from(e.to_string()))?;
256 let nonce_manager = Arc::new(NonceManager::new());
257 let base_info = info_url(secrets.environment).to_string();
258 let base_exchange = exchange_url(secrets.environment).to_string();
259 let info_limiter =
260 shared_rest_limiter(secrets.environment, &base_info, proxy_url.as_deref());
261 let exchange_limiter =
262 shared_rest_limiter(secrets.environment, &base_exchange, proxy_url.as_deref());
263
264 Ok(Self {
265 client: Self::build_http_client(timeout_secs, proxy_url.clone())?,
266 environment: secrets.environment,
267 base_info,
268 base_exchange,
269 signer: Some(signer),
270 nonce_manager: Some(nonce_manager),
271 vault_address: secrets.vault_address,
272 proxy_url: proxy_url.map(SecretString::from),
273 info_limiter,
274 exchange_limiter,
275 })
276 }
277
278 pub fn set_base_info_url(&mut self, url: String) {
280 self.info_limiter = shared_rest_limiter(
281 self.environment,
282 &url,
283 self.proxy_url.as_ref().map(|value| value.expose_secret()),
284 );
285 self.base_info = url;
286 }
287
288 pub fn set_base_exchange_url(&mut self, url: String) {
290 self.exchange_limiter = shared_rest_limiter(
291 self.environment,
292 &url,
293 self.proxy_url.as_ref().map(|value| value.expose_secret()),
294 );
295 self.base_exchange = url;
296 }
297
298 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
304 let secrets = Secrets::from_env(environment)
305 .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
306 Self::with_credentials(&secrets, 60, None)
307 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
308 }
309
310 pub fn from_credentials(
316 private_key: &str,
317 vault_address: Option<&str>,
318 environment: HyperliquidEnvironment,
319 timeout_secs: u64,
320 proxy_url: Option<String>,
321 ) -> Result<Self> {
322 let secrets = Secrets::from_private_key(private_key, vault_address, environment)
323 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
324 Self::with_credentials(&secrets, timeout_secs, proxy_url)
325 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
326 }
327
328 #[must_use]
330 pub fn with_rate_limits(mut self) -> Self {
331 let proxy_url = self.proxy_url.as_ref().map(|value| value.expose_secret());
332 self.info_limiter = shared_rest_limiter(self.environment, &self.base_info, proxy_url);
333 self.exchange_limiter =
334 shared_rest_limiter(self.environment, &self.base_exchange, proxy_url);
335 self
336 }
337
338 #[must_use]
340 pub fn environment(&self) -> HyperliquidEnvironment {
341 self.environment
342 }
343
344 #[must_use]
346 pub fn is_testnet(&self) -> bool {
347 self.environment == HyperliquidEnvironment::Testnet
348 }
349
350 pub fn get_user_address(&self) -> Result<String> {
356 self.signer
357 .as_ref()
358 .ok_or_else(|| Error::auth("No signer configured"))?
359 .address()
360 }
361
362 #[must_use]
364 pub fn has_vault_address(&self) -> bool {
365 self.vault_address.is_some()
366 }
367
368 pub fn get_account_address(&self) -> Result<String> {
375 if let Some(vault) = &self.vault_address {
376 Ok(vault.to_hex())
377 } else {
378 self.get_user_address()
379 }
380 }
381
382 fn build_http_client(
383 timeout_secs: u64,
384 proxy_url: Option<String>,
385 ) -> std::result::Result<HttpClient, HttpClientError> {
386 HttpClient::builder()
387 .redirect_policy(HttpRedirectPolicy::Reject)
388 .headers(Self::default_headers())
389 .header_keys(vec![RETRY_AFTER_HEADER.to_string()])
390 .rate_limiters(Vec::new())
391 .timeout_secs(timeout_secs)
392 .maybe_proxy_url(proxy_url)
393 .build()
394 }
395
396 fn default_headers() -> HashMap<String, String> {
397 let mut headers: HashMap<String, String> =
398 create_standard_nautilus_headers().into_iter().collect();
399 headers.insert("Content-Type".to_string(), "application/json".to_string());
400 headers
401 }
402
403 fn signer_id(&self) -> SignerId {
404 SignerId("hyperliquid:default".into())
405 }
406
407 fn retry_after_ms(headers: &HashMap<String, String>) -> Option<u64> {
408 let retry_after = headers.get(RETRY_AFTER_HEADER)?;
409 retry_after
410 .parse::<u64>()
411 .ok()
412 .map(|seconds| seconds.saturating_mul(1_000))
413 }
414
415 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
417 let request = InfoRequest::meta();
418 let response = self.send_info_request(&request).await?;
419 serde_json::from_value(response).map_err(Error::Serde)
420 }
421
422 pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
424 let request = InfoRequest::spot_meta();
425 let response = self.send_info_request(&request).await?;
426 serde_json::from_value(response).map_err(Error::Serde)
427 }
428
429 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
431 let request = InfoRequest::meta_and_asset_ctxs();
432 let response = self.send_info_request(&request).await?;
433 serde_json::from_value(response).map_err(Error::Serde)
434 }
435
436 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
438 let request = InfoRequest::spot_meta_and_asset_ctxs();
439 let response = self.send_info_request(&request).await?;
440 serde_json::from_value(response).map_err(Error::Serde)
441 }
442
443 pub async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
445 let request = InfoRequest::outcome_meta();
446 let response = self.send_info_request(&request).await?;
447 serde_json::from_value(response).map_err(Error::Serde)
448 }
449
450 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
451 let request = InfoRequest::meta();
452 let response = self.send_info_request(&request).await?;
453 serde_json::from_value(response).map_err(Error::Serde)
454 }
455
456 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
458 let request = InfoRequest::all_perp_metas();
459 let response = self.send_info_request(&request).await?;
460 serde_json::from_value(response).map_err(Error::Serde)
461 }
462
463 pub(crate) async fn load_perp_dexs(&self) -> Result<Vec<Option<PerpDex>>> {
465 let request = InfoRequest::perp_dexs();
466 let response = self.send_info_request(&request).await?;
467 serde_json::from_value(response).map_err(Error::Serde)
468 }
469
470 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
472 let request = InfoRequest::l2_book(coin);
473 let response = self.send_info_request(&request).await?;
474 serde_json::from_value(response).map_err(Error::Serde)
475 }
476
477 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
482 let request = InfoRequest::recent_trades(coin);
483 let response = self.send_info_request(&request).await?;
484 serde_json::from_value(response).map_err(Error::Serde)
485 }
486
487 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
489 let request = InfoRequest::user_fills(user);
490 let response = self.send_info_request(&request).await?;
491 serde_json::from_value(response).map_err(Error::Serde)
492 }
493
494 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
496 let request = InfoRequest::order_status(user, oid);
497 let response = self.send_info_request(&request).await?;
498 serde_json::from_value(response).map_err(Error::Serde)
499 }
500
501 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
503 let request = InfoRequest::open_orders(user);
504 self.send_info_request(&request).await
505 }
506
507 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
509 self.info_frontend_open_orders_for_dex(user, None).await
510 }
511
512 async fn info_frontend_open_orders_for_dex(
513 &self,
514 user: &str,
515 dex: Option<&str>,
516 ) -> Result<Value> {
517 let request = InfoRequest::frontend_open_orders_for_dex(user, dex);
518 self.send_info_request(&request).await
519 }
520
521 pub async fn info_historical_orders(
523 &self,
524 user: &str,
525 ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
526 let request = InfoRequest::historical_orders(user);
527 let response = self.send_info_request(&request).await?;
528 serde_json::from_value(response).map_err(Error::Serde)
529 }
530
531 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
533 self.info_clearinghouse_state_for_dex(user, None).await
534 }
535
536 async fn info_clearinghouse_state_for_dex(
537 &self,
538 user: &str,
539 dex: Option<&str>,
540 ) -> Result<Value> {
541 let request = InfoRequest::clearinghouse_state_for_dex(user, dex);
542 self.send_info_request(&request).await
543 }
544
545 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
547 let request = InfoRequest::spot_clearinghouse_state(user);
548 self.send_info_request(&request).await
549 }
550
551 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
553 let request = InfoRequest::user_fees(user);
554 self.send_info_request(&request).await
555 }
556
557 pub async fn info_candle_snapshot(
559 &self,
560 coin: &str,
561 interval: HyperliquidBarInterval,
562 start_time: u64,
563 end_time: u64,
564 ) -> Result<HyperliquidCandleSnapshot> {
565 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
566 let response = self.send_info_request(&request).await?;
567
568 log::trace!(
569 "Candle snapshot raw response (len={}): {:?}",
570 response.as_array().map_or(0, |a| a.len()),
571 response
572 );
573
574 serde_json::from_value(response).map_err(Error::Serde)
575 }
576
577 pub async fn info_funding_history(
582 &self,
583 coin: &str,
584 start_time: u64,
585 end_time: Option<u64>,
586 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
587 let request = InfoRequest::funding_history(coin, start_time, end_time);
588 let response = self.send_info_request(&request).await?;
589 serde_json::from_value(response).map_err(Error::Serde)
590 }
591
592 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
594 self.send_info_request(request).await
595 }
596
597 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
598 let base_w = info_base_weight(request);
599 let mut attempt = 0u32;
600
601 loop {
602 self.info_limiter.acquire(base_w).await;
603 let response = self.http_roundtrip_info(request).await?;
604
605 if response.status.is_success() {
606 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
608 let extra = info_extra_weight(request, &val);
609 if extra > 0 {
610 self.info_limiter.debit_extra(extra).await;
611 log::debug!(
612 "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
613 );
614 }
615 return Ok(val);
616 }
617
618 if response.status.as_u16() == 429 {
620 if attempt >= RATE_LIMIT_INFO_RETRIES_MAX {
621 let ra = Self::retry_after_ms(&response.headers);
622 return Err(Error::rate_limit("info", base_w, ra));
623 }
624 let delay = Self::retry_after_ms(&response.headers).map_or_else(
625 || {
626 backoff_full_jitter(
627 attempt,
628 RATE_LIMIT_BACKOFF_BASE,
629 RATE_LIMIT_BACKOFF_CAP,
630 )
631 },
632 Duration::from_millis,
633 );
634 log::warn!(
635 "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
636 delay.as_millis()
637 );
638 attempt += 1;
639 tokio::time::sleep(delay).await;
640 continue;
641 }
642
643 if (response.status.is_server_error() || response.status.as_u16() == 408)
645 && attempt < RATE_LIMIT_INFO_RETRIES_MAX
646 {
647 let delay =
648 backoff_full_jitter(attempt, RATE_LIMIT_BACKOFF_BASE, RATE_LIMIT_BACKOFF_CAP);
649 log::warn!(
650 "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
651 response.status.as_u16(),
652 delay.as_millis()
653 );
654 attempt += 1;
655 tokio::time::sleep(delay).await;
656 continue;
657 }
658
659 let error_body = String::from_utf8_lossy(&response.body);
661 return Err(Error::http(
662 response.status.as_u16(),
663 error_body.to_string(),
664 ));
665 }
666 }
667
668 async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
669 let url = &self.base_info;
670 let body = serde_json::to_value(request).map_err(Error::Serde)?;
671 let body_bytes = serde_json::to_string(&body)
672 .map_err(Error::Serde)?
673 .into_bytes();
674
675 self.client
676 .request(
677 Method::POST,
678 url.clone(),
679 None,
680 None,
681 Some(body_bytes),
682 None,
683 None,
684 )
685 .await
686 .map_err(Error::from_http_client)
687 }
688
689 pub async fn post_action(
691 &self,
692 action: &ExchangeAction,
693 ) -> Result<HyperliquidExchangeResponse> {
694 let w = exchange_weight(action);
695 self.exchange_limiter.acquire(w).await;
696
697 let signer = self
698 .signer
699 .as_ref()
700 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
701
702 let nonce_manager = self
703 .nonce_manager
704 .as_ref()
705 .ok_or_else(|| Error::auth("nonce manager missing"))?;
706
707 let signer_id = self.signer_id();
708 let time_nonce = nonce_manager.next(signer_id)?;
709
710 let action_bytes = rmp_serde::to_vec_named(action)
712 .context("serialize action with MessagePack")
713 .map_err(|e| Error::bad_request(e.to_string()))?;
714
715 let sign_request = SignRequest {
716 action: None,
717 action_bytes: Some(action_bytes),
718 time_nonce,
719 action_type: HyperliquidActionType::L1,
720 is_testnet: self.is_testnet(),
721 vault_address: self.vault_address,
722 expires_after: None,
723 };
724
725 let sig = signer.sign(&sign_request)?.signature;
726
727 let nonce_u64 = time_nonce.as_millis() as u64;
728
729 let request = if let Some(vault) = self.vault_address {
730 HyperliquidExchangeRequest::with_vault(
731 action.clone(),
732 nonce_u64,
733 sig,
734 vault.to_string(),
735 )
736 } else {
737 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
738 };
739
740 let response = self.http_roundtrip_exchange(&request).await?;
741
742 if response.status.is_success() {
743 let parsed_response: HyperliquidExchangeResponse =
744 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
745
746 match &parsed_response {
748 HyperliquidExchangeResponse::Status {
749 status,
750 response: response_data,
751 } if status == "err" => {
752 let error_msg = response_data
753 .as_str()
754 .map_or_else(|| response_data.to_string(), |s| s.to_string());
755 log::error!("Hyperliquid API returned error: {error_msg}");
756 Err(Error::bad_request(format!("API error: {error_msg}")))
757 }
758 HyperliquidExchangeResponse::Error { error } => {
759 log::error!("Hyperliquid API returned error: {error}");
760 Err(Error::bad_request(format!("API error: {error}")))
761 }
762 _ => Ok(parsed_response),
763 }
764 } else if response.status.as_u16() == 429 {
765 let ra = Self::retry_after_ms(&response.headers);
766 Err(Error::rate_limit("exchange", w, ra))
767 } else {
768 let error_body = String::from_utf8_lossy(&response.body);
769 log::error!(
770 "Exchange API error (status {}): {}",
771 response.status.as_u16(),
772 error_body
773 );
774 Err(Error::http(
775 response.status.as_u16(),
776 error_body.to_string(),
777 ))
778 }
779 }
780
781 pub fn sign_action_exec_request(
783 &self,
784 action: &HyperliquidExchangeAction,
785 expires_after: Option<u64>,
786 ) -> Result<HyperliquidExchangeRequest<HyperliquidExchangeAction>> {
787 let signer = self
788 .signer
789 .as_ref()
790 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
791
792 let nonce_manager = self
793 .nonce_manager
794 .as_ref()
795 .ok_or_else(|| Error::auth("nonce manager missing"))?;
796
797 let signer_id = self.signer_id();
798 let time_nonce = nonce_manager.next(signer_id)?;
799 let action_bytes = rmp_serde::to_vec_named(action)
803 .context("serialize action with MessagePack")
804 .map_err(|e| Error::bad_request(e.to_string()))?;
805
806 let sig = signer
807 .sign(&SignRequest {
808 action: None,
809 action_bytes: Some(action_bytes),
810 time_nonce,
811 action_type: HyperliquidActionType::L1,
812 is_testnet: self.is_testnet(),
813 vault_address: self.vault_address,
814 expires_after,
815 })?
816 .signature;
817
818 let mut request = if let Some(vault) = self.vault_address {
819 HyperliquidExchangeRequest::with_vault(
820 action.clone(),
821 time_nonce.as_millis() as u64,
822 sig,
823 vault.to_string(),
824 )
825 } else {
826 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
827 };
828 request.expires_after = expires_after;
829 Ok(request)
830 }
831
832 pub async fn post_action_exec(
837 &self,
838 action: &HyperliquidExchangeAction,
839 ) -> Result<HyperliquidExchangeResponse> {
840 let w = exec_action_weight(action);
841 self.exchange_limiter.acquire(w).await;
842
843 let request = self.sign_action_exec_request(action, None)?;
844
845 let response = self.http_roundtrip_exchange(&request).await?;
846
847 if response.status.is_success() {
848 let parsed_response: HyperliquidExchangeResponse =
849 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
850
851 match &parsed_response {
853 HyperliquidExchangeResponse::Status {
854 status,
855 response: response_data,
856 } if status == "err" => {
857 let error_msg = response_data
858 .as_str()
859 .map_or_else(|| response_data.to_string(), |s| s.to_string());
860 log::error!("Hyperliquid API returned error: {error_msg}");
861 Err(Error::bad_request(format!("API error: {error_msg}")))
862 }
863 HyperliquidExchangeResponse::Error { error } => {
864 log::error!("Hyperliquid API returned error: {error}");
865 Err(Error::bad_request(format!("API error: {error}")))
866 }
867 _ => Ok(parsed_response),
868 }
869 } else if response.status.as_u16() == 429 {
870 let ra = Self::retry_after_ms(&response.headers);
871 Err(Error::rate_limit("exchange", w, ra))
872 } else {
873 let error_body = String::from_utf8_lossy(&response.body);
874 Err(Error::http(
875 response.status.as_u16(),
876 error_body.to_string(),
877 ))
878 }
879 }
880
881 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
883 self.info_limiter.snapshot().await
884 }
885
886 async fn http_roundtrip_exchange<T>(
887 &self,
888 request: &HyperliquidExchangeRequest<T>,
889 ) -> Result<HttpResponse>
890 where
891 T: serde::Serialize,
892 {
893 let url = &self.base_exchange;
894 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
895 let body_bytes = body.into_bytes();
896
897 let response = self
898 .client
899 .request(
900 Method::POST,
901 url.clone(),
902 None,
903 None,
904 Some(body_bytes),
905 None,
906 None,
907 )
908 .await
909 .map_err(Error::from_http_client)?;
910
911 Ok(response)
912 }
913}
914
915#[derive(Debug, Clone)]
921#[cfg_attr(
922 feature = "python",
923 pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
924)]
925#[cfg_attr(
926 feature = "python",
927 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
928)]
929pub struct HyperliquidHttpClient {
930 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
931 clock: &'static AtomicTime,
932 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
933 instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
934 asset_indices: Arc<AtomicMap<Ustr, u32>>,
936 spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
938 client_order_id_cloids: Arc<Mutex<AHashMap<ClientOrderId, Cloid>>>,
939 account_id: Option<AccountId>,
940 account_address: Option<String>,
944 normalize_prices: bool,
945 market_order_slippage_bps: u32,
946 include_builder_attribution: bool,
947}
948
949impl Default for HyperliquidHttpClient {
950 fn default() -> Self {
951 Self::new(HyperliquidEnvironment::Mainnet, 60, None)
952 .expect("Failed to create default Hyperliquid HTTP client")
953 }
954}
955
956impl HyperliquidHttpClient {
957 pub fn new(
963 environment: HyperliquidEnvironment,
964 timeout_secs: u64,
965 proxy_url: Option<String>,
966 ) -> std::result::Result<Self, HttpClientError> {
967 let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
968 Ok(Self::from_raw(raw_client))
969 }
970
971 pub fn with_secrets(
977 secrets: &Secrets,
978 timeout_secs: u64,
979 proxy_url: Option<String>,
980 ) -> std::result::Result<Self, HttpClientError> {
981 let raw_client =
982 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
983 Ok(Self::from_raw(raw_client))
984 }
985
986 fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
987 Self {
988 inner: Arc::new(raw_client),
989 clock: get_atomic_clock_realtime(),
990 instruments: Arc::new(AtomicMap::new()),
991 instruments_by_coin: Arc::new(AtomicMap::new()),
992 asset_indices: Arc::new(AtomicMap::new()),
993 spot_fill_coins: Arc::new(AtomicMap::new()),
994 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
995 account_id: None,
996 account_address: None,
997 normalize_prices: true,
998 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
999 include_builder_attribution: true,
1000 }
1001 }
1002
1003 #[must_use]
1005 pub fn get_or_generate_client_order_id_cloid(&self, client_order_id: ClientOrderId) -> Cloid {
1006 let mut cloids = self.client_order_id_cloids.lock();
1007 *cloids
1008 .entry(client_order_id)
1009 .or_insert_with(|| Cloid::from_client_order_id(client_order_id))
1010 }
1011
1012 pub fn cache_client_order_id_cloid(&self, client_order_id: ClientOrderId, cloid: Cloid) {
1014 self.client_order_id_cloids
1015 .lock()
1016 .entry(client_order_id)
1017 .or_insert(cloid);
1018 }
1019
1020 #[must_use]
1022 pub fn cached_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
1023 self.client_order_id_cloids
1024 .lock()
1025 .get(client_order_id)
1026 .copied()
1027 }
1028
1029 #[must_use]
1032 pub(crate) fn unique_cached_client_order_id_cloid(
1033 &self,
1034 client_order_id: &ClientOrderId,
1035 ) -> Option<Cloid> {
1036 let cloids = self.client_order_id_cloids.lock();
1037 let cloid = cloids.get(client_order_id).copied()?;
1038 let mapping_count = cloids
1039 .values()
1040 .filter(|cached_cloid| **cached_cloid == cloid)
1041 .count();
1042
1043 (mapping_count == 1).then_some(cloid)
1044 }
1045
1046 pub fn remove_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
1048 self.client_order_id_cloids.lock().remove(client_order_id)
1049 }
1050
1051 pub fn set_base_info_url(&mut self, url: String) {
1057 Arc::get_mut(&mut self.inner)
1058 .expect("cannot override URL: Arc has multiple references")
1059 .set_base_info_url(url);
1060 }
1061
1062 pub fn set_base_exchange_url(&mut self, url: String) {
1068 Arc::get_mut(&mut self.inner)
1069 .expect("cannot override URL: Arc has multiple references")
1070 .set_base_exchange_url(url);
1071 }
1072
1073 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
1079 let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
1080 Ok(Self {
1081 inner: Arc::new(raw_client),
1082 clock: get_atomic_clock_realtime(),
1083 instruments: Arc::new(AtomicMap::new()),
1084 instruments_by_coin: Arc::new(AtomicMap::new()),
1085 asset_indices: Arc::new(AtomicMap::new()),
1086 spot_fill_coins: Arc::new(AtomicMap::new()),
1087 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1088 account_id: None,
1089 account_address: None,
1090 normalize_prices: true,
1091 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1092 include_builder_attribution: true,
1093 })
1094 }
1095
1096 pub fn with_credentials(
1109 private_key: Option<String>,
1110 vault_address: Option<String>,
1111 account_address: Option<&str>,
1112 environment: HyperliquidEnvironment,
1113 timeout_secs: u64,
1114 proxy_url: Option<String>,
1115 ) -> Result<Self> {
1116 let (pk_env_var, vault_env_var) = credential_env_vars(environment);
1117
1118 let resolved_account_address = resolve_execution_account_address(
1119 private_key.as_deref(),
1120 vault_address.as_deref(),
1121 account_address,
1122 environment,
1123 )?;
1124
1125 let resolved_pk = private_key.or_else(|| std::env::var(pk_env_var).ok());
1127
1128 let resolved_vault = vault_address.or_else(|| std::env::var(vault_env_var).ok());
1130
1131 Self::from_resolved_credentials(
1132 resolved_pk,
1133 resolved_vault.as_deref(),
1134 resolved_account_address,
1135 environment,
1136 timeout_secs,
1137 proxy_url,
1138 )
1139 }
1140
1141 fn from_resolved_credentials(
1142 private_key: Option<String>,
1143 vault_address: Option<&str>,
1144 account_address: Option<String>,
1145 environment: HyperliquidEnvironment,
1146 timeout_secs: u64,
1147 proxy_url: Option<String>,
1148 ) -> Result<Self> {
1149 match private_key {
1150 Some(pk) => {
1151 let raw_client = HyperliquidRawHttpClient::from_credentials(
1152 &pk,
1153 vault_address,
1154 environment,
1155 timeout_secs,
1156 proxy_url,
1157 )?;
1158 Ok(Self {
1159 inner: Arc::new(raw_client),
1160 clock: get_atomic_clock_realtime(),
1161 instruments: Arc::new(AtomicMap::new()),
1162 instruments_by_coin: Arc::new(AtomicMap::new()),
1163 asset_indices: Arc::new(AtomicMap::new()),
1164 spot_fill_coins: Arc::new(AtomicMap::new()),
1165 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1166 account_id: None,
1167 account_address,
1168 normalize_prices: true,
1169 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1170 include_builder_attribution: true,
1171 })
1172 }
1173 None => {
1174 let mut client = Self::new(environment, timeout_secs, proxy_url)
1176 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))?;
1177 client.set_account_address(account_address);
1178 Ok(client)
1179 }
1180 }
1181 }
1182
1183 pub fn from_credentials(
1189 private_key: &str,
1190 vault_address: Option<&str>,
1191 environment: HyperliquidEnvironment,
1192 timeout_secs: u64,
1193 proxy_url: Option<String>,
1194 ) -> Result<Self> {
1195 let raw_client = HyperliquidRawHttpClient::from_credentials(
1196 private_key,
1197 vault_address,
1198 environment,
1199 timeout_secs,
1200 proxy_url,
1201 )?;
1202 Ok(Self {
1203 inner: Arc::new(raw_client),
1204 clock: get_atomic_clock_realtime(),
1205 instruments: Arc::new(AtomicMap::new()),
1206 instruments_by_coin: Arc::new(AtomicMap::new()),
1207 asset_indices: Arc::new(AtomicMap::new()),
1208 spot_fill_coins: Arc::new(AtomicMap::new()),
1209 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1210 account_id: None,
1211 account_address: None,
1212 normalize_prices: true,
1213 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1214 include_builder_attribution: true,
1215 })
1216 }
1217
1218 #[must_use]
1220 pub fn is_testnet(&self) -> bool {
1221 self.inner.is_testnet()
1222 }
1223
1224 #[must_use]
1226 pub fn normalize_prices(&self) -> bool {
1227 self.normalize_prices
1228 }
1229
1230 pub fn set_normalize_prices(&mut self, value: bool) {
1232 self.normalize_prices = value;
1233 }
1234
1235 #[must_use]
1237 pub fn market_order_slippage_bps(&self) -> u32 {
1238 self.market_order_slippage_bps
1239 }
1240
1241 pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1243 self.market_order_slippage_bps = value;
1244 }
1245
1246 #[must_use]
1248 pub fn include_builder_attribution(&self) -> bool {
1249 self.include_builder_attribution
1250 }
1251
1252 pub fn set_include_builder_attribution(&mut self, value: bool) {
1254 self.include_builder_attribution = value;
1255 }
1256
1257 pub fn get_user_address(&self) -> Result<String> {
1263 self.inner.get_user_address()
1264 }
1265
1266 #[must_use]
1268 pub fn has_vault_address(&self) -> bool {
1269 self.inner.has_vault_address()
1270 }
1271
1272 #[must_use]
1277 pub fn builder_attribution(&self) -> Option<HyperliquidExchangeBuilderFee> {
1278 if !self.include_builder_attribution || self.has_vault_address() || self.is_testnet() {
1279 None
1280 } else {
1281 Some(HyperliquidExchangeBuilderFee {
1282 address: NAUTILUS_BUILDER_ADDRESS.to_string(),
1283 fee_tenths_bp: 0,
1284 })
1285 }
1286 }
1287
1288 pub fn get_account_address(&self) -> Result<String> {
1296 if let Some(addr) = &self.account_address {
1297 return Ok(addr.clone());
1298 }
1299 self.inner.get_account_address()
1300 }
1301
1302 pub fn set_account_address(&mut self, address: Option<String>) {
1304 self.account_address = address;
1305 }
1306
1307 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1318 let full_symbol = instrument.symbol().inner();
1319 let coin = instrument.raw_symbol().inner();
1320
1321 match instrument
1322 .info()
1323 .and_then(|info| info.get_u64(ASSET_INDEX_INFO_KEY))
1324 .and_then(|value| u32::try_from(value).ok())
1325 {
1326 Some(asset_index) => self.asset_indices.rcu(|m| {
1327 m.insert(full_symbol, asset_index);
1328 }),
1329 None if coin.starts_with(VAULT_TOKEN_PREFIX) => {}
1332 None if self.asset_indices.get_cloned(&full_symbol).is_none() => log::warn!(
1335 "Instrument '{full_symbol}' carries no '{ASSET_INDEX_INFO_KEY}' info value \
1336 and has no cached asset index; orders for it will be rejected"
1337 ),
1338 None => log::warn!(
1339 "Instrument '{full_symbol}' carries no '{ASSET_INDEX_INFO_KEY}' info value; \
1340 leaving the cached asset index unchanged"
1341 ),
1342 }
1343
1344 self.instruments.rcu(|m| {
1345 m.insert(full_symbol, instrument.clone());
1346 m.insert(coin, instrument.clone());
1348 });
1349
1350 if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
1352 self.instruments_by_coin.rcu(|m| {
1353 m.insert((coin, product_type), instrument.clone());
1354
1355 if let Some(alias_ustr) = cache_alias_for_symbol(full_symbol.as_str())
1380 .map(|alias| Ustr::from(alias.as_str()))
1381 {
1382 let key = (alias_ustr, product_type);
1383 if alias_ustr != coin && !m.contains_key(&key) {
1384 m.insert(key, instrument.clone());
1385 }
1386 }
1387 });
1388 } else {
1389 log::warn!("Unable to determine product type for symbol: {full_symbol}");
1390 }
1391 }
1392
1393 fn get_or_create_instrument(
1394 &self,
1395 coin: &Ustr,
1396 product_type: Option<HyperliquidProductType>,
1397 ) -> Option<InstrumentAny> {
1398 if let Some(pt) = product_type
1399 && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1400 {
1401 return Some(instrument.clone());
1402 }
1403
1404 if product_type.is_none() {
1408 let guard = self.instruments_by_coin.load();
1409
1410 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Outcome)) {
1411 return Some(instrument.clone());
1412 }
1413
1414 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1415 return Some(instrument.clone());
1416 }
1417
1418 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1419 return Some(instrument.clone());
1420 }
1421 }
1422
1423 if coin.starts_with('@')
1425 && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1426 {
1427 if let Some(instrument) = self.instruments.load().get(symbol) {
1430 return Some(instrument.clone());
1431 }
1432 }
1433
1434 if coin.starts_with(VAULT_TOKEN_PREFIX) {
1436 log::debug!("Creating synthetic instrument for vault token: {coin}");
1437
1438 let ts_event = self.clock.get_time_ns();
1439
1440 let symbol_str = format!("{coin}-USDC-SPOT");
1442 let symbol = Symbol::new(&symbol_str);
1443 let venue = *HYPERLIQUID_VENUE;
1444 let instrument_id = InstrumentId::new(symbol, venue);
1445
1446 let base_currency = Currency::new(
1448 coin.as_str(),
1449 8, 0, coin.as_str(),
1452 CurrencyType::Crypto,
1453 );
1454
1455 let quote_currency = Currency::new(
1456 "USDC",
1457 6, 0,
1459 "USDC",
1460 CurrencyType::Crypto,
1461 );
1462
1463 let price_increment = Price::from("0.00000001");
1464 let size_increment = Quantity::from("0.00000001");
1465
1466 let instrument = InstrumentAny::CurrencyPair(
1467 CurrencyPair::builder()
1468 .instrument_id(instrument_id)
1469 .raw_symbol(symbol)
1470 .base_currency(base_currency)
1471 .quote_currency(quote_currency)
1472 .price_precision(8)
1473 .size_precision(8)
1474 .price_increment(price_increment)
1475 .size_increment(size_increment)
1476 .ts_event(ts_event)
1477 .ts_init(ts_event)
1478 .build()
1479 .unwrap(),
1480 );
1481
1482 self.cache_instrument(&instrument);
1483
1484 Some(instrument)
1485 } else {
1486 log::warn!("Instrument not found in cache: {coin}");
1488 None
1489 }
1490 }
1491
1492 pub fn set_account_id(&mut self, account_id: AccountId) {
1496 self.account_id = Some(account_id);
1497 }
1498
1499 pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1501 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1502 let spot_meta = match self.inner.get_spot_meta().await {
1503 Ok(spot_meta) => Some(spot_meta),
1504 Err(e) => {
1505 log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1506 None
1507 }
1508 };
1509
1510 match self.inner.load_all_perp_metas().await {
1512 Ok(all_metas) => {
1513 for (dex_index, meta) in all_metas.iter().enumerate() {
1514 let base = perp_dex_asset_index_base(dex_index);
1515 let settlement_currency = match resolve_perp_settlement_currency(
1516 meta,
1517 spot_meta.as_ref(),
1518 ) {
1519 Ok(settlement_currency) => settlement_currency,
1520 Err(e) => {
1521 return Err(Error::decode(format!(
1522 "failed to resolve perp settlement currency for dex {dex_index}: {e}",
1523 )));
1524 }
1525 };
1526
1527 let perp_defs = parse_perp_instruments_with_settlement(
1528 meta,
1529 base,
1530 settlement_currency.as_str(),
1531 );
1532 log::debug!(
1533 "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1534 perp_defs.len(),
1535 );
1536 defs.extend(perp_defs);
1537 }
1538 }
1539 Err(e) => {
1540 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1541
1542 match self.inner.load_perp_meta().await {
1543 Ok(perp_meta) => {
1544 match resolve_perp_settlement_currency(&perp_meta, spot_meta.as_ref()) {
1545 Ok(settlement_currency) => {
1546 let perp_defs = parse_perp_instruments_with_settlement(
1547 &perp_meta,
1548 0,
1549 settlement_currency.as_str(),
1550 );
1551 log::debug!(
1552 "Loaded Hyperliquid perp defs via fallback: count={}",
1553 perp_defs.len(),
1554 );
1555 defs.extend(perp_defs);
1556 }
1557 Err(e) => {
1558 return Err(Error::decode(format!(
1559 "failed to resolve fallback perp settlement currency: {e}",
1560 )));
1561 }
1562 }
1563 }
1564 Err(e) => {
1565 log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1566 }
1567 }
1568 }
1569 }
1570
1571 if let Some(spot_meta) = spot_meta.as_ref() {
1572 match parse_spot_instruments(spot_meta) {
1573 Ok(spot_defs) => {
1574 log::debug!(
1575 "Loaded Hyperliquid spot definitions: count={}",
1576 spot_defs.len(),
1577 );
1578 defs.extend(spot_defs);
1579 }
1580 Err(e) => {
1581 log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1582 }
1583 }
1584 }
1585
1586 match self.inner.get_outcome_meta().await {
1590 Ok(outcome_meta) => match parse_outcome_instruments(&outcome_meta) {
1591 Ok(outcome_defs) => {
1592 log::debug!(
1593 "Loaded Hyperliquid outcome definitions: count={}",
1594 outcome_defs.len(),
1595 );
1596 defs.extend(outcome_defs);
1597 }
1598 Err(e) => {
1599 log::warn!("Failed to parse Hyperliquid outcome instruments: {e}");
1600 }
1601 },
1602 Err(e) => {
1603 log::debug!("Skipping Hyperliquid outcome metadata: {e}");
1604 }
1605 }
1606
1607 let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1614 let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1615 for def in defs {
1616 if seen_symbols.insert(def.symbol) {
1617 deduped.push(def);
1618 } else {
1619 log::warn!(
1620 "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1621 def.symbol,
1622 def.raw_symbol,
1623 );
1624 }
1625 }
1626 let defs = deduped;
1627
1628 self.asset_indices.rcu(|m| {
1630 for def in &defs {
1631 m.insert(def.symbol, def.asset_index);
1632 }
1633 });
1634 log::debug!(
1635 "Populated asset indices map (count={})",
1636 self.asset_indices.len()
1637 );
1638
1639 Ok(defs)
1640 }
1641
1642 pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1644 let ts_init = self.clock.get_time_ns();
1645 instruments_from_defs_owned(defs, ts_init)
1646 }
1647
1648 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1650 let defs = self.request_instrument_defs().await?;
1651 Ok(self.convert_defs(defs))
1652 }
1653
1654 pub async fn build_all_dex_asset_ctxs_instrument_ids(
1659 &self,
1660 ) -> Result<AHashMap<String, Vec<Option<InstrumentId>>>> {
1661 let all_metas = match self.inner.load_all_perp_metas().await {
1662 Ok(all_metas) => all_metas,
1663 Err(e) => {
1664 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1665 vec![self.inner.load_perp_meta().await?]
1666 }
1667 };
1668
1669 let perp_dexs = match self.inner.load_perp_dexs().await {
1670 Ok(dexs) => Some(dexs),
1671 Err(e) => {
1672 log::warn!("Failed to load perpDexs, inferring dex names from metadata: {e}");
1673 None
1674 }
1675 };
1676
1677 let raw_symbol_to_id =
1678 self.instruments
1679 .load()
1680 .values()
1681 .fold(AHashMap::new(), |mut acc, instrument| {
1682 acc.insert(instrument.raw_symbol().to_string(), instrument.id());
1683 acc
1684 });
1685
1686 let mut mapping = AHashMap::new();
1687
1688 for (dex_index, meta) in all_metas.iter().enumerate() {
1689 let dex_name = resolve_perp_dex_name(dex_index, meta, perp_dexs.as_deref());
1690 let mut instrument_ids = Vec::with_capacity(meta.universe.len());
1691
1692 for asset in &meta.universe {
1693 if let Some(instrument_id) = raw_symbol_to_id.get(&asset.name) {
1694 instrument_ids.push(Some(*instrument_id));
1695 } else {
1696 log::warn!(
1697 "Missing cached Hyperliquid instrument for dex='{}' raw_symbol='{}'",
1698 dex_name,
1699 asset.name
1700 );
1701 instrument_ids.push(None);
1702 }
1703 }
1704
1705 mapping.insert(dex_name, instrument_ids);
1706 }
1707
1708 Ok(mapping)
1709 }
1710
1711 pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1719 self.get_asset_index_for_symbol(Ustr::from(symbol))
1720 }
1721
1722 pub(crate) fn get_asset_index_for_symbol(&self, symbol: Ustr) -> Option<u32> {
1726 self.asset_indices.load().get(&symbol).copied()
1727 }
1728
1729 pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1731 self.get_price_precision_for_symbol(Ustr::from(symbol))
1732 }
1733
1734 pub(crate) fn get_price_precision_for_symbol(&self, symbol: Ustr) -> Option<u8> {
1736 self.instruments
1737 .load()
1738 .get(&symbol)
1739 .map(|inst| inst.price_precision())
1740 }
1741
1742 #[must_use]
1750 pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1751 const SPOT_INDEX_OFFSET: u32 = 10_000;
1752 const BUILDER_PERP_OFFSET: u32 = 100_000;
1753
1754 let guard = self.asset_indices.load();
1755
1756 let mut mapping = AHashMap::new();
1757
1758 for (symbol, &asset_index) in guard.iter() {
1759 if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1761 let pair_index = asset_index - SPOT_INDEX_OFFSET;
1762 let fill_coin = Ustr::from(&format!("@{pair_index}"));
1763 mapping.insert(fill_coin, *symbol);
1764 }
1765 }
1766
1767 self.spot_fill_coins.store(mapping.clone());
1769
1770 mapping
1771 }
1772
1773 #[allow(dead_code)]
1775 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1776 self.inner.load_perp_meta().await
1777 }
1778
1779 #[allow(dead_code)]
1781 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1782 self.inner.load_all_perp_metas().await
1783 }
1784
1785 #[allow(dead_code)]
1787 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1788 self.inner.get_spot_meta().await
1789 }
1790
1791 pub(crate) async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
1793 self.inner.get_outcome_meta().await
1794 }
1795
1796 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1798 self.inner.info_l2_book(coin).await
1799 }
1800
1801 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
1803 self.inner.info_recent_trades(coin).await
1804 }
1805
1806 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1808 self.inner.info_user_fills(user).await
1809 }
1810
1811 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1813 self.inner.info_order_status(user, oid).await
1814 }
1815
1816 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1818 self.inner.info_open_orders(user).await
1819 }
1820
1821 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1823 self.inner.info_frontend_open_orders(user).await
1824 }
1825
1826 async fn info_frontend_open_orders_for_dex(
1827 &self,
1828 user: &str,
1829 dex: Option<&str>,
1830 ) -> Result<Value> {
1831 self.inner
1832 .info_frontend_open_orders_for_dex(user, dex)
1833 .await
1834 }
1835
1836 pub async fn info_historical_orders(
1838 &self,
1839 user: &str,
1840 ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
1841 self.inner.info_historical_orders(user).await
1842 }
1843
1844 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1846 self.inner.info_clearinghouse_state(user).await
1847 }
1848
1849 async fn info_clearinghouse_state_for_dex(
1850 &self,
1851 user: &str,
1852 dex: Option<&str>,
1853 ) -> Result<Value> {
1854 self.inner.info_clearinghouse_state_for_dex(user, dex).await
1855 }
1856
1857 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
1859 self.inner.info_spot_clearinghouse_state(user).await
1860 }
1861
1862 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
1864 self.inner.info_user_fees(user).await
1865 }
1866
1867 pub async fn info_candle_snapshot(
1869 &self,
1870 coin: &str,
1871 interval: HyperliquidBarInterval,
1872 start_time: u64,
1873 end_time: u64,
1874 ) -> Result<HyperliquidCandleSnapshot> {
1875 self.inner
1876 .info_candle_snapshot(coin, interval, start_time, end_time)
1877 .await
1878 }
1879
1880 pub async fn info_funding_history(
1882 &self,
1883 coin: &str,
1884 start_time: u64,
1885 end_time: Option<u64>,
1886 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
1887 self.inner
1888 .info_funding_history(coin, start_time, end_time)
1889 .await
1890 }
1891
1892 pub async fn post_action(
1894 &self,
1895 action: &ExchangeAction,
1896 ) -> Result<HyperliquidExchangeResponse> {
1897 self.inner.post_action(action).await
1898 }
1899
1900 pub async fn post_action_exec(
1902 &self,
1903 action: &HyperliquidExchangeAction,
1904 ) -> Result<HyperliquidExchangeResponse> {
1905 self.inner.post_action_exec(action).await
1906 }
1907
1908 pub fn sign_action_exec_request(
1910 &self,
1911 action: &HyperliquidExchangeAction,
1912 expires_after: Option<u64>,
1913 ) -> Result<HyperliquidExchangeRequest<HyperliquidExchangeAction>> {
1914 self.inner.sign_action_exec_request(action, expires_after)
1915 }
1916
1917 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1919 self.inner.info_meta().await
1920 }
1921
1922 pub async fn cancel_order(
1932 &self,
1933 instrument_id: InstrumentId,
1934 client_order_id: Option<ClientOrderId>,
1935 venue_order_id: Option<VenueOrderId>,
1936 ) -> Result<()> {
1937 let symbol = instrument_id.symbol.inner();
1939 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1940 Error::bad_request(format!(
1941 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1942 ))
1943 })?;
1944
1945 let action = if let Some(client_order_id) = client_order_id {
1946 if let Some(cloid) = self.cached_client_order_id_cloid(&client_order_id) {
1947 HyperliquidExchangeAction::CancelByCloid {
1948 cancels: vec![HyperliquidExchangeCancelByCloidRequest {
1949 asset: asset_id,
1950 cloid,
1951 }],
1952 fast: None,
1953 }
1954 } else if let Some(oid) = venue_order_id {
1955 let oid_u64 = oid
1956 .as_str()
1957 .parse::<u64>()
1958 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1959 HyperliquidExchangeAction::Cancel {
1960 cancels: vec![HyperliquidExchangeCancelOrderRequest {
1961 asset: asset_id,
1962 oid: oid_u64,
1963 }],
1964 fast: None,
1965 }
1966 } else {
1967 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
1968 HyperliquidExchangeAction::CancelByCloid {
1969 cancels: vec![HyperliquidExchangeCancelByCloidRequest {
1970 asset: asset_id,
1971 cloid,
1972 }],
1973 fast: None,
1974 }
1975 }
1976 } else if let Some(oid) = venue_order_id {
1977 let oid_u64 = oid
1978 .as_str()
1979 .parse::<u64>()
1980 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1981 HyperliquidExchangeAction::Cancel {
1982 cancels: vec![HyperliquidExchangeCancelOrderRequest {
1983 asset: asset_id,
1984 oid: oid_u64,
1985 }],
1986 fast: None,
1987 }
1988 } else {
1989 return Err(Error::bad_request(
1990 "Either client_order_id or venue_order_id must be provided",
1991 ));
1992 };
1993
1994 let response = self.inner.post_action_exec(&action).await?;
1996
1997 match response {
1999 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
2000 HyperliquidExchangeResponse::Status {
2001 status,
2002 response: error_data,
2003 } => Err(Error::bad_request(format!(
2004 "Cancel order failed: status={status}, error={error_data}"
2005 ))),
2006 HyperliquidExchangeResponse::Error { error } => {
2007 Err(Error::bad_request(format!("Cancel order error: {error}")))
2008 }
2009 }
2010 }
2011
2012 #[expect(clippy::too_many_arguments)]
2022 pub async fn modify_order(
2023 &self,
2024 instrument_id: InstrumentId,
2025 venue_order_id: Option<VenueOrderId>,
2026 order_side: OrderSide,
2027 order_type: OrderType,
2028 price: Price,
2029 quantity: Quantity,
2030 trigger_price: Option<Price>,
2031 reduce_only: bool,
2032 post_only: bool,
2033 time_in_force: TimeInForce,
2034 client_order_id: Option<ClientOrderId>,
2035 ) -> Result<()> {
2036 let symbol = instrument_id.symbol.inner();
2037 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
2038 Error::bad_request(format!(
2039 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2040 ))
2041 })?;
2042
2043 let oid = match client_order_id
2044 .as_ref()
2045 .and_then(|id| self.unique_cached_client_order_id_cloid(id))
2046 {
2047 Some(cloid) => HyperliquidExchangeModifyTarget::Cloid(cloid),
2048 None => {
2049 let Some(venue_order_id) = venue_order_id.as_ref() else {
2050 return Err(Error::bad_request(
2051 "venue_order_id or unique cached CLOID is required for modify",
2052 ));
2053 };
2054 HyperliquidExchangeModifyTarget::from_venue_order_id(venue_order_id)
2055 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?
2056 }
2057 };
2058
2059 let is_buy = matches!(order_side, OrderSide::Buy);
2060 let decimals = self.get_price_precision_for_symbol(symbol);
2061
2062 let normalized_price = normalize_or_validate_wire_price(
2063 price.as_decimal(),
2064 "Price",
2065 decimals,
2066 self.normalize_prices,
2067 )
2068 .map_err(|e| Error::bad_request(format!("{e}")))?;
2069
2070 let size = quantity.as_decimal().normalize();
2071
2072 let kind = match order_type {
2073 OrderType::Market => HyperliquidExchangeOrderKind::Limit {
2074 limit: HyperliquidExchangeLimitParams {
2075 tif: HyperliquidExchangeTif::Ioc,
2076 },
2077 },
2078 OrderType::Limit => {
2079 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
2080 .map_err(|e| Error::bad_request(format!("{e}")))?;
2081 HyperliquidExchangeOrderKind::Limit {
2082 limit: HyperliquidExchangeLimitParams { tif },
2083 }
2084 }
2085 OrderType::StopMarket
2086 | OrderType::StopLimit
2087 | OrderType::MarketIfTouched
2088 | OrderType::LimitIfTouched => {
2089 if let Some(trig_px) = trigger_price {
2090 let trigger_price_decimal = normalize_or_validate_wire_price(
2091 trig_px.as_decimal(),
2092 "Trigger price",
2093 decimals,
2094 self.normalize_prices,
2095 )
2096 .map_err(|e| Error::bad_request(format!("{e}")))?;
2097 let tpsl = match order_type {
2098 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
2099 _ => HyperliquidExchangeTpSl::Tp,
2100 };
2101 let is_market = matches!(
2102 order_type,
2103 OrderType::StopMarket | OrderType::MarketIfTouched
2104 );
2105 HyperliquidExchangeOrderKind::Trigger {
2106 trigger: HyperliquidExchangeTriggerParams {
2107 is_market,
2108 trigger_px: trigger_price_decimal,
2109 tpsl,
2110 },
2111 }
2112 } else {
2113 return Err(Error::bad_request("Trigger orders require a trigger price"));
2114 }
2115 }
2116 _ => {
2117 return Err(Error::bad_request(format!(
2118 "Order type {order_type:?} not supported for modify"
2119 )));
2120 }
2121 };
2122 let cloid = client_order_id.map(|id| self.get_or_generate_client_order_id_cloid(id));
2123
2124 let order = HyperliquidExchangePlaceOrderRequest {
2125 asset: asset_id,
2126 is_buy,
2127 price: normalized_price,
2128 size,
2129 reduce_only,
2130 kind,
2131 cloid,
2132 };
2133
2134 let action = HyperliquidExchangeAction::Modify {
2135 modify: HyperliquidExchangeModifyOrderRequest { oid, order },
2136 };
2137
2138 let response = self.inner.post_action_exec(&action).await?;
2139
2140 match response {
2141 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
2142 if let Some(inner_error) = extract_inner_error(&response) {
2143 Err(Error::bad_request(format!(
2144 "Modify order rejected: {inner_error}",
2145 )))
2146 } else {
2147 Ok(())
2148 }
2149 }
2150 HyperliquidExchangeResponse::Status {
2151 status,
2152 response: error_data,
2153 } => Err(Error::bad_request(format!(
2154 "Modify order failed: status={status}, error={error_data}"
2155 ))),
2156 HyperliquidExchangeResponse::Error { error } => {
2157 Err(Error::bad_request(format!("Modify order error: {error}")))
2158 }
2159 }
2160 }
2161
2162 pub async fn submit_split_outcome(
2176 &self,
2177 outcome: u32,
2178 amount: Decimal,
2179 ) -> Result<HyperliquidExchangeResponse> {
2180 let action = HyperliquidExchangeAction::UserOutcome {
2181 op: HyperliquidExchangeUserOutcomeOp::SplitOutcome(
2182 HyperliquidExchangeSplitOutcomeParams { outcome, amount },
2183 ),
2184 };
2185 self.inner.post_action_exec(&action).await
2186 }
2187
2188 pub async fn submit_merge_outcome(
2199 &self,
2200 outcome: u32,
2201 amount: Option<Decimal>,
2202 ) -> Result<HyperliquidExchangeResponse> {
2203 let action = HyperliquidExchangeAction::UserOutcome {
2204 op: HyperliquidExchangeUserOutcomeOp::MergeOutcome(
2205 HyperliquidExchangeMergeOutcomeParams { outcome, amount },
2206 ),
2207 };
2208 self.inner.post_action_exec(&action).await
2209 }
2210
2211 pub async fn submit_merge_question(
2221 &self,
2222 question: u32,
2223 amount: Option<Decimal>,
2224 ) -> Result<HyperliquidExchangeResponse> {
2225 let action = HyperliquidExchangeAction::UserOutcome {
2226 op: HyperliquidExchangeUserOutcomeOp::MergeQuestion(
2227 HyperliquidExchangeMergeQuestionParams { question, amount },
2228 ),
2229 };
2230 self.inner.post_action_exec(&action).await
2231 }
2232
2233 pub async fn submit_negate_outcome(
2243 &self,
2244 question: u32,
2245 outcome: u32,
2246 amount: Decimal,
2247 ) -> Result<HyperliquidExchangeResponse> {
2248 let action = HyperliquidExchangeAction::UserOutcome {
2249 op: HyperliquidExchangeUserOutcomeOp::NegateOutcome(
2250 HyperliquidExchangeNegateOutcomeParams {
2251 question,
2252 outcome,
2253 amount,
2254 },
2255 ),
2256 };
2257 self.inner.post_action_exec(&action).await
2258 }
2259
2260 pub async fn request_order_status_reports(
2275 &self,
2276 user: &str,
2277 instrument_id: Option<InstrumentId>,
2278 ) -> Result<Vec<OrderStatusReport>> {
2279 let dexes = self.reconciliation_dexes(instrument_id);
2280 let sweep = self
2281 .request_order_status_reports_for_dexes(user, instrument_id, &dexes)
2282 .await?;
2283
2284 if !sweep.complete {
2285 return Err(Error::bad_request(
2286 "Open-order snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2287 ));
2288 }
2289
2290 Ok(sweep.reports)
2291 }
2292
2293 pub(crate) async fn request_order_status_reports_for_dexes(
2294 &self,
2295 user: &str,
2296 instrument_id: Option<InstrumentId>,
2297 dexes: &[Option<Ustr>],
2298 ) -> Result<ReportSweep<OrderStatusReport>> {
2299 let account_id = self
2300 .account_id
2301 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2302 let mut reports = Vec::new();
2303 let mut complete = true;
2304 let ts_init = self.clock.get_time_ns();
2305
2306 for dex in dexes {
2307 let response = self
2308 .info_frontend_open_orders_for_dex(user, dex.as_deref())
2309 .await?;
2310 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
2311 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
2312
2313 for order_value in orders {
2314 let order: WsBasicOrderData = match serde_json::from_value(order_value) {
2315 Ok(order) => order,
2316 Err(e) => {
2317 log::warn!("Failed to parse order: {e}");
2318 complete = false;
2319 continue;
2320 }
2321 };
2322
2323 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2324 Some(instrument) => instrument,
2325 None => {
2327 complete = false;
2328 continue;
2329 }
2330 };
2331
2332 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2333 continue;
2334 }
2335
2336 match parse_order_status_report_from_basic(
2337 &order,
2338 &HyperliquidOrderStatusEnum::Open,
2339 &instrument,
2340 account_id,
2341 ts_init,
2342 ) {
2343 Ok(report) => reports.push(report),
2344 Err(e) => {
2345 log::error!("Failed to parse order status report: {e}");
2346 complete = false;
2347 }
2348 }
2349 }
2350 }
2351
2352 Ok(ReportSweep { reports, complete })
2353 }
2354
2355 pub async fn request_historical_order_status_reports(
2367 &self,
2368 user: &str,
2369 instrument_id: Option<InstrumentId>,
2370 ) -> Result<Vec<OrderStatusReport>> {
2371 let entries = self.info_historical_orders(user).await?;
2372 let sweep = self.historical_order_status_reports_from_response(entries, instrument_id)?;
2373
2374 if !sweep.complete {
2375 return Err(Error::bad_request(
2376 "Historical-order snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2377 ));
2378 }
2379
2380 Ok(sweep.reports)
2381 }
2382
2383 pub(crate) fn historical_order_status_reports_from_response(
2384 &self,
2385 entries: Vec<HyperliquidOrderStatusEntry>,
2386 instrument_id: Option<InstrumentId>,
2387 ) -> Result<ReportSweep<OrderStatusReport>> {
2388 let account_id = self
2389 .account_id
2390 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2391 let mut reports = Vec::new();
2392 let mut complete = true;
2393 let ts_init = self.clock.get_time_ns();
2394
2395 for entry in entries {
2396 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2397 Some(instrument) => instrument,
2399 None => {
2400 complete = false;
2401 continue;
2402 }
2403 };
2404
2405 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2406 continue;
2407 }
2408
2409 let order_type = entry.order.order_type.as_deref().unwrap_or_default();
2410 let tpsl = if order_type.starts_with("Take Profit") {
2411 Some(crate::common::enums::HyperliquidTpSl::Tp)
2412 } else if order_type.starts_with("Stop") {
2413 Some(crate::common::enums::HyperliquidTpSl::Sl)
2414 } else {
2415 None
2416 };
2417 let is_market = entry
2418 .order
2419 .order_type
2420 .as_deref()
2421 .is_some_and(|label| label.ends_with("Market"));
2422 let historical_order_type = match tpsl.as_ref() {
2423 Some(tpsl) => parse_trigger_order_type(is_market, tpsl),
2424 None if is_market => OrderType::Market,
2425 None => OrderType::Limit,
2426 };
2427 let order = WsBasicOrderData {
2428 coin: entry.order.coin,
2429 side: entry.order.side,
2430 limit_px: entry.order.limit_px,
2431 sz: entry.order.sz,
2432 oid: entry.order.oid,
2433 timestamp: entry.order.timestamp,
2434 orig_sz: entry.order.orig_sz,
2435 cloid: entry.order.cloid,
2436 tif: entry.order.tif,
2437 reduce_only: entry.order.reduce_only,
2438 trigger_px: entry
2439 .order
2440 .trigger_px
2441 .filter(|price| *price != Decimal::ZERO),
2442 is_market: tpsl.is_some().then_some(is_market),
2443 tpsl,
2444 trigger_activated: None,
2445 trailing_stop: None,
2446 };
2447
2448 match parse_order_status_report_from_basic(
2449 &order,
2450 &entry.status,
2451 &instrument,
2452 account_id,
2453 ts_init,
2454 ) {
2455 Ok(mut report) => {
2456 report.order_type = historical_order_type;
2457 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2458 reports.push(report);
2459 }
2460 Err(e) => {
2461 log::error!("Failed to parse historical order status report: {e}");
2462 complete = false;
2463 }
2464 }
2465 }
2466
2467 Ok(ReportSweep {
2468 reports: deduplicate_historical_order_reports(reports),
2469 complete,
2470 })
2471 }
2472
2473 pub async fn request_order_status_report(
2485 &self,
2486 user: &str,
2487 oid: u64,
2488 ) -> Result<Option<OrderStatusReport>> {
2489 let account_id = self
2490 .account_id
2491 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2492
2493 let ts_init = self.clock.get_time_ns();
2494
2495 let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
2500 Ok(response) => match serde_json::from_value(response) {
2501 Ok(v) => v,
2502 Err(e) => {
2503 log::warn!("Failed to parse frontend open orders response: {e}");
2504 Vec::new()
2505 }
2506 },
2507 Err(e) => {
2508 log::warn!(
2509 "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
2510 );
2511 Vec::new()
2512 }
2513 };
2514
2515 if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
2516 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2517 Some(inst) => inst,
2518 None => {
2519 return Err(Error::bad_request(format!(
2520 "Failed to resolve instrument for open order oid {oid} with coin {}",
2521 order.coin,
2522 )));
2523 }
2524 };
2525
2526 let status = if order.trigger_activated == Some(true) {
2527 HyperliquidOrderStatusEnum::Triggered
2528 } else {
2529 HyperliquidOrderStatusEnum::Open
2530 };
2531
2532 return parse_order_status_report_from_basic(
2533 &order,
2534 &status,
2535 &instrument,
2536 account_id,
2537 ts_init,
2538 )
2539 .map(Some)
2540 .map_err(|e| {
2541 Error::bad_request(format!(
2542 "Failed to parse order status report for oid {oid}: {e}"
2543 ))
2544 });
2545 }
2546
2547 let response = self.info_order_status(user, oid).await?;
2549 let entry = match response.into_order() {
2550 Some(e) => e,
2551 None => return Ok(None),
2552 };
2553
2554 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2555 Some(inst) => inst,
2556 None => {
2557 return Err(Error::bad_request(format!(
2558 "Failed to resolve instrument for order oid {oid} with coin {}",
2559 entry.order.coin,
2560 )));
2561 }
2562 };
2563
2564 let basic = WsBasicOrderData {
2569 coin: entry.order.coin,
2570 side: entry.order.side,
2571 limit_px: entry.order.limit_px,
2572 sz: entry.order.sz,
2573 oid: entry.order.oid,
2574 timestamp: entry.order.timestamp,
2575 orig_sz: entry.order.orig_sz,
2576 cloid: entry.order.cloid,
2577 tif: None,
2578 reduce_only: None,
2579 trigger_px: None,
2580 is_market: None,
2581 tpsl: None,
2582 trigger_activated: None,
2583 trailing_stop: None,
2584 };
2585
2586 let mut report = parse_order_status_report_from_basic(
2587 &basic,
2588 &entry.status,
2589 &instrument,
2590 account_id,
2591 ts_init,
2592 )
2593 .map_err(|e| {
2594 Error::bad_request(format!(
2595 "Failed to parse order status report for oid {oid}: {e}"
2596 ))
2597 })?;
2598
2599 if entry.status_timestamp > 0 {
2602 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2603 }
2604
2605 Ok(Some(report))
2606 }
2607
2608 pub async fn request_order_status_report_by_client_order_id(
2619 &self,
2620 user: &str,
2621 client_order_id: &ClientOrderId,
2622 ) -> Result<Option<OrderStatusReport>> {
2623 let account_id = self
2624 .account_id
2625 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2626
2627 let ts_init = self.clock.get_time_ns();
2628
2629 let cached_cloid_hex = self
2630 .cached_client_order_id_cloid(client_order_id)
2631 .map(|cloid| cloid.to_hex());
2632 let cloid = Cloid::from_client_order_id(*client_order_id);
2633 let cloid_hex = cloid.to_hex();
2634
2635 let response = self.info_frontend_open_orders(user).await?;
2636
2637 let orders: Vec<WsBasicOrderData> = serde_json::from_value(response).map_err(|e| {
2638 Error::bad_request(format!("Failed to parse open orders response: {e}"))
2639 })?;
2640
2641 let order = match orders.into_iter().find(|o| {
2642 o.cloid
2643 .as_ref()
2644 .is_some_and(|c| cached_cloid_hex.as_ref() == Some(c) || c == &cloid_hex)
2645 }) {
2646 Some(o) => o,
2647 None => return Ok(None),
2648 };
2649
2650 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2651 Some(inst) => inst,
2652 None => {
2653 return Err(Error::bad_request(format!(
2654 "Failed to resolve instrument for open order with cloid {cloid_hex} and coin {}",
2655 order.coin,
2656 )));
2657 }
2658 };
2659
2660 let status = if order.trigger_activated == Some(true) {
2661 HyperliquidOrderStatusEnum::Triggered
2662 } else {
2663 HyperliquidOrderStatusEnum::Open
2664 };
2665
2666 let mut report =
2667 parse_order_status_report_from_basic(&order, &status, &instrument, account_id, ts_init)
2668 .map_err(|e| {
2669 Error::bad_request(format!(
2670 "Failed to parse order status report for cloid {cloid_hex}: {e}"
2671 ))
2672 })?;
2673
2674 report.client_order_id = Some(*client_order_id);
2675 Ok(Some(report))
2676 }
2677
2678 pub async fn request_fill_reports(
2694 &self,
2695 user: &str,
2696 instrument_id: Option<InstrumentId>,
2697 ) -> Result<Vec<FillReport>> {
2698 let fills_response = self.info_user_fills(user).await?;
2699 let sweep = self.fill_reports_from_response(fills_response, instrument_id)?;
2700
2701 if !sweep.complete {
2702 return Err(Error::bad_request(
2703 "Fill snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2704 ));
2705 }
2706
2707 Ok(sweep.reports)
2708 }
2709
2710 pub(crate) fn fill_reports_from_response(
2711 &self,
2712 fills_response: HyperliquidFills,
2713 instrument_id: Option<InstrumentId>,
2714 ) -> Result<ReportSweep<FillReport>> {
2715 let account_id = self
2716 .account_id
2717 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2718
2719 let mut reports = Vec::new();
2720 let mut complete = true;
2721 let ts_init = self.clock.get_time_ns();
2722
2723 for fill in fills_response {
2724 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
2726 Some(inst) => inst,
2727 None => {
2729 complete = false;
2730 continue;
2731 }
2732 };
2733
2734 if let Some(filter_id) = instrument_id
2736 && instrument.id() != filter_id
2737 {
2738 continue;
2739 }
2740
2741 match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2743 Ok(report) => reports.push(report),
2744 Err(e) => {
2745 log::error!("Failed to parse fill report: {e}");
2746 complete = false;
2747 }
2748 }
2749 }
2750
2751 Ok(ReportSweep { reports, complete })
2752 }
2753
2754 pub async fn request_position_status_reports(
2778 &self,
2779 user: &str,
2780 instrument_id: Option<InstrumentId>,
2781 ) -> Result<Vec<PositionStatusReport>> {
2782 let dexes = self.reconciliation_dexes(instrument_id);
2783 let sweep = self
2784 .request_position_status_reports_for_dexes(user, instrument_id, &dexes)
2785 .await?;
2786
2787 if !sweep.complete {
2788 return Err(Error::bad_request(
2789 "Position snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2790 ));
2791 }
2792
2793 Ok(sweep.reports)
2794 }
2795
2796 pub(crate) async fn request_position_status_reports_for_dexes(
2797 &self,
2798 user: &str,
2799 instrument_id: Option<InstrumentId>,
2800 dexes: &[Option<Ustr>],
2801 ) -> Result<ReportSweep<PositionStatusReport>> {
2802 let account_id = self
2803 .account_id
2804 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2805
2806 let filter_product = instrument_id
2807 .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
2808
2809 let fetch_perp = !matches!(
2810 filter_product,
2811 Some(HyperliquidProductType::Spot | HyperliquidProductType::Outcome)
2812 );
2813 let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
2814
2815 let mut reports = Vec::new();
2816 let mut complete = true;
2817 let ts_init = self.clock.get_time_ns();
2818
2819 if !fetch_perp {
2820 return self
2821 .request_spot_position_status_reports_sweep(user, instrument_id)
2822 .await;
2823 }
2824
2825 for dex in dexes {
2826 let state_response = self
2827 .info_clearinghouse_state_for_dex(user, dex.as_deref())
2828 .await?;
2829 let asset_positions: Vec<serde_json::Value> = state_response
2830 .get("assetPositions")
2831 .and_then(|value| value.as_array())
2832 .ok_or_else(|| {
2833 Error::bad_request("assetPositions not found in clearinghouse state")
2834 })?
2835 .clone();
2836
2837 for position_value in asset_positions {
2838 let coin = position_value
2839 .get("position")
2840 .and_then(|position| position.get("coin"))
2841 .and_then(|coin| coin.as_str())
2842 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
2843
2844 let instrument = match self.get_or_create_instrument(&Ustr::from(coin), None) {
2845 Some(instrument) => instrument,
2846 None => {
2848 complete = false;
2849 continue;
2850 }
2851 };
2852
2853 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2854 continue;
2855 }
2856
2857 match parse_position_status_report(
2858 &position_value,
2859 &instrument,
2860 account_id,
2861 ts_init,
2862 ) {
2863 Ok(report) => reports.push(report),
2864 Err(e) => {
2865 log::error!("Failed to parse position status report: {e}");
2866 complete = false;
2867 }
2868 }
2869 }
2870 }
2871
2872 if fetch_spot {
2875 let spot_sweep = self
2876 .request_spot_position_status_reports_sweep(user, instrument_id)
2877 .await?;
2878 reports.extend(spot_sweep.reports);
2879 complete &= spot_sweep.complete;
2880 }
2881
2882 Ok(ReportSweep { reports, complete })
2883 }
2884
2885 pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
2899 let account_id = self
2900 .account_id
2901 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2902 let state_response = self.info_clearinghouse_state(user).await?;
2903 let ts_init = self.clock.get_time_ns();
2904
2905 log::trace!("Clearinghouse state response: {state_response}");
2906
2907 let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
2908 .map_err(|e| {
2909 log::error!("Failed to parse clearinghouse state: {e}");
2910 log::debug!("Raw response: {state_response}");
2911 Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
2912 })?;
2913
2914 let spot_response = self.info_spot_clearinghouse_state(user).await?;
2917 let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
2918 .map_err(|e| {
2919 log::error!("Failed to parse spot clearinghouse state: {e}");
2920 log::debug!("Raw spot response: {spot_response}");
2921 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2922 })?;
2923
2924 let (balances, margins) =
2925 parse_combined_account_balances_and_margins(&perp_state, &spot_state)
2926 .map_err(|e| Error::decode(e.to_string()))?;
2927
2928 Ok(AccountState::new(
2929 account_id,
2930 AccountType::Margin,
2931 balances,
2932 margins,
2933 true, UUID4::new(),
2935 ts_init,
2936 ts_init,
2937 None,
2938 ))
2939 }
2940
2941 pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
2952 let response = self.info_spot_clearinghouse_state(user).await?;
2953
2954 log::trace!("Spot clearinghouse state response: {response}");
2955
2956 let state: SpotClearinghouseState =
2957 serde_json::from_value(response.clone()).map_err(|e| {
2958 log::error!("Failed to parse spot clearinghouse state: {e}");
2959 log::debug!("Raw response: {response}");
2960 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2961 })?;
2962
2963 parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
2964 }
2965
2966 pub async fn request_spot_position_status_reports(
2982 &self,
2983 user: &str,
2984 instrument_id: Option<InstrumentId>,
2985 ) -> Result<Vec<PositionStatusReport>> {
2986 let sweep = self
2987 .request_spot_position_status_reports_sweep(user, instrument_id)
2988 .await?;
2989
2990 if !sweep.complete {
2991 return Err(Error::bad_request(
2992 "Spot position snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2993 ));
2994 }
2995
2996 Ok(sweep.reports)
2997 }
2998
2999 pub(crate) async fn request_spot_position_status_reports_sweep(
3000 &self,
3001 user: &str,
3002 instrument_id: Option<InstrumentId>,
3003 ) -> Result<ReportSweep<PositionStatusReport>> {
3004 let account_id = self
3005 .account_id
3006 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3007 let response = self.info_spot_clearinghouse_state(user).await?;
3008
3009 let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
3010 log::error!("Failed to parse spot clearinghouse state: {e}");
3011 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
3012 })?;
3013
3014 let ts_init = self.clock.get_time_ns();
3015 let mut reports = Vec::with_capacity(state.balances.len());
3016 let mut complete = true;
3017
3018 for balance in &state.balances {
3019 if balance.total.is_zero() {
3020 continue;
3021 }
3022
3023 if balance.coin == "USDC" {
3028 continue;
3029 }
3030
3031 let product_type = match HyperliquidProductType::from_symbol(balance.coin.as_str()) {
3032 Ok(HyperliquidProductType::Outcome) => HyperliquidProductType::Outcome,
3033 _ => HyperliquidProductType::Spot,
3034 };
3035
3036 let instrument = match self.get_or_create_instrument(&balance.coin, Some(product_type))
3037 {
3038 Some(inst) => inst,
3039 None => {
3041 complete = false;
3042 continue;
3043 }
3044 };
3045
3046 if let Some(filter_id) = instrument_id
3047 && instrument.id() != filter_id
3048 {
3049 continue;
3050 }
3051
3052 match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
3053 Ok(report) => reports.push(report),
3054 Err(e) => {
3055 log::error!(
3056 "Failed to parse spot position status report for {}: {e}",
3057 balance.coin,
3058 );
3059 complete = false;
3060 }
3061 }
3062 }
3063
3064 Ok(ReportSweep { reports, complete })
3065 }
3066
3067 pub async fn request_bars(
3084 &self,
3085 bar_type: BarType,
3086 start: Option<jiff::Timestamp>,
3087 end: Option<jiff::Timestamp>,
3088 limit: Option<u32>,
3089 ) -> Result<Vec<Bar>> {
3090 let instrument_id = bar_type.instrument_id();
3091 let symbol = instrument_id.symbol;
3092
3093 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
3094
3095 let alias = cache_alias_for_symbol(symbol.as_str())
3099 .map(|alias| Ustr::from(alias.as_str()))
3100 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
3101
3102 let instrument = self
3103 .get_or_create_instrument(&alias, product_type)
3104 .ok_or_else(|| {
3105 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3106 })?;
3107
3108 let coin = instrument.raw_symbol().inner();
3113
3114 let price_precision = instrument.price_precision();
3115 let size_precision = instrument.size_precision();
3116
3117 let interval =
3118 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
3119
3120 let now = jiff::Timestamp::now();
3122 let end_time = end.unwrap_or(now).as_millisecond() as u64;
3123 let start_time = if let Some(start) = start {
3124 start.as_millisecond() as u64
3125 } else {
3126 let spec = bar_type.spec();
3128 let step_ms = match spec.aggregation {
3129 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
3130 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
3131 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
3132 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
3133 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
3134 _ => 60_000,
3135 };
3136 end_time.saturating_sub(1000 * step_ms)
3137 };
3138
3139 let candles = self
3140 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
3141 .await?;
3142
3143 let now_ms = now.as_millisecond() as u64;
3145
3146 let mut bars: Vec<Bar> = candles
3147 .iter()
3148 .filter(|candle| candle.end_timestamp < now_ms)
3149 .enumerate()
3150 .filter_map(|(i, candle)| {
3151 candle_to_bar(candle, bar_type, price_precision, size_precision)
3152 .map_err(|e| {
3153 log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
3154 e
3155 })
3156 .ok()
3157 })
3158 .collect();
3159
3160 if let Some(limit) = limit
3162 && limit > 0
3163 && bars.len() > limit as usize
3164 {
3165 bars.truncate(limit as usize);
3166 }
3167
3168 log::debug!(
3169 "Received {} bars for {} (filtered {} incomplete)",
3170 bars.len(),
3171 bar_type,
3172 candles.len() - bars.len()
3173 );
3174 Ok(bars)
3175 }
3176
3177 pub async fn request_public_trades(
3186 &self,
3187 instrument_id: InstrumentId,
3188 start: Option<jiff::Timestamp>,
3189 end: Option<jiff::Timestamp>,
3190 limit: Option<usize>,
3191 ) -> Result<Vec<HyperliquidPublicTrade>> {
3192 let symbol = instrument_id.symbol;
3193 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
3194 let alias = cache_alias_for_symbol(symbol.as_str())
3195 .map(|alias| Ustr::from(alias.as_str()))
3196 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
3197 let instrument = self
3198 .get_or_create_instrument(&alias, product_type)
3199 .ok_or_else(|| {
3200 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3201 })?;
3202
3203 let raw_trades = match self
3204 .info_recent_trades(instrument.raw_symbol().as_ref())
3205 .await
3206 {
3207 Ok(trades) => trades,
3208 Err(e) if e.is_unprocessable_entity() => {
3209 log::warn!(
3210 "Recent public trades endpoint unavailable for {instrument_id} \
3211 (requires the Hyperliquid indexer); returning empty response"
3212 );
3213 Vec::new()
3214 }
3215 Err(e) => return Err(e),
3216 };
3217
3218 let mut trades: Vec<HyperliquidPublicTrade> = raw_trades
3219 .iter()
3220 .filter_map(|raw| match parse_recent_public_trade(raw, &instrument) {
3221 Ok(trade) => Some(trade),
3222 Err(e) => {
3223 log::warn!("Skipping recent public trade for {instrument_id}: {e}");
3224 None
3225 }
3226 })
3227 .collect();
3228 trades.sort_by_key(|trade| trade.ts_event);
3229
3230 Ok(filter_recent_public_trades(
3231 trades,
3232 datetime_to_unix_nanos(start),
3233 datetime_to_unix_nanos(end),
3234 limit.filter(|limit| *limit > 0),
3235 instrument_id,
3236 ))
3237 }
3238
3239 #[expect(clippy::too_many_arguments)]
3246 pub async fn submit_order(
3247 &self,
3248 instrument_id: InstrumentId,
3249 client_order_id: ClientOrderId,
3250 order_side: OrderSide,
3251 order_type: OrderType,
3252 quantity: Quantity,
3253 time_in_force: TimeInForce,
3254 price: Option<Price>,
3255 trigger_price: Option<Price>,
3256 post_only: bool,
3257 reduce_only: bool,
3258 ) -> Result<OrderStatusReport> {
3259 let symbol = instrument_id.symbol.inner();
3260 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3261 Error::bad_request(format!(
3262 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3263 ))
3264 })?;
3265
3266 let is_buy = matches!(order_side, OrderSide::Buy);
3267 let price_precision = self.get_price_precision_for_symbol(symbol);
3268
3269 let price_decimal = match price {
3270 Some(px) => normalize_or_validate_wire_price(
3271 px.as_decimal(),
3272 "Price",
3273 price_precision,
3274 self.normalize_prices,
3275 )
3276 .map_err(|e| Error::bad_request(format!("{e}")))?,
3277 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
3278 None if matches!(
3279 order_type,
3280 OrderType::StopMarket | OrderType::MarketIfTouched
3281 ) =>
3282 {
3283 match trigger_price {
3284 Some(tp) => {
3285 let derived = derive_limit_from_trigger(
3286 tp.as_decimal().normalize(),
3287 is_buy,
3288 self.market_order_slippage_bps,
3289 );
3290 let sig_rounded = round_to_sig_figs(derived, 5);
3291 clamp_price_to_precision(sig_rounded, price_precision.unwrap_or(2), is_buy)
3292 .normalize()
3293 }
3294 None => Decimal::ZERO,
3295 }
3296 }
3297 None => return Err(Error::bad_request("Limit orders require a price")),
3298 };
3299
3300 let size_decimal = quantity.as_decimal().normalize();
3301
3302 let kind = match order_type {
3303 OrderType::Market => HyperliquidExchangeOrderKind::Limit {
3304 limit: HyperliquidExchangeLimitParams {
3305 tif: HyperliquidExchangeTif::Ioc,
3306 },
3307 },
3308 OrderType::Limit => {
3309 let tif = if post_only {
3310 HyperliquidExchangeTif::Alo
3311 } else {
3312 match time_in_force {
3313 TimeInForce::Gtc => HyperliquidExchangeTif::Gtc,
3314 TimeInForce::Ioc => HyperliquidExchangeTif::Ioc,
3315 TimeInForce::Fok
3316 | TimeInForce::Day
3317 | TimeInForce::Gtd
3318 | TimeInForce::AtTheOpen
3319 | TimeInForce::AtTheClose => {
3320 return Err(Error::bad_request(format!(
3321 "Time in force {time_in_force:?} not supported"
3322 )));
3323 }
3324 }
3325 };
3326 HyperliquidExchangeOrderKind::Limit {
3327 limit: HyperliquidExchangeLimitParams { tif },
3328 }
3329 }
3330 OrderType::StopMarket
3331 | OrderType::StopLimit
3332 | OrderType::MarketIfTouched
3333 | OrderType::LimitIfTouched => {
3334 if let Some(trig_px) = trigger_price {
3335 let trigger_price_decimal = normalize_or_validate_wire_price(
3336 trig_px.as_decimal(),
3337 "Trigger price",
3338 price_precision,
3339 self.normalize_prices,
3340 )
3341 .map_err(|e| Error::bad_request(format!("{e}")))?;
3342
3343 let tpsl = match order_type {
3347 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
3348 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
3349 HyperliquidExchangeTpSl::Tp
3350 }
3351 _ => unreachable!(),
3352 };
3353
3354 let is_market = matches!(
3355 order_type,
3356 OrderType::StopMarket | OrderType::MarketIfTouched
3357 );
3358
3359 HyperliquidExchangeOrderKind::Trigger {
3360 trigger: HyperliquidExchangeTriggerParams {
3361 is_market,
3362 trigger_px: trigger_price_decimal,
3363 tpsl,
3364 },
3365 }
3366 } else {
3367 return Err(Error::bad_request("Trigger orders require a trigger price"));
3368 }
3369 }
3370 _ => {
3371 return Err(Error::bad_request(format!(
3372 "Order type {order_type:?} not supported"
3373 )));
3374 }
3375 };
3376
3377 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
3378 let hyperliquid_order = HyperliquidExchangePlaceOrderRequest {
3379 asset,
3380 is_buy,
3381 price: price_decimal,
3382 size: size_decimal,
3383 reduce_only,
3384 kind,
3385 cloid: Some(cloid),
3386 };
3387
3388 let builder = self.builder_attribution();
3389
3390 let action = HyperliquidExchangeAction::Order {
3391 orders: vec![hyperliquid_order],
3392 grouping: HyperliquidExchangeGrouping::Na,
3393 builder,
3394 };
3395
3396 let response = self.inner.post_action_exec(&action).await?;
3397
3398 self.build_submit_order_report(
3401 instrument_id,
3402 client_order_id,
3403 order_side,
3404 order_type,
3405 quantity,
3406 time_in_force,
3407 price,
3408 trigger_price,
3409 response,
3410 )?
3411 .ok_or_else(|| {
3412 Error::bad_request(
3413 "Single-order submission returned no actionable status (deferred trigger child)",
3414 )
3415 })
3416 }
3417
3418 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
3428 if order.is_quote_quantity() {
3429 return Err(Error::bad_request(
3430 "Quote-denominated quantity orders must submit through the execution client \
3431 for quote-to-base conversion",
3432 ));
3433 }
3434
3435 self.submit_order(
3436 order.instrument_id(),
3437 order.client_order_id(),
3438 order.order_side(),
3439 order.order_type(),
3440 order.quantity(),
3441 order.time_in_force(),
3442 order.price(),
3443 order.trigger_price(),
3444 order.is_post_only(),
3445 order.is_reduce_only(),
3446 )
3447 .await
3448 }
3449
3450 #[expect(clippy::too_many_arguments)]
3451 fn create_order_status_report(
3452 &self,
3453 instrument_id: InstrumentId,
3454 client_order_id: Option<ClientOrderId>,
3455 venue_order_id: VenueOrderId,
3456 order_side: OrderSide,
3457 order_type: OrderType,
3458 quantity: Quantity,
3459 time_in_force: TimeInForce,
3460 price: Option<Price>,
3461 trigger_price: Option<Price>,
3462 order_status: OrderStatus,
3463 filled_qty: Quantity,
3464 _instrument: &InstrumentAny,
3465 account_id: AccountId,
3466 ts_init: UnixNanos,
3467 ) -> OrderStatusReport {
3468 let ts_accepted = self.clock.get_time_ns();
3469 let ts_last = ts_accepted;
3470 let report_id = UUID4::new();
3471
3472 let mut report = OrderStatusReport::new(
3473 account_id,
3474 instrument_id,
3475 client_order_id,
3476 venue_order_id,
3477 order_side.into(),
3478 order_type,
3479 time_in_force,
3480 order_status,
3481 quantity,
3482 filled_qty,
3483 ts_accepted,
3484 ts_last,
3485 ts_init,
3486 Some(report_id),
3487 );
3488
3489 if let Some(px) = price {
3490 report = report.with_price(px);
3491 }
3492
3493 if let Some(trig_px) = trigger_price {
3494 report = report
3495 .with_trigger_price(trig_px)
3496 .with_trigger_type(TriggerType::Default);
3497 }
3498
3499 report
3500 }
3501
3502 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
3511 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
3513 let mut client_order_ids = Vec::with_capacity(orders.len());
3514
3515 for order in orders {
3516 if order.is_quote_quantity() {
3517 return Err(Error::bad_request(format!(
3518 "Quote-denominated quantity order {} must submit through the execution \
3519 client for quote-to-base conversion",
3520 order.client_order_id()
3521 )));
3522 }
3523
3524 let instrument_id = order.instrument_id();
3525 let symbol = instrument_id.symbol.inner();
3526 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3527 Error::bad_request(format!(
3528 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3529 ))
3530 })?;
3531 let price_decimals = self.get_price_precision_for_symbol(symbol);
3532 let request = order_to_hyperliquid_request_with_optional_decimals(
3533 order,
3534 asset,
3535 price_decimals,
3536 self.normalize_prices,
3537 self.market_order_slippage_bps,
3538 None,
3539 )
3540 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
3541 client_order_ids.push(order.client_order_id());
3542 hyperliquid_orders.push(request);
3543 }
3544
3545 for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
3546 request.cloid = Some(self.get_or_generate_client_order_id_cloid(client_order_id));
3547 }
3548
3549 let builder = self.builder_attribution();
3550
3551 let grouping =
3552 determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
3553
3554 let action = HyperliquidExchangeAction::Order {
3555 orders: hyperliquid_orders,
3556 grouping,
3557 builder,
3558 };
3559
3560 let response = self.inner.post_action_exec(&action).await?;
3562
3563 self.build_submit_orders_reports(orders, grouping, response)
3564 }
3565
3566 #[expect(clippy::too_many_arguments)]
3582 pub fn build_submit_order_report(
3583 &self,
3584 instrument_id: InstrumentId,
3585 client_order_id: ClientOrderId,
3586 order_side: OrderSide,
3587 order_type: OrderType,
3588 quantity: Quantity,
3589 time_in_force: TimeInForce,
3590 price: Option<Price>,
3591 trigger_price: Option<Price>,
3592 response: HyperliquidExchangeResponse,
3593 ) -> Result<Option<OrderStatusReport>> {
3594 let order_response = parse_order_response(response)?;
3595
3596 let Some(order_status) = order_response.statuses.first() else {
3597 return Ok(None);
3598 };
3599
3600 let account_id = self
3601 .account_id
3602 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3603 let ts_init = self.clock.get_time_ns();
3604
3605 self.build_status_report(
3606 instrument_id,
3607 client_order_id,
3608 order_side,
3609 order_type,
3610 quantity,
3611 time_in_force,
3612 price,
3613 trigger_price,
3614 order_status,
3615 account_id,
3616 ts_init,
3617 )
3618 }
3619
3620 pub fn build_submit_orders_reports(
3635 &self,
3636 orders: &[&OrderAny],
3637 grouping: HyperliquidExchangeGrouping,
3638 response: HyperliquidExchangeResponse,
3639 ) -> Result<Vec<OrderStatusReport>> {
3640 let order_response = parse_order_response(response)?;
3641
3642 let account_id = self
3643 .account_id
3644 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3645 let ts_init = self.clock.get_time_ns();
3646
3647 if grouping == HyperliquidExchangeGrouping::Na
3651 && order_response.statuses.len() != orders.len()
3652 {
3653 return Err(Error::bad_request(format!(
3654 "Mismatch between submitted orders ({}) and response statuses ({})",
3655 orders.len(),
3656 order_response.statuses.len()
3657 )));
3658 }
3659
3660 let mut reports = Vec::with_capacity(order_response.statuses.len());
3663 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
3664 if let Some(report) = self.build_status_report(
3665 order.instrument_id(),
3666 order.client_order_id(),
3667 order.order_side(),
3668 order.order_type(),
3669 order.quantity(),
3670 order.time_in_force(),
3671 order.price(),
3672 order.trigger_price(),
3673 order_status,
3674 account_id,
3675 ts_init,
3676 )? {
3677 reports.push(report);
3678 }
3679 }
3680
3681 Ok(reports)
3682 }
3683
3684 #[expect(clippy::too_many_arguments)]
3692 fn build_status_report(
3693 &self,
3694 instrument_id: InstrumentId,
3695 client_order_id: ClientOrderId,
3696 order_side: OrderSide,
3697 order_type: OrderType,
3698 quantity: Quantity,
3699 time_in_force: TimeInForce,
3700 price: Option<Price>,
3701 trigger_price: Option<Price>,
3702 order_status: &HyperliquidExchangeOrderStatus,
3703 account_id: AccountId,
3704 ts_init: UnixNanos,
3705 ) -> Result<Option<OrderStatusReport>> {
3706 if matches!(order_status, HyperliquidExchangeOrderStatus::Tag(_)) {
3707 return Ok(None);
3708 }
3709
3710 let symbol = instrument_id.symbol.as_str();
3711 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
3712
3713 let asset = cache_alias_for_symbol(symbol).unwrap_or_else(|| symbol.to_string());
3716 let instrument = self
3717 .get_or_create_instrument(&Ustr::from(asset.as_str()), product_type)
3718 .ok_or_else(|| {
3719 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3720 })?;
3721
3722 let report = match order_status {
3723 HyperliquidExchangeOrderStatus::Resting { resting } => self.create_order_status_report(
3724 instrument_id,
3725 Some(client_order_id),
3726 VenueOrderId::new(resting.oid.to_string()),
3727 order_side,
3728 order_type,
3729 quantity,
3730 time_in_force,
3731 price,
3732 trigger_price,
3733 OrderStatus::Accepted,
3734 Quantity::zero(instrument.size_precision()),
3735 &instrument,
3736 account_id,
3737 ts_init,
3738 ),
3739 HyperliquidExchangeOrderStatus::Filled { filled } => {
3740 let filled_qty =
3741 Quantity::from_decimal_dp(filled.total_sz, instrument.size_precision())
3742 .map_err(|e| {
3743 Error::bad_request(format!(
3744 "Invalid filled size {}: {e}",
3745 filled.total_sz
3746 ))
3747 })?;
3748 self.create_order_status_report(
3749 instrument_id,
3750 Some(client_order_id),
3751 VenueOrderId::new(filled.oid.to_string()),
3752 order_side,
3753 order_type,
3754 quantity,
3755 time_in_force,
3756 price,
3757 trigger_price,
3758 OrderStatus::Filled,
3759 filled_qty,
3760 &instrument,
3761 account_id,
3762 ts_init,
3763 )
3764 }
3765 HyperliquidExchangeOrderStatus::Error { error } => {
3766 return Err(Error::bad_request(format!(
3767 "Order {client_order_id} rejected: {error}"
3768 )));
3769 }
3770 HyperliquidExchangeOrderStatus::Tag(_) => unreachable!("handled above"),
3771 };
3772
3773 Ok(Some(report))
3774 }
3775
3776 fn reconciliation_dexes(&self, instrument_id: Option<InstrumentId>) -> Vec<Option<Ustr>> {
3777 if let Some(instrument_id) = instrument_id {
3778 return vec![perp_dex_from_symbol(instrument_id.symbol.as_str())];
3779 }
3780
3781 let cached = self.instruments.load();
3782 reconciliation_dexes_from_builders(
3783 cached
3784 .keys()
3785 .filter_map(|symbol| perp_dex_from_symbol(symbol.as_str())),
3786 )
3787 }
3788
3789 pub(crate) async fn reconciliation_dexes_from_activity(
3790 &self,
3791 historical_orders: &[HyperliquidOrderStatusEntry],
3792 fills: &HyperliquidFills,
3793 ) -> Result<Vec<Option<Ustr>>> {
3794 if historical_orders.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT
3795 || fills.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT
3796 {
3797 let builder_dexes = self
3798 .inner
3799 .load_perp_dexs()
3800 .await?
3801 .into_iter()
3802 .flatten()
3803 .filter(|dex| !dex.name.is_empty())
3804 .map(|dex| Ustr::from(dex.name.as_str()));
3805 return Ok(reconciliation_dexes_from_builders(builder_dexes));
3806 }
3807
3808 let builder_dexes = historical_orders
3809 .iter()
3810 .map(|entry| entry.order.coin.as_str())
3811 .chain(fills.iter().map(|fill| fill.coin.as_str()))
3812 .filter_map(perp_dex_from_activity_coin);
3813
3814 Ok(reconciliation_dexes_from_builders(builder_dexes))
3815 }
3816}
3817
3818#[derive(Debug)]
3824pub(crate) struct ReportSweep<T> {
3825 pub reports: Vec<T>,
3826 pub complete: bool,
3827}
3828
3829fn reconciliation_dexes_from_builders(
3830 builder_dexes: impl IntoIterator<Item = Ustr>,
3831) -> Vec<Option<Ustr>> {
3832 let mut builder_dexes = builder_dexes.into_iter().collect::<Vec<_>>();
3833 builder_dexes.sort_unstable();
3834 builder_dexes.dedup();
3835
3836 let mut dexes = Vec::with_capacity(builder_dexes.len() + 1);
3837 dexes.push(None);
3838 dexes.extend(builder_dexes.into_iter().map(Some));
3839 dexes
3840}
3841
3842fn perp_dex_from_activity_coin(coin: &str) -> Option<Ustr> {
3843 if coin.starts_with(VAULT_TOKEN_PREFIX) {
3844 return None;
3845 }
3846
3847 let (dex, _) = coin.split_once(':')?;
3848 (!dex.is_empty()).then(|| Ustr::from(dex))
3849}
3850
3851fn perp_dex_from_symbol(symbol: &str) -> Option<Ustr> {
3852 symbol
3853 .strip_suffix("-PERP")?
3854 .split_once(':')
3855 .map(|(dex, _)| Ustr::from(dex))
3856}
3857
3858fn parse_order_response(
3863 response: HyperliquidExchangeResponse,
3864) -> Result<HyperliquidExchangeOrderResponseData> {
3865 let response_data = match response {
3866 HyperliquidExchangeResponse::Status {
3867 status,
3868 response: response_data,
3869 } if status == RESPONSE_STATUS_OK => response_data,
3870 HyperliquidExchangeResponse::Error { error } => {
3871 return Err(Error::bad_request(format!(
3872 "Order submission failed: {error}"
3873 )));
3874 }
3875 _ => return Err(Error::bad_request("Unexpected response format")),
3876 };
3877
3878 let data_value = if let Some(data) = response_data.get("data") {
3879 data.clone()
3880 } else {
3881 response_data
3882 };
3883
3884 serde_json::from_value(data_value)
3885 .map_err(|e| Error::bad_request(format!("Failed to parse order response: {e}")))
3886}
3887
3888fn resolve_perp_dex_name(
3889 dex_index: usize,
3890 meta: &PerpMeta,
3891 perp_dexs: Option<&[Option<PerpDex>]>,
3892) -> String {
3893 if dex_index == 0 {
3894 return String::new();
3895 }
3896
3897 if let Some(dex_name) = perp_dexs
3898 .and_then(|dexs| dexs.get(dex_index))
3899 .and_then(|dex| dex.as_ref())
3900 .map(|dex| dex.name.clone())
3901 {
3902 return dex_name;
3903 }
3904
3905 meta.universe
3906 .iter()
3907 .find_map(|asset| asset.name.split_once(':').map(|(dex, _)| dex.to_string()))
3908 .unwrap_or_default()
3909}
3910
3911fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
3916 if dex_index == 0 {
3917 0
3918 } else {
3919 100_000 + dex_index as u32 * 10_000
3920 }
3921}
3922
3923#[cfg(test)]
3924mod tests {
3925 use std::{collections::HashMap, net::SocketAddr, sync::Arc};
3926
3927 use axum::{
3928 Router,
3929 extract::State,
3930 http::StatusCode,
3931 response::{IntoResponse, Json, Response},
3932 routing::post,
3933 };
3934 use nautilus_core::{Params, time::get_atomic_clock_realtime};
3935 use nautilus_model::{
3936 currencies::CURRENCY_MAP,
3937 enums::{CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce},
3938 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol},
3939 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
3940 types::{Currency, Price, Quantity},
3941 };
3942 use nautilus_testkit::http::assert_http_redirect_rejected;
3943 use rstest::rstest;
3944 use rust_decimal_macros::dec;
3945 use serde_json::{Value, json};
3946 use ustr::Ustr;
3947
3948 use super::{
3949 HyperliquidHttpClient, HyperliquidRawHttpClient, RETRY_AFTER_HEADER, resolve_perp_dex_name,
3950 };
3951 use crate::{
3952 common::{
3953 consts::{ASSET_INDEX_INFO_KEY, HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS},
3954 enums::{HyperliquidEnvironment, HyperliquidProductType},
3955 },
3956 http::{
3957 models::{Cloid, HyperliquidExchangeResponse, PerpAsset, PerpDex, PerpMeta},
3958 query::InfoRequest,
3959 },
3960 };
3961
3962 const TEST_PRIVATE_KEY: &str =
3963 "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
3964
3965 #[tokio::test]
3966 async fn test_authenticated_client_rejects_redirects() {
3967 let client = HyperliquidRawHttpClient::build_http_client(3, None).unwrap();
3968 assert_http_redirect_rejected(|url| async move {
3969 client
3970 .get(url, None, None, Some(3), None)
3971 .await
3972 .unwrap()
3973 .status
3974 .as_u16()
3975 })
3976 .await;
3977 }
3978
3979 #[rstest]
3980 fn raw_clients_share_rest_limit_for_one_route() {
3981 let mut first =
3982 HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
3983 let mut second =
3984 HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
3985 first.set_base_info_url("https://shared-http-limit.test/info".to_string());
3986 second.set_base_exchange_url("https://shared-http-limit.test/exchange".to_string());
3987
3988 assert!(Arc::ptr_eq(&first.info_limiter, &second.exchange_limiter));
3989 }
3990
3991 #[rstest]
3992 fn retry_after_ms_parses_integer_seconds() {
3993 let headers = HashMap::from([(RETRY_AFTER_HEADER.to_string(), "3".to_string())]);
3994
3995 assert_eq!(
3996 HyperliquidRawHttpClient::retry_after_ms(&headers),
3997 Some(3_000)
3998 );
3999 }
4000
4001 fn perp_meta_with_assets(names: &[&str]) -> PerpMeta {
4002 PerpMeta {
4003 universe: names
4004 .iter()
4005 .map(|name| PerpAsset {
4006 name: (*name).to_string(),
4007 ..Default::default()
4008 })
4009 .collect(),
4010 margin_tables: Vec::new(),
4011 collateral_token: None,
4012 }
4013 }
4014
4015 #[rstest]
4016 fn resolve_perp_dex_name_uses_empty_string_for_default_dex() {
4017 let meta = perp_meta_with_assets(&["BTC", "ETH"]);
4018 assert_eq!(resolve_perp_dex_name(0, &meta, None), "");
4019 }
4020
4021 #[rstest]
4022 fn resolve_perp_dex_name_prefers_perp_dexs_entry() {
4023 let meta = perp_meta_with_assets(&["xyz:TSLA"]);
4024 let perp_dexs = vec![
4025 None,
4026 Some(PerpDex {
4027 name: "xyz".to_string(),
4028 }),
4029 ];
4030 assert_eq!(resolve_perp_dex_name(1, &meta, Some(&perp_dexs)), "xyz");
4031 }
4032
4033 #[rstest]
4034 fn resolve_perp_dex_name_infers_from_asset_name_when_perp_dexs_missing() {
4035 let meta = perp_meta_with_assets(&["abc:TSLA", "abc:NVDA"]);
4036 assert_eq!(resolve_perp_dex_name(1, &meta, None), "abc");
4037 }
4038
4039 #[rstest]
4040 fn test_build_submit_order_report_elides_waiting_for_fill_tag() {
4041 let mut client =
4045 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4046 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
4047
4048 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
4049 "status": "ok",
4050 "response": {
4051 "type": "order",
4052 "data": {
4053 "statuses": ["waitingForFill"]
4054 }
4055 }
4056 }))
4057 .unwrap();
4058
4059 let result = client
4060 .build_submit_order_report(
4061 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
4062 ClientOrderId::from("O-WAITING-CHILD"),
4063 OrderSide::Buy,
4064 OrderType::StopMarket,
4065 Quantity::from("100"),
4066 TimeInForce::Gtc,
4067 None,
4068 Some(Price::from("0.16136")),
4069 response,
4070 )
4071 .unwrap();
4072
4073 assert!(
4074 result.is_none(),
4075 "Tag status must elide so the order stays SUBMITTED, was {result:?}"
4076 );
4077 }
4078
4079 #[rstest]
4080 fn test_build_submit_order_report_filled_uses_total_sz_decimal() {
4081 let mut client =
4085 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4086 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
4087
4088 let base = Currency::new("ARB", 8, 0, "ARB", CurrencyType::Crypto);
4089 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4090 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4091 let clock = get_atomic_clock_realtime();
4092 let ts = clock.get_time_ns();
4093 let perp = InstrumentAny::CryptoPerpetual(
4094 CryptoPerpetual::builder()
4095 .instrument_id(InstrumentId::new(
4096 Symbol::new("ARB-USD-PERP"),
4097 *HYPERLIQUID_VENUE,
4098 ))
4099 .raw_symbol(Symbol::new("ARB"))
4100 .base_currency(base)
4101 .quote_currency(usd)
4102 .settlement_currency(usdc)
4103 .is_inverse(false)
4104 .price_precision(5)
4105 .size_precision(2)
4106 .price_increment(Price::from("0.00001"))
4107 .size_increment(Quantity::from("0.01"))
4108 .ts_event(ts)
4109 .ts_init(ts)
4110 .build()
4111 .unwrap(),
4112 );
4113 client.cache_instrument(&perp);
4114
4115 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
4116 "status": "ok",
4117 "response": {
4118 "type": "order",
4119 "data": {
4120 "statuses": [{
4121 "filled": {"totalSz": "0.5", "avgPx": "1.2345", "oid": 778899}
4122 }]
4123 }
4124 }
4125 }))
4126 .unwrap();
4127
4128 let report = client
4129 .build_submit_order_report(
4130 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
4131 ClientOrderId::from("O-FILLED-001"),
4132 OrderSide::Buy,
4133 OrderType::Market,
4134 Quantity::from("0.5"),
4135 TimeInForce::Ioc,
4136 None,
4137 None,
4138 response,
4139 )
4140 .unwrap()
4141 .expect("filled status must produce a report");
4142
4143 assert_eq!(report.order_status, OrderStatus::Filled);
4144 assert_eq!(report.venue_order_id.as_str(), "778899");
4145 assert_eq!(report.filled_qty.as_decimal(), dec!(0.5));
4146 }
4147
4148 #[derive(Clone, Default)]
4149 struct OutcomeMetaServerState {
4150 last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
4151 }
4152
4153 async fn handle_outcome_meta_info(
4154 State(state): State<OutcomeMetaServerState>,
4155 body: axum::body::Bytes,
4156 ) -> Response {
4157 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
4158 return (
4159 StatusCode::BAD_REQUEST,
4160 Json(json!({"error": "Invalid JSON body"})),
4161 )
4162 .into_response();
4163 };
4164
4165 *state.last_request_body.lock().await = Some(request_body.clone());
4166
4167 if request_body.get("type").and_then(|value| value.as_str()) != Some("outcomeMeta") {
4168 return (
4169 StatusCode::BAD_REQUEST,
4170 Json(json!({"error": "Expected outcomeMeta request"})),
4171 )
4172 .into_response();
4173 }
4174
4175 Json(json!({
4176 "outcomes": [
4177 {
4178 "outcome": 123,
4179 "name": "Recurring",
4180 "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m",
4181 "sideSpecs": [
4182 {"name": "Yes"},
4183 {"name": "No"}
4184 ]
4185 }
4186 ]
4187 }))
4188 .into_response()
4189 }
4190
4191 async fn start_outcome_meta_server(state: OutcomeMetaServerState) -> SocketAddr {
4192 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4193 let addr = listener.local_addr().unwrap();
4194 let router = Router::new()
4195 .route("/info", post(handle_outcome_meta_info))
4196 .with_state(state);
4197
4198 tokio::spawn(async move {
4199 axum::serve(listener, router).await.unwrap();
4200 });
4201
4202 addr
4203 }
4204
4205 async fn handle_unresolved_collateral_info(body: axum::body::Bytes) -> Response {
4206 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
4207 return (
4208 StatusCode::BAD_REQUEST,
4209 Json(json!({"error": "Invalid JSON body"})),
4210 )
4211 .into_response();
4212 };
4213
4214 match request_body.get("type").and_then(|value| value.as_str()) {
4215 Some("spotMeta") => (
4216 StatusCode::INTERNAL_SERVER_ERROR,
4217 Json(json!({"error": "spot metadata unavailable"})),
4218 )
4219 .into_response(),
4220 Some("allPerpMetas") => Json(json!([
4221 {
4222 "collateralToken": 360,
4223 "marginTables": [],
4224 "universe": [
4225 {
4226 "maxLeverage": 20,
4227 "name": "km:US500",
4228 "szDecimals": 3
4229 }
4230 ]
4231 }
4232 ]))
4233 .into_response(),
4234 _ => Json(json!({"universe": [], "marginTables": []})).into_response(),
4235 }
4236 }
4237
4238 async fn start_unresolved_collateral_server() -> SocketAddr {
4239 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4240 let addr = listener.local_addr().unwrap();
4241 let router = Router::new().route("/info", post(handle_unresolved_collateral_info));
4242
4243 tokio::spawn(async move {
4244 axum::serve(listener, router).await.unwrap();
4245 });
4246
4247 addr
4248 }
4249
4250 #[rstest]
4251 fn stable_json_roundtrips() {
4252 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
4253 let s = serde_json::to_string(&v).unwrap();
4254 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
4256 assert_eq!(parsed["type"], "l2Book");
4257 assert_eq!(parsed["coin"], "BTC");
4258 assert_eq!(parsed, v);
4259 }
4260
4261 #[rstest]
4262 fn info_pretty_shape() {
4263 let r = InfoRequest::l2_book("BTC");
4264 let val = serde_json::to_value(&r).unwrap();
4265 let pretty = serde_json::to_string_pretty(&val).unwrap();
4266 assert!(pretty.contains("\"type\": \"l2Book\""));
4267 assert!(pretty.contains("\"coin\": \"BTC\""));
4268 }
4269
4270 #[rstest]
4271 fn test_client_order_id_cloid_cache_is_stable_and_first_write_wins() {
4272 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4273 let client_order_id = ClientOrderId::new("O-CLOID-CACHE");
4274 let other_client_order_id = ClientOrderId::new("O-CLOID-CACHE-OTHER");
4275 let duplicate_client_order_id = ClientOrderId::new("O-CLOID-CACHE-DUPLICATE");
4276 let explicit_cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
4277
4278 let first = client.get_or_generate_client_order_id_cloid(client_order_id);
4279 let second = client.get_or_generate_client_order_id_cloid(client_order_id);
4280 client.cache_client_order_id_cloid(client_order_id, explicit_cloid);
4281 client.cache_client_order_id_cloid(other_client_order_id, explicit_cloid);
4282 client.cache_client_order_id_cloid(duplicate_client_order_id, explicit_cloid);
4283
4284 assert_eq!(first, Cloid::from_client_order_id(client_order_id));
4285 assert_eq!(first, second);
4286 assert_eq!(
4287 client.cached_client_order_id_cloid(&client_order_id),
4288 Some(first),
4289 "cache insert must not overwrite an existing generated CLOID",
4290 );
4291 assert_eq!(
4292 client.unique_cached_client_order_id_cloid(&client_order_id),
4293 Some(first),
4294 );
4295 assert_eq!(
4296 client.cached_client_order_id_cloid(&other_client_order_id),
4297 Some(explicit_cloid),
4298 );
4299 assert_eq!(
4300 client.unique_cached_client_order_id_cloid(&other_client_order_id),
4301 None,
4302 "duplicate CLOID mappings are not safe modify targets",
4303 );
4304 assert_eq!(
4305 client.remove_client_order_id_cloid(&client_order_id),
4306 Some(first),
4307 );
4308 assert_eq!(client.cached_client_order_id_cloid(&client_order_id), None);
4309 }
4310
4311 #[rstest]
4312 fn test_builder_attribution_defaults_to_mainnet_builder() {
4313 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4314 let builder = client
4315 .builder_attribution()
4316 .expect("mainnet client should include builder attribution by default");
4317
4318 assert!(client.include_builder_attribution());
4319 assert_eq!(builder.address, NAUTILUS_BUILDER_ADDRESS);
4320 assert_eq!(builder.fee_tenths_bp, 0);
4321 }
4322
4323 #[rstest]
4324 fn test_builder_attribution_disabled_returns_none() {
4325 let mut client =
4326 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4327 client.set_include_builder_attribution(false);
4328
4329 assert!(!client.include_builder_attribution());
4330 assert!(client.builder_attribution().is_none());
4331 }
4332
4333 #[rstest]
4334 fn test_builder_attribution_omitted_on_testnet() {
4335 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
4336
4337 assert!(client.include_builder_attribution());
4338 assert!(client.builder_attribution().is_none());
4339 }
4340
4341 #[rstest]
4342 #[tokio::test]
4343 async fn test_production_client_get_outcome_meta_uses_outcome_meta_request() {
4344 let state = OutcomeMetaServerState::default();
4345 let addr = start_outcome_meta_server(state.clone()).await;
4346 let mut client =
4347 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4348 client.set_base_info_url(format!("http://{addr}/info"));
4349
4350 let meta = client.get_outcome_meta().await.unwrap();
4351 let request_body = state.last_request_body.lock().await.clone().unwrap();
4352
4353 assert_eq!(request_body, json!({"type": "outcomeMeta"}));
4354 assert_eq!(meta.outcomes.len(), 1);
4355 assert_eq!(meta.outcomes[0].outcome, 123);
4356 assert_eq!(meta.outcomes[0].name, "Recurring");
4357 assert_eq!(meta.outcomes[0].side_specs.len(), 2);
4358 assert_eq!(meta.outcomes[0].side_specs[0].name, "Yes");
4359 assert_eq!(meta.outcomes[0].side_specs[1].name, "No");
4360 }
4361
4362 #[rstest]
4363 #[tokio::test]
4364 async fn test_request_instrument_defs_errors_when_non_usdc_collateral_unresolved() {
4365 let addr = start_unresolved_collateral_server().await;
4366 let mut client =
4367 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4368 client.set_base_info_url(format!("http://{addr}/info"));
4369
4370 let err = client.request_instrument_defs().await.unwrap_err();
4371
4372 assert_eq!(
4373 err.to_string(),
4374 "decode error: failed to resolve perp settlement currency for dex 0: \
4375 Spot metadata required to resolve perp collateral token 360",
4376 );
4377 }
4378
4379 #[rstest]
4380 fn test_with_credentials_preserves_explicit_account_address() {
4381 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4382 let client = HyperliquidHttpClient::with_credentials(
4383 Some(TEST_PRIVATE_KEY.to_string()),
4384 None,
4385 Some(account_address),
4386 HyperliquidEnvironment::Mainnet,
4387 60,
4388 None,
4389 )
4390 .unwrap();
4391
4392 assert_eq!(client.get_account_address().unwrap(), account_address);
4393 }
4394
4395 #[rstest]
4396 fn test_from_resolved_credentials_preserves_account_address_without_private_key() {
4397 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4398 let client = HyperliquidHttpClient::from_resolved_credentials(
4399 None,
4400 None,
4401 Some(account_address.to_string()),
4402 HyperliquidEnvironment::Mainnet,
4403 60,
4404 None,
4405 )
4406 .unwrap();
4407
4408 assert_eq!(client.get_account_address().unwrap(), account_address);
4409 }
4410
4411 #[rstest]
4412 fn test_cache_instrument_by_raw_symbol() {
4413 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4414
4415 let base_code = "vntls:vCURSOR";
4417 let quote_code = "USDC";
4418
4419 {
4421 let mut currency_map = CURRENCY_MAP.lock();
4422 if !currency_map.contains_key(base_code) {
4423 currency_map.insert(
4424 base_code.to_string(),
4425 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
4426 );
4427 }
4428 }
4429
4430 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
4431 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
4432
4433 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
4435 let venue = *HYPERLIQUID_VENUE;
4436 let instrument_id = InstrumentId::new(symbol, venue);
4437
4438 let raw_symbol = Symbol::new(base_code);
4440
4441 let clock = get_atomic_clock_realtime();
4442 let ts = clock.get_time_ns();
4443
4444 let instrument = InstrumentAny::CurrencyPair(
4445 CurrencyPair::builder()
4446 .instrument_id(instrument_id)
4447 .raw_symbol(raw_symbol)
4448 .base_currency(base_currency)
4449 .quote_currency(quote_currency)
4450 .price_precision(8)
4451 .size_precision(8)
4452 .price_increment(Price::from("0.00000001"))
4453 .size_increment(Quantity::from("0.00000001"))
4454 .ts_event(ts)
4455 .ts_init(ts)
4456 .build()
4457 .unwrap(),
4458 );
4459
4460 client.cache_instrument(&instrument);
4462
4463 let instruments = client.instruments.load();
4465 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
4466 assert!(
4467 by_full_symbol.is_some(),
4468 "Instrument should be accessible by full symbol"
4469 );
4470 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
4471
4472 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
4474 assert!(
4475 by_raw_symbol.is_some(),
4476 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
4477 );
4478 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
4479 drop(instruments);
4480
4481 let instruments_by_coin = client.instruments_by_coin.load();
4483 let by_coin =
4484 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
4485 assert!(
4486 by_coin.is_some(),
4487 "Instrument should be accessible by coin and product type"
4488 );
4489 assert_eq!(by_coin.unwrap().id(), instrument.id());
4490 drop(instruments_by_coin);
4491
4492 let retrieved_with_type = client.get_or_create_instrument(
4494 &Ustr::from("vntls:vCURSOR"),
4495 Some(HyperliquidProductType::Spot),
4496 );
4497 assert!(retrieved_with_type.is_some());
4498 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
4499
4500 let retrieved_without_type =
4502 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
4503 assert!(retrieved_without_type.is_some());
4504 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
4505 }
4506
4507 #[rstest]
4508 fn test_get_or_create_instrument_outcome_fallback_no_product_type() {
4509 use nautilus_core::time::get_atomic_clock_realtime;
4515 use nautilus_model::{
4516 enums::AssetClass,
4517 identifiers::{InstrumentId, Symbol},
4518 instruments::{BinaryOption, InstrumentAny},
4519 types::{Currency, Price, Quantity},
4520 };
4521
4522 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4523 let coin = "#500";
4524 let token = "+500";
4525
4526 let usdh = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
4527 let symbol = Symbol::new(token);
4528 let raw_symbol = Symbol::new(coin);
4529 let venue = *HYPERLIQUID_VENUE;
4530 let instrument_id = InstrumentId::new(symbol, venue);
4531
4532 let clock = get_atomic_clock_realtime();
4533 let ts = clock.get_time_ns();
4534
4535 let binary = InstrumentAny::BinaryOption(
4536 BinaryOption::builder()
4537 .instrument_id(instrument_id)
4538 .raw_symbol(raw_symbol)
4539 .asset_class(AssetClass::Alternative)
4540 .currency(usdh)
4541 .activation_ns(Default::default())
4542 .expiration_ns(Default::default())
4543 .price_precision(4)
4544 .size_precision(2)
4545 .price_increment(Price::from("0.0001"))
4546 .size_increment(Quantity::from("0.01"))
4547 .ts_event(ts)
4548 .ts_init(ts)
4549 .build()
4550 .unwrap(),
4551 );
4552
4553 client.cache_instrument(&binary);
4554
4555 let with_type = client
4556 .get_or_create_instrument(&Ustr::from(coin), Some(HyperliquidProductType::Outcome));
4557 assert!(with_type.is_some());
4558 assert_eq!(with_type.unwrap().id(), instrument_id);
4559
4560 let no_type = client.get_or_create_instrument(&Ustr::from(coin), None);
4561 assert!(
4562 no_type.is_some(),
4563 "Outcome coin must resolve through the no-product fallback",
4564 );
4565 assert_eq!(no_type.unwrap().id(), instrument_id);
4566
4567 let missing = client.get_or_create_instrument(&Ustr::from("#9999"), None);
4568 assert!(missing.is_none());
4569 }
4570
4571 #[rstest]
4572 fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
4573 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4578
4579 let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
4580 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4581 let clock = get_atomic_clock_realtime();
4582 let ts = clock.get_time_ns();
4583
4584 let canonical = InstrumentAny::CurrencyPair(
4585 CurrencyPair::builder()
4586 .instrument_id(InstrumentId::new(
4587 Symbol::new("HYPE-USDC-SPOT"),
4588 *HYPERLIQUID_VENUE,
4589 ))
4590 .raw_symbol(Symbol::new("@107"))
4591 .base_currency(hype)
4592 .quote_currency(usdc)
4593 .price_precision(5)
4594 .size_precision(2)
4595 .price_increment(Price::from("0.00001"))
4596 .size_increment(Quantity::from("0.01"))
4597 .ts_event(ts)
4598 .ts_init(ts)
4599 .build()
4600 .unwrap(),
4601 );
4602
4603 let non_canonical = InstrumentAny::CurrencyPair(
4604 CurrencyPair::builder()
4605 .instrument_id(InstrumentId::new(
4606 Symbol::new("HYPE-USDC-SPOT"),
4607 *HYPERLIQUID_VENUE,
4608 ))
4609 .raw_symbol(Symbol::new("@999"))
4610 .base_currency(hype)
4611 .quote_currency(usdc)
4612 .price_precision(5)
4613 .size_precision(2)
4614 .price_increment(Price::from("0.00001"))
4615 .size_increment(Quantity::from("0.01"))
4616 .ts_event(ts)
4617 .ts_init(ts)
4618 .build()
4619 .unwrap(),
4620 );
4621
4622 client.cache_instrument(&canonical);
4623 client.cache_instrument(&non_canonical);
4624
4625 let instruments_by_coin = client.instruments_by_coin.load();
4626 let by_base = instruments_by_coin
4627 .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
4628 .expect("base alias must resolve");
4629 assert_eq!(
4630 by_base.raw_symbol().inner().as_str(),
4631 "@107",
4632 "base alias must point to the canonical pair, not the one cached later",
4633 );
4634 }
4635
4636 #[rstest]
4637 fn test_cache_instrument_perp_aliases_sanitized_base() {
4638 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4644
4645 let base_currency = Currency::new(
4646 "dex:STREAMABCD****",
4647 8,
4648 0,
4649 "dex:STREAMABCD****",
4650 CurrencyType::Crypto,
4651 );
4652 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4653 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4654 let clock = get_atomic_clock_realtime();
4655 let ts = clock.get_time_ns();
4656
4657 let hip3 = InstrumentAny::CryptoPerpetual(
4658 CryptoPerpetual::builder()
4659 .instrument_id(InstrumentId::new(
4660 Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
4661 *HYPERLIQUID_VENUE,
4662 ))
4663 .raw_symbol(Symbol::new("dex:STREAMABCD****"))
4664 .base_currency(base_currency)
4665 .quote_currency(usd)
4666 .settlement_currency(usdc)
4667 .is_inverse(false)
4668 .price_precision(6)
4669 .size_precision(3)
4670 .price_increment(Price::from("0.000001"))
4671 .size_increment(Quantity::from("0.001"))
4672 .ts_event(ts)
4673 .ts_init(ts)
4674 .build()
4675 .unwrap(),
4676 );
4677
4678 client.cache_instrument(&hip3);
4679
4680 let instruments_by_coin = client.instruments_by_coin.load();
4681 let by_raw = instruments_by_coin
4682 .get(&(
4683 Ustr::from("dex:STREAMABCD****"),
4684 HyperliquidProductType::Perp,
4685 ))
4686 .expect("venue coin lookup must resolve");
4687 assert_eq!(by_raw.id(), hip3.id());
4688
4689 let by_sanitized = instruments_by_coin
4690 .get(&(
4691 Ustr::from("dex:STREAMABCDxxxx"),
4692 HyperliquidProductType::Perp,
4693 ))
4694 .expect("sanitized base lookup must resolve");
4695 assert_eq!(by_sanitized.id(), hip3.id());
4696 drop(instruments_by_coin);
4697
4698 let resolved = client
4700 .get_or_create_instrument(
4701 &Ustr::from("dex:STREAMABCDxxxx"),
4702 Some(HyperliquidProductType::Perp),
4703 )
4704 .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
4705 assert_eq!(resolved.id(), hip3.id());
4706 }
4707
4708 fn perp_with_asset_index(symbol: &str, asset_index: Option<u32>) -> InstrumentAny {
4709 let base_currency = Currency::new("NEW", 8, 0, "NEW", CurrencyType::Crypto);
4710 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4711 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4712 let ts = get_atomic_clock_realtime().get_time_ns();
4713 let info = asset_index.map(|asset_index| {
4714 let mut info = Params::new();
4715 info.insert(ASSET_INDEX_INFO_KEY.to_string(), asset_index.into());
4716 info
4717 });
4718
4719 InstrumentAny::CryptoPerpetual(
4720 CryptoPerpetual::builder()
4721 .instrument_id(InstrumentId::new(Symbol::new(symbol), *HYPERLIQUID_VENUE))
4722 .raw_symbol(Symbol::new("NEW"))
4723 .base_currency(base_currency)
4724 .quote_currency(usd)
4725 .settlement_currency(usdc)
4726 .is_inverse(false)
4727 .price_precision(6)
4728 .size_precision(3)
4729 .price_increment(Price::from("0.000001"))
4730 .size_increment(Quantity::from("0.001"))
4731 .maybe_info(info)
4732 .ts_event(ts)
4733 .ts_init(ts)
4734 .build()
4735 .unwrap(),
4736 )
4737 }
4738
4739 #[rstest]
4740 fn test_cache_instrument_registers_asset_index_from_info() {
4741 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4742
4743 client.cache_instrument(&perp_with_asset_index("NEW-USD-PERP", Some(42)));
4744
4745 assert_eq!(client.get_asset_index("NEW-USD-PERP"), Some(42));
4746 }
4747
4748 #[rstest]
4749 fn test_cache_instrument_without_asset_index_info_retains_existing_index() {
4750 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4753
4754 client.cache_instrument(&perp_with_asset_index("NEW-USD-PERP", Some(42)));
4755 client.cache_instrument(&perp_with_asset_index("NEW-USD-PERP", None));
4756 client.cache_instrument(&perp_with_asset_index("OTHER-USD-PERP", None));
4757
4758 assert_eq!(client.get_asset_index("NEW-USD-PERP"), Some(42));
4759 assert_eq!(client.get_asset_index("OTHER-USD-PERP"), None);
4760 }
4761}