1use std::{
24 collections::HashMap,
25 num::NonZeroU32,
26 sync::{Arc, LazyLock},
27 time::Duration,
28};
29
30use ahash::AHashMap;
31use anyhow::Context;
32use nautilus_common::cache::InstrumentLookupError;
33use nautilus_core::{
34 AtomicMap, UUID4, UnixNanos,
35 datetime::datetime_to_unix_nanos,
36 string::secret::SecretString,
37 time::{AtomicTime, get_atomic_clock_realtime},
38};
39use nautilus_model::{
40 data::{Bar, BarType},
41 enums::{
42 AccountType, BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce,
43 TriggerType,
44 },
45 events::AccountState,
46 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
47 instruments::{CurrencyPair, Instrument, InstrumentAny},
48 orders::{Order, OrderAny},
49 reports::{FillReport, OrderStatusReport, PositionStatusReport},
50 types::{AccountBalance, Currency, Price, Quantity},
51};
52use nautilus_network::{
53 http::{
54 HttpClient, HttpClientError, HttpRedirectPolicy, HttpResponse, Method,
55 create_standard_nautilus_headers,
56 },
57 ratelimiter::quota::Quota,
58};
59use parking_lot::Mutex;
60use rust_decimal::Decimal;
61use serde_json::Value;
62use ustr::Ustr;
63
64use crate::{
65 account::resolve_execution_account_address,
66 common::{
67 consts::{
68 ASSET_INDEX_INFO_KEY, HYPERLIQUID_REST_WEIGHT_PER_MINUTE, HYPERLIQUID_VENUE,
69 NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url,
70 },
71 credential::{Secrets, VaultAddress, credential_env_vars},
72 enums::{
73 HyperliquidAccountAbstraction, HyperliquidBarInterval, HyperliquidEnvironment,
74 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
75 },
76 parse::{
77 bar_type_to_interval, cache_alias_for_symbol, clamp_price_to_precision,
78 derive_limit_from_trigger, determine_order_list_grouping, extract_inner_error,
79 normalize_or_validate_wire_price, order_to_hyperliquid_request_with_optional_decimals,
80 parse_combined_account_balances_and_margins, parse_outcome_symbol,
81 parse_spot_account_balances, parse_trigger_order_type, parse_trigger_order_type_label,
82 round_to_sig_figs, time_in_force_to_hyperliquid_tif,
83 },
84 },
85 data::candle_to_bar,
86 data_types::HyperliquidPublicTrade,
87 http::{
88 error::{Error, Result},
89 models::{
90 ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeAction,
91 HyperliquidExchangeBuilderFee, HyperliquidExchangeCancelByCloidRequest,
92 HyperliquidExchangeCancelOrderRequest, HyperliquidExchangeGrouping,
93 HyperliquidExchangeLimitParams, HyperliquidExchangeMergeOutcomeParams,
94 HyperliquidExchangeMergeQuestionParams, HyperliquidExchangeModifyOrderRequest,
95 HyperliquidExchangeModifyTarget, HyperliquidExchangeNegateOutcomeParams,
96 HyperliquidExchangeOrderKind, HyperliquidExchangeOrderResponseData,
97 HyperliquidExchangeOrderStatus, HyperliquidExchangePlaceOrderRequest,
98 HyperliquidExchangeRequest, HyperliquidExchangeResponse,
99 HyperliquidExchangeSplitOutcomeParams, HyperliquidExchangeTif, HyperliquidExchangeTpSl,
100 HyperliquidExchangeTriggerParams, HyperliquidExchangeUserOutcomeOp, HyperliquidFills,
101 HyperliquidFundingHistoryEntry, HyperliquidL2Book, HyperliquidMeta,
102 HyperliquidOrderStatus, HyperliquidOrderStatusEntry, HyperliquidRecentTrade,
103 OutcomeMeta, PerpDex, PerpMeta, PerpMetaAndCtxs, RESPONSE_STATUS_OK,
104 SpotClearinghouseState, SpotMeta, SpotMetaAndCtxs,
105 },
106 parse::{
107 DEFAULT_OUTCOME_QUOTE_CURRENCY, HyperliquidInstrumentDef, create_instrument_from_def,
108 filter_recent_public_trades, get_outcome_currency, instruments_from_defs_owned,
109 parse_fill_report, parse_order_status_report_from_basic, parse_outcome_instruments,
110 parse_perp_instruments_with_settlement, parse_position_status_report,
111 parse_recent_public_trade, parse_spot_instruments, parse_spot_position_status_report,
112 parse_unlisted_outcome_instrument, resolve_perp_settlement_currency,
113 },
114 query::{ExchangeAction, InfoRequest},
115 rate_limits::{
116 RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
117 exec_action_weight, info_base_weight, info_extra_weight, shared_rest_limiter,
118 },
119 },
120 signing::{
121 HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
122 },
123 websocket::messages::WsBasicOrderData,
124};
125
126fn deduplicate_historical_order_reports(reports: Vec<OrderStatusReport>) -> Vec<OrderStatusReport> {
127 let mut best_by_venue_order_id = AHashMap::new();
128
129 for candidate in reports {
130 let Some(current) = best_by_venue_order_id.remove(&candidate.venue_order_id) else {
131 best_by_venue_order_id.insert(candidate.venue_order_id, candidate);
132 continue;
133 };
134 let (mut best, other) = if historical_report_is_more_advanced(&candidate, ¤t) {
135 (candidate, current)
136 } else {
137 (current, candidate)
138 };
139
140 if matches!(
141 best.order_type,
142 OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
143 ) {
144 best.price = best.price.or(other.price);
145 }
146 best.trigger_price = best.trigger_price.or(other.trigger_price);
147 best.trigger_type = best.trigger_type.or(other.trigger_type);
148 best_by_venue_order_id.insert(best.venue_order_id, best);
149 }
150
151 best_by_venue_order_id
152 .into_values()
153 .map(untrigger_closed_report_without_trigger_price)
154 .collect()
155}
156
157fn untrigger_closed_report_without_trigger_price(
162 mut report: OrderStatusReport,
163) -> OrderStatusReport {
164 let is_trigger_type = matches!(
165 report.order_type,
166 OrderType::StopMarket
167 | OrderType::StopLimit
168 | OrderType::MarketIfTouched
169 | OrderType::LimitIfTouched
170 );
171
172 if !is_trigger_type || report.trigger_price.is_some() || !report.order_status.is_closed() {
173 return report;
174 }
175
176 let order_type = match report.order_type {
177 OrderType::StopLimit | OrderType::LimitIfTouched if report.price.is_some() => {
178 OrderType::Limit
179 }
180 _ => {
181 report.price = None;
183 OrderType::Market
184 }
185 };
186 log::debug!(
187 "Historical {} order {} has no trigger price, reconciling as {order_type}",
188 report.order_type,
189 report.venue_order_id,
190 );
191 report.order_type = order_type;
192 report
193}
194
195fn historical_report_is_more_advanced(
196 candidate: &OrderStatusReport,
197 current: &OrderStatusReport,
198) -> bool {
199 candidate.filled_qty > current.filled_qty
200 || (candidate.filled_qty == current.filled_qty
201 && (historical_status_priority(candidate.order_status)
202 > historical_status_priority(current.order_status)
203 || (candidate.order_status == current.order_status
204 && candidate.ts_last > current.ts_last)))
205}
206
207const fn historical_status_priority(status: OrderStatus) -> u8 {
208 match status {
209 OrderStatus::Initialized | OrderStatus::Submitted | OrderStatus::Emulated => 0,
210 OrderStatus::Released | OrderStatus::Denied => 1,
211 OrderStatus::Accepted | OrderStatus::PendingUpdate | OrderStatus::PendingCancel => 2,
212 OrderStatus::Triggered => 3,
213 OrderStatus::PartiallyFilled => 4,
214 OrderStatus::Canceled | OrderStatus::Expired | OrderStatus::Rejected => 5,
215 OrderStatus::Filled | OrderStatus::Voided => 6,
216 }
217}
218
219pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
224 Quota::per_minute(NonZeroU32::new(HYPERLIQUID_REST_WEIGHT_PER_MINUTE).unwrap())
225});
226
227pub(crate) const HYPERLIQUID_RECENT_HISTORY_LIMIT: usize = 2_000;
228const RATE_LIMIT_BACKOFF_BASE: Duration = Duration::from_millis(125);
229const RATE_LIMIT_BACKOFF_CAP: Duration = Duration::from_secs(5);
230const RATE_LIMIT_INFO_RETRIES_MAX: u32 = 3;
231const RETRY_AFTER_HEADER: &str = "retry-after";
232const VAULT_TOKEN_PREFIX: &str = "vntls:";
233
234#[derive(Debug, Clone)]
239#[cfg_attr(
240 feature = "python",
241 pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
242)]
243pub struct HyperliquidRawHttpClient {
244 client: HttpClient,
245 environment: HyperliquidEnvironment,
246 base_info: String,
247 base_exchange: String,
248 signer: Option<HyperliquidEip712Signer>,
249 nonce_manager: Option<Arc<NonceManager>>,
250 vault_address: Option<VaultAddress>,
251 proxy_url: Option<SecretString>,
252 info_limiter: Arc<WeightedLimiter>,
253 exchange_limiter: Arc<WeightedLimiter>,
254}
255
256impl HyperliquidRawHttpClient {
257 pub fn new(
263 environment: HyperliquidEnvironment,
264 timeout_secs: u64,
265 proxy_url: Option<String>,
266 ) -> std::result::Result<Self, HttpClientError> {
267 let base_info = info_url(environment).to_string();
268 let base_exchange = exchange_url(environment).to_string();
269 let info_limiter = shared_rest_limiter(environment, &base_info, proxy_url.as_deref());
270 let exchange_limiter =
271 shared_rest_limiter(environment, &base_exchange, proxy_url.as_deref());
272
273 Ok(Self {
274 client: Self::build_http_client(timeout_secs, proxy_url.clone())?,
275 environment,
276 base_info,
277 base_exchange,
278 signer: None,
279 nonce_manager: None,
280 vault_address: None,
281 proxy_url: proxy_url.map(SecretString::from),
282 info_limiter,
283 exchange_limiter,
284 })
285 }
286
287 pub fn with_credentials(
294 secrets: &Secrets,
295 timeout_secs: u64,
296 proxy_url: Option<String>,
297 ) -> std::result::Result<Self, HttpClientError> {
298 let signer = HyperliquidEip712Signer::new(&secrets.private_key)
299 .map_err(|e| HttpClientError::from(e.to_string()))?;
300 let nonce_manager = Arc::new(NonceManager::new());
301 let base_info = info_url(secrets.environment).to_string();
302 let base_exchange = exchange_url(secrets.environment).to_string();
303 let info_limiter =
304 shared_rest_limiter(secrets.environment, &base_info, proxy_url.as_deref());
305 let exchange_limiter =
306 shared_rest_limiter(secrets.environment, &base_exchange, proxy_url.as_deref());
307
308 Ok(Self {
309 client: Self::build_http_client(timeout_secs, proxy_url.clone())?,
310 environment: secrets.environment,
311 base_info,
312 base_exchange,
313 signer: Some(signer),
314 nonce_manager: Some(nonce_manager),
315 vault_address: secrets.vault_address,
316 proxy_url: proxy_url.map(SecretString::from),
317 info_limiter,
318 exchange_limiter,
319 })
320 }
321
322 pub fn set_base_info_url(&mut self, url: String) {
324 self.info_limiter = shared_rest_limiter(
325 self.environment,
326 &url,
327 self.proxy_url.as_ref().map(|value| value.expose_secret()),
328 );
329 self.base_info = url;
330 }
331
332 pub fn set_base_exchange_url(&mut self, url: String) {
334 self.exchange_limiter = shared_rest_limiter(
335 self.environment,
336 &url,
337 self.proxy_url.as_ref().map(|value| value.expose_secret()),
338 );
339 self.base_exchange = url;
340 }
341
342 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
348 let secrets = Secrets::from_env(environment)
349 .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
350 Self::with_credentials(&secrets, 60, None)
351 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
352 }
353
354 pub fn from_credentials(
360 private_key: &str,
361 vault_address: Option<&str>,
362 environment: HyperliquidEnvironment,
363 timeout_secs: u64,
364 proxy_url: Option<String>,
365 ) -> Result<Self> {
366 let secrets = Secrets::from_private_key(private_key, vault_address, environment)
367 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
368 Self::with_credentials(&secrets, timeout_secs, proxy_url)
369 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
370 }
371
372 #[must_use]
374 pub fn with_rate_limits(mut self) -> Self {
375 let proxy_url = self.proxy_url.as_ref().map(|value| value.expose_secret());
376 self.info_limiter = shared_rest_limiter(self.environment, &self.base_info, proxy_url);
377 self.exchange_limiter =
378 shared_rest_limiter(self.environment, &self.base_exchange, proxy_url);
379 self
380 }
381
382 #[must_use]
384 pub fn environment(&self) -> HyperliquidEnvironment {
385 self.environment
386 }
387
388 #[must_use]
390 pub fn is_testnet(&self) -> bool {
391 self.environment == HyperliquidEnvironment::Testnet
392 }
393
394 pub fn get_user_address(&self) -> Result<String> {
400 self.signer
401 .as_ref()
402 .ok_or_else(|| Error::auth("No signer configured"))?
403 .address()
404 }
405
406 #[must_use]
408 pub fn has_vault_address(&self) -> bool {
409 self.vault_address.is_some()
410 }
411
412 pub fn get_account_address(&self) -> Result<String> {
419 if let Some(vault) = &self.vault_address {
420 Ok(vault.to_hex())
421 } else {
422 self.get_user_address()
423 }
424 }
425
426 fn build_http_client(
427 timeout_secs: u64,
428 proxy_url: Option<String>,
429 ) -> std::result::Result<HttpClient, HttpClientError> {
430 HttpClient::builder()
431 .redirect_policy(HttpRedirectPolicy::Reject)
432 .headers(Self::default_headers())
433 .header_keys(vec![RETRY_AFTER_HEADER.to_string()])
434 .rate_limiters(Vec::new())
435 .timeout_secs(timeout_secs)
436 .maybe_proxy_url(proxy_url)
437 .build()
438 }
439
440 fn default_headers() -> HashMap<String, String> {
441 let mut headers: HashMap<String, String> =
442 create_standard_nautilus_headers().into_iter().collect();
443 headers.insert("Content-Type".to_string(), "application/json".to_string());
444 headers
445 }
446
447 fn signer_id(&self) -> SignerId {
448 SignerId("hyperliquid:default".into())
449 }
450
451 fn retry_after_ms(headers: &HashMap<String, String>) -> Option<u64> {
452 let retry_after = headers.get(RETRY_AFTER_HEADER)?;
453 retry_after
454 .parse::<u64>()
455 .ok()
456 .map(|seconds| seconds.saturating_mul(1_000))
457 }
458
459 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
461 let request = InfoRequest::meta();
462 let response = self.send_info_request(&request).await?;
463 serde_json::from_value(response).map_err(Error::Serde)
464 }
465
466 pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
468 let request = InfoRequest::spot_meta();
469 let response = self.send_info_request(&request).await?;
470 serde_json::from_value(response).map_err(Error::Serde)
471 }
472
473 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
475 let request = InfoRequest::meta_and_asset_ctxs();
476 let response = self.send_info_request(&request).await?;
477 serde_json::from_value(response).map_err(Error::Serde)
478 }
479
480 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
482 let request = InfoRequest::spot_meta_and_asset_ctxs();
483 let response = self.send_info_request(&request).await?;
484 serde_json::from_value(response).map_err(Error::Serde)
485 }
486
487 pub async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
489 let request = InfoRequest::outcome_meta();
490 let response = self.send_info_request(&request).await?;
491 serde_json::from_value(response).map_err(Error::Serde)
492 }
493
494 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
495 let request = InfoRequest::meta();
496 let response = self.send_info_request(&request).await?;
497 serde_json::from_value(response).map_err(Error::Serde)
498 }
499
500 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
502 let request = InfoRequest::all_perp_metas();
503 let response = self.send_info_request(&request).await?;
504 serde_json::from_value(response).map_err(Error::Serde)
505 }
506
507 pub(crate) async fn load_perp_dexs(&self) -> Result<Vec<Option<PerpDex>>> {
509 let request = InfoRequest::perp_dexs();
510 let response = self.send_info_request(&request).await?;
511 serde_json::from_value(response).map_err(Error::Serde)
512 }
513
514 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
516 let request = InfoRequest::l2_book(coin);
517 let response = self.send_info_request(&request).await?;
518 serde_json::from_value(response).map_err(Error::Serde)
519 }
520
521 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
526 let request = InfoRequest::recent_trades(coin);
527 let response = self.send_info_request(&request).await?;
528 serde_json::from_value(response).map_err(Error::Serde)
529 }
530
531 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
533 let request = InfoRequest::user_fills(user);
534 let response = self.send_info_request(&request).await?;
535 serde_json::from_value(response).map_err(Error::Serde)
536 }
537
538 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
540 let request = InfoRequest::order_status(user, oid);
541 let response = self.send_info_request(&request).await?;
542 serde_json::from_value(response).map_err(Error::Serde)
543 }
544
545 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
547 let request = InfoRequest::open_orders(user);
548 self.send_info_request(&request).await
549 }
550
551 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
553 self.info_frontend_open_orders_for_dex(user, None).await
554 }
555
556 async fn info_frontend_open_orders_for_dex(
557 &self,
558 user: &str,
559 dex: Option<&str>,
560 ) -> Result<Value> {
561 let request = InfoRequest::frontend_open_orders_for_dex(user, dex);
562 self.send_info_request(&request).await
563 }
564
565 pub async fn info_historical_orders(
567 &self,
568 user: &str,
569 ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
570 let request = InfoRequest::historical_orders(user);
571 let response = self.send_info_request(&request).await?;
572 serde_json::from_value(response).map_err(Error::Serde)
573 }
574
575 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
577 self.info_clearinghouse_state_for_dex(user, None).await
578 }
579
580 async fn info_clearinghouse_state_for_dex(
581 &self,
582 user: &str,
583 dex: Option<&str>,
584 ) -> Result<Value> {
585 let request = InfoRequest::clearinghouse_state_for_dex(user, dex);
586 self.send_info_request(&request).await
587 }
588
589 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
591 let request = InfoRequest::spot_clearinghouse_state(user);
592 self.send_info_request(&request).await
593 }
594
595 pub async fn info_user_abstraction(&self, user: &str) -> Result<Value> {
597 let request = InfoRequest::user_abstraction(user);
598 self.send_info_request(&request).await
599 }
600
601 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
603 let request = InfoRequest::user_fees(user);
604 self.send_info_request(&request).await
605 }
606
607 pub async fn info_candle_snapshot(
609 &self,
610 coin: &str,
611 interval: HyperliquidBarInterval,
612 start_time: u64,
613 end_time: u64,
614 ) -> Result<HyperliquidCandleSnapshot> {
615 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
616 let response = self.send_info_request(&request).await?;
617
618 log::trace!(
619 "Candle snapshot raw response (len={}): {:?}",
620 response.as_array().map_or(0, |a| a.len()),
621 response
622 );
623
624 serde_json::from_value(response).map_err(Error::Serde)
625 }
626
627 pub async fn info_funding_history(
632 &self,
633 coin: &str,
634 start_time: u64,
635 end_time: Option<u64>,
636 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
637 let request = InfoRequest::funding_history(coin, start_time, end_time);
638 let response = self.send_info_request(&request).await?;
639 serde_json::from_value(response).map_err(Error::Serde)
640 }
641
642 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
644 self.send_info_request(request).await
645 }
646
647 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
648 let base_w = info_base_weight(request);
649 let mut attempt = 0u32;
650
651 loop {
652 self.info_limiter.acquire(base_w).await;
653 let response = self.http_roundtrip_info(request).await?;
654
655 if response.status.is_success() {
656 let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
658 let extra = info_extra_weight(request, &val);
659 if extra > 0 {
660 self.info_limiter.debit_extra(extra).await;
661 log::debug!(
662 "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
663 );
664 }
665 return Ok(val);
666 }
667
668 if response.status.as_u16() == 429 {
670 if attempt >= RATE_LIMIT_INFO_RETRIES_MAX {
671 let ra = Self::retry_after_ms(&response.headers);
672 return Err(Error::rate_limit("info", base_w, ra));
673 }
674 let delay = Self::retry_after_ms(&response.headers).map_or_else(
675 || {
676 backoff_full_jitter(
677 attempt,
678 RATE_LIMIT_BACKOFF_BASE,
679 RATE_LIMIT_BACKOFF_CAP,
680 )
681 },
682 Duration::from_millis,
683 );
684 log::warn!(
685 "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
686 delay.as_millis()
687 );
688 attempt += 1;
689 tokio::time::sleep(delay).await;
690 continue;
691 }
692
693 if (response.status.is_server_error() || response.status.as_u16() == 408)
695 && attempt < RATE_LIMIT_INFO_RETRIES_MAX
696 {
697 let delay =
698 backoff_full_jitter(attempt, RATE_LIMIT_BACKOFF_BASE, RATE_LIMIT_BACKOFF_CAP);
699 log::debug!(
700 "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
701 response.status.as_u16(),
702 delay.as_millis()
703 );
704 attempt += 1;
705 tokio::time::sleep(delay).await;
706 continue;
707 }
708
709 let error_body = String::from_utf8_lossy(&response.body);
711 return Err(Error::http(
712 response.status.as_u16(),
713 error_body.to_string(),
714 ));
715 }
716 }
717
718 async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
719 let url = &self.base_info;
720 let body = serde_json::to_value(request).map_err(Error::Serde)?;
721 let body_bytes = serde_json::to_string(&body)
722 .map_err(Error::Serde)?
723 .into_bytes();
724
725 self.client
726 .request(
727 Method::POST,
728 url.clone(),
729 None,
730 None,
731 Some(body_bytes),
732 None,
733 None,
734 )
735 .await
736 .map_err(Error::from_http_client)
737 }
738
739 pub async fn post_action(
741 &self,
742 action: &ExchangeAction,
743 ) -> Result<HyperliquidExchangeResponse> {
744 let w = exchange_weight(action);
745 self.exchange_limiter.acquire(w).await;
746
747 let signer = self
748 .signer
749 .as_ref()
750 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
751
752 let nonce_manager = self
753 .nonce_manager
754 .as_ref()
755 .ok_or_else(|| Error::auth("nonce manager missing"))?;
756
757 let signer_id = self.signer_id();
758 let time_nonce = nonce_manager.next(signer_id)?;
759
760 let action_bytes = rmp_serde::to_vec_named(action)
762 .context("serialize action with MessagePack")
763 .map_err(|e| Error::bad_request(e.to_string()))?;
764
765 let sign_request = SignRequest {
766 action: None,
767 action_bytes: Some(action_bytes),
768 time_nonce,
769 action_type: HyperliquidActionType::L1,
770 is_testnet: self.is_testnet(),
771 vault_address: self.vault_address,
772 expires_after: None,
773 };
774
775 let sig = signer.sign(&sign_request)?.signature;
776
777 let nonce_u64 = time_nonce.as_millis() as u64;
778
779 let request = if let Some(vault) = self.vault_address {
780 HyperliquidExchangeRequest::with_vault(
781 action.clone(),
782 nonce_u64,
783 sig,
784 vault.to_string(),
785 )
786 } else {
787 HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
788 };
789
790 let response = self.http_roundtrip_exchange(&request).await?;
791
792 if response.status.is_success() {
793 let parsed_response: HyperliquidExchangeResponse =
794 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
795
796 match &parsed_response {
798 HyperliquidExchangeResponse::Status {
799 status,
800 response: response_data,
801 } if status == "err" => {
802 let error_msg = response_data
803 .as_str()
804 .map_or_else(|| response_data.to_string(), |s| s.to_string());
805 log::error!("Hyperliquid API returned error: {error_msg}");
806 Err(Error::bad_request(format!("API error: {error_msg}")))
807 }
808 HyperliquidExchangeResponse::Error { error } => {
809 log::error!("Hyperliquid API returned error: {error}");
810 Err(Error::bad_request(format!("API error: {error}")))
811 }
812 _ => Ok(parsed_response),
813 }
814 } else if response.status.as_u16() == 429 {
815 let ra = Self::retry_after_ms(&response.headers);
816 Err(Error::rate_limit("exchange", w, ra))
817 } else {
818 let error_body = String::from_utf8_lossy(&response.body);
819 log::error!(
820 "Exchange API error (status {}): {}",
821 response.status.as_u16(),
822 error_body
823 );
824 Err(Error::http(
825 response.status.as_u16(),
826 error_body.to_string(),
827 ))
828 }
829 }
830
831 pub fn sign_action_exec_request(
833 &self,
834 action: &HyperliquidExchangeAction,
835 expires_after: Option<u64>,
836 ) -> Result<HyperliquidExchangeRequest<HyperliquidExchangeAction>> {
837 let signer = self
838 .signer
839 .as_ref()
840 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
841
842 let nonce_manager = self
843 .nonce_manager
844 .as_ref()
845 .ok_or_else(|| Error::auth("nonce manager missing"))?;
846
847 let signer_id = self.signer_id();
848 let time_nonce = nonce_manager.next(signer_id)?;
849 let action_bytes = rmp_serde::to_vec_named(action)
853 .context("serialize action with MessagePack")
854 .map_err(|e| Error::bad_request(e.to_string()))?;
855
856 let sig = signer
857 .sign(&SignRequest {
858 action: None,
859 action_bytes: Some(action_bytes),
860 time_nonce,
861 action_type: HyperliquidActionType::L1,
862 is_testnet: self.is_testnet(),
863 vault_address: self.vault_address,
864 expires_after,
865 })?
866 .signature;
867
868 let mut request = if let Some(vault) = self.vault_address {
869 HyperliquidExchangeRequest::with_vault(
870 action.clone(),
871 time_nonce.as_millis() as u64,
872 sig,
873 vault.to_string(),
874 )
875 } else {
876 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
877 };
878 request.expires_after = expires_after;
879 Ok(request)
880 }
881
882 pub async fn post_action_exec(
887 &self,
888 action: &HyperliquidExchangeAction,
889 ) -> Result<HyperliquidExchangeResponse> {
890 let w = exec_action_weight(action);
891 self.exchange_limiter.acquire(w).await;
892
893 let request = self.sign_action_exec_request(action, None)?;
894
895 let response = self.http_roundtrip_exchange(&request).await?;
896
897 if response.status.is_success() {
898 let parsed_response: HyperliquidExchangeResponse =
899 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
900
901 match &parsed_response {
903 HyperliquidExchangeResponse::Status {
904 status,
905 response: response_data,
906 } if status == "err" => {
907 let error_msg = response_data
908 .as_str()
909 .map_or_else(|| response_data.to_string(), |s| s.to_string());
910 log::error!("Hyperliquid API returned error: {error_msg}");
911 Err(Error::bad_request(format!("API error: {error_msg}")))
912 }
913 HyperliquidExchangeResponse::Error { error } => {
914 log::error!("Hyperliquid API returned error: {error}");
915 Err(Error::bad_request(format!("API error: {error}")))
916 }
917 _ => Ok(parsed_response),
918 }
919 } else if response.status.as_u16() == 429 {
920 let ra = Self::retry_after_ms(&response.headers);
921 Err(Error::rate_limit("exchange", w, ra))
922 } else {
923 let error_body = String::from_utf8_lossy(&response.body);
924 Err(Error::http(
925 response.status.as_u16(),
926 error_body.to_string(),
927 ))
928 }
929 }
930
931 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
933 self.info_limiter.snapshot().await
934 }
935
936 async fn http_roundtrip_exchange<T>(
937 &self,
938 request: &HyperliquidExchangeRequest<T>,
939 ) -> Result<HttpResponse>
940 where
941 T: serde::Serialize,
942 {
943 let url = &self.base_exchange;
944 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
945 let body_bytes = body.into_bytes();
946
947 let response = self
948 .client
949 .request(
950 Method::POST,
951 url.clone(),
952 None,
953 None,
954 Some(body_bytes),
955 None,
956 None,
957 )
958 .await
959 .map_err(Error::from_http_client)?;
960
961 Ok(response)
962 }
963}
964
965#[derive(Debug, Clone)]
971#[cfg_attr(
972 feature = "python",
973 pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
974)]
975#[cfg_attr(
976 feature = "python",
977 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
978)]
979pub struct HyperliquidHttpClient {
980 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
981 clock: &'static AtomicTime,
982 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
983 instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
984 asset_indices: Arc<AtomicMap<Ustr, u32>>,
986 spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
988 client_order_id_cloids: Arc<Mutex<AHashMap<ClientOrderId, Cloid>>>,
989 account_id: Option<AccountId>,
990 account_address: Option<String>,
994 normalize_prices: bool,
995 market_order_slippage_bps: u32,
996 include_builder_attribution: bool,
997}
998
999impl Default for HyperliquidHttpClient {
1000 fn default() -> Self {
1001 Self::new(HyperliquidEnvironment::Mainnet, 60, None)
1002 .expect("Failed to create default Hyperliquid HTTP client")
1003 }
1004}
1005
1006impl HyperliquidHttpClient {
1007 pub fn new(
1013 environment: HyperliquidEnvironment,
1014 timeout_secs: u64,
1015 proxy_url: Option<String>,
1016 ) -> std::result::Result<Self, HttpClientError> {
1017 let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
1018 Ok(Self::from_raw(raw_client))
1019 }
1020
1021 pub fn with_secrets(
1027 secrets: &Secrets,
1028 timeout_secs: u64,
1029 proxy_url: Option<String>,
1030 ) -> std::result::Result<Self, HttpClientError> {
1031 let raw_client =
1032 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
1033 Ok(Self::from_raw(raw_client))
1034 }
1035
1036 fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
1037 Self {
1038 inner: Arc::new(raw_client),
1039 clock: get_atomic_clock_realtime(),
1040 instruments: Arc::new(AtomicMap::new()),
1041 instruments_by_coin: Arc::new(AtomicMap::new()),
1042 asset_indices: Arc::new(AtomicMap::new()),
1043 spot_fill_coins: Arc::new(AtomicMap::new()),
1044 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1045 account_id: None,
1046 account_address: None,
1047 normalize_prices: true,
1048 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1049 include_builder_attribution: true,
1050 }
1051 }
1052
1053 #[must_use]
1055 pub fn get_or_generate_client_order_id_cloid(&self, client_order_id: ClientOrderId) -> Cloid {
1056 let mut cloids = self.client_order_id_cloids.lock();
1057 *cloids
1058 .entry(client_order_id)
1059 .or_insert_with(|| Cloid::from_client_order_id(client_order_id))
1060 }
1061
1062 pub fn cache_client_order_id_cloid(&self, client_order_id: ClientOrderId, cloid: Cloid) {
1064 self.client_order_id_cloids
1065 .lock()
1066 .entry(client_order_id)
1067 .or_insert(cloid);
1068 }
1069
1070 #[must_use]
1072 pub fn cached_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
1073 self.client_order_id_cloids
1074 .lock()
1075 .get(client_order_id)
1076 .copied()
1077 }
1078
1079 #[must_use]
1082 pub(crate) fn unique_cached_client_order_id_cloid(
1083 &self,
1084 client_order_id: &ClientOrderId,
1085 ) -> Option<Cloid> {
1086 let cloids = self.client_order_id_cloids.lock();
1087 let cloid = cloids.get(client_order_id).copied()?;
1088 let mapping_count = cloids
1089 .values()
1090 .filter(|cached_cloid| **cached_cloid == cloid)
1091 .count();
1092
1093 (mapping_count == 1).then_some(cloid)
1094 }
1095
1096 pub fn remove_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
1098 self.client_order_id_cloids.lock().remove(client_order_id)
1099 }
1100
1101 pub fn set_base_info_url(&mut self, url: String) {
1107 Arc::get_mut(&mut self.inner)
1108 .expect("cannot override URL: Arc has multiple references")
1109 .set_base_info_url(url);
1110 }
1111
1112 pub fn set_base_exchange_url(&mut self, url: String) {
1118 Arc::get_mut(&mut self.inner)
1119 .expect("cannot override URL: Arc has multiple references")
1120 .set_base_exchange_url(url);
1121 }
1122
1123 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
1129 let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
1130 Ok(Self {
1131 inner: Arc::new(raw_client),
1132 clock: get_atomic_clock_realtime(),
1133 instruments: Arc::new(AtomicMap::new()),
1134 instruments_by_coin: Arc::new(AtomicMap::new()),
1135 asset_indices: Arc::new(AtomicMap::new()),
1136 spot_fill_coins: Arc::new(AtomicMap::new()),
1137 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1138 account_id: None,
1139 account_address: None,
1140 normalize_prices: true,
1141 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1142 include_builder_attribution: true,
1143 })
1144 }
1145
1146 pub fn with_credentials(
1159 private_key: Option<String>,
1160 vault_address: Option<String>,
1161 account_address: Option<&str>,
1162 environment: HyperliquidEnvironment,
1163 timeout_secs: u64,
1164 proxy_url: Option<String>,
1165 ) -> Result<Self> {
1166 let (pk_env_var, vault_env_var) = credential_env_vars(environment);
1167
1168 let resolved_account_address = resolve_execution_account_address(
1169 private_key.as_deref(),
1170 vault_address.as_deref(),
1171 account_address,
1172 environment,
1173 )?;
1174
1175 let resolved_pk = private_key.or_else(|| std::env::var(pk_env_var).ok());
1177
1178 let resolved_vault = vault_address.or_else(|| std::env::var(vault_env_var).ok());
1180
1181 Self::from_resolved_credentials(
1182 resolved_pk,
1183 resolved_vault.as_deref(),
1184 resolved_account_address,
1185 environment,
1186 timeout_secs,
1187 proxy_url,
1188 )
1189 }
1190
1191 fn from_resolved_credentials(
1192 private_key: Option<String>,
1193 vault_address: Option<&str>,
1194 account_address: Option<String>,
1195 environment: HyperliquidEnvironment,
1196 timeout_secs: u64,
1197 proxy_url: Option<String>,
1198 ) -> Result<Self> {
1199 match private_key {
1200 Some(pk) => {
1201 let raw_client = HyperliquidRawHttpClient::from_credentials(
1202 &pk,
1203 vault_address,
1204 environment,
1205 timeout_secs,
1206 proxy_url,
1207 )?;
1208 Ok(Self {
1209 inner: Arc::new(raw_client),
1210 clock: get_atomic_clock_realtime(),
1211 instruments: Arc::new(AtomicMap::new()),
1212 instruments_by_coin: Arc::new(AtomicMap::new()),
1213 asset_indices: Arc::new(AtomicMap::new()),
1214 spot_fill_coins: Arc::new(AtomicMap::new()),
1215 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1216 account_id: None,
1217 account_address,
1218 normalize_prices: true,
1219 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1220 include_builder_attribution: true,
1221 })
1222 }
1223 None => {
1224 let mut client = Self::new(environment, timeout_secs, proxy_url)
1226 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))?;
1227 client.set_account_address(account_address);
1228 Ok(client)
1229 }
1230 }
1231 }
1232
1233 pub fn from_credentials(
1239 private_key: &str,
1240 vault_address: Option<&str>,
1241 environment: HyperliquidEnvironment,
1242 timeout_secs: u64,
1243 proxy_url: Option<String>,
1244 ) -> Result<Self> {
1245 let raw_client = HyperliquidRawHttpClient::from_credentials(
1246 private_key,
1247 vault_address,
1248 environment,
1249 timeout_secs,
1250 proxy_url,
1251 )?;
1252 Ok(Self {
1253 inner: Arc::new(raw_client),
1254 clock: get_atomic_clock_realtime(),
1255 instruments: Arc::new(AtomicMap::new()),
1256 instruments_by_coin: Arc::new(AtomicMap::new()),
1257 asset_indices: Arc::new(AtomicMap::new()),
1258 spot_fill_coins: Arc::new(AtomicMap::new()),
1259 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1260 account_id: None,
1261 account_address: None,
1262 normalize_prices: true,
1263 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1264 include_builder_attribution: true,
1265 })
1266 }
1267
1268 #[must_use]
1270 pub fn is_testnet(&self) -> bool {
1271 self.inner.is_testnet()
1272 }
1273
1274 #[must_use]
1276 pub fn normalize_prices(&self) -> bool {
1277 self.normalize_prices
1278 }
1279
1280 pub fn set_normalize_prices(&mut self, value: bool) {
1282 self.normalize_prices = value;
1283 }
1284
1285 #[must_use]
1287 pub fn market_order_slippage_bps(&self) -> u32 {
1288 self.market_order_slippage_bps
1289 }
1290
1291 pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1293 self.market_order_slippage_bps = value;
1294 }
1295
1296 #[must_use]
1298 pub fn include_builder_attribution(&self) -> bool {
1299 self.include_builder_attribution
1300 }
1301
1302 pub fn set_include_builder_attribution(&mut self, value: bool) {
1304 self.include_builder_attribution = value;
1305 }
1306
1307 pub fn get_user_address(&self) -> Result<String> {
1313 self.inner.get_user_address()
1314 }
1315
1316 #[must_use]
1318 pub fn has_vault_address(&self) -> bool {
1319 self.inner.has_vault_address()
1320 }
1321
1322 #[must_use]
1327 pub fn builder_attribution(&self) -> Option<HyperliquidExchangeBuilderFee> {
1328 if !self.include_builder_attribution || self.has_vault_address() || self.is_testnet() {
1329 None
1330 } else {
1331 Some(HyperliquidExchangeBuilderFee {
1332 address: NAUTILUS_BUILDER_ADDRESS.to_string(),
1333 fee_tenths_bp: 0,
1334 })
1335 }
1336 }
1337
1338 pub fn get_account_address(&self) -> Result<String> {
1346 if let Some(addr) = &self.account_address {
1347 return Ok(addr.clone());
1348 }
1349 self.inner.get_account_address()
1350 }
1351
1352 pub fn set_account_address(&mut self, address: Option<String>) {
1354 self.account_address = address;
1355 }
1356
1357 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1368 let full_symbol = instrument.symbol().inner();
1369 let coin = instrument.raw_symbol().inner();
1370
1371 match instrument
1372 .info()
1373 .and_then(|info| info.get_u64(ASSET_INDEX_INFO_KEY))
1374 .and_then(|value| u32::try_from(value).ok())
1375 {
1376 Some(asset_index) => self.asset_indices.rcu(|m| {
1377 m.insert(full_symbol, asset_index);
1378 }),
1379 None if coin.starts_with(VAULT_TOKEN_PREFIX) => {}
1382 None if self.asset_indices.get_cloned(&full_symbol).is_none() => log::warn!(
1385 "Instrument '{full_symbol}' carries no '{ASSET_INDEX_INFO_KEY}' info value \
1386 and has no cached asset index; orders for it will be rejected"
1387 ),
1388 None => log::warn!(
1389 "Instrument '{full_symbol}' carries no '{ASSET_INDEX_INFO_KEY}' info value; \
1390 leaving the cached asset index unchanged"
1391 ),
1392 }
1393
1394 self.instruments.rcu(|m| {
1395 m.insert(full_symbol, instrument.clone());
1396 m.insert(coin, instrument.clone());
1398 });
1399
1400 if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
1402 self.instruments_by_coin.rcu(|m| {
1403 m.insert((coin, product_type), instrument.clone());
1404
1405 if let Some(alias_ustr) = cache_alias_for_symbol(full_symbol.as_str())
1430 .map(|alias| Ustr::from(alias.as_str()))
1431 {
1432 let key = (alias_ustr, product_type);
1433 if alias_ustr != coin && !m.contains_key(&key) {
1434 m.insert(key, instrument.clone());
1435 }
1436 }
1437 });
1438 } else {
1439 log::warn!("Unable to determine product type for symbol: {full_symbol}");
1440 }
1441 }
1442
1443 fn get_or_create_instrument(
1444 &self,
1445 coin: &Ustr,
1446 product_type: Option<HyperliquidProductType>,
1447 ) -> Option<InstrumentAny> {
1448 if let Some(pt) = product_type
1449 && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1450 {
1451 return Some(instrument.clone());
1452 }
1453
1454 if product_type.is_none() {
1458 let guard = self.instruments_by_coin.load();
1459
1460 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Outcome)) {
1461 return Some(instrument.clone());
1462 }
1463
1464 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1465 return Some(instrument.clone());
1466 }
1467
1468 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1469 return Some(instrument.clone());
1470 }
1471 }
1472
1473 if coin.starts_with('@')
1475 && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1476 {
1477 if let Some(instrument) = self.instruments.load().get(symbol) {
1480 return Some(instrument.clone());
1481 }
1482 }
1483
1484 if let Ok(asset_id) = parse_outcome_symbol(coin)
1487 && let Ok(def) = parse_unlisted_outcome_instrument(asset_id)
1488 && let Some(instrument) = create_instrument_from_def(&def, self.clock.get_time_ns())
1489 {
1490 log::debug!("Creating instrument for unlisted outcome coin: {coin}");
1491 self.cache_instrument(&instrument);
1492 return Some(instrument);
1493 }
1494
1495 if coin.starts_with(VAULT_TOKEN_PREFIX) {
1497 log::debug!("Creating synthetic instrument for vault token: {coin}");
1498
1499 let ts_event = self.clock.get_time_ns();
1500
1501 let symbol_str = format!("{coin}-USDC-SPOT");
1503 let symbol = Symbol::new(&symbol_str);
1504 let venue = *HYPERLIQUID_VENUE;
1505 let instrument_id = InstrumentId::new(symbol, venue);
1506
1507 let base_currency = Currency::new(
1509 coin.as_str(),
1510 8, 0, coin.as_str(),
1513 CurrencyType::Crypto,
1514 );
1515
1516 let quote_currency = Currency::new(
1517 "USDC",
1518 6, 0,
1520 "USDC",
1521 CurrencyType::Crypto,
1522 );
1523
1524 let price_increment = Price::from("0.00000001");
1525 let size_increment = Quantity::from("0.00000001");
1526
1527 let instrument = InstrumentAny::CurrencyPair(
1528 CurrencyPair::builder()
1529 .instrument_id(instrument_id)
1530 .raw_symbol(symbol)
1531 .base_currency(base_currency)
1532 .quote_currency(quote_currency)
1533 .price_precision(8)
1534 .size_precision(8)
1535 .price_increment(price_increment)
1536 .size_increment(size_increment)
1537 .ts_event(ts_event)
1538 .ts_init(ts_event)
1539 .build()
1540 .unwrap(),
1541 );
1542
1543 self.cache_instrument(&instrument);
1544
1545 Some(instrument)
1546 } else {
1547 log::warn!("Instrument not found in cache: {coin}");
1549 None
1550 }
1551 }
1552
1553 pub fn set_account_id(&mut self, account_id: AccountId) {
1557 self.account_id = Some(account_id);
1558 }
1559
1560 pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1562 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1563 let spot_meta = match self.inner.get_spot_meta().await {
1564 Ok(spot_meta) => Some(spot_meta),
1565 Err(e) => {
1566 log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1567 None
1568 }
1569 };
1570
1571 match self.inner.load_all_perp_metas().await {
1573 Ok(all_metas) => {
1574 for (dex_index, meta) in all_metas.iter().enumerate() {
1575 let base = perp_dex_asset_index_base(dex_index);
1576 let settlement_currency = match resolve_perp_settlement_currency(
1577 meta,
1578 spot_meta.as_ref(),
1579 ) {
1580 Ok(settlement_currency) => settlement_currency,
1581 Err(e) => {
1582 return Err(Error::decode(format!(
1583 "failed to resolve perp settlement currency for dex {dex_index}: {e}",
1584 )));
1585 }
1586 };
1587
1588 let perp_defs = parse_perp_instruments_with_settlement(
1589 meta,
1590 base,
1591 settlement_currency.as_str(),
1592 );
1593 log::debug!(
1594 "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1595 perp_defs.len(),
1596 );
1597 defs.extend(perp_defs);
1598 }
1599 }
1600 Err(e) => {
1601 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1602
1603 match self.inner.load_perp_meta().await {
1604 Ok(perp_meta) => {
1605 match resolve_perp_settlement_currency(&perp_meta, spot_meta.as_ref()) {
1606 Ok(settlement_currency) => {
1607 let perp_defs = parse_perp_instruments_with_settlement(
1608 &perp_meta,
1609 0,
1610 settlement_currency.as_str(),
1611 );
1612 log::debug!(
1613 "Loaded Hyperliquid perp defs via fallback: count={}",
1614 perp_defs.len(),
1615 );
1616 defs.extend(perp_defs);
1617 }
1618 Err(e) => {
1619 return Err(Error::decode(format!(
1620 "failed to resolve fallback perp settlement currency: {e}",
1621 )));
1622 }
1623 }
1624 }
1625 Err(e) => {
1626 log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1627 }
1628 }
1629 }
1630 }
1631
1632 if let Some(spot_meta) = spot_meta.as_ref() {
1633 match parse_spot_instruments(spot_meta) {
1634 Ok(spot_defs) => {
1635 log::debug!(
1636 "Loaded Hyperliquid spot definitions: count={}",
1637 spot_defs.len(),
1638 );
1639 defs.extend(spot_defs);
1640 }
1641 Err(e) => {
1642 log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1643 }
1644 }
1645 }
1646
1647 match self.inner.get_outcome_meta().await {
1651 Ok(outcome_meta) => match parse_outcome_instruments(&outcome_meta) {
1652 Ok(outcome_defs) => {
1653 log::debug!(
1654 "Loaded Hyperliquid outcome definitions: count={}",
1655 outcome_defs.len(),
1656 );
1657 defs.extend(outcome_defs);
1658 }
1659 Err(e) => {
1660 log::warn!("Failed to parse Hyperliquid outcome instruments: {e}");
1661 }
1662 },
1663 Err(e) => {
1664 log::debug!("Skipping Hyperliquid outcome metadata: {e}");
1665 }
1666 }
1667
1668 let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1675 let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1676 for def in defs {
1677 if seen_symbols.insert(def.symbol) {
1678 deduped.push(def);
1679 } else {
1680 log::warn!(
1681 "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1682 def.symbol,
1683 def.raw_symbol,
1684 );
1685 }
1686 }
1687 let defs = deduped;
1688
1689 self.asset_indices.rcu(|m| {
1691 for def in &defs {
1692 m.insert(def.symbol, def.asset_index);
1693 }
1694 });
1695 log::debug!(
1696 "Populated asset indices map (count={})",
1697 self.asset_indices.len()
1698 );
1699
1700 Ok(defs)
1701 }
1702
1703 pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1705 let ts_init = self.clock.get_time_ns();
1706 instruments_from_defs_owned(defs, ts_init)
1707 }
1708
1709 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1711 let defs = self.request_instrument_defs().await?;
1712 Ok(self.convert_defs(defs))
1713 }
1714
1715 pub async fn build_all_dex_asset_ctxs_instrument_ids(
1720 &self,
1721 ) -> Result<AHashMap<String, Vec<Option<InstrumentId>>>> {
1722 let all_metas = match self.inner.load_all_perp_metas().await {
1723 Ok(all_metas) => all_metas,
1724 Err(e) => {
1725 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1726 vec![self.inner.load_perp_meta().await?]
1727 }
1728 };
1729
1730 let perp_dexs = match self.inner.load_perp_dexs().await {
1731 Ok(dexs) => Some(dexs),
1732 Err(e) => {
1733 log::warn!("Failed to load perpDexs, inferring dex names from metadata: {e}");
1734 None
1735 }
1736 };
1737
1738 let raw_symbol_to_id =
1739 self.instruments
1740 .load()
1741 .values()
1742 .fold(AHashMap::new(), |mut acc, instrument| {
1743 acc.insert(instrument.raw_symbol().to_string(), instrument.id());
1744 acc
1745 });
1746
1747 let mut mapping = AHashMap::new();
1748
1749 for (dex_index, meta) in all_metas.iter().enumerate() {
1750 let dex_name = resolve_perp_dex_name(dex_index, meta, perp_dexs.as_deref());
1751 let mut instrument_ids = Vec::with_capacity(meta.universe.len());
1752
1753 for asset in &meta.universe {
1754 if let Some(instrument_id) = raw_symbol_to_id.get(&asset.name) {
1755 instrument_ids.push(Some(*instrument_id));
1756 } else {
1757 log::warn!(
1758 "Missing cached Hyperliquid instrument for dex='{}' raw_symbol='{}'",
1759 dex_name,
1760 asset.name
1761 );
1762 instrument_ids.push(None);
1763 }
1764 }
1765
1766 mapping.insert(dex_name, instrument_ids);
1767 }
1768
1769 Ok(mapping)
1770 }
1771
1772 pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1780 self.get_asset_index_for_symbol(Ustr::from(symbol))
1781 }
1782
1783 pub(crate) fn get_asset_index_for_symbol(&self, symbol: Ustr) -> Option<u32> {
1787 self.asset_indices.load().get(&symbol).copied()
1788 }
1789
1790 pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1792 self.get_price_precision_for_symbol(Ustr::from(symbol))
1793 }
1794
1795 pub(crate) fn get_price_precision_for_symbol(&self, symbol: Ustr) -> Option<u8> {
1797 self.instruments
1798 .load()
1799 .get(&symbol)
1800 .map(|inst| inst.price_precision())
1801 }
1802
1803 #[must_use]
1811 pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1812 const SPOT_INDEX_OFFSET: u32 = 10_000;
1813 const BUILDER_PERP_OFFSET: u32 = 100_000;
1814
1815 let guard = self.asset_indices.load();
1816
1817 let mut mapping = AHashMap::new();
1818
1819 for (symbol, &asset_index) in guard.iter() {
1820 if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1822 let pair_index = asset_index - SPOT_INDEX_OFFSET;
1823 let fill_coin = Ustr::from(&format!("@{pair_index}"));
1824 mapping.insert(fill_coin, *symbol);
1825 }
1826 }
1827
1828 self.spot_fill_coins.store(mapping.clone());
1830
1831 mapping
1832 }
1833
1834 #[allow(dead_code)]
1836 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1837 self.inner.load_perp_meta().await
1838 }
1839
1840 #[allow(dead_code)]
1842 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1843 self.inner.load_all_perp_metas().await
1844 }
1845
1846 #[allow(dead_code)]
1848 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1849 self.inner.get_spot_meta().await
1850 }
1851
1852 pub(crate) async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
1854 self.inner.get_outcome_meta().await
1855 }
1856
1857 pub(crate) fn outcome_quote_currency(
1858 &self,
1859 instrument_id: InstrumentId,
1860 outcome_index: u32,
1861 meta: &OutcomeMeta,
1862 ) -> Currency {
1863 if let Some(instrument) = self.instruments.load().get(&instrument_id.symbol.inner()) {
1864 return instrument.quote_currency();
1865 }
1866
1867 let code = meta
1868 .outcomes
1869 .iter()
1870 .find(|market| market.outcome == outcome_index)
1871 .and_then(|market| market.quote_token.as_deref())
1872 .unwrap_or(DEFAULT_OUTCOME_QUOTE_CURRENCY);
1873 get_outcome_currency(code)
1874 }
1875
1876 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1878 self.inner.info_l2_book(coin).await
1879 }
1880
1881 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
1883 self.inner.info_recent_trades(coin).await
1884 }
1885
1886 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1888 self.inner.info_user_fills(user).await
1889 }
1890
1891 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1893 self.inner.info_order_status(user, oid).await
1894 }
1895
1896 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1898 self.inner.info_open_orders(user).await
1899 }
1900
1901 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1903 self.inner.info_frontend_open_orders(user).await
1904 }
1905
1906 async fn info_frontend_open_orders_for_dex(
1907 &self,
1908 user: &str,
1909 dex: Option<&str>,
1910 ) -> Result<Value> {
1911 self.inner
1912 .info_frontend_open_orders_for_dex(user, dex)
1913 .await
1914 }
1915
1916 pub async fn info_historical_orders(
1918 &self,
1919 user: &str,
1920 ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
1921 self.inner.info_historical_orders(user).await
1922 }
1923
1924 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1926 self.inner.info_clearinghouse_state(user).await
1927 }
1928
1929 async fn info_clearinghouse_state_for_dex(
1930 &self,
1931 user: &str,
1932 dex: Option<&str>,
1933 ) -> Result<Value> {
1934 self.inner.info_clearinghouse_state_for_dex(user, dex).await
1935 }
1936
1937 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
1939 self.inner.info_spot_clearinghouse_state(user).await
1940 }
1941
1942 pub async fn info_user_abstraction(&self, user: &str) -> Result<HyperliquidAccountAbstraction> {
1952 let response = self.inner.info_user_abstraction(user).await?;
1953
1954 let abstraction = response
1958 .as_str()
1959 .and_then(|_| serde_json::from_value(response.clone()).ok())
1960 .ok_or_else(|| {
1961 Error::decode(format!(
1962 "Failed to parse user abstraction: expected a mode string, was {response}"
1963 ))
1964 })?;
1965
1966 if abstraction == HyperliquidAccountAbstraction::Unknown {
1967 log::warn!(
1968 "Unrecognized Hyperliquid account abstraction {response}, using perp clearinghouse balances"
1969 );
1970 } else {
1971 log::debug!("Hyperliquid account abstraction: {abstraction}");
1972 }
1973
1974 Ok(abstraction)
1975 }
1976
1977 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
1979 self.inner.info_user_fees(user).await
1980 }
1981
1982 pub async fn info_candle_snapshot(
1984 &self,
1985 coin: &str,
1986 interval: HyperliquidBarInterval,
1987 start_time: u64,
1988 end_time: u64,
1989 ) -> Result<HyperliquidCandleSnapshot> {
1990 self.inner
1991 .info_candle_snapshot(coin, interval, start_time, end_time)
1992 .await
1993 }
1994
1995 pub async fn info_funding_history(
1997 &self,
1998 coin: &str,
1999 start_time: u64,
2000 end_time: Option<u64>,
2001 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
2002 self.inner
2003 .info_funding_history(coin, start_time, end_time)
2004 .await
2005 }
2006
2007 pub async fn post_action(
2009 &self,
2010 action: &ExchangeAction,
2011 ) -> Result<HyperliquidExchangeResponse> {
2012 self.inner.post_action(action).await
2013 }
2014
2015 pub async fn post_action_exec(
2017 &self,
2018 action: &HyperliquidExchangeAction,
2019 ) -> Result<HyperliquidExchangeResponse> {
2020 self.inner.post_action_exec(action).await
2021 }
2022
2023 pub fn sign_action_exec_request(
2025 &self,
2026 action: &HyperliquidExchangeAction,
2027 expires_after: Option<u64>,
2028 ) -> Result<HyperliquidExchangeRequest<HyperliquidExchangeAction>> {
2029 self.inner.sign_action_exec_request(action, expires_after)
2030 }
2031
2032 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
2034 self.inner.info_meta().await
2035 }
2036
2037 pub async fn cancel_order(
2047 &self,
2048 instrument_id: InstrumentId,
2049 client_order_id: Option<ClientOrderId>,
2050 venue_order_id: Option<VenueOrderId>,
2051 ) -> Result<()> {
2052 let symbol = instrument_id.symbol.inner();
2054 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
2055 Error::bad_request(format!(
2056 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2057 ))
2058 })?;
2059
2060 let action = if let Some(client_order_id) = client_order_id {
2061 if let Some(cloid) = self.cached_client_order_id_cloid(&client_order_id) {
2062 HyperliquidExchangeAction::CancelByCloid {
2063 cancels: vec![HyperliquidExchangeCancelByCloidRequest {
2064 asset: asset_id,
2065 cloid,
2066 }],
2067 fast: None,
2068 }
2069 } else if let Some(oid) = venue_order_id {
2070 let oid_u64 = oid
2071 .as_str()
2072 .parse::<u64>()
2073 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
2074 HyperliquidExchangeAction::Cancel {
2075 cancels: vec![HyperliquidExchangeCancelOrderRequest {
2076 asset: asset_id,
2077 oid: oid_u64,
2078 }],
2079 fast: None,
2080 }
2081 } else {
2082 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
2083 HyperliquidExchangeAction::CancelByCloid {
2084 cancels: vec![HyperliquidExchangeCancelByCloidRequest {
2085 asset: asset_id,
2086 cloid,
2087 }],
2088 fast: None,
2089 }
2090 }
2091 } else if let Some(oid) = venue_order_id {
2092 let oid_u64 = oid
2093 .as_str()
2094 .parse::<u64>()
2095 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
2096 HyperliquidExchangeAction::Cancel {
2097 cancels: vec![HyperliquidExchangeCancelOrderRequest {
2098 asset: asset_id,
2099 oid: oid_u64,
2100 }],
2101 fast: None,
2102 }
2103 } else {
2104 return Err(Error::bad_request(
2105 "Either client_order_id or venue_order_id must be provided",
2106 ));
2107 };
2108
2109 let response = self.inner.post_action_exec(&action).await?;
2111
2112 match response {
2114 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
2115 HyperliquidExchangeResponse::Status {
2116 status,
2117 response: error_data,
2118 } => Err(Error::bad_request(format!(
2119 "Cancel order failed: status={status}, error={error_data}"
2120 ))),
2121 HyperliquidExchangeResponse::Error { error } => {
2122 Err(Error::bad_request(format!("Cancel order error: {error}")))
2123 }
2124 }
2125 }
2126
2127 #[expect(clippy::too_many_arguments)]
2137 pub async fn modify_order(
2138 &self,
2139 instrument_id: InstrumentId,
2140 venue_order_id: Option<VenueOrderId>,
2141 order_side: OrderSide,
2142 order_type: OrderType,
2143 price: Price,
2144 quantity: Quantity,
2145 trigger_price: Option<Price>,
2146 reduce_only: bool,
2147 post_only: bool,
2148 time_in_force: TimeInForce,
2149 client_order_id: Option<ClientOrderId>,
2150 ) -> Result<()> {
2151 let symbol = instrument_id.symbol.inner();
2152 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
2153 Error::bad_request(format!(
2154 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2155 ))
2156 })?;
2157
2158 let oid = match client_order_id
2159 .as_ref()
2160 .and_then(|id| self.unique_cached_client_order_id_cloid(id))
2161 {
2162 Some(cloid) => HyperliquidExchangeModifyTarget::Cloid(cloid),
2163 None => {
2164 let Some(venue_order_id) = venue_order_id.as_ref() else {
2165 return Err(Error::bad_request(
2166 "venue_order_id or unique cached CLOID is required for modify",
2167 ));
2168 };
2169 HyperliquidExchangeModifyTarget::from_venue_order_id(venue_order_id)
2170 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?
2171 }
2172 };
2173
2174 let is_buy = matches!(order_side, OrderSide::Buy);
2175 let decimals = self.get_price_precision_for_symbol(symbol);
2176
2177 let normalized_price = normalize_or_validate_wire_price(
2178 price.as_decimal(),
2179 "Price",
2180 decimals,
2181 self.normalize_prices,
2182 )
2183 .map_err(|e| Error::bad_request(format!("{e}")))?;
2184
2185 let size = quantity.as_decimal().normalize();
2186
2187 let kind = match order_type {
2188 OrderType::Market => HyperliquidExchangeOrderKind::Limit {
2189 limit: HyperliquidExchangeLimitParams {
2190 tif: HyperliquidExchangeTif::Ioc,
2191 },
2192 },
2193 OrderType::Limit => {
2194 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
2195 .map_err(|e| Error::bad_request(format!("{e}")))?;
2196 HyperliquidExchangeOrderKind::Limit {
2197 limit: HyperliquidExchangeLimitParams { tif },
2198 }
2199 }
2200 OrderType::StopMarket
2201 | OrderType::StopLimit
2202 | OrderType::MarketIfTouched
2203 | OrderType::LimitIfTouched => {
2204 if let Some(trig_px) = trigger_price {
2205 let trigger_price_decimal = normalize_or_validate_wire_price(
2206 trig_px.as_decimal(),
2207 "Trigger price",
2208 decimals,
2209 self.normalize_prices,
2210 )
2211 .map_err(|e| Error::bad_request(format!("{e}")))?;
2212 let tpsl = match order_type {
2213 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
2214 _ => HyperliquidExchangeTpSl::Tp,
2215 };
2216 let is_market = matches!(
2217 order_type,
2218 OrderType::StopMarket | OrderType::MarketIfTouched
2219 );
2220 HyperliquidExchangeOrderKind::Trigger {
2221 trigger: HyperliquidExchangeTriggerParams {
2222 is_market,
2223 trigger_px: trigger_price_decimal,
2224 tpsl,
2225 },
2226 }
2227 } else {
2228 return Err(Error::bad_request("Trigger orders require a trigger price"));
2229 }
2230 }
2231 _ => {
2232 return Err(Error::bad_request(format!(
2233 "Order type {order_type:?} not supported for modify"
2234 )));
2235 }
2236 };
2237 let cloid = client_order_id.map(|id| self.get_or_generate_client_order_id_cloid(id));
2238
2239 let order = HyperliquidExchangePlaceOrderRequest {
2240 asset: asset_id,
2241 is_buy,
2242 price: normalized_price,
2243 size,
2244 reduce_only,
2245 kind,
2246 cloid,
2247 };
2248
2249 let action = HyperliquidExchangeAction::Modify {
2250 modify: HyperliquidExchangeModifyOrderRequest { oid, order },
2251 };
2252
2253 let response = self.inner.post_action_exec(&action).await?;
2254
2255 match response {
2256 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
2257 if let Some(inner_error) = extract_inner_error(&response) {
2258 Err(Error::bad_request(format!(
2259 "Modify order rejected: {inner_error}",
2260 )))
2261 } else {
2262 Ok(())
2263 }
2264 }
2265 HyperliquidExchangeResponse::Status {
2266 status,
2267 response: error_data,
2268 } => Err(Error::bad_request(format!(
2269 "Modify order failed: status={status}, error={error_data}"
2270 ))),
2271 HyperliquidExchangeResponse::Error { error } => {
2272 Err(Error::bad_request(format!("Modify order error: {error}")))
2273 }
2274 }
2275 }
2276
2277 pub async fn submit_split_outcome(
2291 &self,
2292 outcome: u32,
2293 amount: Decimal,
2294 ) -> Result<HyperliquidExchangeResponse> {
2295 let action = HyperliquidExchangeAction::UserOutcome {
2296 op: HyperliquidExchangeUserOutcomeOp::SplitOutcome(
2297 HyperliquidExchangeSplitOutcomeParams { outcome, amount },
2298 ),
2299 };
2300 self.inner.post_action_exec(&action).await
2301 }
2302
2303 pub async fn submit_merge_outcome(
2314 &self,
2315 outcome: u32,
2316 amount: Option<Decimal>,
2317 ) -> Result<HyperliquidExchangeResponse> {
2318 let action = HyperliquidExchangeAction::UserOutcome {
2319 op: HyperliquidExchangeUserOutcomeOp::MergeOutcome(
2320 HyperliquidExchangeMergeOutcomeParams { outcome, amount },
2321 ),
2322 };
2323 self.inner.post_action_exec(&action).await
2324 }
2325
2326 pub async fn submit_merge_question(
2336 &self,
2337 question: u32,
2338 amount: Option<Decimal>,
2339 ) -> Result<HyperliquidExchangeResponse> {
2340 let action = HyperliquidExchangeAction::UserOutcome {
2341 op: HyperliquidExchangeUserOutcomeOp::MergeQuestion(
2342 HyperliquidExchangeMergeQuestionParams { question, amount },
2343 ),
2344 };
2345 self.inner.post_action_exec(&action).await
2346 }
2347
2348 pub async fn submit_negate_outcome(
2358 &self,
2359 question: u32,
2360 outcome: u32,
2361 amount: Decimal,
2362 ) -> Result<HyperliquidExchangeResponse> {
2363 let action = HyperliquidExchangeAction::UserOutcome {
2364 op: HyperliquidExchangeUserOutcomeOp::NegateOutcome(
2365 HyperliquidExchangeNegateOutcomeParams {
2366 question,
2367 outcome,
2368 amount,
2369 },
2370 ),
2371 };
2372 self.inner.post_action_exec(&action).await
2373 }
2374
2375 pub async fn request_order_status_reports(
2390 &self,
2391 user: &str,
2392 instrument_id: Option<InstrumentId>,
2393 ) -> Result<Vec<OrderStatusReport>> {
2394 let dexes = self.reconciliation_dexes(instrument_id);
2395 let sweep = self
2396 .request_order_status_reports_for_dexes(user, instrument_id, &dexes)
2397 .await?;
2398
2399 if !sweep.complete {
2400 return Err(Error::bad_request(
2401 "Open-order snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2402 ));
2403 }
2404
2405 Ok(sweep.reports)
2406 }
2407
2408 pub(crate) async fn request_order_status_reports_for_dexes(
2409 &self,
2410 user: &str,
2411 instrument_id: Option<InstrumentId>,
2412 dexes: &[Option<Ustr>],
2413 ) -> Result<ReportSweep<OrderStatusReport>> {
2414 let account_id = self
2415 .account_id
2416 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2417 let mut reports = Vec::new();
2418 let mut complete = true;
2419 let ts_init = self.clock.get_time_ns();
2420
2421 for dex in dexes {
2422 let response = self
2423 .info_frontend_open_orders_for_dex(user, dex.as_deref())
2424 .await?;
2425 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
2426 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
2427
2428 for order_value in orders {
2429 let order: WsBasicOrderData = match serde_json::from_value(order_value) {
2430 Ok(order) => order,
2431 Err(e) => {
2432 log::warn!("Failed to parse order: {e}");
2433 complete = false;
2434 continue;
2435 }
2436 };
2437
2438 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2439 Some(instrument) => instrument,
2440 None => {
2442 complete = false;
2443 continue;
2444 }
2445 };
2446
2447 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2448 continue;
2449 }
2450
2451 match parse_order_status_report_from_basic(
2452 &order,
2453 &HyperliquidOrderStatusEnum::Open,
2454 &instrument,
2455 account_id,
2456 ts_init,
2457 ) {
2458 Ok(report) => reports.push(report),
2459 Err(e) => {
2460 log::error!("Failed to parse order status report: {e}");
2461 complete = false;
2462 }
2463 }
2464 }
2465 }
2466
2467 Ok(ReportSweep { reports, complete })
2468 }
2469
2470 pub async fn request_historical_order_status_reports(
2482 &self,
2483 user: &str,
2484 instrument_id: Option<InstrumentId>,
2485 ) -> Result<Vec<OrderStatusReport>> {
2486 let entries = self.info_historical_orders(user).await?;
2487 let sweep = self.historical_order_status_reports_from_response(entries, instrument_id)?;
2488
2489 if !sweep.complete {
2490 return Err(Error::bad_request(
2491 "Historical-order snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2492 ));
2493 }
2494
2495 Ok(sweep.reports)
2496 }
2497
2498 pub(crate) fn historical_order_status_reports_from_response(
2499 &self,
2500 entries: Vec<HyperliquidOrderStatusEntry>,
2501 instrument_id: Option<InstrumentId>,
2502 ) -> Result<ReportSweep<OrderStatusReport>> {
2503 let account_id = self
2504 .account_id
2505 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2506 let mut reports = Vec::new();
2507 let mut complete = true;
2508 let ts_init = self.clock.get_time_ns();
2509
2510 for entry in entries {
2511 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2512 Some(instrument) => instrument,
2514 None => {
2515 complete = false;
2516 continue;
2517 }
2518 };
2519
2520 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2521 continue;
2522 }
2523
2524 let label = entry.order.order_type.as_deref().unwrap_or_default();
2525 let tpsl = parse_trigger_order_type_label(label).map(|(tpsl, _)| tpsl);
2526 let is_market = label.ends_with("Market");
2527 let historical_order_type = match tpsl.as_ref() {
2528 Some(tpsl) => parse_trigger_order_type(is_market, tpsl),
2529 None if is_market => OrderType::Market,
2530 None => OrderType::Limit,
2531 };
2532 let order = WsBasicOrderData {
2533 coin: entry.order.coin,
2534 side: entry.order.side,
2535 limit_px: entry.order.limit_px,
2536 sz: entry.order.sz,
2537 oid: entry.order.oid,
2538 timestamp: entry.order.timestamp,
2539 orig_sz: entry.order.orig_sz,
2540 cloid: entry.order.cloid,
2541 tif: entry.order.tif,
2542 reduce_only: entry.order.reduce_only,
2543 trigger_px: entry
2544 .order
2545 .trigger_px
2546 .filter(|price| *price != Decimal::ZERO),
2547 is_market: tpsl.is_some().then_some(is_market),
2548 tpsl,
2549 trigger_activated: None,
2550 trailing_stop: None,
2551 order_type: None,
2552 };
2553
2554 match parse_order_status_report_from_basic(
2555 &order,
2556 &entry.status,
2557 &instrument,
2558 account_id,
2559 ts_init,
2560 ) {
2561 Ok(mut report) => {
2562 report.order_type = historical_order_type;
2563 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2564 reports.push(report);
2565 }
2566 Err(e) => {
2567 log::error!("Failed to parse historical order status report: {e}");
2568 complete = false;
2569 }
2570 }
2571 }
2572
2573 Ok(ReportSweep {
2574 reports: deduplicate_historical_order_reports(reports),
2575 complete,
2576 })
2577 }
2578
2579 pub async fn request_order_status_report(
2591 &self,
2592 user: &str,
2593 oid: u64,
2594 ) -> Result<Option<OrderStatusReport>> {
2595 let account_id = self
2596 .account_id
2597 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2598
2599 let ts_init = self.clock.get_time_ns();
2600
2601 let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
2606 Ok(response) => match serde_json::from_value(response) {
2607 Ok(v) => v,
2608 Err(e) => {
2609 log::warn!("Failed to parse frontend open orders response: {e}");
2610 Vec::new()
2611 }
2612 },
2613 Err(e) => {
2614 log::warn!(
2615 "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
2616 );
2617 Vec::new()
2618 }
2619 };
2620
2621 if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
2622 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2623 Some(inst) => inst,
2624 None => {
2625 return Err(Error::bad_request(format!(
2626 "Failed to resolve instrument for open order oid {oid} with coin {}",
2627 order.coin,
2628 )));
2629 }
2630 };
2631
2632 let status = if order.trigger_activated == Some(true) {
2633 HyperliquidOrderStatusEnum::Triggered
2634 } else {
2635 HyperliquidOrderStatusEnum::Open
2636 };
2637
2638 return parse_order_status_report_from_basic(
2639 &order,
2640 &status,
2641 &instrument,
2642 account_id,
2643 ts_init,
2644 )
2645 .map(Some)
2646 .map_err(|e| {
2647 Error::bad_request(format!(
2648 "Failed to parse order status report for oid {oid}: {e}"
2649 ))
2650 });
2651 }
2652
2653 let response = self.info_order_status(user, oid).await?;
2655 let entry = match response.into_order() {
2656 Some(e) => e,
2657 None => return Ok(None),
2658 };
2659
2660 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2661 Some(inst) => inst,
2662 None => {
2663 return Err(Error::bad_request(format!(
2664 "Failed to resolve instrument for order oid {oid} with coin {}",
2665 entry.order.coin,
2666 )));
2667 }
2668 };
2669
2670 let basic = WsBasicOrderData {
2677 coin: entry.order.coin,
2678 side: entry.order.side,
2679 limit_px: entry.order.limit_px,
2680 sz: entry.order.sz,
2681 oid: entry.order.oid,
2682 timestamp: entry.order.timestamp,
2683 orig_sz: entry.order.orig_sz,
2684 cloid: entry.order.cloid,
2685 tif: None,
2686 reduce_only: entry.order.reduce_only,
2687 trigger_px: None,
2688 is_market: None,
2689 tpsl: None,
2690 trigger_activated: None,
2691 trailing_stop: None,
2692 order_type: None,
2693 };
2694
2695 let mut report = parse_order_status_report_from_basic(
2696 &basic,
2697 &entry.status,
2698 &instrument,
2699 account_id,
2700 ts_init,
2701 )
2702 .map_err(|e| {
2703 Error::bad_request(format!(
2704 "Failed to parse order status report for oid {oid}: {e}"
2705 ))
2706 })?;
2707
2708 if entry.status_timestamp > 0 {
2711 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2712 }
2713
2714 Ok(Some(report))
2715 }
2716
2717 pub async fn request_order_status_report_by_client_order_id(
2728 &self,
2729 user: &str,
2730 client_order_id: &ClientOrderId,
2731 ) -> Result<Option<OrderStatusReport>> {
2732 let account_id = self
2733 .account_id
2734 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2735
2736 let ts_init = self.clock.get_time_ns();
2737
2738 let cached_cloid_hex = self
2739 .cached_client_order_id_cloid(client_order_id)
2740 .map(|cloid| cloid.to_hex());
2741 let cloid = Cloid::from_client_order_id(*client_order_id);
2742 let cloid_hex = cloid.to_hex();
2743
2744 let response = self.info_frontend_open_orders(user).await?;
2745
2746 let orders: Vec<WsBasicOrderData> = serde_json::from_value(response).map_err(|e| {
2747 Error::bad_request(format!("Failed to parse open orders response: {e}"))
2748 })?;
2749
2750 let order = match orders.into_iter().find(|o| {
2751 o.cloid
2752 .as_ref()
2753 .is_some_and(|c| cached_cloid_hex.as_ref() == Some(c) || c == &cloid_hex)
2754 }) {
2755 Some(o) => o,
2756 None => return Ok(None),
2757 };
2758
2759 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2760 Some(inst) => inst,
2761 None => {
2762 return Err(Error::bad_request(format!(
2763 "Failed to resolve instrument for open order with cloid {cloid_hex} and coin {}",
2764 order.coin,
2765 )));
2766 }
2767 };
2768
2769 let status = if order.trigger_activated == Some(true) {
2770 HyperliquidOrderStatusEnum::Triggered
2771 } else {
2772 HyperliquidOrderStatusEnum::Open
2773 };
2774
2775 let mut report =
2776 parse_order_status_report_from_basic(&order, &status, &instrument, account_id, ts_init)
2777 .map_err(|e| {
2778 Error::bad_request(format!(
2779 "Failed to parse order status report for cloid {cloid_hex}: {e}"
2780 ))
2781 })?;
2782
2783 report.client_order_id = Some(*client_order_id);
2784 Ok(Some(report))
2785 }
2786
2787 pub async fn request_fill_reports(
2803 &self,
2804 user: &str,
2805 instrument_id: Option<InstrumentId>,
2806 ) -> Result<Vec<FillReport>> {
2807 let fills_response = self.info_user_fills(user).await?;
2808 let sweep = self.fill_reports_from_response(fills_response, instrument_id)?;
2809
2810 if !sweep.complete {
2811 return Err(Error::bad_request(
2812 "Fill snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2813 ));
2814 }
2815
2816 Ok(sweep.reports)
2817 }
2818
2819 pub(crate) fn fill_reports_from_response(
2820 &self,
2821 fills_response: HyperliquidFills,
2822 instrument_id: Option<InstrumentId>,
2823 ) -> Result<ReportSweep<FillReport>> {
2824 let account_id = self
2825 .account_id
2826 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2827
2828 let mut reports = Vec::new();
2829 let mut complete = true;
2830 let ts_init = self.clock.get_time_ns();
2831
2832 for fill in fills_response {
2833 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
2835 Some(inst) => inst,
2836 None => {
2838 complete = false;
2839 continue;
2840 }
2841 };
2842
2843 if let Some(filter_id) = instrument_id
2845 && instrument.id() != filter_id
2846 {
2847 continue;
2848 }
2849
2850 match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2852 Ok(report) => reports.push(report),
2853 Err(e) => {
2854 log::error!("Failed to parse fill report: {e}");
2855 complete = false;
2856 }
2857 }
2858 }
2859
2860 Ok(ReportSweep { reports, complete })
2861 }
2862
2863 pub async fn request_position_status_reports(
2887 &self,
2888 user: &str,
2889 instrument_id: Option<InstrumentId>,
2890 ) -> Result<Vec<PositionStatusReport>> {
2891 let dexes = self.reconciliation_dexes(instrument_id);
2892 let sweep = self
2893 .request_position_status_reports_for_dexes(user, instrument_id, &dexes)
2894 .await?;
2895
2896 if !sweep.complete {
2897 return Err(Error::bad_request(
2898 "Position snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2899 ));
2900 }
2901
2902 Ok(sweep.reports)
2903 }
2904
2905 pub(crate) async fn request_position_status_reports_for_dexes(
2906 &self,
2907 user: &str,
2908 instrument_id: Option<InstrumentId>,
2909 dexes: &[Option<Ustr>],
2910 ) -> Result<ReportSweep<PositionStatusReport>> {
2911 let account_id = self
2912 .account_id
2913 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2914
2915 let filter_product = instrument_id
2916 .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
2917
2918 let fetch_perp = !matches!(
2919 filter_product,
2920 Some(HyperliquidProductType::Spot | HyperliquidProductType::Outcome)
2921 );
2922 let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
2923
2924 let mut reports = Vec::new();
2925 let mut complete = true;
2926 let ts_init = self.clock.get_time_ns();
2927
2928 if !fetch_perp {
2929 return self
2930 .request_spot_position_status_reports_sweep(user, instrument_id)
2931 .await;
2932 }
2933
2934 for dex in dexes {
2935 let state_response = self
2936 .info_clearinghouse_state_for_dex(user, dex.as_deref())
2937 .await?;
2938 let asset_positions: Vec<serde_json::Value> = state_response
2939 .get("assetPositions")
2940 .and_then(|value| value.as_array())
2941 .ok_or_else(|| {
2942 Error::bad_request("assetPositions not found in clearinghouse state")
2943 })?
2944 .clone();
2945
2946 for position_value in asset_positions {
2947 let coin = position_value
2948 .get("position")
2949 .and_then(|position| position.get("coin"))
2950 .and_then(|coin| coin.as_str())
2951 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
2952
2953 let instrument = match self.get_or_create_instrument(&Ustr::from(coin), None) {
2954 Some(instrument) => instrument,
2955 None => {
2957 complete = false;
2958 continue;
2959 }
2960 };
2961
2962 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2963 continue;
2964 }
2965
2966 match parse_position_status_report(
2967 &position_value,
2968 &instrument,
2969 account_id,
2970 ts_init,
2971 ) {
2972 Ok(report) => reports.push(report),
2973 Err(e) => {
2974 log::error!("Failed to parse position status report: {e}");
2975 complete = false;
2976 }
2977 }
2978 }
2979 }
2980
2981 if fetch_spot {
2984 let spot_sweep = self
2985 .request_spot_position_status_reports_sweep(user, instrument_id)
2986 .await?;
2987 reports.extend(spot_sweep.reports);
2988 complete &= spot_sweep.complete;
2989 }
2990
2991 Ok(ReportSweep { reports, complete })
2992 }
2993
2994 pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
3013 let account_id = self
3014 .account_id
3015 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3016 let state_response = self.info_clearinghouse_state(user).await?;
3017 let ts_init = self.clock.get_time_ns();
3018
3019 log::trace!("Clearinghouse state response: {state_response}");
3020
3021 let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
3022 .map_err(|e| {
3023 log::error!("Failed to parse clearinghouse state: {e}");
3024 log::debug!("Raw response: {state_response}");
3025 Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
3026 })?;
3027
3028 let spot_response = self.info_spot_clearinghouse_state(user).await?;
3031 let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
3032 .map_err(|e| {
3033 log::error!("Failed to parse spot clearinghouse state: {e}");
3034 log::debug!("Raw spot response: {spot_response}");
3035 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
3036 })?;
3037
3038 let abstraction = self.info_user_abstraction(user).await?;
3040
3041 let (balances, margins) =
3042 parse_combined_account_balances_and_margins(&perp_state, &spot_state, abstraction)
3043 .map_err(|e| Error::decode(e.to_string()))?;
3044
3045 Ok(AccountState::new(
3046 account_id,
3047 AccountType::Margin,
3048 balances,
3049 margins,
3050 true, UUID4::new(),
3052 ts_init,
3053 ts_init,
3054 None,
3055 ))
3056 }
3057
3058 pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
3069 let response = self.info_spot_clearinghouse_state(user).await?;
3070
3071 log::trace!("Spot clearinghouse state response: {response}");
3072
3073 let state: SpotClearinghouseState =
3074 serde_json::from_value(response.clone()).map_err(|e| {
3075 log::error!("Failed to parse spot clearinghouse state: {e}");
3076 log::debug!("Raw response: {response}");
3077 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
3078 })?;
3079
3080 parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
3081 }
3082
3083 pub async fn request_spot_position_status_reports(
3099 &self,
3100 user: &str,
3101 instrument_id: Option<InstrumentId>,
3102 ) -> Result<Vec<PositionStatusReport>> {
3103 let sweep = self
3104 .request_spot_position_status_reports_sweep(user, instrument_id)
3105 .await?;
3106
3107 if !sweep.complete {
3108 return Err(Error::bad_request(
3109 "Spot position snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
3110 ));
3111 }
3112
3113 Ok(sweep.reports)
3114 }
3115
3116 pub(crate) async fn request_spot_position_status_reports_sweep(
3117 &self,
3118 user: &str,
3119 instrument_id: Option<InstrumentId>,
3120 ) -> Result<ReportSweep<PositionStatusReport>> {
3121 let account_id = self
3122 .account_id
3123 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3124 let response = self.info_spot_clearinghouse_state(user).await?;
3125
3126 let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
3127 log::error!("Failed to parse spot clearinghouse state: {e}");
3128 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
3129 })?;
3130
3131 let ts_init = self.clock.get_time_ns();
3132 let mut reports = Vec::with_capacity(state.balances.len());
3133 let mut complete = true;
3134
3135 for balance in &state.balances {
3136 if balance.total.is_zero() {
3137 continue;
3138 }
3139
3140 if balance.coin == "USDC" {
3145 continue;
3146 }
3147
3148 let product_type = match HyperliquidProductType::from_symbol(balance.coin.as_str()) {
3149 Ok(HyperliquidProductType::Outcome) => HyperliquidProductType::Outcome,
3150 _ => HyperliquidProductType::Spot,
3151 };
3152
3153 let instrument = match self.get_or_create_instrument(&balance.coin, Some(product_type))
3154 {
3155 Some(inst) => inst,
3156 None => {
3158 complete = false;
3159 continue;
3160 }
3161 };
3162
3163 if let Some(filter_id) = instrument_id
3164 && instrument.id() != filter_id
3165 {
3166 continue;
3167 }
3168
3169 match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
3170 Ok(report) => reports.push(report),
3171 Err(e) => {
3172 log::error!(
3173 "Failed to parse spot position status report for {}: {e}",
3174 balance.coin,
3175 );
3176 complete = false;
3177 }
3178 }
3179 }
3180
3181 Ok(ReportSweep { reports, complete })
3182 }
3183
3184 pub async fn request_bars(
3201 &self,
3202 bar_type: BarType,
3203 start: Option<jiff::Timestamp>,
3204 end: Option<jiff::Timestamp>,
3205 limit: Option<u32>,
3206 ) -> Result<Vec<Bar>> {
3207 let instrument_id = bar_type.instrument_id();
3208 let symbol = instrument_id.symbol;
3209
3210 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
3211
3212 let alias = cache_alias_for_symbol(symbol.as_str())
3216 .map(|alias| Ustr::from(alias.as_str()))
3217 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
3218
3219 let instrument = self
3220 .get_or_create_instrument(&alias, product_type)
3221 .ok_or_else(|| {
3222 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3223 })?;
3224
3225 let coin = instrument.raw_symbol().inner();
3230
3231 let price_precision = instrument.price_precision();
3232 let size_precision = instrument.size_precision();
3233
3234 let interval =
3235 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
3236
3237 let now = jiff::Timestamp::now();
3239 let end_time = end.unwrap_or(now).as_millisecond() as u64;
3240 let start_time = if let Some(start) = start {
3241 start.as_millisecond() as u64
3242 } else {
3243 let spec = bar_type.spec();
3245 let step_ms = match spec.aggregation {
3246 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
3247 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
3248 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
3249 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
3250 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
3251 _ => 60_000,
3252 };
3253 end_time.saturating_sub(1000 * step_ms)
3254 };
3255
3256 let candles = self
3257 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
3258 .await?;
3259
3260 let now_ms = now.as_millisecond() as u64;
3262
3263 let mut bars: Vec<Bar> = candles
3264 .iter()
3265 .filter(|candle| candle.end_timestamp < now_ms)
3266 .enumerate()
3267 .filter_map(|(i, candle)| {
3268 candle_to_bar(candle, bar_type, price_precision, size_precision)
3269 .map_err(|e| {
3270 log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
3271 e
3272 })
3273 .ok()
3274 })
3275 .collect();
3276
3277 if let Some(limit) = limit
3279 && limit > 0
3280 && bars.len() > limit as usize
3281 {
3282 bars.truncate(limit as usize);
3283 }
3284
3285 log::debug!(
3286 "Received {} bars for {} (filtered {} incomplete)",
3287 bars.len(),
3288 bar_type,
3289 candles.len() - bars.len()
3290 );
3291 Ok(bars)
3292 }
3293
3294 pub async fn request_public_trades(
3303 &self,
3304 instrument_id: InstrumentId,
3305 start: Option<jiff::Timestamp>,
3306 end: Option<jiff::Timestamp>,
3307 limit: Option<usize>,
3308 ) -> Result<Vec<HyperliquidPublicTrade>> {
3309 let symbol = instrument_id.symbol;
3310 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
3311 let alias = cache_alias_for_symbol(symbol.as_str())
3312 .map(|alias| Ustr::from(alias.as_str()))
3313 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
3314 let instrument = self
3315 .get_or_create_instrument(&alias, product_type)
3316 .ok_or_else(|| {
3317 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3318 })?;
3319
3320 let raw_trades = match self
3321 .info_recent_trades(instrument.raw_symbol().as_ref())
3322 .await
3323 {
3324 Ok(trades) => trades,
3325 Err(e) if e.is_unprocessable_entity() => {
3326 log::warn!(
3327 "Recent public trades endpoint unavailable for {instrument_id} \
3328 (requires the Hyperliquid indexer); returning empty response"
3329 );
3330 Vec::new()
3331 }
3332 Err(e) => return Err(e),
3333 };
3334
3335 let mut trades: Vec<HyperliquidPublicTrade> = raw_trades
3336 .iter()
3337 .filter_map(|raw| match parse_recent_public_trade(raw, &instrument) {
3338 Ok(trade) => Some(trade),
3339 Err(e) => {
3340 log::warn!("Skipping recent public trade for {instrument_id}: {e}");
3341 None
3342 }
3343 })
3344 .collect();
3345 trades.sort_by_key(|trade| trade.ts_event);
3346
3347 Ok(filter_recent_public_trades(
3348 trades,
3349 datetime_to_unix_nanos(start),
3350 datetime_to_unix_nanos(end),
3351 limit.filter(|limit| *limit > 0),
3352 instrument_id,
3353 ))
3354 }
3355
3356 #[expect(clippy::too_many_arguments)]
3363 pub async fn submit_order(
3364 &self,
3365 instrument_id: InstrumentId,
3366 client_order_id: ClientOrderId,
3367 order_side: OrderSide,
3368 order_type: OrderType,
3369 quantity: Quantity,
3370 time_in_force: TimeInForce,
3371 price: Option<Price>,
3372 trigger_price: Option<Price>,
3373 post_only: bool,
3374 reduce_only: bool,
3375 ) -> Result<OrderStatusReport> {
3376 let symbol = instrument_id.symbol.inner();
3377 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3378 Error::bad_request(format!(
3379 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3380 ))
3381 })?;
3382
3383 let is_buy = matches!(order_side, OrderSide::Buy);
3384 let price_precision = self.get_price_precision_for_symbol(symbol);
3385
3386 let price_decimal = match price {
3387 Some(px) => normalize_or_validate_wire_price(
3388 px.as_decimal(),
3389 "Price",
3390 price_precision,
3391 self.normalize_prices,
3392 )
3393 .map_err(|e| Error::bad_request(format!("{e}")))?,
3394 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
3395 None if matches!(
3396 order_type,
3397 OrderType::StopMarket | OrderType::MarketIfTouched
3398 ) =>
3399 {
3400 match trigger_price {
3401 Some(tp) => {
3402 let derived = derive_limit_from_trigger(
3403 tp.as_decimal().normalize(),
3404 is_buy,
3405 self.market_order_slippage_bps,
3406 );
3407 let sig_rounded = round_to_sig_figs(derived, 5);
3408 clamp_price_to_precision(sig_rounded, price_precision.unwrap_or(2), is_buy)
3409 .normalize()
3410 }
3411 None => Decimal::ZERO,
3412 }
3413 }
3414 None => return Err(Error::bad_request("Limit orders require a price")),
3415 };
3416
3417 let size_decimal = quantity.as_decimal().normalize();
3418
3419 let kind = match order_type {
3420 OrderType::Market => HyperliquidExchangeOrderKind::Limit {
3421 limit: HyperliquidExchangeLimitParams {
3422 tif: HyperliquidExchangeTif::Ioc,
3423 },
3424 },
3425 OrderType::Limit => {
3426 let tif = if post_only {
3427 HyperliquidExchangeTif::Alo
3428 } else {
3429 match time_in_force {
3430 TimeInForce::Gtc => HyperliquidExchangeTif::Gtc,
3431 TimeInForce::Ioc => HyperliquidExchangeTif::Ioc,
3432 TimeInForce::Fok
3433 | TimeInForce::Day
3434 | TimeInForce::Gtd
3435 | TimeInForce::AtTheOpen
3436 | TimeInForce::AtTheClose => {
3437 return Err(Error::bad_request(format!(
3438 "Time in force {time_in_force:?} not supported"
3439 )));
3440 }
3441 }
3442 };
3443 HyperliquidExchangeOrderKind::Limit {
3444 limit: HyperliquidExchangeLimitParams { tif },
3445 }
3446 }
3447 OrderType::StopMarket
3448 | OrderType::StopLimit
3449 | OrderType::MarketIfTouched
3450 | OrderType::LimitIfTouched => {
3451 if let Some(trig_px) = trigger_price {
3452 let trigger_price_decimal = normalize_or_validate_wire_price(
3453 trig_px.as_decimal(),
3454 "Trigger price",
3455 price_precision,
3456 self.normalize_prices,
3457 )
3458 .map_err(|e| Error::bad_request(format!("{e}")))?;
3459
3460 let tpsl = match order_type {
3464 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
3465 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
3466 HyperliquidExchangeTpSl::Tp
3467 }
3468 _ => unreachable!(),
3469 };
3470
3471 let is_market = matches!(
3472 order_type,
3473 OrderType::StopMarket | OrderType::MarketIfTouched
3474 );
3475
3476 HyperliquidExchangeOrderKind::Trigger {
3477 trigger: HyperliquidExchangeTriggerParams {
3478 is_market,
3479 trigger_px: trigger_price_decimal,
3480 tpsl,
3481 },
3482 }
3483 } else {
3484 return Err(Error::bad_request("Trigger orders require a trigger price"));
3485 }
3486 }
3487 _ => {
3488 return Err(Error::bad_request(format!(
3489 "Order type {order_type:?} not supported"
3490 )));
3491 }
3492 };
3493
3494 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
3495 let hyperliquid_order = HyperliquidExchangePlaceOrderRequest {
3496 asset,
3497 is_buy,
3498 price: price_decimal,
3499 size: size_decimal,
3500 reduce_only,
3501 kind,
3502 cloid: Some(cloid),
3503 };
3504
3505 let builder = self.builder_attribution();
3506
3507 let action = HyperliquidExchangeAction::Order {
3508 orders: vec![hyperliquid_order],
3509 grouping: HyperliquidExchangeGrouping::Na,
3510 builder,
3511 };
3512
3513 let response = self.inner.post_action_exec(&action).await?;
3514
3515 self.build_submit_order_report(
3518 instrument_id,
3519 client_order_id,
3520 order_side,
3521 order_type,
3522 quantity,
3523 time_in_force,
3524 price,
3525 trigger_price,
3526 response,
3527 )?
3528 .ok_or_else(|| {
3529 Error::bad_request(
3530 "Single-order submission returned no actionable status (deferred trigger child)",
3531 )
3532 })
3533 }
3534
3535 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
3545 if order.is_quote_quantity() {
3546 return Err(Error::bad_request(
3547 "Quote-denominated quantity orders must submit through the execution client \
3548 for quote-to-base conversion",
3549 ));
3550 }
3551
3552 self.submit_order(
3553 order.instrument_id(),
3554 order.client_order_id(),
3555 order.order_side(),
3556 order.order_type(),
3557 order.quantity(),
3558 order.time_in_force(),
3559 order.price(),
3560 order.trigger_price(),
3561 order.is_post_only(),
3562 order.is_reduce_only(),
3563 )
3564 .await
3565 }
3566
3567 #[expect(clippy::too_many_arguments)]
3568 fn create_order_status_report(
3569 &self,
3570 instrument_id: InstrumentId,
3571 client_order_id: Option<ClientOrderId>,
3572 venue_order_id: VenueOrderId,
3573 order_side: OrderSide,
3574 order_type: OrderType,
3575 quantity: Quantity,
3576 time_in_force: TimeInForce,
3577 price: Option<Price>,
3578 trigger_price: Option<Price>,
3579 order_status: OrderStatus,
3580 filled_qty: Quantity,
3581 _instrument: &InstrumentAny,
3582 account_id: AccountId,
3583 ts_init: UnixNanos,
3584 ) -> OrderStatusReport {
3585 let ts_accepted = self.clock.get_time_ns();
3586 let ts_last = ts_accepted;
3587 let report_id = UUID4::new();
3588
3589 let mut report = OrderStatusReport::new(
3590 account_id,
3591 instrument_id,
3592 client_order_id,
3593 venue_order_id,
3594 order_side.into(),
3595 order_type,
3596 time_in_force,
3597 order_status,
3598 quantity,
3599 filled_qty,
3600 ts_accepted,
3601 ts_last,
3602 ts_init,
3603 Some(report_id),
3604 );
3605
3606 if let Some(px) = price {
3607 report = report.with_price(px);
3608 }
3609
3610 if let Some(trig_px) = trigger_price {
3611 report = report
3612 .with_trigger_price(trig_px)
3613 .with_trigger_type(TriggerType::Default);
3614 }
3615
3616 report
3617 }
3618
3619 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
3628 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
3630 let mut client_order_ids = Vec::with_capacity(orders.len());
3631
3632 for order in orders {
3633 if order.is_quote_quantity() {
3634 return Err(Error::bad_request(format!(
3635 "Quote-denominated quantity order {} must submit through the execution \
3636 client for quote-to-base conversion",
3637 order.client_order_id()
3638 )));
3639 }
3640
3641 let instrument_id = order.instrument_id();
3642 let symbol = instrument_id.symbol.inner();
3643 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3644 Error::bad_request(format!(
3645 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3646 ))
3647 })?;
3648 let price_decimals = self.get_price_precision_for_symbol(symbol);
3649 let request = order_to_hyperliquid_request_with_optional_decimals(
3650 order,
3651 asset,
3652 price_decimals,
3653 self.normalize_prices,
3654 self.market_order_slippage_bps,
3655 None,
3656 )
3657 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
3658 client_order_ids.push(order.client_order_id());
3659 hyperliquid_orders.push(request);
3660 }
3661
3662 for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
3663 request.cloid = Some(self.get_or_generate_client_order_id_cloid(client_order_id));
3664 }
3665
3666 let builder = self.builder_attribution();
3667
3668 let grouping =
3669 determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
3670
3671 let action = HyperliquidExchangeAction::Order {
3672 orders: hyperliquid_orders,
3673 grouping,
3674 builder,
3675 };
3676
3677 let response = self.inner.post_action_exec(&action).await?;
3679
3680 self.build_submit_orders_reports(orders, grouping, response)
3681 }
3682
3683 #[expect(clippy::too_many_arguments)]
3699 pub fn build_submit_order_report(
3700 &self,
3701 instrument_id: InstrumentId,
3702 client_order_id: ClientOrderId,
3703 order_side: OrderSide,
3704 order_type: OrderType,
3705 quantity: Quantity,
3706 time_in_force: TimeInForce,
3707 price: Option<Price>,
3708 trigger_price: Option<Price>,
3709 response: HyperliquidExchangeResponse,
3710 ) -> Result<Option<OrderStatusReport>> {
3711 let order_response = parse_order_response(response)?;
3712
3713 let Some(order_status) = order_response.statuses.first() else {
3714 return Ok(None);
3715 };
3716
3717 let account_id = self
3718 .account_id
3719 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3720 let ts_init = self.clock.get_time_ns();
3721
3722 self.build_status_report(
3723 instrument_id,
3724 client_order_id,
3725 order_side,
3726 order_type,
3727 quantity,
3728 time_in_force,
3729 price,
3730 trigger_price,
3731 order_status,
3732 account_id,
3733 ts_init,
3734 )
3735 }
3736
3737 pub fn build_submit_orders_reports(
3752 &self,
3753 orders: &[&OrderAny],
3754 grouping: HyperliquidExchangeGrouping,
3755 response: HyperliquidExchangeResponse,
3756 ) -> Result<Vec<OrderStatusReport>> {
3757 let order_response = parse_order_response(response)?;
3758
3759 let account_id = self
3760 .account_id
3761 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3762 let ts_init = self.clock.get_time_ns();
3763
3764 if grouping == HyperliquidExchangeGrouping::Na
3768 && order_response.statuses.len() != orders.len()
3769 {
3770 return Err(Error::bad_request(format!(
3771 "Mismatch between submitted orders ({}) and response statuses ({})",
3772 orders.len(),
3773 order_response.statuses.len()
3774 )));
3775 }
3776
3777 let mut reports = Vec::with_capacity(order_response.statuses.len());
3780 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
3781 if let Some(report) = self.build_status_report(
3782 order.instrument_id(),
3783 order.client_order_id(),
3784 order.order_side(),
3785 order.order_type(),
3786 order.quantity(),
3787 order.time_in_force(),
3788 order.price(),
3789 order.trigger_price(),
3790 order_status,
3791 account_id,
3792 ts_init,
3793 )? {
3794 reports.push(report);
3795 }
3796 }
3797
3798 Ok(reports)
3799 }
3800
3801 #[expect(clippy::too_many_arguments)]
3809 fn build_status_report(
3810 &self,
3811 instrument_id: InstrumentId,
3812 client_order_id: ClientOrderId,
3813 order_side: OrderSide,
3814 order_type: OrderType,
3815 quantity: Quantity,
3816 time_in_force: TimeInForce,
3817 price: Option<Price>,
3818 trigger_price: Option<Price>,
3819 order_status: &HyperliquidExchangeOrderStatus,
3820 account_id: AccountId,
3821 ts_init: UnixNanos,
3822 ) -> Result<Option<OrderStatusReport>> {
3823 if matches!(order_status, HyperliquidExchangeOrderStatus::Tag(_)) {
3824 return Ok(None);
3825 }
3826
3827 let symbol = instrument_id.symbol.as_str();
3828 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
3829
3830 let asset = cache_alias_for_symbol(symbol).unwrap_or_else(|| symbol.to_string());
3833 let instrument = self
3834 .get_or_create_instrument(&Ustr::from(asset.as_str()), product_type)
3835 .ok_or_else(|| {
3836 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3837 })?;
3838
3839 let report = match order_status {
3840 HyperliquidExchangeOrderStatus::Resting { resting } => self.create_order_status_report(
3841 instrument_id,
3842 Some(client_order_id),
3843 VenueOrderId::new(resting.oid.to_string()),
3844 order_side,
3845 order_type,
3846 quantity,
3847 time_in_force,
3848 price,
3849 trigger_price,
3850 OrderStatus::Accepted,
3851 Quantity::zero(instrument.size_precision()),
3852 &instrument,
3853 account_id,
3854 ts_init,
3855 ),
3856 HyperliquidExchangeOrderStatus::Filled { filled } => {
3857 let filled_qty =
3858 Quantity::from_decimal_dp(filled.total_sz, instrument.size_precision())
3859 .map_err(|e| {
3860 Error::bad_request(format!(
3861 "Invalid filled size {}: {e}",
3862 filled.total_sz
3863 ))
3864 })?;
3865 self.create_order_status_report(
3866 instrument_id,
3867 Some(client_order_id),
3868 VenueOrderId::new(filled.oid.to_string()),
3869 order_side,
3870 order_type,
3871 quantity,
3872 time_in_force,
3873 price,
3874 trigger_price,
3875 OrderStatus::Filled,
3876 filled_qty,
3877 &instrument,
3878 account_id,
3879 ts_init,
3880 )
3881 }
3882 HyperliquidExchangeOrderStatus::Error { error } => {
3883 return Err(Error::bad_request(format!(
3884 "Order {client_order_id} rejected: {error}"
3885 )));
3886 }
3887 HyperliquidExchangeOrderStatus::Tag(_) => unreachable!("handled above"),
3888 };
3889
3890 Ok(Some(report))
3891 }
3892
3893 fn reconciliation_dexes(&self, instrument_id: Option<InstrumentId>) -> Vec<Option<Ustr>> {
3894 if let Some(instrument_id) = instrument_id {
3895 return vec![perp_dex_from_symbol(instrument_id.symbol.as_str())];
3896 }
3897
3898 let cached = self.instruments.load();
3899 reconciliation_dexes_from_builders(
3900 cached
3901 .keys()
3902 .filter_map(|symbol| perp_dex_from_symbol(symbol.as_str())),
3903 )
3904 }
3905
3906 pub(crate) async fn reconciliation_dexes_from_activity(
3907 &self,
3908 historical_orders: &[HyperliquidOrderStatusEntry],
3909 fills: &HyperliquidFills,
3910 ) -> Result<Vec<Option<Ustr>>> {
3911 if historical_orders.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT
3912 || fills.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT
3913 {
3914 let builder_dexes = self
3915 .inner
3916 .load_perp_dexs()
3917 .await?
3918 .into_iter()
3919 .flatten()
3920 .filter(|dex| !dex.name.is_empty())
3921 .map(|dex| Ustr::from(dex.name.as_str()));
3922 return Ok(reconciliation_dexes_from_builders(builder_dexes));
3923 }
3924
3925 let builder_dexes = historical_orders
3926 .iter()
3927 .map(|entry| entry.order.coin.as_str())
3928 .chain(fills.iter().map(|fill| fill.coin.as_str()))
3929 .filter_map(perp_dex_from_activity_coin);
3930
3931 Ok(reconciliation_dexes_from_builders(builder_dexes))
3932 }
3933}
3934
3935#[derive(Debug)]
3941pub(crate) struct ReportSweep<T> {
3942 pub reports: Vec<T>,
3943 pub complete: bool,
3944}
3945
3946fn reconciliation_dexes_from_builders(
3947 builder_dexes: impl IntoIterator<Item = Ustr>,
3948) -> Vec<Option<Ustr>> {
3949 let mut builder_dexes = builder_dexes.into_iter().collect::<Vec<_>>();
3950 builder_dexes.sort_unstable();
3951 builder_dexes.dedup();
3952
3953 let mut dexes = Vec::with_capacity(builder_dexes.len() + 1);
3954 dexes.push(None);
3955 dexes.extend(builder_dexes.into_iter().map(Some));
3956 dexes
3957}
3958
3959fn perp_dex_from_activity_coin(coin: &str) -> Option<Ustr> {
3960 if coin.starts_with(VAULT_TOKEN_PREFIX) {
3961 return None;
3962 }
3963
3964 let (dex, _) = coin.split_once(':')?;
3965 (!dex.is_empty()).then(|| Ustr::from(dex))
3966}
3967
3968fn perp_dex_from_symbol(symbol: &str) -> Option<Ustr> {
3969 symbol
3970 .strip_suffix("-PERP")?
3971 .split_once(':')
3972 .map(|(dex, _)| Ustr::from(dex))
3973}
3974
3975fn parse_order_response(
3980 response: HyperliquidExchangeResponse,
3981) -> Result<HyperliquidExchangeOrderResponseData> {
3982 let response_data = match response {
3983 HyperliquidExchangeResponse::Status {
3984 status,
3985 response: response_data,
3986 } if status == RESPONSE_STATUS_OK => response_data,
3987 HyperliquidExchangeResponse::Error { error } => {
3988 return Err(Error::bad_request(format!(
3989 "Order submission failed: {error}"
3990 )));
3991 }
3992 _ => return Err(Error::bad_request("Unexpected response format")),
3993 };
3994
3995 let data_value = if let Some(data) = response_data.get("data") {
3996 data.clone()
3997 } else {
3998 response_data
3999 };
4000
4001 serde_json::from_value(data_value)
4002 .map_err(|e| Error::bad_request(format!("Failed to parse order response: {e}")))
4003}
4004
4005fn resolve_perp_dex_name(
4006 dex_index: usize,
4007 meta: &PerpMeta,
4008 perp_dexs: Option<&[Option<PerpDex>]>,
4009) -> String {
4010 if dex_index == 0 {
4011 return String::new();
4012 }
4013
4014 if let Some(dex_name) = perp_dexs
4015 .and_then(|dexs| dexs.get(dex_index))
4016 .and_then(|dex| dex.as_ref())
4017 .map(|dex| dex.name.clone())
4018 {
4019 return dex_name;
4020 }
4021
4022 meta.universe
4023 .iter()
4024 .find_map(|asset| asset.name.split_once(':').map(|(dex, _)| dex.to_string()))
4025 .unwrap_or_default()
4026}
4027
4028fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
4033 if dex_index == 0 {
4034 0
4035 } else {
4036 100_000 + dex_index as u32 * 10_000
4037 }
4038}
4039
4040#[cfg(test)]
4041mod tests {
4042 use std::{collections::HashMap, net::SocketAddr, sync::Arc};
4043
4044 use axum::{
4045 Router,
4046 extract::State,
4047 http::StatusCode,
4048 response::{IntoResponse, Json, Response},
4049 routing::post,
4050 };
4051 use nautilus_core::{Params, UUID4, UnixNanos, time::get_atomic_clock_realtime};
4052 use nautilus_model::{
4053 currencies::CURRENCY_MAP,
4054 enums::{CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType},
4055 events::{OrderEventAny, OrderInitialized},
4056 identifiers::{
4057 AccountId, ClientOrderId, InstrumentId, StrategyId, Symbol, TraderId, VenueOrderId,
4058 },
4059 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
4060 orders::OrderAny,
4061 reports::OrderStatusReport,
4062 types::{Currency, Price, Quantity},
4063 };
4064 use nautilus_testkit::http::assert_http_redirect_rejected;
4065 use rstest::rstest;
4066 use rust_decimal_macros::dec;
4067 use serde_json::{Value, json};
4068 use ustr::Ustr;
4069
4070 use super::{
4071 HyperliquidHttpClient, HyperliquidRawHttpClient, RETRY_AFTER_HEADER, resolve_perp_dex_name,
4072 };
4073 use crate::{
4074 common::{
4075 consts::{ASSET_INDEX_INFO_KEY, HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS},
4076 enums::{HyperliquidEnvironment, HyperliquidProductType},
4077 },
4078 http::{
4079 models::{
4080 Cloid, HyperliquidExchangeResponse, HyperliquidOrderStatusEntry, PerpAsset,
4081 PerpDex, PerpMeta,
4082 },
4083 query::InfoRequest,
4084 },
4085 };
4086
4087 const TEST_PRIVATE_KEY: &str =
4088 "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
4089
4090 #[tokio::test]
4091 async fn test_authenticated_client_rejects_redirects() {
4092 let client = HyperliquidRawHttpClient::build_http_client(3, None).unwrap();
4093 assert_http_redirect_rejected(|url| async move {
4094 client
4095 .get(url, None, None, Some(3), None)
4096 .await
4097 .unwrap()
4098 .status
4099 .as_u16()
4100 })
4101 .await;
4102 }
4103
4104 #[rstest]
4105 fn raw_clients_share_rest_limit_for_one_route() {
4106 let mut first =
4107 HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
4108 let mut second =
4109 HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
4110 first.set_base_info_url("https://shared-http-limit.test/info".to_string());
4111 second.set_base_exchange_url("https://shared-http-limit.test/exchange".to_string());
4112
4113 assert!(Arc::ptr_eq(&first.info_limiter, &second.exchange_limiter));
4114 }
4115
4116 #[rstest]
4117 fn retry_after_ms_parses_integer_seconds() {
4118 let headers = HashMap::from([(RETRY_AFTER_HEADER.to_string(), "3".to_string())]);
4119
4120 assert_eq!(
4121 HyperliquidRawHttpClient::retry_after_ms(&headers),
4122 Some(3_000)
4123 );
4124 }
4125
4126 fn perp_meta_with_assets(names: &[&str]) -> PerpMeta {
4127 PerpMeta {
4128 universe: names
4129 .iter()
4130 .map(|name| PerpAsset {
4131 name: (*name).to_string(),
4132 ..Default::default()
4133 })
4134 .collect(),
4135 margin_tables: Vec::new(),
4136 collateral_token: None,
4137 }
4138 }
4139
4140 #[rstest]
4141 fn resolve_perp_dex_name_uses_empty_string_for_default_dex() {
4142 let meta = perp_meta_with_assets(&["BTC", "ETH"]);
4143 assert_eq!(resolve_perp_dex_name(0, &meta, None), "");
4144 }
4145
4146 #[rstest]
4147 fn resolve_perp_dex_name_prefers_perp_dexs_entry() {
4148 let meta = perp_meta_with_assets(&["xyz:TSLA"]);
4149 let perp_dexs = vec![
4150 None,
4151 Some(PerpDex {
4152 name: "xyz".to_string(),
4153 }),
4154 ];
4155 assert_eq!(resolve_perp_dex_name(1, &meta, Some(&perp_dexs)), "xyz");
4156 }
4157
4158 #[rstest]
4159 fn resolve_perp_dex_name_infers_from_asset_name_when_perp_dexs_missing() {
4160 let meta = perp_meta_with_assets(&["abc:TSLA", "abc:NVDA"]);
4161 assert_eq!(resolve_perp_dex_name(1, &meta, None), "abc");
4162 }
4163
4164 #[rstest]
4165 fn test_build_submit_order_report_elides_waiting_for_fill_tag() {
4166 let mut client =
4170 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4171 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
4172
4173 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
4174 "status": "ok",
4175 "response": {
4176 "type": "order",
4177 "data": {
4178 "statuses": ["waitingForFill"]
4179 }
4180 }
4181 }))
4182 .unwrap();
4183
4184 let result = client
4185 .build_submit_order_report(
4186 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
4187 ClientOrderId::from("O-WAITING-CHILD"),
4188 OrderSide::Buy,
4189 OrderType::StopMarket,
4190 Quantity::from("100"),
4191 TimeInForce::Gtc,
4192 None,
4193 Some(Price::from("0.16136")),
4194 response,
4195 )
4196 .unwrap();
4197
4198 assert!(
4199 result.is_none(),
4200 "Tag status must elide so the order stays SUBMITTED, was {result:?}"
4201 );
4202 }
4203
4204 #[rstest]
4205 fn test_build_submit_order_report_filled_uses_total_sz_decimal() {
4206 let mut client =
4210 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4211 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
4212
4213 let base = Currency::new("ARB", 8, 0, "ARB", CurrencyType::Crypto);
4214 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4215 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4216 let clock = get_atomic_clock_realtime();
4217 let ts = clock.get_time_ns();
4218 let perp = InstrumentAny::CryptoPerpetual(
4219 CryptoPerpetual::builder()
4220 .instrument_id(InstrumentId::new(
4221 Symbol::new("ARB-USD-PERP"),
4222 *HYPERLIQUID_VENUE,
4223 ))
4224 .raw_symbol(Symbol::new("ARB"))
4225 .base_currency(base)
4226 .quote_currency(usd)
4227 .settlement_currency(usdc)
4228 .is_inverse(false)
4229 .price_precision(5)
4230 .size_precision(2)
4231 .price_increment(Price::from("0.00001"))
4232 .size_increment(Quantity::from("0.01"))
4233 .ts_event(ts)
4234 .ts_init(ts)
4235 .build()
4236 .unwrap(),
4237 );
4238 client.cache_instrument(&perp);
4239
4240 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
4241 "status": "ok",
4242 "response": {
4243 "type": "order",
4244 "data": {
4245 "statuses": [{
4246 "filled": {"totalSz": "0.5", "avgPx": "1.2345", "oid": 778899}
4247 }]
4248 }
4249 }
4250 }))
4251 .unwrap();
4252
4253 let report = client
4254 .build_submit_order_report(
4255 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
4256 ClientOrderId::from("O-FILLED-001"),
4257 OrderSide::Buy,
4258 OrderType::Market,
4259 Quantity::from("0.5"),
4260 TimeInForce::Ioc,
4261 None,
4262 None,
4263 response,
4264 )
4265 .unwrap()
4266 .expect("filled status must produce a report");
4267
4268 assert_eq!(report.order_status, OrderStatus::Filled);
4269 assert_eq!(report.venue_order_id.as_str(), "778899");
4270 assert_eq!(report.filled_qty.as_decimal(), dec!(0.5));
4271 }
4272
4273 #[derive(Clone, Default)]
4274 struct OutcomeMetaServerState {
4275 last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
4276 }
4277
4278 async fn handle_outcome_meta_info(
4279 State(state): State<OutcomeMetaServerState>,
4280 body: axum::body::Bytes,
4281 ) -> Response {
4282 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
4283 return (
4284 StatusCode::BAD_REQUEST,
4285 Json(json!({"error": "Invalid JSON body"})),
4286 )
4287 .into_response();
4288 };
4289
4290 *state.last_request_body.lock().await = Some(request_body.clone());
4291
4292 if request_body.get("type").and_then(|value| value.as_str()) != Some("outcomeMeta") {
4293 return (
4294 StatusCode::BAD_REQUEST,
4295 Json(json!({"error": "Expected outcomeMeta request"})),
4296 )
4297 .into_response();
4298 }
4299
4300 Json(json!({
4301 "outcomes": [
4302 {
4303 "outcome": 123,
4304 "quoteToken": "USDC",
4305 "name": "Recurring",
4306 "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m",
4307 "sideSpecs": [
4308 {"name": "Yes"},
4309 {"name": "No"}
4310 ]
4311 }
4312 ]
4313 }))
4314 .into_response()
4315 }
4316
4317 async fn start_outcome_meta_server(state: OutcomeMetaServerState) -> SocketAddr {
4318 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4319 let addr = listener.local_addr().unwrap();
4320 let router = Router::new()
4321 .route("/info", post(handle_outcome_meta_info))
4322 .with_state(state);
4323
4324 tokio::spawn(async move {
4325 axum::serve(listener, router).await.unwrap();
4326 });
4327
4328 addr
4329 }
4330
4331 async fn handle_unresolved_collateral_info(body: axum::body::Bytes) -> Response {
4332 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
4333 return (
4334 StatusCode::BAD_REQUEST,
4335 Json(json!({"error": "Invalid JSON body"})),
4336 )
4337 .into_response();
4338 };
4339
4340 match request_body.get("type").and_then(|value| value.as_str()) {
4341 Some("spotMeta") => (
4342 StatusCode::INTERNAL_SERVER_ERROR,
4343 Json(json!({"error": "spot metadata unavailable"})),
4344 )
4345 .into_response(),
4346 Some("allPerpMetas") => Json(json!([
4347 {
4348 "collateralToken": 360,
4349 "marginTables": [],
4350 "universe": [
4351 {
4352 "maxLeverage": 20,
4353 "name": "km:US500",
4354 "szDecimals": 3
4355 }
4356 ]
4357 }
4358 ]))
4359 .into_response(),
4360 _ => Json(json!({"universe": [], "marginTables": []})).into_response(),
4361 }
4362 }
4363
4364 async fn start_unresolved_collateral_server() -> SocketAddr {
4365 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4366 let addr = listener.local_addr().unwrap();
4367 let router = Router::new().route("/info", post(handle_unresolved_collateral_info));
4368
4369 tokio::spawn(async move {
4370 axum::serve(listener, router).await.unwrap();
4371 });
4372
4373 addr
4374 }
4375
4376 #[rstest]
4377 fn stable_json_roundtrips() {
4378 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
4379 let s = serde_json::to_string(&v).unwrap();
4380 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
4382 assert_eq!(parsed["type"], "l2Book");
4383 assert_eq!(parsed["coin"], "BTC");
4384 assert_eq!(parsed, v);
4385 }
4386
4387 #[rstest]
4388 fn info_pretty_shape() {
4389 let r = InfoRequest::l2_book("BTC");
4390 let val = serde_json::to_value(&r).unwrap();
4391 let pretty = serde_json::to_string_pretty(&val).unwrap();
4392 assert!(pretty.contains("\"type\": \"l2Book\""));
4393 assert!(pretty.contains("\"coin\": \"BTC\""));
4394 }
4395
4396 #[rstest]
4397 fn test_client_order_id_cloid_cache_is_stable_and_first_write_wins() {
4398 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4399 let client_order_id = ClientOrderId::new("O-CLOID-CACHE");
4400 let other_client_order_id = ClientOrderId::new("O-CLOID-CACHE-OTHER");
4401 let duplicate_client_order_id = ClientOrderId::new("O-CLOID-CACHE-DUPLICATE");
4402 let explicit_cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
4403
4404 let first = client.get_or_generate_client_order_id_cloid(client_order_id);
4405 let second = client.get_or_generate_client_order_id_cloid(client_order_id);
4406 client.cache_client_order_id_cloid(client_order_id, explicit_cloid);
4407 client.cache_client_order_id_cloid(other_client_order_id, explicit_cloid);
4408 client.cache_client_order_id_cloid(duplicate_client_order_id, explicit_cloid);
4409
4410 assert_eq!(first, Cloid::from_client_order_id(client_order_id));
4411 assert_eq!(first, second);
4412 assert_eq!(
4413 client.cached_client_order_id_cloid(&client_order_id),
4414 Some(first),
4415 "cache insert must not overwrite an existing generated CLOID",
4416 );
4417 assert_eq!(
4418 client.unique_cached_client_order_id_cloid(&client_order_id),
4419 Some(first),
4420 );
4421 assert_eq!(
4422 client.cached_client_order_id_cloid(&other_client_order_id),
4423 Some(explicit_cloid),
4424 );
4425 assert_eq!(
4426 client.unique_cached_client_order_id_cloid(&other_client_order_id),
4427 None,
4428 "duplicate CLOID mappings are not safe modify targets",
4429 );
4430 assert_eq!(
4431 client.remove_client_order_id_cloid(&client_order_id),
4432 Some(first),
4433 );
4434 assert_eq!(client.cached_client_order_id_cloid(&client_order_id), None);
4435 }
4436
4437 #[rstest]
4438 fn test_builder_attribution_defaults_to_mainnet_builder() {
4439 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4440 let builder = client
4441 .builder_attribution()
4442 .expect("mainnet client should include builder attribution by default");
4443
4444 assert!(client.include_builder_attribution());
4445 assert_eq!(builder.address, NAUTILUS_BUILDER_ADDRESS);
4446 assert_eq!(builder.fee_tenths_bp, 0);
4447 }
4448
4449 #[rstest]
4450 fn test_builder_attribution_disabled_returns_none() {
4451 let mut client =
4452 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4453 client.set_include_builder_attribution(false);
4454
4455 assert!(!client.include_builder_attribution());
4456 assert!(client.builder_attribution().is_none());
4457 }
4458
4459 #[rstest]
4460 fn test_builder_attribution_omitted_on_testnet() {
4461 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
4462
4463 assert!(client.include_builder_attribution());
4464 assert!(client.builder_attribution().is_none());
4465 }
4466
4467 #[rstest]
4468 #[tokio::test]
4469 async fn test_production_client_get_outcome_meta_uses_outcome_meta_request() {
4470 let state = OutcomeMetaServerState::default();
4471 let addr = start_outcome_meta_server(state.clone()).await;
4472 let mut client =
4473 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4474 client.set_base_info_url(format!("http://{addr}/info"));
4475
4476 let meta = client.get_outcome_meta().await.unwrap();
4477 let request_body = state.last_request_body.lock().await.clone().unwrap();
4478
4479 assert_eq!(request_body, json!({"type": "outcomeMeta"}));
4480 assert_eq!(meta.outcomes.len(), 1);
4481 assert_eq!(meta.outcomes[0].outcome, 123);
4482 assert_eq!(meta.outcomes[0].quote_token.as_deref(), Some("USDC"));
4483 assert_eq!(meta.outcomes[0].name, "Recurring");
4484 assert_eq!(meta.outcomes[0].side_specs.len(), 2);
4485 assert_eq!(meta.outcomes[0].side_specs[0].name, "Yes");
4486 assert_eq!(meta.outcomes[0].side_specs[1].name, "No");
4487 }
4488
4489 #[rstest]
4490 #[tokio::test]
4491 async fn test_request_instrument_defs_errors_when_non_usdc_collateral_unresolved() {
4492 let addr = start_unresolved_collateral_server().await;
4493 let mut client =
4494 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4495 client.set_base_info_url(format!("http://{addr}/info"));
4496
4497 let err = client.request_instrument_defs().await.unwrap_err();
4498
4499 assert_eq!(
4500 err.to_string(),
4501 "decode error: failed to resolve perp settlement currency for dex 0: \
4502 Spot metadata required to resolve perp collateral token 360",
4503 );
4504 }
4505
4506 #[rstest]
4507 fn test_with_credentials_preserves_explicit_account_address() {
4508 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4509 let client = HyperliquidHttpClient::with_credentials(
4510 Some(TEST_PRIVATE_KEY.to_string()),
4511 None,
4512 Some(account_address),
4513 HyperliquidEnvironment::Mainnet,
4514 60,
4515 None,
4516 )
4517 .unwrap();
4518
4519 assert_eq!(client.get_account_address().unwrap(), account_address);
4520 }
4521
4522 #[rstest]
4523 fn test_from_resolved_credentials_preserves_account_address_without_private_key() {
4524 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4525 let client = HyperliquidHttpClient::from_resolved_credentials(
4526 None,
4527 None,
4528 Some(account_address.to_string()),
4529 HyperliquidEnvironment::Mainnet,
4530 60,
4531 None,
4532 )
4533 .unwrap();
4534
4535 assert_eq!(client.get_account_address().unwrap(), account_address);
4536 }
4537
4538 #[rstest]
4539 fn test_cache_instrument_by_raw_symbol() {
4540 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4541
4542 let base_code = "vntls:vCURSOR";
4544 let quote_code = "USDC";
4545
4546 {
4548 let mut currency_map = CURRENCY_MAP.lock();
4549 if !currency_map.contains_key(base_code) {
4550 currency_map.insert(
4551 base_code.to_string(),
4552 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
4553 );
4554 }
4555 }
4556
4557 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
4558 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
4559
4560 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
4562 let venue = *HYPERLIQUID_VENUE;
4563 let instrument_id = InstrumentId::new(symbol, venue);
4564
4565 let raw_symbol = Symbol::new(base_code);
4567
4568 let clock = get_atomic_clock_realtime();
4569 let ts = clock.get_time_ns();
4570
4571 let instrument = InstrumentAny::CurrencyPair(
4572 CurrencyPair::builder()
4573 .instrument_id(instrument_id)
4574 .raw_symbol(raw_symbol)
4575 .base_currency(base_currency)
4576 .quote_currency(quote_currency)
4577 .price_precision(8)
4578 .size_precision(8)
4579 .price_increment(Price::from("0.00000001"))
4580 .size_increment(Quantity::from("0.00000001"))
4581 .ts_event(ts)
4582 .ts_init(ts)
4583 .build()
4584 .unwrap(),
4585 );
4586
4587 client.cache_instrument(&instrument);
4589
4590 let instruments = client.instruments.load();
4592 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
4593 assert!(
4594 by_full_symbol.is_some(),
4595 "Instrument should be accessible by full symbol"
4596 );
4597 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
4598
4599 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
4601 assert!(
4602 by_raw_symbol.is_some(),
4603 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
4604 );
4605 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
4606 drop(instruments);
4607
4608 let instruments_by_coin = client.instruments_by_coin.load();
4610 let by_coin =
4611 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
4612 assert!(
4613 by_coin.is_some(),
4614 "Instrument should be accessible by coin and product type"
4615 );
4616 assert_eq!(by_coin.unwrap().id(), instrument.id());
4617 drop(instruments_by_coin);
4618
4619 let retrieved_with_type = client.get_or_create_instrument(
4621 &Ustr::from("vntls:vCURSOR"),
4622 Some(HyperliquidProductType::Spot),
4623 );
4624 assert!(retrieved_with_type.is_some());
4625 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
4626
4627 let retrieved_without_type =
4629 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
4630 assert!(retrieved_without_type.is_some());
4631 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
4632 }
4633
4634 #[rstest]
4635 fn test_get_or_create_instrument_outcome_fallback_no_product_type() {
4636 use nautilus_core::time::get_atomic_clock_realtime;
4642 use nautilus_model::{
4643 enums::AssetClass,
4644 identifiers::{InstrumentId, Symbol},
4645 instruments::{BinaryOption, InstrumentAny},
4646 types::{Currency, Price, Quantity},
4647 };
4648
4649 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4650 let coin = "#500";
4651 let token = "+500";
4652
4653 let usdh = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
4654 let symbol = Symbol::new(token);
4655 let raw_symbol = Symbol::new(coin);
4656 let venue = *HYPERLIQUID_VENUE;
4657 let instrument_id = InstrumentId::new(symbol, venue);
4658
4659 let clock = get_atomic_clock_realtime();
4660 let ts = clock.get_time_ns();
4661
4662 let binary = InstrumentAny::BinaryOption(
4663 BinaryOption::builder()
4664 .instrument_id(instrument_id)
4665 .raw_symbol(raw_symbol)
4666 .asset_class(AssetClass::Alternative)
4667 .currency(usdh)
4668 .activation_ns(Default::default())
4669 .expiration_ns(Default::default())
4670 .price_precision(4)
4671 .size_precision(2)
4672 .price_increment(Price::from("0.0001"))
4673 .size_increment(Quantity::from("0.01"))
4674 .ts_event(ts)
4675 .ts_init(ts)
4676 .build()
4677 .unwrap(),
4678 );
4679
4680 client.cache_instrument(&binary);
4681
4682 let with_type = client
4683 .get_or_create_instrument(&Ustr::from(coin), Some(HyperliquidProductType::Outcome));
4684 assert!(with_type.is_some());
4685 let with_type = with_type.unwrap();
4686 assert_eq!(with_type.id(), instrument_id);
4687 assert_eq!(with_type.quote_currency(), usdh);
4688
4689 let no_type = client.get_or_create_instrument(&Ustr::from(coin), None);
4690 assert!(
4691 no_type.is_some(),
4692 "Outcome coin must resolve through the no-product fallback",
4693 );
4694 let no_type = no_type.unwrap();
4695 assert_eq!(no_type.id(), instrument_id);
4696 assert_eq!(no_type.quote_currency(), usdh);
4697
4698 let missing = client.get_or_create_instrument(&Ustr::from("#9999"), None);
4699 assert!(missing.is_none());
4700 }
4701
4702 #[rstest]
4703 #[case::coin_form("#200", None, "20-YES-OUTCOME", "#200", 100_000_200)]
4704 #[case::token_form(
4705 "+201",
4706 Some(HyperliquidProductType::Outcome),
4707 "20-NO-OUTCOME",
4708 "#201",
4709 100_000_201
4710 )]
4711 fn test_get_or_create_instrument_resolves_unlisted_outcome(
4712 #[case] coin: &str,
4713 #[case] product_type: Option<HyperliquidProductType>,
4714 #[case] symbol: &str,
4715 #[case] raw_symbol: &str,
4716 #[case] asset_index: u32,
4717 ) {
4718 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4721
4722 let instrument = client
4723 .get_or_create_instrument(&Ustr::from(coin), product_type)
4724 .expect("unlisted outcome coin must resolve from its encoding");
4725
4726 assert_eq!(
4727 instrument.id(),
4728 InstrumentId::new(Symbol::new(symbol), *HYPERLIQUID_VENUE)
4729 );
4730 assert_eq!(instrument.raw_symbol(), Symbol::new(raw_symbol));
4731 assert_eq!(instrument.price_precision(), 5);
4732 assert_eq!(instrument.size_precision(), 0);
4733 assert_eq!(instrument.quote_currency(), Currency::USDC());
4734 assert_eq!(instrument.settlement_currency(), Currency::USDC());
4735 assert_eq!(client.get_asset_index(symbol), Some(asset_index));
4736 }
4737
4738 #[rstest]
4739 fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
4740 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4745
4746 let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
4747 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4748 let clock = get_atomic_clock_realtime();
4749 let ts = clock.get_time_ns();
4750
4751 let canonical = InstrumentAny::CurrencyPair(
4752 CurrencyPair::builder()
4753 .instrument_id(InstrumentId::new(
4754 Symbol::new("HYPE-USDC-SPOT"),
4755 *HYPERLIQUID_VENUE,
4756 ))
4757 .raw_symbol(Symbol::new("@107"))
4758 .base_currency(hype)
4759 .quote_currency(usdc)
4760 .price_precision(5)
4761 .size_precision(2)
4762 .price_increment(Price::from("0.00001"))
4763 .size_increment(Quantity::from("0.01"))
4764 .ts_event(ts)
4765 .ts_init(ts)
4766 .build()
4767 .unwrap(),
4768 );
4769
4770 let non_canonical = InstrumentAny::CurrencyPair(
4771 CurrencyPair::builder()
4772 .instrument_id(InstrumentId::new(
4773 Symbol::new("HYPE-USDC-SPOT"),
4774 *HYPERLIQUID_VENUE,
4775 ))
4776 .raw_symbol(Symbol::new("@999"))
4777 .base_currency(hype)
4778 .quote_currency(usdc)
4779 .price_precision(5)
4780 .size_precision(2)
4781 .price_increment(Price::from("0.00001"))
4782 .size_increment(Quantity::from("0.01"))
4783 .ts_event(ts)
4784 .ts_init(ts)
4785 .build()
4786 .unwrap(),
4787 );
4788
4789 client.cache_instrument(&canonical);
4790 client.cache_instrument(&non_canonical);
4791
4792 let instruments_by_coin = client.instruments_by_coin.load();
4793 let by_base = instruments_by_coin
4794 .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
4795 .expect("base alias must resolve");
4796 assert_eq!(
4797 by_base.raw_symbol().inner().as_str(),
4798 "@107",
4799 "base alias must point to the canonical pair, not the one cached later",
4800 );
4801 }
4802
4803 #[rstest]
4804 fn test_cache_instrument_perp_aliases_sanitized_base() {
4805 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4811
4812 let base_currency = Currency::new(
4813 "dex:STREAMABCD****",
4814 8,
4815 0,
4816 "dex:STREAMABCD****",
4817 CurrencyType::Crypto,
4818 );
4819 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4820 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4821 let clock = get_atomic_clock_realtime();
4822 let ts = clock.get_time_ns();
4823
4824 let hip3 = InstrumentAny::CryptoPerpetual(
4825 CryptoPerpetual::builder()
4826 .instrument_id(InstrumentId::new(
4827 Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
4828 *HYPERLIQUID_VENUE,
4829 ))
4830 .raw_symbol(Symbol::new("dex:STREAMABCD****"))
4831 .base_currency(base_currency)
4832 .quote_currency(usd)
4833 .settlement_currency(usdc)
4834 .is_inverse(false)
4835 .price_precision(6)
4836 .size_precision(3)
4837 .price_increment(Price::from("0.000001"))
4838 .size_increment(Quantity::from("0.001"))
4839 .ts_event(ts)
4840 .ts_init(ts)
4841 .build()
4842 .unwrap(),
4843 );
4844
4845 client.cache_instrument(&hip3);
4846
4847 let instruments_by_coin = client.instruments_by_coin.load();
4848 let by_raw = instruments_by_coin
4849 .get(&(
4850 Ustr::from("dex:STREAMABCD****"),
4851 HyperliquidProductType::Perp,
4852 ))
4853 .expect("venue coin lookup must resolve");
4854 assert_eq!(by_raw.id(), hip3.id());
4855
4856 let by_sanitized = instruments_by_coin
4857 .get(&(
4858 Ustr::from("dex:STREAMABCDxxxx"),
4859 HyperliquidProductType::Perp,
4860 ))
4861 .expect("sanitized base lookup must resolve");
4862 assert_eq!(by_sanitized.id(), hip3.id());
4863 drop(instruments_by_coin);
4864
4865 let resolved = client
4867 .get_or_create_instrument(
4868 &Ustr::from("dex:STREAMABCDxxxx"),
4869 Some(HyperliquidProductType::Perp),
4870 )
4871 .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
4872 assert_eq!(resolved.id(), hip3.id());
4873 }
4874
4875 fn perp_with_asset_index(symbol: &str, asset_index: Option<u32>) -> InstrumentAny {
4876 let base_currency = Currency::new("NEW", 8, 0, "NEW", CurrencyType::Crypto);
4877 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4878 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4879 let ts = get_atomic_clock_realtime().get_time_ns();
4880 let info = asset_index.map(|asset_index| {
4881 let mut info = Params::new();
4882 info.insert(ASSET_INDEX_INFO_KEY.to_string(), asset_index.into());
4883 info
4884 });
4885
4886 InstrumentAny::CryptoPerpetual(
4887 CryptoPerpetual::builder()
4888 .instrument_id(InstrumentId::new(Symbol::new(symbol), *HYPERLIQUID_VENUE))
4889 .raw_symbol(Symbol::new("NEW"))
4890 .base_currency(base_currency)
4891 .quote_currency(usd)
4892 .settlement_currency(usdc)
4893 .is_inverse(false)
4894 .price_precision(6)
4895 .size_precision(3)
4896 .price_increment(Price::from("0.000001"))
4897 .size_increment(Quantity::from("0.001"))
4898 .maybe_info(info)
4899 .ts_event(ts)
4900 .ts_init(ts)
4901 .build()
4902 .unwrap(),
4903 )
4904 }
4905
4906 #[rstest]
4907 fn test_cache_instrument_registers_asset_index_from_info() {
4908 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4909
4910 client.cache_instrument(&perp_with_asset_index("NEW-USD-PERP", Some(42)));
4911
4912 assert_eq!(client.get_asset_index("NEW-USD-PERP"), Some(42));
4913 }
4914
4915 #[rstest]
4916 fn test_cache_instrument_without_asset_index_info_retains_existing_index() {
4917 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4920
4921 client.cache_instrument(&perp_with_asset_index("NEW-USD-PERP", Some(42)));
4922 client.cache_instrument(&perp_with_asset_index("NEW-USD-PERP", None));
4923 client.cache_instrument(&perp_with_asset_index("OTHER-USD-PERP", None));
4924
4925 assert_eq!(client.get_asset_index("NEW-USD-PERP"), Some(42));
4926 assert_eq!(client.get_asset_index("OTHER-USD-PERP"), None);
4927 }
4928
4929 fn btc_history_client() -> HyperliquidHttpClient {
4930 let mut client =
4931 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4932 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
4933
4934 let base = Currency::new("BTC", 8, 0, "BTC", CurrencyType::Crypto);
4935 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4936 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4937 let ts = get_atomic_clock_realtime().get_time_ns();
4938 let perp = InstrumentAny::CryptoPerpetual(
4939 CryptoPerpetual::builder()
4940 .instrument_id(InstrumentId::new(
4941 Symbol::new("BTC-USD-PERP"),
4942 *HYPERLIQUID_VENUE,
4943 ))
4944 .raw_symbol(Symbol::new("BTC"))
4945 .base_currency(base)
4946 .quote_currency(usd)
4947 .settlement_currency(usdc)
4948 .is_inverse(false)
4949 .price_precision(1)
4950 .size_precision(5)
4951 .price_increment(Price::from("0.1"))
4952 .size_increment(Quantity::from("0.00001"))
4953 .ts_event(ts)
4954 .ts_init(ts)
4955 .build()
4956 .unwrap(),
4957 );
4958 client.cache_instrument(&perp);
4959 client
4960 }
4961
4962 fn history_rows(rows: Value) -> Vec<HyperliquidOrderStatusEntry> {
4963 serde_json::from_value(rows).unwrap()
4964 }
4965
4966 fn assert_initializes_order(report: &OrderStatusReport) -> OrderAny {
4968 let initialized = OrderInitialized::new_checked(
4969 TraderId::from("TESTER-001"),
4970 StrategyId::from("EXTERNAL"),
4971 report.instrument_id,
4972 ClientOrderId::from(report.venue_order_id.as_str()),
4973 report.order_side.expect("report has an order side"),
4974 report.order_type,
4975 report.quantity,
4976 report.time_in_force,
4977 report.post_only,
4978 report.reduce_only,
4979 false,
4980 true,
4981 UUID4::new(),
4982 UnixNanos::default(),
4983 UnixNanos::default(),
4984 report.price,
4985 report.activation_price,
4986 report.trigger_price,
4987 report.trigger_type,
4988 report.limit_offset,
4989 report.trailing_offset,
4990 report.trailing_offset_type,
4991 report.expire_time,
4992 report.display_qty,
4993 None,
4994 None,
4995 report.contingency_type,
4996 report.order_list_id,
4997 report.linked_order_ids.clone(),
4998 report.parent_order_id,
4999 None,
5000 None,
5001 None,
5002 None,
5003 )
5004 .expect("report must initialize an order");
5005 OrderAny::from_events(vec![OrderEventAny::Initialized(initialized)])
5006 .expect("order must replay from its initialization")
5007 }
5008
5009 fn triggered_stop_rows() -> Value {
5012 let trigger_row = |status: &str, ts: u64| {
5013 json!({
5014 "order": {
5015 "coin": "BTC", "side": "A", "limitPx": "58800.0", "sz": "0.0",
5016 "oid": 900000001_u64, "timestamp": 1700000000000_u64,
5017 "triggerCondition": "Price below 59500", "isTrigger": true,
5018 "triggerPx": "59500.0", "children": [], "isPositionTpsl": true,
5019 "reduceOnly": true, "orderType": "Stop Market", "origSz": "0.0", "tif": null,
5020 "cloid": "0x00000000000000000000000000000a01"
5021 },
5022 "status": status,
5023 "statusTimestamp": ts
5024 })
5025 };
5026 json!([
5027 filled_stop_row(),
5028 trigger_row("triggered", 1700000600000),
5029 trigger_row("open", 1700000000000),
5030 ])
5031 }
5032
5033 fn filled_stop_row() -> Value {
5034 json!({
5035 "order": {
5036 "coin": "BTC", "side": "A", "limitPx": "58200.0", "sz": "0.0",
5037 "oid": 900000001_u64, "timestamp": 1700000600000_u64,
5038 "triggerCondition": "Triggered", "isTrigger": false, "triggerPx": "0.0",
5039 "children": [], "isPositionTpsl": true, "reduceOnly": true,
5040 "orderType": "Stop Market", "origSz": "0.02", "tif": "Gtc",
5041 "cloid": "0x00000000000000000000000000000a01"
5042 },
5043 "status": "filled",
5044 "statusTimestamp": 1700000600000_u64
5045 })
5046 }
5047
5048 #[rstest]
5049 fn test_historical_reports_triggered_stop_keeps_trigger_from_earlier_rows() {
5050 let client = btc_history_client();
5051
5052 let sweep = client
5053 .historical_order_status_reports_from_response(
5054 history_rows(triggered_stop_rows()),
5055 None,
5056 )
5057 .unwrap();
5058
5059 assert!(sweep.complete);
5060 assert_eq!(sweep.reports.len(), 1);
5061 let report = &sweep.reports[0];
5062 assert_eq!(report.venue_order_id, VenueOrderId::new("900000001"));
5063 assert_eq!(report.order_type, OrderType::StopMarket);
5064 assert_eq!(report.order_status, OrderStatus::Filled);
5065 assert_eq!(report.filled_qty, Quantity::from("0.02"));
5066 assert_eq!(report.trigger_price, Some(Price::from("59500.0")));
5067 assert_eq!(report.trigger_type, Some(TriggerType::Default));
5068 assert_initializes_order(report);
5069 }
5070
5071 #[rstest]
5072 fn test_historical_reports_triggered_stop_limit_keeps_trigger_and_price() {
5073 let client = btc_history_client();
5074 let mut rows = triggered_stop_rows();
5075 for row in rows.as_array_mut().unwrap() {
5076 row["order"]["orderType"] = json!("Stop Limit");
5077 }
5078
5079 let sweep = client
5080 .historical_order_status_reports_from_response(history_rows(rows), None)
5081 .unwrap();
5082
5083 assert_eq!(sweep.reports.len(), 1);
5084 let report = &sweep.reports[0];
5085 assert_eq!(report.order_type, OrderType::StopLimit);
5086 assert_eq!(report.price, Some(Price::from("58800.0")));
5088 assert_eq!(report.trigger_price, Some(Price::from("59500.0")));
5089 assert_eq!(report.trigger_type, Some(TriggerType::Default));
5090 assert_initializes_order(report);
5091 }
5092
5093 #[rstest]
5094 #[case("Stop Market", "filled", OrderType::Market)]
5095 #[case("Take Profit Market", "filled", OrderType::Market)]
5096 #[case("Stop Limit", "filled", OrderType::Market)]
5097 #[case("Take Profit Limit", "filled", OrderType::Market)]
5098 #[case("Stop Market", "canceled", OrderType::Market)]
5099 #[case("Take Profit Market", "canceled", OrderType::Market)]
5100 #[case("Stop Limit", "canceled", OrderType::Limit)]
5101 #[case("Take Profit Limit", "canceled", OrderType::Limit)]
5102 fn test_historical_reports_closed_trigger_without_trigger_rows_is_untriggered(
5103 #[case] label: &str,
5104 #[case] status: &str,
5105 #[case] expected: OrderType,
5106 ) {
5107 let client = btc_history_client();
5110 let mut row = filled_stop_row();
5111 row["order"]["orderType"] = json!(label);
5112 row["status"] = json!(status);
5113 let expected_filled = if status == "canceled" {
5115 row["order"]["sz"] = json!("0.02");
5116 "0"
5117 } else {
5118 "0.02"
5119 };
5120
5121 let sweep = client
5122 .historical_order_status_reports_from_response(history_rows(json!([row])), None)
5123 .unwrap();
5124
5125 assert_eq!(sweep.reports.len(), 1);
5126 let report = &sweep.reports[0];
5127 assert_eq!(report.order_type, expected);
5128 assert_eq!(report.filled_qty, Quantity::from(expected_filled));
5129 assert!(report.trigger_price.is_none());
5130 assert!(report.trigger_type.is_none());
5131 assert_eq!(report.price.is_some(), expected == OrderType::Limit);
5132 assert!(report.reduce_only);
5133 assert_initializes_order(report);
5134 }
5135
5136 #[rstest]
5137 fn test_historical_reports_triggered_stop_filled_with_remainder() {
5138 let client = btc_history_client();
5140 let mut row = filled_stop_row();
5141 row["order"]["sz"] = json!("0.0008");
5142
5143 let sweep = client
5144 .historical_order_status_reports_from_response(history_rows(json!([row])), None)
5145 .unwrap();
5146
5147 assert_eq!(sweep.reports.len(), 1);
5148 let report = &sweep.reports[0];
5149 assert_eq!(report.order_status, OrderStatus::Canceled);
5150 assert_eq!(report.order_type, OrderType::Market);
5151 assert_eq!(report.filled_qty, Quantity::from("0.0192"));
5152 assert!(report.price.is_none());
5153 assert_initializes_order(report);
5154 }
5155
5156 #[rstest]
5157 fn test_historical_reports_open_trigger_without_trigger_price_keeps_order_type() {
5158 let client = btc_history_client();
5161 let mut row = filled_stop_row();
5162 row["status"] = json!("triggered");
5163
5164 let sweep = client
5165 .historical_order_status_reports_from_response(history_rows(json!([row])), None)
5166 .unwrap();
5167
5168 assert_eq!(sweep.reports.len(), 1);
5169 assert_eq!(sweep.reports[0].order_type, OrderType::StopMarket);
5170 }
5171
5172 #[rstest]
5175 fn test_historical_reports_partially_filled_ioc_is_canceled() {
5176 let client = btc_history_client();
5177 let row = |status: &str, sz: &str| {
5178 json!({
5179 "order": {
5180 "coin": "BTC", "side": "A", "limitPx": "60100.0", "sz": sz,
5181 "oid": 900000002_u64, "timestamp": 1700001000000_u64,
5182 "triggerCondition": "N/A", "isTrigger": false, "triggerPx": "0.0",
5183 "children": [], "isPositionTpsl": false, "reduceOnly": false,
5184 "orderType": "Limit", "origSz": "0.015", "tif": "Ioc",
5185 "cloid": "0x00000000000000000000000000000a02"
5186 },
5187 "status": status,
5188 "statusTimestamp": 1700001000000_u64
5189 })
5190 };
5191
5192 let sweep = client
5193 .historical_order_status_reports_from_response(
5194 history_rows(json!([row("filled", "0.0005"), row("open", "0.015")])),
5195 None,
5196 )
5197 .unwrap();
5198
5199 assert_eq!(sweep.reports.len(), 1);
5200 let report = &sweep.reports[0];
5201 assert_eq!(report.order_status, OrderStatus::Canceled);
5202 assert_eq!(report.order_type, OrderType::Limit);
5203 assert_eq!(report.quantity, Quantity::from("0.015"));
5204 assert_eq!(report.filled_qty, Quantity::from("0.0145"));
5205 assert_eq!(report.price, Some(Price::from("60100.0")));
5207 assert_eq!(
5208 report.cancel_reason.as_deref(),
5209 Some("Unfilled remainder canceled")
5210 );
5211 assert_initializes_order(report);
5212 }
5213}