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