1use std::{
24 collections::HashMap,
25 num::NonZeroU32,
26 sync::{Arc, LazyLock, Mutex},
27 time::Duration,
28};
29
30use ahash::AHashMap;
31use anyhow::Context;
32use nautilus_common::cache::InstrumentLookupError;
33use nautilus_core::{
34 AtomicMap, MUTEX_POISONED, UUID4, UnixNanos,
35 consts::NAUTILUS_USER_AGENT,
36 datetime::datetime_to_unix_nanos,
37 time::{AtomicTime, get_atomic_clock_realtime},
38};
39use nautilus_model::{
40 data::{Bar, BarType},
41 enums::{
42 AccountType, BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce,
43 TriggerType,
44 },
45 events::AccountState,
46 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
47 instruments::{CurrencyPair, Instrument, InstrumentAny},
48 orders::{Order, OrderAny},
49 reports::{FillReport, OrderStatusReport, PositionStatusReport},
50 types::{AccountBalance, Currency, Price, Quantity},
51};
52use nautilus_network::{
53 http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
54 ratelimiter::quota::Quota,
55};
56use rust_decimal::Decimal;
57use serde_json::Value;
58use ustr::Ustr;
59
60use crate::{
61 account::resolve_execution_account_address,
62 common::{
63 consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url},
64 credential::{Secrets, VaultAddress, credential_env_vars},
65 enums::{
66 HyperliquidBarInterval, HyperliquidEnvironment,
67 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
68 },
69 parse::{
70 bar_type_to_interval, cache_alias_for_symbol, clamp_price_to_precision,
71 derive_limit_from_trigger, determine_order_list_grouping, extract_inner_error,
72 normalize_price, order_to_hyperliquid_request_with_asset_and_cloid,
73 parse_combined_account_balances_and_margins, parse_spot_account_balances,
74 parse_trigger_order_type, round_to_sig_figs, time_in_force_to_hyperliquid_tif,
75 },
76 },
77 data::candle_to_bar,
78 data_types::HyperliquidPublicTrade,
79 http::{
80 error::{Error, Result},
81 models::{
82 ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeRequest,
83 HyperliquidExchangeResponse, HyperliquidExecAction, HyperliquidExecBuilderFee,
84 HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
85 HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecMergeOutcomeParams,
86 HyperliquidExecMergeQuestionParams, HyperliquidExecModifyOrderRequest,
87 HyperliquidExecModifyTarget, HyperliquidExecNegateOutcomeParams,
88 HyperliquidExecOrderKind, HyperliquidExecOrderResponseData, HyperliquidExecOrderStatus,
89 HyperliquidExecPlaceOrderRequest, HyperliquidExecSplitOutcomeParams,
90 HyperliquidExecTif, HyperliquidExecTpSl, HyperliquidExecTriggerParams,
91 HyperliquidExecUserOutcomeOp, HyperliquidFills, HyperliquidFundingHistoryEntry,
92 HyperliquidL2Book, HyperliquidMeta, HyperliquidOrderStatus,
93 HyperliquidOrderStatusEntry, HyperliquidRecentTrade, OutcomeMeta, PerpDex, PerpMeta,
94 PerpMetaAndCtxs, RESPONSE_STATUS_OK, SpotClearinghouseState, SpotMeta, SpotMetaAndCtxs,
95 },
96 parse::{
97 HyperliquidInstrumentDef, filter_recent_public_trades, instruments_from_defs_owned,
98 parse_fill_report, parse_order_status_report_from_basic, parse_outcome_instruments,
99 parse_perp_instruments_with_settlement, parse_position_status_report,
100 parse_recent_public_trade, parse_spot_instruments, parse_spot_position_status_report,
101 resolve_perp_settlement_currency,
102 },
103 query::{ExchangeAction, InfoRequest},
104 rate_limits::{
105 RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
106 exec_action_weight, info_base_weight, info_extra_weight,
107 },
108 },
109 signing::{
110 HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
111 },
112 websocket::messages::WsBasicOrderData,
113};
114
115fn deduplicate_historical_order_reports(reports: Vec<OrderStatusReport>) -> Vec<OrderStatusReport> {
116 let mut best_by_venue_order_id = AHashMap::new();
117
118 for candidate in reports {
119 let Some(current) = best_by_venue_order_id.remove(&candidate.venue_order_id) else {
120 best_by_venue_order_id.insert(candidate.venue_order_id, candidate);
121 continue;
122 };
123 let (mut best, other) = if historical_report_is_more_advanced(&candidate, ¤t) {
124 (candidate, current)
125 } else {
126 (current, candidate)
127 };
128
129 if matches!(
130 best.order_type,
131 OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
132 ) {
133 best.price = best.price.or(other.price);
134 }
135 best.trigger_price = best.trigger_price.or(other.trigger_price);
136 best_by_venue_order_id.insert(best.venue_order_id, best);
137 }
138
139 best_by_venue_order_id.into_values().collect()
140}
141
142fn historical_report_is_more_advanced(
143 candidate: &OrderStatusReport,
144 current: &OrderStatusReport,
145) -> bool {
146 candidate.filled_qty > current.filled_qty
147 || (candidate.filled_qty == current.filled_qty
148 && (historical_status_priority(candidate.order_status)
149 > historical_status_priority(current.order_status)
150 || (candidate.order_status == current.order_status
151 && candidate.ts_last > current.ts_last)))
152}
153
154const fn historical_status_priority(status: OrderStatus) -> u8 {
155 match status {
156 OrderStatus::Initialized | OrderStatus::Submitted | OrderStatus::Emulated => 0,
157 OrderStatus::Released | OrderStatus::Denied => 1,
158 OrderStatus::Accepted | OrderStatus::PendingUpdate | OrderStatus::PendingCancel => 2,
159 OrderStatus::Triggered => 3,
160 OrderStatus::PartiallyFilled => 4,
161 OrderStatus::Canceled | OrderStatus::Expired | OrderStatus::Rejected => 5,
162 OrderStatus::Filled | OrderStatus::Voided => 6,
163 }
164}
165
166pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
168 LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
169
170#[derive(Debug, Clone)]
175#[cfg_attr(
176 feature = "python",
177 pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
178)]
179pub struct HyperliquidRawHttpClient {
180 client: HttpClient,
181 environment: HyperliquidEnvironment,
182 base_info: String,
183 base_exchange: String,
184 signer: Option<HyperliquidEip712Signer>,
185 nonce_manager: Option<Arc<NonceManager>>,
186 vault_address: Option<VaultAddress>,
187 rest_limiter: Arc<WeightedLimiter>,
188 rate_limit_backoff_base: Duration,
189 rate_limit_backoff_cap: Duration,
190 rate_limit_max_attempts_info: u32,
191}
192
193impl HyperliquidRawHttpClient {
194 pub fn new(
200 environment: HyperliquidEnvironment,
201 timeout_secs: u64,
202 proxy_url: Option<String>,
203 ) -> std::result::Result<Self, HttpClientError> {
204 Ok(Self {
205 client: HttpClient::new(
206 Self::default_headers(),
207 vec![],
208 vec![],
209 Some(*HYPERLIQUID_REST_QUOTA),
210 Some(timeout_secs),
211 proxy_url,
212 )?,
213 environment,
214 base_info: info_url(environment).to_string(),
215 base_exchange: exchange_url(environment).to_string(),
216 signer: None,
217 nonce_manager: None,
218 vault_address: None,
219 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
220 rate_limit_backoff_base: Duration::from_millis(125),
221 rate_limit_backoff_cap: Duration::from_secs(5),
222 rate_limit_max_attempts_info: 3,
223 })
224 }
225
226 pub fn with_credentials(
233 secrets: &Secrets,
234 timeout_secs: u64,
235 proxy_url: Option<String>,
236 ) -> std::result::Result<Self, HttpClientError> {
237 let signer = HyperliquidEip712Signer::new(&secrets.private_key)
238 .map_err(|e| HttpClientError::from(e.to_string()))?;
239 let nonce_manager = Arc::new(NonceManager::new());
240
241 Ok(Self {
242 client: HttpClient::new(
243 Self::default_headers(),
244 vec![],
245 vec![],
246 Some(*HYPERLIQUID_REST_QUOTA),
247 Some(timeout_secs),
248 proxy_url,
249 )?,
250 environment: secrets.environment,
251 base_info: info_url(secrets.environment).to_string(),
252 base_exchange: exchange_url(secrets.environment).to_string(),
253 signer: Some(signer),
254 nonce_manager: Some(nonce_manager),
255 vault_address: secrets.vault_address,
256 rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
257 rate_limit_backoff_base: Duration::from_millis(125),
258 rate_limit_backoff_cap: Duration::from_secs(5),
259 rate_limit_max_attempts_info: 3,
260 })
261 }
262
263 pub fn set_base_info_url(&mut self, url: String) {
265 self.base_info = url;
266 }
267
268 pub fn set_base_exchange_url(&mut self, url: String) {
270 self.base_exchange = url;
271 }
272
273 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
279 let secrets = Secrets::from_env(environment)
280 .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
281 Self::with_credentials(&secrets, 60, None)
282 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
283 }
284
285 pub fn from_credentials(
291 private_key: &str,
292 vault_address: Option<&str>,
293 environment: HyperliquidEnvironment,
294 timeout_secs: u64,
295 proxy_url: Option<String>,
296 ) -> Result<Self> {
297 let secrets = Secrets::from_private_key(private_key, vault_address, environment)
298 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
299 Self::with_credentials(&secrets, timeout_secs, proxy_url)
300 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
301 }
302
303 #[must_use]
305 pub fn with_rate_limits(mut self) -> Self {
306 self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
307 self.rate_limit_backoff_base = Duration::from_millis(125);
308 self.rate_limit_backoff_cap = Duration::from_secs(5);
309 self.rate_limit_max_attempts_info = 3;
310 self
311 }
312
313 #[must_use]
315 pub fn environment(&self) -> HyperliquidEnvironment {
316 self.environment
317 }
318
319 #[must_use]
321 pub fn is_testnet(&self) -> bool {
322 self.environment == HyperliquidEnvironment::Testnet
323 }
324
325 pub fn get_user_address(&self) -> Result<String> {
331 self.signer
332 .as_ref()
333 .ok_or_else(|| Error::auth("No signer configured"))?
334 .address()
335 }
336
337 #[must_use]
339 pub fn has_vault_address(&self) -> bool {
340 self.vault_address.is_some()
341 }
342
343 pub fn get_account_address(&self) -> Result<String> {
350 if let Some(vault) = &self.vault_address {
351 Ok(vault.to_hex())
352 } else {
353 self.get_user_address()
354 }
355 }
356
357 fn default_headers() -> HashMap<String, String> {
358 HashMap::from([
359 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
360 ("Content-Type".to_string(), "application/json".to_string()),
361 ])
362 }
363
364 fn signer_id(&self) -> SignerId {
365 SignerId("hyperliquid:default".into())
366 }
367
368 fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
369 let retry_after = headers.get("retry-after")?;
370 retry_after.parse::<u64>().ok().map(|s| s * 1000) }
372
373 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
375 let request = InfoRequest::meta();
376 let response = self.send_info_request(&request).await?;
377 serde_json::from_value(response).map_err(Error::Serde)
378 }
379
380 pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
382 let request = InfoRequest::spot_meta();
383 let response = self.send_info_request(&request).await?;
384 serde_json::from_value(response).map_err(Error::Serde)
385 }
386
387 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
389 let request = InfoRequest::meta_and_asset_ctxs();
390 let response = self.send_info_request(&request).await?;
391 serde_json::from_value(response).map_err(Error::Serde)
392 }
393
394 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
396 let request = InfoRequest::spot_meta_and_asset_ctxs();
397 let response = self.send_info_request(&request).await?;
398 serde_json::from_value(response).map_err(Error::Serde)
399 }
400
401 pub async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
403 let request = InfoRequest::outcome_meta();
404 let response = self.send_info_request(&request).await?;
405 serde_json::from_value(response).map_err(Error::Serde)
406 }
407
408 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
409 let request = InfoRequest::meta();
410 let response = self.send_info_request(&request).await?;
411 serde_json::from_value(response).map_err(Error::Serde)
412 }
413
414 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
416 let request = InfoRequest::all_perp_metas();
417 let response = self.send_info_request(&request).await?;
418 serde_json::from_value(response).map_err(Error::Serde)
419 }
420
421 pub(crate) async fn load_perp_dexs(&self) -> Result<Vec<Option<PerpDex>>> {
423 let request = InfoRequest::perp_dexs();
424 let response = self.send_info_request(&request).await?;
425 serde_json::from_value(response).map_err(Error::Serde)
426 }
427
428 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
430 let request = InfoRequest::l2_book(coin);
431 let response = self.send_info_request(&request).await?;
432 serde_json::from_value(response).map_err(Error::Serde)
433 }
434
435 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
440 let request = InfoRequest::recent_trades(coin);
441 let response = self.send_info_request(&request).await?;
442 serde_json::from_value(response).map_err(Error::Serde)
443 }
444
445 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
447 let request = InfoRequest::user_fills(user);
448 let response = self.send_info_request(&request).await?;
449 serde_json::from_value(response).map_err(Error::Serde)
450 }
451
452 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
454 let request = InfoRequest::order_status(user, oid);
455 let response = self.send_info_request(&request).await?;
456 serde_json::from_value(response).map_err(Error::Serde)
457 }
458
459 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
461 let request = InfoRequest::open_orders(user);
462 self.send_info_request(&request).await
463 }
464
465 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
467 self.info_frontend_open_orders_for_dex(user, None).await
468 }
469
470 async fn info_frontend_open_orders_for_dex(
471 &self,
472 user: &str,
473 dex: Option<&str>,
474 ) -> Result<Value> {
475 let request = InfoRequest::frontend_open_orders_for_dex(user, dex);
476 self.send_info_request(&request).await
477 }
478
479 pub async fn info_historical_orders(
481 &self,
482 user: &str,
483 ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
484 let request = InfoRequest::historical_orders(user);
485 let response = self.send_info_request(&request).await?;
486 serde_json::from_value(response).map_err(Error::Serde)
487 }
488
489 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
491 self.info_clearinghouse_state_for_dex(user, None).await
492 }
493
494 async fn info_clearinghouse_state_for_dex(
495 &self,
496 user: &str,
497 dex: Option<&str>,
498 ) -> Result<Value> {
499 let request = InfoRequest::clearinghouse_state_for_dex(user, dex);
500 self.send_info_request(&request).await
501 }
502
503 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
505 let request = InfoRequest::spot_clearinghouse_state(user);
506 self.send_info_request(&request).await
507 }
508
509 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
511 let request = InfoRequest::user_fees(user);
512 self.send_info_request(&request).await
513 }
514
515 pub async fn info_candle_snapshot(
517 &self,
518 coin: &str,
519 interval: HyperliquidBarInterval,
520 start_time: u64,
521 end_time: u64,
522 ) -> Result<HyperliquidCandleSnapshot> {
523 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
524 let response = self.send_info_request(&request).await?;
525
526 log::trace!(
527 "Candle snapshot raw response (len={}): {:?}",
528 response.as_array().map_or(0, |a| a.len()),
529 response
530 );
531
532 serde_json::from_value(response).map_err(Error::Serde)
533 }
534
535 pub async fn info_funding_history(
540 &self,
541 coin: &str,
542 start_time: u64,
543 end_time: Option<u64>,
544 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
545 let request = InfoRequest::funding_history(coin, start_time, end_time);
546 let response = self.send_info_request(&request).await?;
547 serde_json::from_value(response).map_err(Error::Serde)
548 }
549
550 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
552 self.send_info_request(request).await
553 }
554
555 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
556 let base_w = info_base_weight(request);
557 self.rest_limiter.acquire(base_w).await;
558
559 let mut attempt = 0u32;
560
561 loop {
562 let response = self.http_roundtrip_info(request).await?;
563
564 if response.status.is_success() {
565 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
567 let extra = info_extra_weight(request, &val);
568 if extra > 0 {
569 self.rest_limiter.debit_extra(extra).await;
570 log::debug!(
571 "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
572 );
573 }
574 return Ok(val);
575 }
576
577 if response.status.as_u16() == 429 {
579 if attempt >= self.rate_limit_max_attempts_info {
580 let ra = self.parse_retry_after_simple(&response.headers);
581 return Err(Error::rate_limit("info", base_w, ra));
582 }
583 let delay = self
584 .parse_retry_after_simple(&response.headers)
585 .map_or_else(
586 || {
587 backoff_full_jitter(
588 attempt,
589 self.rate_limit_backoff_base,
590 self.rate_limit_backoff_cap,
591 )
592 },
593 Duration::from_millis,
594 );
595 log::warn!(
596 "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
597 delay.as_millis()
598 );
599 attempt += 1;
600 tokio::time::sleep(delay).await;
601 self.rest_limiter.acquire(1).await;
603 continue;
604 }
605
606 if (response.status.is_server_error() || response.status.as_u16() == 408)
608 && attempt < self.rate_limit_max_attempts_info
609 {
610 let delay = backoff_full_jitter(
611 attempt,
612 self.rate_limit_backoff_base,
613 self.rate_limit_backoff_cap,
614 );
615 log::warn!(
616 "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
617 response.status.as_u16(),
618 delay.as_millis()
619 );
620 attempt += 1;
621 tokio::time::sleep(delay).await;
622 continue;
623 }
624
625 let error_body = String::from_utf8_lossy(&response.body);
627 return Err(Error::http(
628 response.status.as_u16(),
629 error_body.to_string(),
630 ));
631 }
632 }
633
634 async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
635 let url = &self.base_info;
636 let body = serde_json::to_value(request).map_err(Error::Serde)?;
637 let body_bytes = serde_json::to_string(&body)
638 .map_err(Error::Serde)?
639 .into_bytes();
640
641 self.client
642 .request(
643 Method::POST,
644 url.clone(),
645 None,
646 None,
647 Some(body_bytes),
648 None,
649 None,
650 )
651 .await
652 .map_err(Error::from_http_client)
653 }
654
655 pub async fn post_action(
657 &self,
658 action: &ExchangeAction,
659 ) -> Result<HyperliquidExchangeResponse> {
660 let w = exchange_weight(action);
661 self.rest_limiter.acquire(w).await;
662
663 let signer = self
664 .signer
665 .as_ref()
666 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
667
668 let nonce_manager = self
669 .nonce_manager
670 .as_ref()
671 .ok_or_else(|| Error::auth("nonce manager missing"))?;
672
673 let signer_id = self.signer_id();
674 let time_nonce = nonce_manager.next(signer_id)?;
675
676 let action_bytes = rmp_serde::to_vec_named(action)
678 .context("serialize action with MessagePack")
679 .map_err(|e| Error::bad_request(e.to_string()))?;
680
681 let sign_request = SignRequest {
682 action: None,
683 action_bytes: Some(action_bytes),
684 time_nonce,
685 action_type: HyperliquidActionType::L1,
686 is_testnet: self.is_testnet(),
687 vault_address: self.vault_address,
688 expires_after: None,
689 };
690
691 let sig = signer.sign(&sign_request)?.signature;
692
693 let nonce_u64 = time_nonce.as_millis() as u64;
694
695 let request = if let Some(vault) = self.vault_address {
696 HyperliquidExchangeRequest::with_vault(
697 action.clone(),
698 nonce_u64,
699 sig,
700 vault.to_string(),
701 )
702 } else {
703 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
704 };
705
706 let response = self.http_roundtrip_exchange(&request).await?;
707
708 if response.status.is_success() {
709 let parsed_response: HyperliquidExchangeResponse =
710 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
711
712 match &parsed_response {
714 HyperliquidExchangeResponse::Status {
715 status,
716 response: response_data,
717 } if status == "err" => {
718 let error_msg = response_data
719 .as_str()
720 .map_or_else(|| response_data.to_string(), |s| s.to_string());
721 log::error!("Hyperliquid API returned error: {error_msg}");
722 Err(Error::bad_request(format!("API error: {error_msg}")))
723 }
724 HyperliquidExchangeResponse::Error { error } => {
725 log::error!("Hyperliquid API returned error: {error}");
726 Err(Error::bad_request(format!("API error: {error}")))
727 }
728 _ => Ok(parsed_response),
729 }
730 } else if response.status.as_u16() == 429 {
731 let ra = self.parse_retry_after_simple(&response.headers);
732 Err(Error::rate_limit("exchange", w, ra))
733 } else {
734 let error_body = String::from_utf8_lossy(&response.body);
735 log::error!(
736 "Exchange API error (status {}): {}",
737 response.status.as_u16(),
738 error_body
739 );
740 Err(Error::http(
741 response.status.as_u16(),
742 error_body.to_string(),
743 ))
744 }
745 }
746
747 pub fn sign_action_exec_request(
749 &self,
750 action: &HyperliquidExecAction,
751 expires_after: Option<u64>,
752 ) -> Result<HyperliquidExchangeRequest<HyperliquidExecAction>> {
753 let signer = self
754 .signer
755 .as_ref()
756 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
757
758 let nonce_manager = self
759 .nonce_manager
760 .as_ref()
761 .ok_or_else(|| Error::auth("nonce manager missing"))?;
762
763 let signer_id = self.signer_id();
764 let time_nonce = nonce_manager.next(signer_id)?;
765 let action_bytes = rmp_serde::to_vec_named(action)
769 .context("serialize action with MessagePack")
770 .map_err(|e| Error::bad_request(e.to_string()))?;
771
772 let sig = signer
773 .sign(&SignRequest {
774 action: None,
775 action_bytes: Some(action_bytes),
776 time_nonce,
777 action_type: HyperliquidActionType::L1,
778 is_testnet: self.is_testnet(),
779 vault_address: self.vault_address,
780 expires_after,
781 })?
782 .signature;
783
784 let mut request = if let Some(vault) = self.vault_address {
785 HyperliquidExchangeRequest::with_vault(
786 action.clone(),
787 time_nonce.as_millis() as u64,
788 sig,
789 vault.to_string(),
790 )
791 } else {
792 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
793 };
794 request.expires_after = expires_after;
795 Ok(request)
796 }
797
798 pub async fn post_action_exec(
803 &self,
804 action: &HyperliquidExecAction,
805 ) -> Result<HyperliquidExchangeResponse> {
806 let w = exec_action_weight(action);
807 self.rest_limiter.acquire(w).await;
808
809 let request = self.sign_action_exec_request(action, None)?;
810
811 let response = self.http_roundtrip_exchange(&request).await?;
812
813 if response.status.is_success() {
814 let parsed_response: HyperliquidExchangeResponse =
815 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
816
817 match &parsed_response {
819 HyperliquidExchangeResponse::Status {
820 status,
821 response: response_data,
822 } if status == "err" => {
823 let error_msg = response_data
824 .as_str()
825 .map_or_else(|| response_data.to_string(), |s| s.to_string());
826 log::error!("Hyperliquid API returned error: {error_msg}");
827 Err(Error::bad_request(format!("API error: {error_msg}")))
828 }
829 HyperliquidExchangeResponse::Error { error } => {
830 log::error!("Hyperliquid API returned error: {error}");
831 Err(Error::bad_request(format!("API error: {error}")))
832 }
833 _ => Ok(parsed_response),
834 }
835 } else if response.status.as_u16() == 429 {
836 let ra = self.parse_retry_after_simple(&response.headers);
837 Err(Error::rate_limit("exchange", w, ra))
838 } else {
839 let error_body = String::from_utf8_lossy(&response.body);
840 Err(Error::http(
841 response.status.as_u16(),
842 error_body.to_string(),
843 ))
844 }
845 }
846
847 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
850 self.rest_limiter.snapshot().await
851 }
852 async fn http_roundtrip_exchange<T>(
853 &self,
854 request: &HyperliquidExchangeRequest<T>,
855 ) -> Result<HttpResponse>
856 where
857 T: serde::Serialize,
858 {
859 let url = &self.base_exchange;
860 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
861 let body_bytes = body.into_bytes();
862
863 let response = self
864 .client
865 .request(
866 Method::POST,
867 url.clone(),
868 None,
869 None,
870 Some(body_bytes),
871 None,
872 None,
873 )
874 .await
875 .map_err(Error::from_http_client)?;
876
877 Ok(response)
878 }
879}
880
881#[derive(Debug, Clone)]
887#[cfg_attr(
888 feature = "python",
889 pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
890)]
891#[cfg_attr(
892 feature = "python",
893 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
894)]
895pub struct HyperliquidHttpClient {
896 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
897 clock: &'static AtomicTime,
898 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
899 instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
900 asset_indices: Arc<AtomicMap<Ustr, u32>>,
902 spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
904 client_order_id_cloids: Arc<Mutex<AHashMap<ClientOrderId, Cloid>>>,
905 account_id: Option<AccountId>,
906 account_address: Option<String>,
910 normalize_prices: bool,
911 market_order_slippage_bps: u32,
912 include_builder_attribution: bool,
913}
914
915impl Default for HyperliquidHttpClient {
916 fn default() -> Self {
917 Self::new(HyperliquidEnvironment::Mainnet, 60, None)
918 .expect("Failed to create default Hyperliquid HTTP client")
919 }
920}
921
922impl HyperliquidHttpClient {
923 pub fn new(
929 environment: HyperliquidEnvironment,
930 timeout_secs: u64,
931 proxy_url: Option<String>,
932 ) -> std::result::Result<Self, HttpClientError> {
933 let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
934 Ok(Self::from_raw(raw_client))
935 }
936
937 pub fn with_secrets(
943 secrets: &Secrets,
944 timeout_secs: u64,
945 proxy_url: Option<String>,
946 ) -> std::result::Result<Self, HttpClientError> {
947 let raw_client =
948 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
949 Ok(Self::from_raw(raw_client))
950 }
951
952 fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
953 Self {
954 inner: Arc::new(raw_client),
955 clock: get_atomic_clock_realtime(),
956 instruments: Arc::new(AtomicMap::new()),
957 instruments_by_coin: Arc::new(AtomicMap::new()),
958 asset_indices: Arc::new(AtomicMap::new()),
959 spot_fill_coins: Arc::new(AtomicMap::new()),
960 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
961 account_id: None,
962 account_address: None,
963 normalize_prices: true,
964 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
965 include_builder_attribution: true,
966 }
967 }
968
969 #[allow(
971 clippy::missing_panics_doc,
972 reason = "cloid cache mutex poisoning is not expected"
973 )]
974 #[must_use]
975 pub fn get_or_generate_client_order_id_cloid(&self, client_order_id: ClientOrderId) -> Cloid {
976 let mut cloids = self.client_order_id_cloids.lock().expect(MUTEX_POISONED);
977 *cloids
978 .entry(client_order_id)
979 .or_insert_with(|| Cloid::from_client_order_id(client_order_id))
980 }
981
982 #[allow(
984 clippy::missing_panics_doc,
985 reason = "cloid cache mutex poisoning is not expected"
986 )]
987 pub fn cache_client_order_id_cloid(&self, client_order_id: ClientOrderId, cloid: Cloid) {
988 self.client_order_id_cloids
989 .lock()
990 .expect(MUTEX_POISONED)
991 .entry(client_order_id)
992 .or_insert(cloid);
993 }
994
995 #[allow(
997 clippy::missing_panics_doc,
998 reason = "cloid cache mutex poisoning is not expected"
999 )]
1000 #[must_use]
1001 pub fn cached_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
1002 self.client_order_id_cloids
1003 .lock()
1004 .expect(MUTEX_POISONED)
1005 .get(client_order_id)
1006 .copied()
1007 }
1008
1009 #[allow(
1012 clippy::missing_panics_doc,
1013 reason = "cloid cache mutex poisoning is not expected"
1014 )]
1015 #[must_use]
1016 pub(crate) fn unique_cached_client_order_id_cloid(
1017 &self,
1018 client_order_id: &ClientOrderId,
1019 ) -> Option<Cloid> {
1020 let cloids = self.client_order_id_cloids.lock().expect(MUTEX_POISONED);
1021 let cloid = cloids.get(client_order_id).copied()?;
1022 let mapping_count = cloids
1023 .values()
1024 .filter(|cached_cloid| **cached_cloid == cloid)
1025 .count();
1026
1027 (mapping_count == 1).then_some(cloid)
1028 }
1029
1030 #[allow(
1032 clippy::missing_panics_doc,
1033 reason = "cloid cache mutex poisoning is not expected"
1034 )]
1035 pub fn remove_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
1036 self.client_order_id_cloids
1037 .lock()
1038 .expect(MUTEX_POISONED)
1039 .remove(client_order_id)
1040 }
1041
1042 pub fn set_base_info_url(&mut self, url: String) {
1048 Arc::get_mut(&mut self.inner)
1049 .expect("cannot override URL: Arc has multiple references")
1050 .set_base_info_url(url);
1051 }
1052
1053 pub fn set_base_exchange_url(&mut self, url: String) {
1059 Arc::get_mut(&mut self.inner)
1060 .expect("cannot override URL: Arc has multiple references")
1061 .set_base_exchange_url(url);
1062 }
1063
1064 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
1070 let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
1071 Ok(Self {
1072 inner: Arc::new(raw_client),
1073 clock: get_atomic_clock_realtime(),
1074 instruments: Arc::new(AtomicMap::new()),
1075 instruments_by_coin: Arc::new(AtomicMap::new()),
1076 asset_indices: Arc::new(AtomicMap::new()),
1077 spot_fill_coins: Arc::new(AtomicMap::new()),
1078 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1079 account_id: None,
1080 account_address: None,
1081 normalize_prices: true,
1082 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1083 include_builder_attribution: true,
1084 })
1085 }
1086
1087 pub fn with_credentials(
1100 private_key: Option<String>,
1101 vault_address: Option<String>,
1102 account_address: Option<&str>,
1103 environment: HyperliquidEnvironment,
1104 timeout_secs: u64,
1105 proxy_url: Option<String>,
1106 ) -> Result<Self> {
1107 let (pk_env_var, vault_env_var) = credential_env_vars(environment);
1108
1109 let resolved_account_address = resolve_execution_account_address(
1110 private_key.as_deref(),
1111 vault_address.as_deref(),
1112 account_address,
1113 environment,
1114 )?;
1115
1116 let resolved_pk = private_key.or_else(|| std::env::var(pk_env_var).ok());
1118
1119 let resolved_vault = vault_address.or_else(|| std::env::var(vault_env_var).ok());
1121
1122 Self::from_resolved_credentials(
1123 resolved_pk,
1124 resolved_vault.as_deref(),
1125 resolved_account_address,
1126 environment,
1127 timeout_secs,
1128 proxy_url,
1129 )
1130 }
1131
1132 fn from_resolved_credentials(
1133 private_key: Option<String>,
1134 vault_address: Option<&str>,
1135 account_address: Option<String>,
1136 environment: HyperliquidEnvironment,
1137 timeout_secs: u64,
1138 proxy_url: Option<String>,
1139 ) -> Result<Self> {
1140 match private_key {
1141 Some(pk) => {
1142 let raw_client = HyperliquidRawHttpClient::from_credentials(
1143 &pk,
1144 vault_address,
1145 environment,
1146 timeout_secs,
1147 proxy_url,
1148 )?;
1149 Ok(Self {
1150 inner: Arc::new(raw_client),
1151 clock: get_atomic_clock_realtime(),
1152 instruments: Arc::new(AtomicMap::new()),
1153 instruments_by_coin: Arc::new(AtomicMap::new()),
1154 asset_indices: Arc::new(AtomicMap::new()),
1155 spot_fill_coins: Arc::new(AtomicMap::new()),
1156 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1157 account_id: None,
1158 account_address,
1159 normalize_prices: true,
1160 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1161 include_builder_attribution: true,
1162 })
1163 }
1164 None => {
1165 let mut client = Self::new(environment, timeout_secs, proxy_url)
1167 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))?;
1168 client.set_account_address(account_address);
1169 Ok(client)
1170 }
1171 }
1172 }
1173
1174 pub fn from_credentials(
1180 private_key: &str,
1181 vault_address: Option<&str>,
1182 environment: HyperliquidEnvironment,
1183 timeout_secs: u64,
1184 proxy_url: Option<String>,
1185 ) -> Result<Self> {
1186 let raw_client = HyperliquidRawHttpClient::from_credentials(
1187 private_key,
1188 vault_address,
1189 environment,
1190 timeout_secs,
1191 proxy_url,
1192 )?;
1193 Ok(Self {
1194 inner: Arc::new(raw_client),
1195 clock: get_atomic_clock_realtime(),
1196 instruments: Arc::new(AtomicMap::new()),
1197 instruments_by_coin: Arc::new(AtomicMap::new()),
1198 asset_indices: Arc::new(AtomicMap::new()),
1199 spot_fill_coins: Arc::new(AtomicMap::new()),
1200 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1201 account_id: None,
1202 account_address: None,
1203 normalize_prices: true,
1204 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1205 include_builder_attribution: true,
1206 })
1207 }
1208
1209 #[must_use]
1211 pub fn is_testnet(&self) -> bool {
1212 self.inner.is_testnet()
1213 }
1214
1215 #[must_use]
1217 pub fn normalize_prices(&self) -> bool {
1218 self.normalize_prices
1219 }
1220
1221 pub fn set_normalize_prices(&mut self, value: bool) {
1223 self.normalize_prices = value;
1224 }
1225
1226 #[must_use]
1228 pub fn market_order_slippage_bps(&self) -> u32 {
1229 self.market_order_slippage_bps
1230 }
1231
1232 pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1234 self.market_order_slippage_bps = value;
1235 }
1236
1237 #[must_use]
1239 pub fn include_builder_attribution(&self) -> bool {
1240 self.include_builder_attribution
1241 }
1242
1243 pub fn set_include_builder_attribution(&mut self, value: bool) {
1245 self.include_builder_attribution = value;
1246 }
1247
1248 pub fn get_user_address(&self) -> Result<String> {
1254 self.inner.get_user_address()
1255 }
1256
1257 #[must_use]
1259 pub fn has_vault_address(&self) -> bool {
1260 self.inner.has_vault_address()
1261 }
1262
1263 #[must_use]
1268 pub fn builder_attribution(&self) -> Option<HyperliquidExecBuilderFee> {
1269 if !self.include_builder_attribution || self.has_vault_address() || self.is_testnet() {
1270 None
1271 } else {
1272 Some(HyperliquidExecBuilderFee {
1273 address: NAUTILUS_BUILDER_ADDRESS.to_string(),
1274 fee_tenths_bp: 0,
1275 })
1276 }
1277 }
1278
1279 pub fn get_account_address(&self) -> Result<String> {
1287 if let Some(addr) = &self.account_address {
1288 return Ok(addr.clone());
1289 }
1290 self.inner.get_account_address()
1291 }
1292
1293 pub fn set_account_address(&mut self, address: Option<String>) {
1295 self.account_address = address;
1296 }
1297
1298 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1303 let full_symbol = instrument.symbol().inner();
1304 let coin = instrument.raw_symbol().inner();
1305
1306 self.instruments.rcu(|m| {
1307 m.insert(full_symbol, instrument.clone());
1308 m.insert(coin, instrument.clone());
1310 });
1311
1312 if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
1314 self.instruments_by_coin.rcu(|m| {
1315 m.insert((coin, product_type), instrument.clone());
1316
1317 if let Some(alias_ustr) = cache_alias_for_symbol(full_symbol.as_str())
1342 .map(|alias| Ustr::from(alias.as_str()))
1343 {
1344 let key = (alias_ustr, product_type);
1345 if alias_ustr != coin && !m.contains_key(&key) {
1346 m.insert(key, instrument.clone());
1347 }
1348 }
1349 });
1350 } else {
1351 log::warn!("Unable to determine product type for symbol: {full_symbol}");
1352 }
1353 }
1354
1355 fn get_or_create_instrument(
1356 &self,
1357 coin: &Ustr,
1358 product_type: Option<HyperliquidProductType>,
1359 ) -> Option<InstrumentAny> {
1360 if let Some(pt) = product_type
1361 && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1362 {
1363 return Some(instrument.clone());
1364 }
1365
1366 if product_type.is_none() {
1370 let guard = self.instruments_by_coin.load();
1371
1372 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Outcome)) {
1373 return Some(instrument.clone());
1374 }
1375
1376 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1377 return Some(instrument.clone());
1378 }
1379
1380 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1381 return Some(instrument.clone());
1382 }
1383 }
1384
1385 if coin.as_str().starts_with('@')
1387 && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1388 {
1389 if let Some(instrument) = self.instruments.load().get(symbol) {
1392 return Some(instrument.clone());
1393 }
1394 }
1395
1396 if coin.as_str().starts_with("vntls:") {
1398 log::debug!("Creating synthetic instrument for vault token: {coin}");
1399
1400 let ts_event = self.clock.get_time_ns();
1401
1402 let symbol_str = format!("{coin}-USDC-SPOT");
1404 let symbol = Symbol::new(&symbol_str);
1405 let venue = *HYPERLIQUID_VENUE;
1406 let instrument_id = InstrumentId::new(symbol, venue);
1407
1408 let base_currency = Currency::new(
1410 coin.as_str(),
1411 8, 0, coin.as_str(),
1414 CurrencyType::Crypto,
1415 );
1416
1417 let quote_currency = Currency::new(
1418 "USDC",
1419 6, 0,
1421 "USDC",
1422 CurrencyType::Crypto,
1423 );
1424
1425 let price_increment = Price::from("0.00000001");
1426 let size_increment = Quantity::from("0.00000001");
1427
1428 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1429 instrument_id,
1430 symbol,
1431 base_currency,
1432 quote_currency,
1433 8, 8, price_increment,
1436 size_increment,
1437 None, None, None, None, None, None, None, None, None, None, None, None, None, None, ts_event,
1452 ts_event,
1453 ));
1454
1455 self.cache_instrument(&instrument);
1456
1457 Some(instrument)
1458 } else {
1459 log::warn!("Instrument not found in cache: {coin}");
1461 None
1462 }
1463 }
1464
1465 pub fn set_account_id(&mut self, account_id: AccountId) {
1469 self.account_id = Some(account_id);
1470 }
1471
1472 pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1474 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1475 let spot_meta = match self.inner.get_spot_meta().await {
1476 Ok(spot_meta) => Some(spot_meta),
1477 Err(e) => {
1478 log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1479 None
1480 }
1481 };
1482
1483 match self.inner.load_all_perp_metas().await {
1485 Ok(all_metas) => {
1486 for (dex_index, meta) in all_metas.iter().enumerate() {
1487 let base = perp_dex_asset_index_base(dex_index);
1488 let settlement_currency = match resolve_perp_settlement_currency(
1489 meta,
1490 spot_meta.as_ref(),
1491 ) {
1492 Ok(settlement_currency) => settlement_currency,
1493 Err(e) => {
1494 return Err(Error::decode(format!(
1495 "failed to resolve perp settlement currency for dex {dex_index}: {e}",
1496 )));
1497 }
1498 };
1499
1500 let perp_defs = parse_perp_instruments_with_settlement(
1501 meta,
1502 base,
1503 settlement_currency.as_str(),
1504 );
1505 log::debug!(
1506 "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1507 perp_defs.len(),
1508 );
1509 defs.extend(perp_defs);
1510 }
1511 }
1512 Err(e) => {
1513 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1514
1515 match self.inner.load_perp_meta().await {
1516 Ok(perp_meta) => {
1517 match resolve_perp_settlement_currency(&perp_meta, spot_meta.as_ref()) {
1518 Ok(settlement_currency) => {
1519 let perp_defs = parse_perp_instruments_with_settlement(
1520 &perp_meta,
1521 0,
1522 settlement_currency.as_str(),
1523 );
1524 log::debug!(
1525 "Loaded Hyperliquid perp defs via fallback: count={}",
1526 perp_defs.len(),
1527 );
1528 defs.extend(perp_defs);
1529 }
1530 Err(e) => {
1531 return Err(Error::decode(format!(
1532 "failed to resolve fallback perp settlement currency: {e}",
1533 )));
1534 }
1535 }
1536 }
1537 Err(e) => {
1538 log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1539 }
1540 }
1541 }
1542 }
1543
1544 if let Some(spot_meta) = spot_meta.as_ref() {
1545 match parse_spot_instruments(spot_meta) {
1546 Ok(spot_defs) => {
1547 log::debug!(
1548 "Loaded Hyperliquid spot definitions: count={}",
1549 spot_defs.len(),
1550 );
1551 defs.extend(spot_defs);
1552 }
1553 Err(e) => {
1554 log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1555 }
1556 }
1557 }
1558
1559 match self.inner.get_outcome_meta().await {
1563 Ok(outcome_meta) => match parse_outcome_instruments(&outcome_meta) {
1564 Ok(outcome_defs) => {
1565 log::debug!(
1566 "Loaded Hyperliquid outcome definitions: count={}",
1567 outcome_defs.len(),
1568 );
1569 defs.extend(outcome_defs);
1570 }
1571 Err(e) => {
1572 log::warn!("Failed to parse Hyperliquid outcome instruments: {e}");
1573 }
1574 },
1575 Err(e) => {
1576 log::debug!("Skipping Hyperliquid outcome metadata: {e}");
1577 }
1578 }
1579
1580 let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1587 let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1588 for def in defs {
1589 if seen_symbols.insert(def.symbol) {
1590 deduped.push(def);
1591 } else {
1592 log::warn!(
1593 "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1594 def.symbol,
1595 def.raw_symbol,
1596 );
1597 }
1598 }
1599 let defs = deduped;
1600
1601 self.asset_indices.rcu(|m| {
1603 for def in &defs {
1604 m.insert(def.symbol, def.asset_index);
1605 }
1606 });
1607 log::debug!(
1608 "Populated asset indices map (count={})",
1609 self.asset_indices.len()
1610 );
1611
1612 Ok(defs)
1613 }
1614
1615 pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1617 let ts_init = self.clock.get_time_ns();
1618 instruments_from_defs_owned(defs, ts_init)
1619 }
1620
1621 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1623 let defs = self.request_instrument_defs().await?;
1624 Ok(self.convert_defs(defs))
1625 }
1626
1627 pub async fn build_all_dex_asset_ctxs_instrument_ids(
1632 &self,
1633 ) -> Result<AHashMap<String, Vec<Option<InstrumentId>>>> {
1634 let all_metas = match self.inner.load_all_perp_metas().await {
1635 Ok(all_metas) => all_metas,
1636 Err(e) => {
1637 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1638 vec![self.inner.load_perp_meta().await?]
1639 }
1640 };
1641
1642 let perp_dexs = match self.inner.load_perp_dexs().await {
1643 Ok(dexs) => Some(dexs),
1644 Err(e) => {
1645 log::warn!("Failed to load perpDexs, inferring dex names from metadata: {e}");
1646 None
1647 }
1648 };
1649
1650 let raw_symbol_to_id =
1651 self.instruments
1652 .load()
1653 .values()
1654 .fold(AHashMap::new(), |mut acc, instrument| {
1655 acc.insert(instrument.raw_symbol().to_string(), instrument.id());
1656 acc
1657 });
1658
1659 let mut mapping = AHashMap::new();
1660
1661 for (dex_index, meta) in all_metas.iter().enumerate() {
1662 let dex_name = resolve_perp_dex_name(dex_index, meta, perp_dexs.as_deref());
1663 let mut instrument_ids = Vec::with_capacity(meta.universe.len());
1664
1665 for asset in &meta.universe {
1666 if let Some(instrument_id) = raw_symbol_to_id.get(&asset.name) {
1667 instrument_ids.push(Some(*instrument_id));
1668 } else {
1669 log::warn!(
1670 "Missing cached Hyperliquid instrument for dex='{}' raw_symbol='{}'",
1671 dex_name,
1672 asset.name
1673 );
1674 instrument_ids.push(None);
1675 }
1676 }
1677
1678 mapping.insert(dex_name, instrument_ids);
1679 }
1680
1681 Ok(mapping)
1682 }
1683
1684 pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1692 self.get_asset_index_for_symbol(Ustr::from(symbol))
1693 }
1694
1695 pub(crate) fn get_asset_index_for_symbol(&self, symbol: Ustr) -> Option<u32> {
1699 self.asset_indices.load().get(&symbol).copied()
1700 }
1701
1702 pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1704 self.get_price_precision_for_symbol(Ustr::from(symbol))
1705 }
1706
1707 pub(crate) fn get_price_precision_for_symbol(&self, symbol: Ustr) -> Option<u8> {
1709 self.instruments
1710 .load()
1711 .get(&symbol)
1712 .map(|inst| inst.price_precision())
1713 }
1714
1715 #[must_use]
1723 pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1724 const SPOT_INDEX_OFFSET: u32 = 10_000;
1725 const BUILDER_PERP_OFFSET: u32 = 100_000;
1726
1727 let guard = self.asset_indices.load();
1728
1729 let mut mapping = AHashMap::new();
1730
1731 for (symbol, &asset_index) in guard.iter() {
1732 if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1734 let pair_index = asset_index - SPOT_INDEX_OFFSET;
1735 let fill_coin = Ustr::from(&format!("@{pair_index}"));
1736 mapping.insert(fill_coin, *symbol);
1737 }
1738 }
1739
1740 self.spot_fill_coins.store(mapping.clone());
1742
1743 mapping
1744 }
1745
1746 #[allow(dead_code)]
1748 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1749 self.inner.load_perp_meta().await
1750 }
1751
1752 #[allow(dead_code)]
1754 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1755 self.inner.load_all_perp_metas().await
1756 }
1757
1758 #[allow(dead_code)]
1760 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1761 self.inner.get_spot_meta().await
1762 }
1763
1764 pub(crate) async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
1766 self.inner.get_outcome_meta().await
1767 }
1768
1769 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1771 self.inner.info_l2_book(coin).await
1772 }
1773
1774 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
1776 self.inner.info_recent_trades(coin).await
1777 }
1778
1779 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1781 self.inner.info_user_fills(user).await
1782 }
1783
1784 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1786 self.inner.info_order_status(user, oid).await
1787 }
1788
1789 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1791 self.inner.info_open_orders(user).await
1792 }
1793
1794 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1796 self.inner.info_frontend_open_orders(user).await
1797 }
1798
1799 async fn info_frontend_open_orders_for_dex(
1800 &self,
1801 user: &str,
1802 dex: Option<&str>,
1803 ) -> Result<Value> {
1804 self.inner
1805 .info_frontend_open_orders_for_dex(user, dex)
1806 .await
1807 }
1808
1809 pub async fn info_historical_orders(
1811 &self,
1812 user: &str,
1813 ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
1814 self.inner.info_historical_orders(user).await
1815 }
1816
1817 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1819 self.inner.info_clearinghouse_state(user).await
1820 }
1821
1822 async fn info_clearinghouse_state_for_dex(
1823 &self,
1824 user: &str,
1825 dex: Option<&str>,
1826 ) -> Result<Value> {
1827 self.inner.info_clearinghouse_state_for_dex(user, dex).await
1828 }
1829
1830 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
1832 self.inner.info_spot_clearinghouse_state(user).await
1833 }
1834
1835 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
1837 self.inner.info_user_fees(user).await
1838 }
1839
1840 pub async fn info_candle_snapshot(
1842 &self,
1843 coin: &str,
1844 interval: HyperliquidBarInterval,
1845 start_time: u64,
1846 end_time: u64,
1847 ) -> Result<HyperliquidCandleSnapshot> {
1848 self.inner
1849 .info_candle_snapshot(coin, interval, start_time, end_time)
1850 .await
1851 }
1852
1853 pub async fn info_funding_history(
1855 &self,
1856 coin: &str,
1857 start_time: u64,
1858 end_time: Option<u64>,
1859 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
1860 self.inner
1861 .info_funding_history(coin, start_time, end_time)
1862 .await
1863 }
1864
1865 pub async fn post_action(
1867 &self,
1868 action: &ExchangeAction,
1869 ) -> Result<HyperliquidExchangeResponse> {
1870 self.inner.post_action(action).await
1871 }
1872
1873 pub async fn post_action_exec(
1875 &self,
1876 action: &HyperliquidExecAction,
1877 ) -> Result<HyperliquidExchangeResponse> {
1878 self.inner.post_action_exec(action).await
1879 }
1880
1881 pub fn sign_action_exec_request(
1883 &self,
1884 action: &HyperliquidExecAction,
1885 expires_after: Option<u64>,
1886 ) -> Result<HyperliquidExchangeRequest<HyperliquidExecAction>> {
1887 self.inner.sign_action_exec_request(action, expires_after)
1888 }
1889
1890 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1892 self.inner.info_meta().await
1893 }
1894
1895 pub async fn cancel_order(
1905 &self,
1906 instrument_id: InstrumentId,
1907 client_order_id: Option<ClientOrderId>,
1908 venue_order_id: Option<VenueOrderId>,
1909 ) -> Result<()> {
1910 let symbol = instrument_id.symbol.inner();
1912 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1913 Error::bad_request(format!(
1914 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1915 ))
1916 })?;
1917
1918 let action = if let Some(client_order_id) = client_order_id {
1919 if let Some(cloid) = self.cached_client_order_id_cloid(&client_order_id) {
1920 HyperliquidExecAction::CancelByCloid {
1921 cancels: vec![HyperliquidExecCancelByCloidRequest {
1922 asset: asset_id,
1923 cloid,
1924 }],
1925 fast: None,
1926 }
1927 } else if let Some(oid) = venue_order_id {
1928 let oid_u64 = oid
1929 .as_str()
1930 .parse::<u64>()
1931 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1932 HyperliquidExecAction::Cancel {
1933 cancels: vec![HyperliquidExecCancelOrderRequest {
1934 asset: asset_id,
1935 oid: oid_u64,
1936 }],
1937 fast: None,
1938 }
1939 } else {
1940 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
1941 HyperliquidExecAction::CancelByCloid {
1942 cancels: vec![HyperliquidExecCancelByCloidRequest {
1943 asset: asset_id,
1944 cloid,
1945 }],
1946 fast: None,
1947 }
1948 }
1949 } else if let Some(oid) = venue_order_id {
1950 let oid_u64 = oid
1951 .as_str()
1952 .parse::<u64>()
1953 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1954 HyperliquidExecAction::Cancel {
1955 cancels: vec![HyperliquidExecCancelOrderRequest {
1956 asset: asset_id,
1957 oid: oid_u64,
1958 }],
1959 fast: None,
1960 }
1961 } else {
1962 return Err(Error::bad_request(
1963 "Either client_order_id or venue_order_id must be provided",
1964 ));
1965 };
1966
1967 let response = self.inner.post_action_exec(&action).await?;
1969
1970 match response {
1972 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
1973 HyperliquidExchangeResponse::Status {
1974 status,
1975 response: error_data,
1976 } => Err(Error::bad_request(format!(
1977 "Cancel order failed: status={status}, error={error_data}"
1978 ))),
1979 HyperliquidExchangeResponse::Error { error } => {
1980 Err(Error::bad_request(format!("Cancel order error: {error}")))
1981 }
1982 }
1983 }
1984
1985 #[expect(clippy::too_many_arguments)]
1995 pub async fn modify_order(
1996 &self,
1997 instrument_id: InstrumentId,
1998 venue_order_id: Option<VenueOrderId>,
1999 order_side: OrderSide,
2000 order_type: OrderType,
2001 price: Price,
2002 quantity: Quantity,
2003 trigger_price: Option<Price>,
2004 reduce_only: bool,
2005 post_only: bool,
2006 time_in_force: TimeInForce,
2007 client_order_id: Option<ClientOrderId>,
2008 ) -> Result<()> {
2009 let symbol = instrument_id.symbol.inner();
2010 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
2011 Error::bad_request(format!(
2012 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2013 ))
2014 })?;
2015
2016 let oid = match client_order_id
2017 .as_ref()
2018 .and_then(|id| self.unique_cached_client_order_id_cloid(id))
2019 {
2020 Some(cloid) => HyperliquidExecModifyTarget::Cloid(cloid),
2021 None => {
2022 let Some(venue_order_id) = venue_order_id.as_ref() else {
2023 return Err(Error::bad_request(
2024 "venue_order_id or unique cached CLOID is required for modify",
2025 ));
2026 };
2027 HyperliquidExecModifyTarget::from_venue_order_id(venue_order_id)
2028 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?
2029 }
2030 };
2031
2032 let is_buy = matches!(order_side, OrderSide::Buy);
2033 let decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
2034
2035 let normalized_price = if self.normalize_prices {
2036 normalize_price(price.as_decimal(), decimals).normalize()
2037 } else {
2038 price.as_decimal().normalize()
2039 };
2040
2041 let size = quantity.as_decimal().normalize();
2042
2043 let kind = match order_type {
2044 OrderType::Market => HyperliquidExecOrderKind::Limit {
2045 limit: HyperliquidExecLimitParams {
2046 tif: HyperliquidExecTif::Ioc,
2047 },
2048 },
2049 OrderType::Limit => {
2050 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
2051 .map_err(|e| Error::bad_request(format!("{e}")))?;
2052 HyperliquidExecOrderKind::Limit {
2053 limit: HyperliquidExecLimitParams { tif },
2054 }
2055 }
2056 OrderType::StopMarket
2057 | OrderType::StopLimit
2058 | OrderType::MarketIfTouched
2059 | OrderType::LimitIfTouched => {
2060 if let Some(trig_px) = trigger_price {
2061 let trigger_price_decimal = if self.normalize_prices {
2062 normalize_price(trig_px.as_decimal(), decimals).normalize()
2063 } else {
2064 trig_px.as_decimal().normalize()
2065 };
2066 let tpsl = match order_type {
2067 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
2068 _ => HyperliquidExecTpSl::Tp,
2069 };
2070 let is_market = matches!(
2071 order_type,
2072 OrderType::StopMarket | OrderType::MarketIfTouched
2073 );
2074 HyperliquidExecOrderKind::Trigger {
2075 trigger: HyperliquidExecTriggerParams {
2076 is_market,
2077 trigger_px: trigger_price_decimal,
2078 tpsl,
2079 },
2080 }
2081 } else {
2082 return Err(Error::bad_request("Trigger orders require a trigger price"));
2083 }
2084 }
2085 _ => {
2086 return Err(Error::bad_request(format!(
2087 "Order type {order_type:?} not supported for modify"
2088 )));
2089 }
2090 };
2091 let cloid = client_order_id.map(|id| self.get_or_generate_client_order_id_cloid(id));
2092
2093 let order = HyperliquidExecPlaceOrderRequest {
2094 asset: asset_id,
2095 is_buy,
2096 price: normalized_price,
2097 size,
2098 reduce_only,
2099 kind,
2100 cloid,
2101 };
2102
2103 let action = HyperliquidExecAction::Modify {
2104 modify: HyperliquidExecModifyOrderRequest { oid, order },
2105 };
2106
2107 let response = self.inner.post_action_exec(&action).await?;
2108
2109 match response {
2110 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
2111 if let Some(inner_error) = extract_inner_error(&response) {
2112 Err(Error::bad_request(format!(
2113 "Modify order rejected: {inner_error}",
2114 )))
2115 } else {
2116 Ok(())
2117 }
2118 }
2119 HyperliquidExchangeResponse::Status {
2120 status,
2121 response: error_data,
2122 } => Err(Error::bad_request(format!(
2123 "Modify order failed: status={status}, error={error_data}"
2124 ))),
2125 HyperliquidExchangeResponse::Error { error } => {
2126 Err(Error::bad_request(format!("Modify order error: {error}")))
2127 }
2128 }
2129 }
2130
2131 pub async fn submit_split_outcome(
2145 &self,
2146 outcome: u32,
2147 amount: Decimal,
2148 ) -> Result<HyperliquidExchangeResponse> {
2149 let action = HyperliquidExecAction::UserOutcome {
2150 op: HyperliquidExecUserOutcomeOp::SplitOutcome(HyperliquidExecSplitOutcomeParams {
2151 outcome,
2152 amount,
2153 }),
2154 };
2155 self.inner.post_action_exec(&action).await
2156 }
2157
2158 pub async fn submit_merge_outcome(
2169 &self,
2170 outcome: u32,
2171 amount: Option<Decimal>,
2172 ) -> Result<HyperliquidExchangeResponse> {
2173 let action = HyperliquidExecAction::UserOutcome {
2174 op: HyperliquidExecUserOutcomeOp::MergeOutcome(HyperliquidExecMergeOutcomeParams {
2175 outcome,
2176 amount,
2177 }),
2178 };
2179 self.inner.post_action_exec(&action).await
2180 }
2181
2182 pub async fn submit_merge_question(
2192 &self,
2193 question: u32,
2194 amount: Option<Decimal>,
2195 ) -> Result<HyperliquidExchangeResponse> {
2196 let action = HyperliquidExecAction::UserOutcome {
2197 op: HyperliquidExecUserOutcomeOp::MergeQuestion(HyperliquidExecMergeQuestionParams {
2198 question,
2199 amount,
2200 }),
2201 };
2202 self.inner.post_action_exec(&action).await
2203 }
2204
2205 pub async fn submit_negate_outcome(
2215 &self,
2216 question: u32,
2217 outcome: u32,
2218 amount: Decimal,
2219 ) -> Result<HyperliquidExchangeResponse> {
2220 let action = HyperliquidExecAction::UserOutcome {
2221 op: HyperliquidExecUserOutcomeOp::NegateOutcome(HyperliquidExecNegateOutcomeParams {
2222 question,
2223 outcome,
2224 amount,
2225 }),
2226 };
2227 self.inner.post_action_exec(&action).await
2228 }
2229
2230 pub async fn request_order_status_reports(
2243 &self,
2244 user: &str,
2245 instrument_id: Option<InstrumentId>,
2246 ) -> Result<Vec<OrderStatusReport>> {
2247 let account_id = self
2248 .account_id
2249 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2250 let mut reports = Vec::new();
2251 let ts_init = self.clock.get_time_ns();
2252
2253 for dex in self.reconciliation_dexes(instrument_id) {
2254 let response = self
2255 .info_frontend_open_orders_for_dex(user, dex.as_deref())
2256 .await?;
2257 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
2258 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
2259
2260 for order_value in orders {
2261 let order: WsBasicOrderData = match serde_json::from_value(order_value) {
2262 Ok(order) => order,
2263 Err(e) => {
2264 log::warn!("Failed to parse order: {e}");
2265 continue;
2266 }
2267 };
2268
2269 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2270 Some(instrument) => instrument,
2271 None => continue,
2272 };
2273
2274 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2275 continue;
2276 }
2277
2278 match parse_order_status_report_from_basic(
2279 &order,
2280 &HyperliquidOrderStatusEnum::Open,
2281 &instrument,
2282 account_id,
2283 ts_init,
2284 ) {
2285 Ok(report) => reports.push(report),
2286 Err(e) => log::error!("Failed to parse order status report: {e}"),
2287 }
2288 }
2289 }
2290
2291 Ok(reports)
2292 }
2293
2294 pub async fn request_historical_order_status_reports(
2300 &self,
2301 user: &str,
2302 instrument_id: Option<InstrumentId>,
2303 ) -> Result<Vec<OrderStatusReport>> {
2304 let account_id = self
2305 .account_id
2306 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2307 let entries = self.info_historical_orders(user).await?;
2308 let mut reports = Vec::new();
2309 let ts_init = self.clock.get_time_ns();
2310
2311 for entry in entries {
2312 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2313 Some(instrument) => instrument,
2314 None => continue,
2315 };
2316
2317 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2318 continue;
2319 }
2320
2321 let order_type = entry.order.order_type.as_deref().unwrap_or_default();
2322 let tpsl = if order_type.starts_with("Take Profit") {
2323 Some(crate::common::enums::HyperliquidTpSl::Tp)
2324 } else if order_type.starts_with("Stop") {
2325 Some(crate::common::enums::HyperliquidTpSl::Sl)
2326 } else {
2327 None
2328 };
2329 let is_market = entry
2330 .order
2331 .order_type
2332 .as_deref()
2333 .is_some_and(|label| label.ends_with("Market"));
2334 let historical_order_type = match tpsl.as_ref() {
2335 Some(tpsl) => parse_trigger_order_type(is_market, tpsl),
2336 None if is_market => OrderType::Market,
2337 None => OrderType::Limit,
2338 };
2339 let order = WsBasicOrderData {
2340 coin: entry.order.coin,
2341 side: entry.order.side,
2342 limit_px: entry.order.limit_px,
2343 sz: entry.order.sz,
2344 oid: entry.order.oid,
2345 timestamp: entry.order.timestamp,
2346 orig_sz: entry.order.orig_sz,
2347 cloid: entry.order.cloid,
2348 tif: entry.order.tif,
2349 reduce_only: entry.order.reduce_only,
2350 trigger_px: entry
2351 .order
2352 .trigger_px
2353 .filter(|price| *price != Decimal::ZERO),
2354 is_market: tpsl.is_some().then_some(is_market),
2355 tpsl,
2356 trigger_activated: None,
2357 trailing_stop: None,
2358 };
2359
2360 match parse_order_status_report_from_basic(
2361 &order,
2362 &entry.status,
2363 &instrument,
2364 account_id,
2365 ts_init,
2366 ) {
2367 Ok(mut report) => {
2368 report.order_type = historical_order_type;
2369 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2370 reports.push(report);
2371 }
2372 Err(e) => log::error!("Failed to parse historical order status report: {e}"),
2373 }
2374 }
2375
2376 Ok(deduplicate_historical_order_reports(reports))
2377 }
2378
2379 pub async fn request_order_status_report(
2389 &self,
2390 user: &str,
2391 oid: u64,
2392 ) -> Result<Option<OrderStatusReport>> {
2393 let account_id = self
2394 .account_id
2395 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2396
2397 let ts_init = self.clock.get_time_ns();
2398
2399 let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
2404 Ok(response) => match serde_json::from_value(response) {
2405 Ok(v) => v,
2406 Err(e) => {
2407 log::warn!("Failed to parse frontend open orders response: {e}");
2408 Vec::new()
2409 }
2410 },
2411 Err(e) => {
2412 log::warn!(
2413 "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
2414 );
2415 Vec::new()
2416 }
2417 };
2418
2419 if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
2420 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2421 Some(inst) => inst,
2422 None => return Ok(None),
2423 };
2424
2425 let status = if order.trigger_activated == Some(true) {
2426 HyperliquidOrderStatusEnum::Triggered
2427 } else {
2428 HyperliquidOrderStatusEnum::Open
2429 };
2430
2431 return match parse_order_status_report_from_basic(
2432 &order,
2433 &status,
2434 &instrument,
2435 account_id,
2436 ts_init,
2437 ) {
2438 Ok(report) => Ok(Some(report)),
2439 Err(e) => {
2440 log::error!("Failed to parse order status report for oid {oid}: {e}");
2441 Ok(None)
2442 }
2443 };
2444 }
2445
2446 let response = self.info_order_status(user, oid).await?;
2448 let entry = match response.into_order() {
2449 Some(e) => e,
2450 None => return Ok(None),
2451 };
2452
2453 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2454 Some(inst) => inst,
2455 None => return Ok(None),
2456 };
2457
2458 let basic = WsBasicOrderData {
2463 coin: entry.order.coin,
2464 side: entry.order.side,
2465 limit_px: entry.order.limit_px,
2466 sz: entry.order.sz,
2467 oid: entry.order.oid,
2468 timestamp: entry.order.timestamp,
2469 orig_sz: entry.order.orig_sz,
2470 cloid: entry.order.cloid,
2471 tif: None,
2472 reduce_only: None,
2473 trigger_px: None,
2474 is_market: None,
2475 tpsl: None,
2476 trigger_activated: None,
2477 trailing_stop: None,
2478 };
2479
2480 match parse_order_status_report_from_basic(
2481 &basic,
2482 &entry.status,
2483 &instrument,
2484 account_id,
2485 ts_init,
2486 ) {
2487 Ok(mut report) => {
2488 if entry.status_timestamp > 0 {
2491 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2492 }
2493 Ok(Some(report))
2494 }
2495 Err(e) => {
2496 log::error!("Failed to parse order status report for oid {oid}: {e}");
2497 Ok(None)
2498 }
2499 }
2500 }
2501
2502 pub async fn request_order_status_report_by_client_order_id(
2511 &self,
2512 user: &str,
2513 client_order_id: &ClientOrderId,
2514 ) -> Result<Option<OrderStatusReport>> {
2515 let account_id = self
2516 .account_id
2517 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2518
2519 let ts_init = self.clock.get_time_ns();
2520
2521 let cached_cloid_hex = self
2522 .cached_client_order_id_cloid(client_order_id)
2523 .map(|cloid| cloid.to_hex());
2524 let cloid = Cloid::from_client_order_id(*client_order_id);
2525 let cloid_hex = cloid.to_hex();
2526
2527 let response = self.info_frontend_open_orders(user).await?;
2528 let orders: Vec<WsBasicOrderData> = match serde_json::from_value(response) {
2529 Ok(v) => v,
2530 Err(e) => {
2531 log::warn!("Failed to parse frontend open orders response: {e}");
2532 return Ok(None);
2533 }
2534 };
2535
2536 let order = match orders.into_iter().find(|o| {
2537 o.cloid
2538 .as_ref()
2539 .is_some_and(|c| cached_cloid_hex.as_ref() == Some(c) || c == &cloid_hex)
2540 }) {
2541 Some(o) => o,
2542 None => return Ok(None),
2543 };
2544
2545 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2546 Some(inst) => inst,
2547 None => return Ok(None),
2548 };
2549
2550 let status = if order.trigger_activated == Some(true) {
2551 HyperliquidOrderStatusEnum::Triggered
2552 } else {
2553 HyperliquidOrderStatusEnum::Open
2554 };
2555
2556 match parse_order_status_report_from_basic(
2557 &order,
2558 &status,
2559 &instrument,
2560 account_id,
2561 ts_init,
2562 ) {
2563 Ok(mut report) => {
2564 report.client_order_id = Some(*client_order_id);
2565 Ok(Some(report))
2566 }
2567 Err(e) => {
2568 log::error!("Failed to parse order status report for cloid {cloid_hex}: {e}");
2569 Ok(None)
2570 }
2571 }
2572 }
2573
2574 pub async fn request_fill_reports(
2588 &self,
2589 user: &str,
2590 instrument_id: Option<InstrumentId>,
2591 ) -> Result<Vec<FillReport>> {
2592 let account_id = self
2593 .account_id
2594 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2595 let fills_response = self.info_user_fills(user).await?;
2596
2597 let mut reports = Vec::new();
2598 let ts_init = self.clock.get_time_ns();
2599
2600 for fill in fills_response {
2601 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
2603 Some(inst) => inst,
2604 None => continue, };
2606
2607 if let Some(filter_id) = instrument_id
2609 && instrument.id() != filter_id
2610 {
2611 continue;
2612 }
2613
2614 match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2616 Ok(report) => reports.push(report),
2617 Err(e) => log::error!("Failed to parse fill report: {e}"),
2618 }
2619 }
2620
2621 Ok(reports)
2622 }
2623
2624 pub async fn request_position_status_reports(
2648 &self,
2649 user: &str,
2650 instrument_id: Option<InstrumentId>,
2651 ) -> Result<Vec<PositionStatusReport>> {
2652 let account_id = self
2653 .account_id
2654 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2655
2656 let filter_product = instrument_id
2657 .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
2658
2659 let fetch_perp = !matches!(
2660 filter_product,
2661 Some(HyperliquidProductType::Spot | HyperliquidProductType::Outcome)
2662 );
2663 let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
2664
2665 let mut reports = Vec::new();
2666 let ts_init = self.clock.get_time_ns();
2667
2668 if !fetch_perp {
2669 let spot_reports = self
2670 .request_spot_position_status_reports(user, instrument_id)
2671 .await?;
2672 reports.extend(spot_reports);
2673 return Ok(reports);
2674 }
2675
2676 for dex in self.reconciliation_dexes(instrument_id) {
2677 let state_response = self
2678 .info_clearinghouse_state_for_dex(user, dex.as_deref())
2679 .await?;
2680 let asset_positions: Vec<serde_json::Value> = state_response
2681 .get("assetPositions")
2682 .and_then(|value| value.as_array())
2683 .ok_or_else(|| {
2684 Error::bad_request("assetPositions not found in clearinghouse state")
2685 })?
2686 .clone();
2687
2688 for position_value in asset_positions {
2689 let coin = position_value
2690 .get("position")
2691 .and_then(|position| position.get("coin"))
2692 .and_then(|coin| coin.as_str())
2693 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
2694
2695 let instrument = match self.get_or_create_instrument(&Ustr::from(coin), None) {
2696 Some(instrument) => instrument,
2697 None => continue,
2698 };
2699
2700 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2701 continue;
2702 }
2703
2704 match parse_position_status_report(
2705 &position_value,
2706 &instrument,
2707 account_id,
2708 ts_init,
2709 ) {
2710 Ok(report) => reports.push(report),
2711 Err(e) => log::error!("Failed to parse position status report: {e}"),
2712 }
2713 }
2714 }
2715
2716 if fetch_spot {
2719 let spot_reports = self
2720 .request_spot_position_status_reports(user, instrument_id)
2721 .await?;
2722 reports.extend(spot_reports);
2723 }
2724
2725 Ok(reports)
2726 }
2727
2728 pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
2742 let account_id = self
2743 .account_id
2744 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2745 let state_response = self.info_clearinghouse_state(user).await?;
2746 let ts_init = self.clock.get_time_ns();
2747
2748 log::trace!("Clearinghouse state response: {state_response}");
2749
2750 let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
2751 .map_err(|e| {
2752 log::error!("Failed to parse clearinghouse state: {e}");
2753 log::debug!("Raw response: {state_response}");
2754 Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
2755 })?;
2756
2757 let spot_response = self.info_spot_clearinghouse_state(user).await?;
2760 let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
2761 .map_err(|e| {
2762 log::error!("Failed to parse spot clearinghouse state: {e}");
2763 log::debug!("Raw spot response: {spot_response}");
2764 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2765 })?;
2766
2767 let (balances, margins) =
2768 parse_combined_account_balances_and_margins(&perp_state, &spot_state)
2769 .map_err(|e| Error::decode(e.to_string()))?;
2770
2771 Ok(AccountState::new(
2772 account_id,
2773 AccountType::Margin,
2774 balances,
2775 margins,
2776 true, UUID4::new(),
2778 ts_init,
2779 ts_init,
2780 None,
2781 ))
2782 }
2783
2784 pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
2795 let response = self.info_spot_clearinghouse_state(user).await?;
2796
2797 log::trace!("Spot clearinghouse state response: {response}");
2798
2799 let state: SpotClearinghouseState =
2800 serde_json::from_value(response.clone()).map_err(|e| {
2801 log::error!("Failed to parse spot clearinghouse state: {e}");
2802 log::debug!("Raw response: {response}");
2803 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2804 })?;
2805
2806 parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
2807 }
2808
2809 pub async fn request_spot_position_status_reports(
2824 &self,
2825 user: &str,
2826 instrument_id: Option<InstrumentId>,
2827 ) -> Result<Vec<PositionStatusReport>> {
2828 let account_id = self
2829 .account_id
2830 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2831 let response = self.info_spot_clearinghouse_state(user).await?;
2832
2833 let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
2834 log::error!("Failed to parse spot clearinghouse state: {e}");
2835 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2836 })?;
2837
2838 let ts_init = self.clock.get_time_ns();
2839 let mut reports = Vec::with_capacity(state.balances.len());
2840
2841 for balance in &state.balances {
2842 if balance.total.is_zero() {
2843 continue;
2844 }
2845
2846 if balance.coin.as_str() == "USDC" {
2851 continue;
2852 }
2853
2854 let product_type = match HyperliquidProductType::from_symbol(balance.coin.as_str()) {
2855 Ok(HyperliquidProductType::Outcome) => HyperliquidProductType::Outcome,
2856 _ => HyperliquidProductType::Spot,
2857 };
2858
2859 let instrument = match self.get_or_create_instrument(&balance.coin, Some(product_type))
2860 {
2861 Some(inst) => inst,
2862 None => continue,
2863 };
2864
2865 if let Some(filter_id) = instrument_id
2866 && instrument.id() != filter_id
2867 {
2868 continue;
2869 }
2870
2871 match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
2872 Ok(report) => reports.push(report),
2873 Err(e) => log::error!(
2874 "Failed to parse spot position status report for {}: {e}",
2875 balance.coin,
2876 ),
2877 }
2878 }
2879
2880 Ok(reports)
2881 }
2882
2883 pub async fn request_bars(
2900 &self,
2901 bar_type: BarType,
2902 start: Option<jiff::Timestamp>,
2903 end: Option<jiff::Timestamp>,
2904 limit: Option<u32>,
2905 ) -> Result<Vec<Bar>> {
2906 let instrument_id = bar_type.instrument_id();
2907 let symbol = instrument_id.symbol;
2908
2909 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
2910
2911 let alias = cache_alias_for_symbol(symbol.as_str())
2915 .map(|alias| Ustr::from(alias.as_str()))
2916 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
2917
2918 let instrument = self
2919 .get_or_create_instrument(&alias, product_type)
2920 .ok_or_else(|| {
2921 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
2922 })?;
2923
2924 let coin = instrument.raw_symbol().inner();
2929
2930 let price_precision = instrument.price_precision();
2931 let size_precision = instrument.size_precision();
2932
2933 let interval =
2934 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
2935
2936 let now = jiff::Timestamp::now();
2938 let end_time = end.unwrap_or(now).as_millisecond() as u64;
2939 let start_time = if let Some(start) = start {
2940 start.as_millisecond() as u64
2941 } else {
2942 let spec = bar_type.spec();
2944 let step_ms = match spec.aggregation {
2945 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
2946 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
2947 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
2948 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
2949 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
2950 _ => 60_000,
2951 };
2952 end_time.saturating_sub(1000 * step_ms)
2953 };
2954
2955 let candles = self
2956 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
2957 .await?;
2958
2959 let now_ms = now.as_millisecond() as u64;
2961
2962 let mut bars: Vec<Bar> = candles
2963 .iter()
2964 .filter(|candle| candle.end_timestamp < now_ms)
2965 .enumerate()
2966 .filter_map(|(i, candle)| {
2967 candle_to_bar(candle, bar_type, price_precision, size_precision)
2968 .map_err(|e| {
2969 log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
2970 e
2971 })
2972 .ok()
2973 })
2974 .collect();
2975
2976 if let Some(limit) = limit
2978 && limit > 0
2979 && bars.len() > limit as usize
2980 {
2981 bars.truncate(limit as usize);
2982 }
2983
2984 log::debug!(
2985 "Received {} bars for {} (filtered {} incomplete)",
2986 bars.len(),
2987 bar_type,
2988 candles.len() - bars.len()
2989 );
2990 Ok(bars)
2991 }
2992
2993 pub async fn request_public_trades(
3002 &self,
3003 instrument_id: InstrumentId,
3004 start: Option<jiff::Timestamp>,
3005 end: Option<jiff::Timestamp>,
3006 limit: Option<usize>,
3007 ) -> Result<Vec<HyperliquidPublicTrade>> {
3008 let symbol = instrument_id.symbol;
3009 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
3010 let alias = cache_alias_for_symbol(symbol.as_str())
3011 .map(|alias| Ustr::from(alias.as_str()))
3012 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
3013 let instrument = self
3014 .get_or_create_instrument(&alias, product_type)
3015 .ok_or_else(|| {
3016 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3017 })?;
3018
3019 let raw_trades = match self
3020 .info_recent_trades(instrument.raw_symbol().as_ref())
3021 .await
3022 {
3023 Ok(trades) => trades,
3024 Err(e) if e.is_unprocessable_entity() => {
3025 log::warn!(
3026 "Recent public trades endpoint unavailable for {instrument_id} \
3027 (requires the Hyperliquid indexer); returning empty response"
3028 );
3029 Vec::new()
3030 }
3031 Err(e) => return Err(e),
3032 };
3033
3034 let mut trades: Vec<HyperliquidPublicTrade> = raw_trades
3035 .iter()
3036 .filter_map(|raw| match parse_recent_public_trade(raw, &instrument) {
3037 Ok(trade) => Some(trade),
3038 Err(e) => {
3039 log::warn!("Skipping recent public trade for {instrument_id}: {e}");
3040 None
3041 }
3042 })
3043 .collect();
3044 trades.sort_by_key(|trade| trade.ts_event);
3045
3046 Ok(filter_recent_public_trades(
3047 trades,
3048 datetime_to_unix_nanos(start),
3049 datetime_to_unix_nanos(end),
3050 limit.filter(|limit| *limit > 0),
3051 instrument_id,
3052 ))
3053 }
3054
3055 #[expect(clippy::too_many_arguments)]
3062 pub async fn submit_order(
3063 &self,
3064 instrument_id: InstrumentId,
3065 client_order_id: ClientOrderId,
3066 order_side: OrderSide,
3067 order_type: OrderType,
3068 quantity: Quantity,
3069 time_in_force: TimeInForce,
3070 price: Option<Price>,
3071 trigger_price: Option<Price>,
3072 post_only: bool,
3073 reduce_only: bool,
3074 ) -> Result<OrderStatusReport> {
3075 let symbol = instrument_id.symbol.inner();
3076 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3077 Error::bad_request(format!(
3078 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3079 ))
3080 })?;
3081
3082 let is_buy = matches!(order_side, OrderSide::Buy);
3083 let price_precision = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
3084
3085 let price_decimal = match price {
3086 Some(px) if self.normalize_prices => {
3087 normalize_price(px.as_decimal(), price_precision).normalize()
3088 }
3089 Some(px) => px.as_decimal().normalize(),
3090 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
3091 None if matches!(
3092 order_type,
3093 OrderType::StopMarket | OrderType::MarketIfTouched
3094 ) =>
3095 {
3096 match trigger_price {
3097 Some(tp) => {
3098 let derived = derive_limit_from_trigger(
3099 tp.as_decimal().normalize(),
3100 is_buy,
3101 self.market_order_slippage_bps,
3102 );
3103 let sig_rounded = round_to_sig_figs(derived, 5);
3104 clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
3105 }
3106 None => Decimal::ZERO,
3107 }
3108 }
3109 None => return Err(Error::bad_request("Limit orders require a price")),
3110 };
3111
3112 let size_decimal = quantity.as_decimal().normalize();
3113
3114 let kind = match order_type {
3115 OrderType::Market => HyperliquidExecOrderKind::Limit {
3116 limit: HyperliquidExecLimitParams {
3117 tif: HyperliquidExecTif::Ioc,
3118 },
3119 },
3120 OrderType::Limit => {
3121 let tif = if post_only {
3122 HyperliquidExecTif::Alo
3123 } else {
3124 match time_in_force {
3125 TimeInForce::Gtc => HyperliquidExecTif::Gtc,
3126 TimeInForce::Ioc => HyperliquidExecTif::Ioc,
3127 TimeInForce::Fok
3128 | TimeInForce::Day
3129 | TimeInForce::Gtd
3130 | TimeInForce::AtTheOpen
3131 | TimeInForce::AtTheClose => {
3132 return Err(Error::bad_request(format!(
3133 "Time in force {time_in_force:?} not supported"
3134 )));
3135 }
3136 }
3137 };
3138 HyperliquidExecOrderKind::Limit {
3139 limit: HyperliquidExecLimitParams { tif },
3140 }
3141 }
3142 OrderType::StopMarket
3143 | OrderType::StopLimit
3144 | OrderType::MarketIfTouched
3145 | OrderType::LimitIfTouched => {
3146 if let Some(trig_px) = trigger_price {
3147 let trigger_price_decimal = if self.normalize_prices {
3148 normalize_price(trig_px.as_decimal(), price_precision).normalize()
3149 } else {
3150 trig_px.as_decimal().normalize()
3151 };
3152
3153 let tpsl = match order_type {
3157 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
3158 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
3159 HyperliquidExecTpSl::Tp
3160 }
3161 _ => unreachable!(),
3162 };
3163
3164 let is_market = matches!(
3165 order_type,
3166 OrderType::StopMarket | OrderType::MarketIfTouched
3167 );
3168
3169 HyperliquidExecOrderKind::Trigger {
3170 trigger: HyperliquidExecTriggerParams {
3171 is_market,
3172 trigger_px: trigger_price_decimal,
3173 tpsl,
3174 },
3175 }
3176 } else {
3177 return Err(Error::bad_request("Trigger orders require a trigger price"));
3178 }
3179 }
3180 _ => {
3181 return Err(Error::bad_request(format!(
3182 "Order type {order_type:?} not supported"
3183 )));
3184 }
3185 };
3186
3187 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
3188 let hyperliquid_order = HyperliquidExecPlaceOrderRequest {
3189 asset,
3190 is_buy,
3191 price: price_decimal,
3192 size: size_decimal,
3193 reduce_only,
3194 kind,
3195 cloid: Some(cloid),
3196 };
3197
3198 let builder = self.builder_attribution();
3199
3200 let action = HyperliquidExecAction::Order {
3201 orders: vec![hyperliquid_order],
3202 grouping: HyperliquidExecGrouping::Na,
3203 builder,
3204 };
3205
3206 let response = self.inner.post_action_exec(&action).await?;
3207
3208 self.build_submit_order_report(
3211 instrument_id,
3212 client_order_id,
3213 order_side,
3214 order_type,
3215 quantity,
3216 time_in_force,
3217 price,
3218 trigger_price,
3219 response,
3220 )?
3221 .ok_or_else(|| {
3222 Error::bad_request(
3223 "Single-order submission returned no actionable status (deferred trigger child)",
3224 )
3225 })
3226 }
3227
3228 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
3232 self.submit_order(
3233 order.instrument_id(),
3234 order.client_order_id(),
3235 order.order_side(),
3236 order.order_type(),
3237 order.quantity(),
3238 order.time_in_force(),
3239 order.price(),
3240 order.trigger_price(),
3241 order.is_post_only(),
3242 order.is_reduce_only(),
3243 )
3244 .await
3245 }
3246
3247 #[expect(clippy::too_many_arguments)]
3248 fn create_order_status_report(
3249 &self,
3250 instrument_id: InstrumentId,
3251 client_order_id: Option<ClientOrderId>,
3252 venue_order_id: VenueOrderId,
3253 order_side: OrderSide,
3254 order_type: OrderType,
3255 quantity: Quantity,
3256 time_in_force: TimeInForce,
3257 price: Option<Price>,
3258 trigger_price: Option<Price>,
3259 order_status: OrderStatus,
3260 filled_qty: Quantity,
3261 _instrument: &InstrumentAny,
3262 account_id: AccountId,
3263 ts_init: UnixNanos,
3264 ) -> OrderStatusReport {
3265 let ts_accepted = self.clock.get_time_ns();
3266 let ts_last = ts_accepted;
3267 let report_id = UUID4::new();
3268
3269 let mut report = OrderStatusReport::new(
3270 account_id,
3271 instrument_id,
3272 client_order_id,
3273 venue_order_id,
3274 order_side,
3275 order_type,
3276 time_in_force,
3277 order_status,
3278 quantity,
3279 filled_qty,
3280 ts_accepted,
3281 ts_last,
3282 ts_init,
3283 Some(report_id),
3284 );
3285
3286 if let Some(px) = price {
3287 report = report.with_price(px);
3288 }
3289
3290 if let Some(trig_px) = trigger_price {
3291 report = report
3292 .with_trigger_price(trig_px)
3293 .with_trigger_type(TriggerType::Default);
3294 }
3295
3296 report
3297 }
3298
3299 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
3306 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
3308 let mut client_order_ids = Vec::with_capacity(orders.len());
3309
3310 for order in orders {
3311 let instrument_id = order.instrument_id();
3312 let symbol = instrument_id.symbol.inner();
3313 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3314 Error::bad_request(format!(
3315 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3316 ))
3317 })?;
3318 let price_decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
3319 let request = order_to_hyperliquid_request_with_asset_and_cloid(
3320 order,
3321 asset,
3322 price_decimals,
3323 self.normalize_prices,
3324 self.market_order_slippage_bps,
3325 None,
3326 )
3327 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
3328 client_order_ids.push(order.client_order_id());
3329 hyperliquid_orders.push(request);
3330 }
3331
3332 for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
3333 request.cloid = Some(self.get_or_generate_client_order_id_cloid(client_order_id));
3334 }
3335
3336 let builder = self.builder_attribution();
3337
3338 let grouping =
3339 determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
3340
3341 let action = HyperliquidExecAction::Order {
3342 orders: hyperliquid_orders,
3343 grouping,
3344 builder,
3345 };
3346
3347 let response = self.inner.post_action_exec(&action).await?;
3349
3350 self.build_submit_orders_reports(orders, grouping, response)
3351 }
3352
3353 #[expect(clippy::too_many_arguments)]
3369 pub fn build_submit_order_report(
3370 &self,
3371 instrument_id: InstrumentId,
3372 client_order_id: ClientOrderId,
3373 order_side: OrderSide,
3374 order_type: OrderType,
3375 quantity: Quantity,
3376 time_in_force: TimeInForce,
3377 price: Option<Price>,
3378 trigger_price: Option<Price>,
3379 response: HyperliquidExchangeResponse,
3380 ) -> Result<Option<OrderStatusReport>> {
3381 let order_response = parse_order_response(response)?;
3382
3383 let Some(order_status) = order_response.statuses.first() else {
3384 return Ok(None);
3385 };
3386
3387 let account_id = self
3388 .account_id
3389 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3390 let ts_init = self.clock.get_time_ns();
3391
3392 self.build_status_report(
3393 instrument_id,
3394 client_order_id,
3395 order_side,
3396 order_type,
3397 quantity,
3398 time_in_force,
3399 price,
3400 trigger_price,
3401 order_status,
3402 account_id,
3403 ts_init,
3404 )
3405 }
3406
3407 pub fn build_submit_orders_reports(
3422 &self,
3423 orders: &[&OrderAny],
3424 grouping: HyperliquidExecGrouping,
3425 response: HyperliquidExchangeResponse,
3426 ) -> Result<Vec<OrderStatusReport>> {
3427 let order_response = parse_order_response(response)?;
3428
3429 let account_id = self
3430 .account_id
3431 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3432 let ts_init = self.clock.get_time_ns();
3433
3434 if grouping == HyperliquidExecGrouping::Na && order_response.statuses.len() != orders.len()
3438 {
3439 return Err(Error::bad_request(format!(
3440 "Mismatch between submitted orders ({}) and response statuses ({})",
3441 orders.len(),
3442 order_response.statuses.len()
3443 )));
3444 }
3445
3446 let mut reports = Vec::with_capacity(order_response.statuses.len());
3449 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
3450 if let Some(report) = self.build_status_report(
3451 order.instrument_id(),
3452 order.client_order_id(),
3453 order.order_side(),
3454 order.order_type(),
3455 order.quantity(),
3456 order.time_in_force(),
3457 order.price(),
3458 order.trigger_price(),
3459 order_status,
3460 account_id,
3461 ts_init,
3462 )? {
3463 reports.push(report);
3464 }
3465 }
3466
3467 Ok(reports)
3468 }
3469
3470 #[expect(clippy::too_many_arguments)]
3478 fn build_status_report(
3479 &self,
3480 instrument_id: InstrumentId,
3481 client_order_id: ClientOrderId,
3482 order_side: OrderSide,
3483 order_type: OrderType,
3484 quantity: Quantity,
3485 time_in_force: TimeInForce,
3486 price: Option<Price>,
3487 trigger_price: Option<Price>,
3488 order_status: &HyperliquidExecOrderStatus,
3489 account_id: AccountId,
3490 ts_init: UnixNanos,
3491 ) -> Result<Option<OrderStatusReport>> {
3492 if matches!(order_status, HyperliquidExecOrderStatus::Tag(_)) {
3493 return Ok(None);
3494 }
3495
3496 let symbol = instrument_id.symbol.as_str();
3497 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
3498
3499 let asset = cache_alias_for_symbol(symbol).unwrap_or_else(|| symbol.to_string());
3502 let instrument = self
3503 .get_or_create_instrument(&Ustr::from(asset.as_str()), product_type)
3504 .ok_or_else(|| {
3505 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3506 })?;
3507
3508 let report = match order_status {
3509 HyperliquidExecOrderStatus::Resting { resting } => self.create_order_status_report(
3510 instrument_id,
3511 Some(client_order_id),
3512 VenueOrderId::new(resting.oid.to_string()),
3513 order_side,
3514 order_type,
3515 quantity,
3516 time_in_force,
3517 price,
3518 trigger_price,
3519 OrderStatus::Accepted,
3520 Quantity::zero(instrument.size_precision()),
3521 &instrument,
3522 account_id,
3523 ts_init,
3524 ),
3525 HyperliquidExecOrderStatus::Filled { filled } => {
3526 let filled_qty =
3527 Quantity::from_decimal_dp(filled.total_sz, instrument.size_precision())
3528 .map_err(|e| {
3529 Error::bad_request(format!(
3530 "Invalid filled size {}: {e}",
3531 filled.total_sz
3532 ))
3533 })?;
3534 self.create_order_status_report(
3535 instrument_id,
3536 Some(client_order_id),
3537 VenueOrderId::new(filled.oid.to_string()),
3538 order_side,
3539 order_type,
3540 quantity,
3541 time_in_force,
3542 price,
3543 trigger_price,
3544 OrderStatus::Filled,
3545 filled_qty,
3546 &instrument,
3547 account_id,
3548 ts_init,
3549 )
3550 }
3551 HyperliquidExecOrderStatus::Error { error } => {
3552 return Err(Error::bad_request(format!(
3553 "Order {client_order_id} rejected: {error}"
3554 )));
3555 }
3556 HyperliquidExecOrderStatus::Tag(_) => unreachable!("handled above"),
3557 };
3558
3559 Ok(Some(report))
3560 }
3561
3562 fn reconciliation_dexes(&self, instrument_id: Option<InstrumentId>) -> Vec<Option<Ustr>> {
3563 if let Some(instrument_id) = instrument_id {
3564 return vec![perp_dex_from_symbol(instrument_id.symbol.as_str())];
3565 }
3566
3567 let cached = self.instruments.load();
3568 let mut builder_dexs = cached
3569 .keys()
3570 .filter_map(|symbol| perp_dex_from_symbol(symbol.as_str()))
3571 .collect::<Vec<_>>();
3572 builder_dexs.sort_unstable_by(|a, b| a.as_str().cmp(b.as_str()));
3573 builder_dexs.dedup();
3574
3575 let mut dexes = Vec::with_capacity(builder_dexs.len() + 1);
3576 dexes.push(None);
3577 dexes.extend(builder_dexs.into_iter().map(Some));
3578 dexes
3579 }
3580}
3581
3582fn perp_dex_from_symbol(symbol: &str) -> Option<Ustr> {
3583 symbol
3584 .strip_suffix("-PERP")?
3585 .split_once(':')
3586 .map(|(dex, _)| Ustr::from(dex))
3587}
3588
3589fn parse_order_response(
3594 response: HyperliquidExchangeResponse,
3595) -> Result<HyperliquidExecOrderResponseData> {
3596 let response_data = match response {
3597 HyperliquidExchangeResponse::Status {
3598 status,
3599 response: response_data,
3600 } if status == RESPONSE_STATUS_OK => response_data,
3601 HyperliquidExchangeResponse::Error { error } => {
3602 return Err(Error::bad_request(format!(
3603 "Order submission failed: {error}"
3604 )));
3605 }
3606 _ => return Err(Error::bad_request("Unexpected response format")),
3607 };
3608
3609 let data_value = if let Some(data) = response_data.get("data") {
3610 data.clone()
3611 } else {
3612 response_data
3613 };
3614
3615 serde_json::from_value(data_value)
3616 .map_err(|e| Error::bad_request(format!("Failed to parse order response: {e}")))
3617}
3618
3619fn resolve_perp_dex_name(
3620 dex_index: usize,
3621 meta: &PerpMeta,
3622 perp_dexs: Option<&[Option<PerpDex>]>,
3623) -> String {
3624 if dex_index == 0 {
3625 return String::new();
3626 }
3627
3628 if let Some(dex_name) = perp_dexs
3629 .and_then(|dexs| dexs.get(dex_index))
3630 .and_then(|dex| dex.as_ref())
3631 .map(|dex| dex.name.clone())
3632 {
3633 return dex_name;
3634 }
3635
3636 meta.universe
3637 .iter()
3638 .find_map(|asset| asset.name.split_once(':').map(|(dex, _)| dex.to_string()))
3639 .unwrap_or_default()
3640}
3641
3642fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
3647 if dex_index == 0 {
3648 0
3649 } else {
3650 100_000 + dex_index as u32 * 10_000
3651 }
3652}
3653
3654#[cfg(test)]
3655mod tests {
3656 use std::{net::SocketAddr, sync::Arc};
3657
3658 use axum::{
3659 Router,
3660 extract::State,
3661 http::StatusCode,
3662 response::{IntoResponse, Json, Response},
3663 routing::post,
3664 };
3665 use nautilus_core::{MUTEX_POISONED, time::get_atomic_clock_realtime};
3666 use nautilus_model::{
3667 currencies::CURRENCY_MAP,
3668 enums::{CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce},
3669 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol},
3670 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
3671 types::{Currency, Price, Quantity},
3672 };
3673 use rstest::rstest;
3674 use rust_decimal_macros::dec;
3675 use serde_json::{Value, json};
3676 use ustr::Ustr;
3677
3678 use super::{HyperliquidHttpClient, resolve_perp_dex_name};
3679 use crate::{
3680 common::{
3681 consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS},
3682 enums::{HyperliquidEnvironment, HyperliquidProductType},
3683 },
3684 http::{
3685 models::{Cloid, HyperliquidExchangeResponse, PerpAsset, PerpDex, PerpMeta},
3686 query::InfoRequest,
3687 },
3688 };
3689
3690 const TEST_PRIVATE_KEY: &str =
3691 "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
3692
3693 fn perp_meta_with_assets(names: &[&str]) -> PerpMeta {
3694 PerpMeta {
3695 universe: names
3696 .iter()
3697 .map(|name| PerpAsset {
3698 name: (*name).to_string(),
3699 ..Default::default()
3700 })
3701 .collect(),
3702 margin_tables: Vec::new(),
3703 collateral_token: None,
3704 }
3705 }
3706
3707 #[rstest]
3708 fn resolve_perp_dex_name_uses_empty_string_for_default_dex() {
3709 let meta = perp_meta_with_assets(&["BTC", "ETH"]);
3710 assert_eq!(resolve_perp_dex_name(0, &meta, None), "");
3711 }
3712
3713 #[rstest]
3714 fn resolve_perp_dex_name_prefers_perp_dexs_entry() {
3715 let meta = perp_meta_with_assets(&["xyz:TSLA"]);
3716 let perp_dexs = vec![
3717 None,
3718 Some(PerpDex {
3719 name: "xyz".to_string(),
3720 }),
3721 ];
3722 assert_eq!(resolve_perp_dex_name(1, &meta, Some(&perp_dexs)), "xyz");
3723 }
3724
3725 #[rstest]
3726 fn resolve_perp_dex_name_infers_from_asset_name_when_perp_dexs_missing() {
3727 let meta = perp_meta_with_assets(&["abc:TSLA", "abc:NVDA"]);
3728 assert_eq!(resolve_perp_dex_name(1, &meta, None), "abc");
3729 }
3730
3731 #[rstest]
3732 fn test_build_submit_order_report_elides_waiting_for_fill_tag() {
3733 let mut client =
3737 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3738 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
3739
3740 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
3741 "status": "ok",
3742 "response": {
3743 "type": "order",
3744 "data": {
3745 "statuses": ["waitingForFill"]
3746 }
3747 }
3748 }))
3749 .unwrap();
3750
3751 let result = client
3752 .build_submit_order_report(
3753 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
3754 ClientOrderId::from("O-WAITING-CHILD"),
3755 OrderSide::Buy,
3756 OrderType::StopMarket,
3757 Quantity::from("100"),
3758 TimeInForce::Gtc,
3759 None,
3760 Some(Price::from("0.16136")),
3761 response,
3762 )
3763 .unwrap();
3764
3765 assert!(
3766 result.is_none(),
3767 "Tag status must elide so the order stays SUBMITTED, was {result:?}"
3768 );
3769 }
3770
3771 #[rstest]
3772 fn test_build_submit_order_report_filled_uses_total_sz_decimal() {
3773 let mut client =
3777 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3778 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
3779
3780 let base = Currency::new("ARB", 8, 0, "ARB", CurrencyType::Crypto);
3781 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
3782 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3783 let clock = get_atomic_clock_realtime();
3784 let ts = clock.get_time_ns();
3785 let perp = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
3786 InstrumentId::new(Symbol::new("ARB-USD-PERP"), *HYPERLIQUID_VENUE),
3787 Symbol::new("ARB"),
3788 base,
3789 usd,
3790 usdc,
3791 false,
3792 5,
3793 2,
3794 Price::from("0.00001"),
3795 Quantity::from("0.01"),
3796 None,
3797 None,
3798 None,
3799 None,
3800 None,
3801 None,
3802 None,
3803 None,
3804 None,
3805 None,
3806 None,
3807 None,
3808 None,
3809 None,
3810 ts,
3811 ts,
3812 ));
3813 client.cache_instrument(&perp);
3814
3815 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
3816 "status": "ok",
3817 "response": {
3818 "type": "order",
3819 "data": {
3820 "statuses": [{
3821 "filled": {"totalSz": "0.5", "avgPx": "1.2345", "oid": 778899}
3822 }]
3823 }
3824 }
3825 }))
3826 .unwrap();
3827
3828 let report = client
3829 .build_submit_order_report(
3830 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
3831 ClientOrderId::from("O-FILLED-001"),
3832 OrderSide::Buy,
3833 OrderType::Market,
3834 Quantity::from("0.5"),
3835 TimeInForce::Ioc,
3836 None,
3837 None,
3838 response,
3839 )
3840 .unwrap()
3841 .expect("filled status must produce a report");
3842
3843 assert_eq!(report.order_status, OrderStatus::Filled);
3844 assert_eq!(report.venue_order_id.as_str(), "778899");
3845 assert_eq!(report.filled_qty.as_decimal(), dec!(0.5));
3846 }
3847
3848 #[derive(Clone, Default)]
3849 struct OutcomeMetaServerState {
3850 last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
3851 }
3852
3853 async fn handle_outcome_meta_info(
3854 State(state): State<OutcomeMetaServerState>,
3855 body: axum::body::Bytes,
3856 ) -> Response {
3857 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
3858 return (
3859 StatusCode::BAD_REQUEST,
3860 Json(json!({"error": "Invalid JSON body"})),
3861 )
3862 .into_response();
3863 };
3864
3865 *state.last_request_body.lock().await = Some(request_body.clone());
3866
3867 if request_body.get("type").and_then(|value| value.as_str()) != Some("outcomeMeta") {
3868 return (
3869 StatusCode::BAD_REQUEST,
3870 Json(json!({"error": "Expected outcomeMeta request"})),
3871 )
3872 .into_response();
3873 }
3874
3875 Json(json!({
3876 "outcomes": [
3877 {
3878 "outcome": 123,
3879 "name": "Recurring",
3880 "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m",
3881 "sideSpecs": [
3882 {"name": "Yes"},
3883 {"name": "No"}
3884 ]
3885 }
3886 ]
3887 }))
3888 .into_response()
3889 }
3890
3891 async fn start_outcome_meta_server(state: OutcomeMetaServerState) -> SocketAddr {
3892 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3893 let addr = listener.local_addr().unwrap();
3894 let router = Router::new()
3895 .route("/info", post(handle_outcome_meta_info))
3896 .with_state(state);
3897
3898 tokio::spawn(async move {
3899 axum::serve(listener, router).await.unwrap();
3900 });
3901
3902 addr
3903 }
3904
3905 async fn handle_unresolved_collateral_info(body: axum::body::Bytes) -> Response {
3906 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
3907 return (
3908 StatusCode::BAD_REQUEST,
3909 Json(json!({"error": "Invalid JSON body"})),
3910 )
3911 .into_response();
3912 };
3913
3914 match request_body.get("type").and_then(|value| value.as_str()) {
3915 Some("spotMeta") => (
3916 StatusCode::INTERNAL_SERVER_ERROR,
3917 Json(json!({"error": "spot metadata unavailable"})),
3918 )
3919 .into_response(),
3920 Some("allPerpMetas") => Json(json!([
3921 {
3922 "collateralToken": 360,
3923 "marginTables": [],
3924 "universe": [
3925 {
3926 "maxLeverage": 20,
3927 "name": "km:US500",
3928 "szDecimals": 3
3929 }
3930 ]
3931 }
3932 ]))
3933 .into_response(),
3934 _ => Json(json!({"universe": [], "marginTables": []})).into_response(),
3935 }
3936 }
3937
3938 async fn start_unresolved_collateral_server() -> SocketAddr {
3939 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3940 let addr = listener.local_addr().unwrap();
3941 let router = Router::new().route("/info", post(handle_unresolved_collateral_info));
3942
3943 tokio::spawn(async move {
3944 axum::serve(listener, router).await.unwrap();
3945 });
3946
3947 addr
3948 }
3949
3950 #[rstest]
3951 fn stable_json_roundtrips() {
3952 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
3953 let s = serde_json::to_string(&v).unwrap();
3954 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
3956 assert_eq!(parsed["type"], "l2Book");
3957 assert_eq!(parsed["coin"], "BTC");
3958 assert_eq!(parsed, v);
3959 }
3960
3961 #[rstest]
3962 fn info_pretty_shape() {
3963 let r = InfoRequest::l2_book("BTC");
3964 let val = serde_json::to_value(&r).unwrap();
3965 let pretty = serde_json::to_string_pretty(&val).unwrap();
3966 assert!(pretty.contains("\"type\": \"l2Book\""));
3967 assert!(pretty.contains("\"coin\": \"BTC\""));
3968 }
3969
3970 #[rstest]
3971 fn test_client_order_id_cloid_cache_is_stable_and_first_write_wins() {
3972 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3973 let client_order_id = ClientOrderId::new("O-CLOID-CACHE");
3974 let other_client_order_id = ClientOrderId::new("O-CLOID-CACHE-OTHER");
3975 let duplicate_client_order_id = ClientOrderId::new("O-CLOID-CACHE-DUPLICATE");
3976 let explicit_cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
3977
3978 let first = client.get_or_generate_client_order_id_cloid(client_order_id);
3979 let second = client.get_or_generate_client_order_id_cloid(client_order_id);
3980 client.cache_client_order_id_cloid(client_order_id, explicit_cloid);
3981 client.cache_client_order_id_cloid(other_client_order_id, explicit_cloid);
3982 client.cache_client_order_id_cloid(duplicate_client_order_id, explicit_cloid);
3983
3984 assert_eq!(first, Cloid::from_client_order_id(client_order_id));
3985 assert_eq!(first, second);
3986 assert_eq!(
3987 client.cached_client_order_id_cloid(&client_order_id),
3988 Some(first),
3989 "cache insert must not overwrite an existing generated CLOID",
3990 );
3991 assert_eq!(
3992 client.unique_cached_client_order_id_cloid(&client_order_id),
3993 Some(first),
3994 );
3995 assert_eq!(
3996 client.cached_client_order_id_cloid(&other_client_order_id),
3997 Some(explicit_cloid),
3998 );
3999 assert_eq!(
4000 client.unique_cached_client_order_id_cloid(&other_client_order_id),
4001 None,
4002 "duplicate CLOID mappings are not safe modify targets",
4003 );
4004 assert_eq!(
4005 client.remove_client_order_id_cloid(&client_order_id),
4006 Some(first),
4007 );
4008 assert_eq!(client.cached_client_order_id_cloid(&client_order_id), None);
4009 }
4010
4011 #[rstest]
4012 fn test_builder_attribution_defaults_to_mainnet_builder() {
4013 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4014 let builder = client
4015 .builder_attribution()
4016 .expect("mainnet client should include builder attribution by default");
4017
4018 assert!(client.include_builder_attribution());
4019 assert_eq!(builder.address, NAUTILUS_BUILDER_ADDRESS);
4020 assert_eq!(builder.fee_tenths_bp, 0);
4021 }
4022
4023 #[rstest]
4024 fn test_builder_attribution_disabled_returns_none() {
4025 let mut client =
4026 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4027 client.set_include_builder_attribution(false);
4028
4029 assert!(!client.include_builder_attribution());
4030 assert!(client.builder_attribution().is_none());
4031 }
4032
4033 #[rstest]
4034 fn test_builder_attribution_omitted_on_testnet() {
4035 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
4036
4037 assert!(client.include_builder_attribution());
4038 assert!(client.builder_attribution().is_none());
4039 }
4040
4041 #[rstest]
4042 #[tokio::test]
4043 async fn test_production_client_get_outcome_meta_uses_outcome_meta_request() {
4044 let state = OutcomeMetaServerState::default();
4045 let addr = start_outcome_meta_server(state.clone()).await;
4046 let mut client =
4047 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4048 client.set_base_info_url(format!("http://{addr}/info"));
4049
4050 let meta = client.get_outcome_meta().await.unwrap();
4051 let request_body = state.last_request_body.lock().await.clone().unwrap();
4052
4053 assert_eq!(request_body, json!({"type": "outcomeMeta"}));
4054 assert_eq!(meta.outcomes.len(), 1);
4055 assert_eq!(meta.outcomes[0].outcome, 123);
4056 assert_eq!(meta.outcomes[0].name, "Recurring");
4057 assert_eq!(meta.outcomes[0].side_specs.len(), 2);
4058 assert_eq!(meta.outcomes[0].side_specs[0].name, "Yes");
4059 assert_eq!(meta.outcomes[0].side_specs[1].name, "No");
4060 }
4061
4062 #[rstest]
4063 #[tokio::test]
4064 async fn test_request_instrument_defs_errors_when_non_usdc_collateral_unresolved() {
4065 let addr = start_unresolved_collateral_server().await;
4066 let mut client =
4067 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4068 client.set_base_info_url(format!("http://{addr}/info"));
4069
4070 let err = client.request_instrument_defs().await.unwrap_err();
4071
4072 assert_eq!(
4073 err.to_string(),
4074 "decode error: failed to resolve perp settlement currency for dex 0: \
4075 Spot metadata required to resolve perp collateral token 360",
4076 );
4077 }
4078
4079 #[rstest]
4080 fn test_with_credentials_preserves_explicit_account_address() {
4081 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4082 let client = HyperliquidHttpClient::with_credentials(
4083 Some(TEST_PRIVATE_KEY.to_string()),
4084 None,
4085 Some(account_address),
4086 HyperliquidEnvironment::Mainnet,
4087 60,
4088 None,
4089 )
4090 .unwrap();
4091
4092 assert_eq!(client.get_account_address().unwrap(), account_address);
4093 }
4094
4095 #[rstest]
4096 fn test_from_resolved_credentials_preserves_account_address_without_private_key() {
4097 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4098 let client = HyperliquidHttpClient::from_resolved_credentials(
4099 None,
4100 None,
4101 Some(account_address.to_string()),
4102 HyperliquidEnvironment::Mainnet,
4103 60,
4104 None,
4105 )
4106 .unwrap();
4107
4108 assert_eq!(client.get_account_address().unwrap(), account_address);
4109 }
4110
4111 #[rstest]
4112 fn test_cache_instrument_by_raw_symbol() {
4113 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4114
4115 let base_code = "vntls:vCURSOR";
4117 let quote_code = "USDC";
4118
4119 {
4121 let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
4122 if !currency_map.contains_key(base_code) {
4123 currency_map.insert(
4124 base_code.to_string(),
4125 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
4126 );
4127 }
4128 }
4129
4130 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
4131 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
4132
4133 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
4135 let venue = *HYPERLIQUID_VENUE;
4136 let instrument_id = InstrumentId::new(symbol, venue);
4137
4138 let raw_symbol = Symbol::new(base_code);
4140
4141 let clock = get_atomic_clock_realtime();
4142 let ts = clock.get_time_ns();
4143
4144 let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
4145 instrument_id,
4146 raw_symbol,
4147 base_currency,
4148 quote_currency,
4149 8,
4150 8,
4151 Price::from("0.00000001"),
4152 Quantity::from("0.00000001"),
4153 None,
4154 None,
4155 None,
4156 None,
4157 None,
4158 None,
4159 None,
4160 None,
4161 None,
4162 None,
4163 None, None, None, None, ts,
4168 ts,
4169 ));
4170
4171 client.cache_instrument(&instrument);
4173
4174 let instruments = client.instruments.load();
4176 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
4177 assert!(
4178 by_full_symbol.is_some(),
4179 "Instrument should be accessible by full symbol"
4180 );
4181 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
4182
4183 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
4185 assert!(
4186 by_raw_symbol.is_some(),
4187 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
4188 );
4189 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
4190 drop(instruments);
4191
4192 let instruments_by_coin = client.instruments_by_coin.load();
4194 let by_coin =
4195 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
4196 assert!(
4197 by_coin.is_some(),
4198 "Instrument should be accessible by coin and product type"
4199 );
4200 assert_eq!(by_coin.unwrap().id(), instrument.id());
4201 drop(instruments_by_coin);
4202
4203 let retrieved_with_type = client.get_or_create_instrument(
4205 &Ustr::from("vntls:vCURSOR"),
4206 Some(HyperliquidProductType::Spot),
4207 );
4208 assert!(retrieved_with_type.is_some());
4209 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
4210
4211 let retrieved_without_type =
4213 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
4214 assert!(retrieved_without_type.is_some());
4215 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
4216 }
4217
4218 #[rstest]
4219 fn test_get_or_create_instrument_outcome_fallback_no_product_type() {
4220 use nautilus_core::time::get_atomic_clock_realtime;
4226 use nautilus_model::{
4227 enums::AssetClass,
4228 identifiers::{InstrumentId, Symbol},
4229 instruments::{BinaryOption, InstrumentAny},
4230 types::{Currency, Price, Quantity},
4231 };
4232
4233 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4234 let coin = "#500";
4235 let token = "+500";
4236
4237 let usdh = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
4238 let symbol = Symbol::new(token);
4239 let raw_symbol = Symbol::new(coin);
4240 let venue = *HYPERLIQUID_VENUE;
4241 let instrument_id = InstrumentId::new(symbol, venue);
4242
4243 let clock = get_atomic_clock_realtime();
4244 let ts = clock.get_time_ns();
4245
4246 let binary = InstrumentAny::BinaryOption(BinaryOption::new(
4247 instrument_id,
4248 raw_symbol,
4249 AssetClass::Alternative,
4250 usdh,
4251 Default::default(),
4252 Default::default(),
4253 4,
4254 2,
4255 Price::from("0.0001"),
4256 Quantity::from("0.01"),
4257 None,
4258 None,
4259 None,
4260 None,
4261 None,
4262 None,
4263 None,
4264 None,
4265 None,
4266 None,
4267 None,
4268 None,
4269 None,
4270 None,
4271 ts,
4272 ts,
4273 ));
4274
4275 client.cache_instrument(&binary);
4276
4277 let with_type = client
4278 .get_or_create_instrument(&Ustr::from(coin), Some(HyperliquidProductType::Outcome));
4279 assert!(with_type.is_some());
4280 assert_eq!(with_type.unwrap().id(), instrument_id);
4281
4282 let no_type = client.get_or_create_instrument(&Ustr::from(coin), None);
4283 assert!(
4284 no_type.is_some(),
4285 "Outcome coin must resolve through the no-product fallback",
4286 );
4287 assert_eq!(no_type.unwrap().id(), instrument_id);
4288
4289 let missing = client.get_or_create_instrument(&Ustr::from("#9999"), None);
4290 assert!(missing.is_none());
4291 }
4292
4293 #[rstest]
4294 fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
4295 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4300
4301 let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
4302 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4303 let clock = get_atomic_clock_realtime();
4304 let ts = clock.get_time_ns();
4305
4306 let canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
4307 InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
4308 Symbol::new("@107"),
4309 hype,
4310 usdc,
4311 5,
4312 2,
4313 Price::from("0.00001"),
4314 Quantity::from("0.01"),
4315 None,
4316 None,
4317 None,
4318 None,
4319 None,
4320 None,
4321 None,
4322 None,
4323 None,
4324 None,
4325 None,
4326 None,
4327 None,
4328 None,
4329 ts,
4330 ts,
4331 ));
4332
4333 let non_canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
4334 InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
4335 Symbol::new("@999"),
4336 hype,
4337 usdc,
4338 5,
4339 2,
4340 Price::from("0.00001"),
4341 Quantity::from("0.01"),
4342 None,
4343 None,
4344 None,
4345 None,
4346 None,
4347 None,
4348 None,
4349 None,
4350 None,
4351 None,
4352 None,
4353 None,
4354 None,
4355 None,
4356 ts,
4357 ts,
4358 ));
4359
4360 client.cache_instrument(&canonical);
4361 client.cache_instrument(&non_canonical);
4362
4363 let instruments_by_coin = client.instruments_by_coin.load();
4364 let by_base = instruments_by_coin
4365 .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
4366 .expect("base alias must resolve");
4367 assert_eq!(
4368 by_base.raw_symbol().inner().as_str(),
4369 "@107",
4370 "base alias must point to the canonical pair, not the one cached later",
4371 );
4372 }
4373
4374 #[rstest]
4375 fn test_cache_instrument_perp_aliases_sanitized_base() {
4376 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4382
4383 let base_currency = Currency::new(
4384 "dex:STREAMABCD****",
4385 8,
4386 0,
4387 "dex:STREAMABCD****",
4388 CurrencyType::Crypto,
4389 );
4390 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4391 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4392 let clock = get_atomic_clock_realtime();
4393 let ts = clock.get_time_ns();
4394
4395 let hip3 = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
4396 InstrumentId::new(
4397 Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
4398 *HYPERLIQUID_VENUE,
4399 ),
4400 Symbol::new("dex:STREAMABCD****"),
4401 base_currency,
4402 usd,
4403 usdc,
4404 false,
4405 6,
4406 3,
4407 Price::from("0.000001"),
4408 Quantity::from("0.001"),
4409 None,
4410 None,
4411 None,
4412 None,
4413 None,
4414 None,
4415 None,
4416 None,
4417 None,
4418 None,
4419 None,
4420 None,
4421 None,
4422 None,
4423 ts,
4424 ts,
4425 ));
4426
4427 client.cache_instrument(&hip3);
4428
4429 let instruments_by_coin = client.instruments_by_coin.load();
4430 let by_raw = instruments_by_coin
4431 .get(&(
4432 Ustr::from("dex:STREAMABCD****"),
4433 HyperliquidProductType::Perp,
4434 ))
4435 .expect("venue coin lookup must resolve");
4436 assert_eq!(by_raw.id(), hip3.id());
4437
4438 let by_sanitized = instruments_by_coin
4439 .get(&(
4440 Ustr::from("dex:STREAMABCDxxxx"),
4441 HyperliquidProductType::Perp,
4442 ))
4443 .expect("sanitized base lookup must resolve");
4444 assert_eq!(by_sanitized.id(), hip3.id());
4445 drop(instruments_by_coin);
4446
4447 let resolved = client
4449 .get_or_create_instrument(
4450 &Ustr::from("dex:STREAMABCDxxxx"),
4451 Some(HyperliquidProductType::Perp),
4452 )
4453 .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
4454 assert_eq!(resolved.id(), hip3.id());
4455 }
4456}