1use std::{
24 collections::HashMap,
25 num::NonZeroU32,
26 sync::{Arc, LazyLock},
27 time::Duration,
28};
29
30use ahash::{AHashMap, AHashSet};
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, FundingRateUpdate},
41 enums::{
42 AccountType, BarAggregation, CurrencyType, MarketStatusAction, OrderSide, OrderStatus,
43 OrderType, TimeInForce, 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, parse_retry_after,
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::{normalize_account_address, 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, funding_entry_to_update},
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 HyperliquidExchangeResponseData, HyperliquidExchangeSplitOutcomeParams,
100 HyperliquidExchangeTif, HyperliquidExchangeTpSl, HyperliquidExchangeTriggerParams,
101 HyperliquidExchangeUserOutcomeOp, HyperliquidFills, HyperliquidFundingHistoryEntry,
102 HyperliquidL2Book, HyperliquidMeta, HyperliquidOrderStatus,
103 HyperliquidOrderStatusEntry, HyperliquidRecentTrade, OutcomeMeta, PerpDex, PerpMeta,
104 PerpMetaAndCtxs, RESPONSE_STATUS_OK, 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
234pub const HYPERLIQUID_FUNDING_HISTORY_MAX_LIMIT: usize = 500;
236const DEFAULT_FUNDING_RATES_LIMIT: usize = 7 * 24;
238
239#[derive(Debug, Clone)]
244#[cfg_attr(
245 feature = "python",
246 pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
247)]
248pub struct HyperliquidRawHttpClient {
249 client: HttpClient,
250 environment: HyperliquidEnvironment,
251 base_info: String,
252 base_exchange: String,
253 signer: Option<HyperliquidEip712Signer>,
254 nonce_manager: Option<Arc<NonceManager>>,
255 vault_address: Option<VaultAddress>,
256 proxy_url: Option<SecretString>,
257 info_limiter: Arc<WeightedLimiter>,
258 exchange_limiter: Arc<WeightedLimiter>,
259 info_max_retries: u32,
260 info_retry_delay_initial: Duration,
261 info_retry_delay_max: Duration,
262}
263
264impl HyperliquidRawHttpClient {
265 pub fn new(
271 environment: HyperliquidEnvironment,
272 timeout_secs: u64,
273 proxy_url: Option<String>,
274 ) -> std::result::Result<Self, HttpClientError> {
275 let base_info = info_url(environment).to_string();
276 let base_exchange = exchange_url(environment).to_string();
277 let info_limiter = shared_rest_limiter(environment, &base_info, proxy_url.as_deref());
278 let exchange_limiter =
279 shared_rest_limiter(environment, &base_exchange, proxy_url.as_deref());
280
281 Ok(Self {
282 client: Self::build_http_client(timeout_secs, proxy_url.clone())?,
283 environment,
284 base_info,
285 base_exchange,
286 signer: None,
287 nonce_manager: None,
288 vault_address: None,
289 proxy_url: proxy_url.map(SecretString::from),
290 info_limiter,
291 exchange_limiter,
292 info_max_retries: RATE_LIMIT_INFO_RETRIES_MAX,
293 info_retry_delay_initial: RATE_LIMIT_BACKOFF_BASE,
294 info_retry_delay_max: RATE_LIMIT_BACKOFF_CAP,
295 })
296 }
297
298 pub fn with_credentials(
305 secrets: &Secrets,
306 timeout_secs: u64,
307 proxy_url: Option<String>,
308 ) -> std::result::Result<Self, HttpClientError> {
309 let signer = HyperliquidEip712Signer::new(&secrets.private_key)
310 .map_err(|e| HttpClientError::from(e.to_string()))?;
311 let nonce_manager = Arc::new(NonceManager::new());
312 let base_info = info_url(secrets.environment).to_string();
313 let base_exchange = exchange_url(secrets.environment).to_string();
314 let info_limiter =
315 shared_rest_limiter(secrets.environment, &base_info, proxy_url.as_deref());
316 let exchange_limiter =
317 shared_rest_limiter(secrets.environment, &base_exchange, proxy_url.as_deref());
318
319 Ok(Self {
320 client: Self::build_http_client(timeout_secs, proxy_url.clone())?,
321 environment: secrets.environment,
322 base_info,
323 base_exchange,
324 signer: Some(signer),
325 nonce_manager: Some(nonce_manager),
326 vault_address: secrets.vault_address,
327 proxy_url: proxy_url.map(SecretString::from),
328 info_limiter,
329 exchange_limiter,
330 info_max_retries: RATE_LIMIT_INFO_RETRIES_MAX,
331 info_retry_delay_initial: RATE_LIMIT_BACKOFF_BASE,
332 info_retry_delay_max: RATE_LIMIT_BACKOFF_CAP,
333 })
334 }
335
336 pub fn set_base_info_url(&mut self, url: String) {
338 self.info_limiter = shared_rest_limiter(
339 self.environment,
340 &url,
341 self.proxy_url.as_ref().map(|value| value.expose_secret()),
342 );
343 self.base_info = url;
344 }
345
346 pub fn set_base_exchange_url(&mut self, url: String) {
348 self.exchange_limiter = shared_rest_limiter(
349 self.environment,
350 &url,
351 self.proxy_url.as_ref().map(|value| value.expose_secret()),
352 );
353 self.base_exchange = url;
354 }
355
356 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
362 let secrets = Secrets::from_env(environment)
363 .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
364 Self::with_credentials(&secrets, 60, None)
365 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
366 }
367
368 pub fn from_credentials(
374 private_key: &str,
375 vault_address: Option<&str>,
376 environment: HyperliquidEnvironment,
377 timeout_secs: u64,
378 proxy_url: Option<String>,
379 ) -> Result<Self> {
380 let secrets = Secrets::from_private_key(private_key, vault_address, environment)
381 .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
382 Self::with_credentials(&secrets, timeout_secs, proxy_url)
383 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
384 }
385
386 #[must_use]
388 pub fn with_rate_limits(mut self) -> Self {
389 let proxy_url = self.proxy_url.as_ref().map(|value| value.expose_secret());
390 self.info_limiter = shared_rest_limiter(self.environment, &self.base_info, proxy_url);
391 self.exchange_limiter =
392 shared_rest_limiter(self.environment, &self.base_exchange, proxy_url);
393 self
394 }
395
396 #[must_use]
398 pub fn environment(&self) -> HyperliquidEnvironment {
399 self.environment
400 }
401
402 #[must_use]
404 pub fn is_testnet(&self) -> bool {
405 self.environment == HyperliquidEnvironment::Testnet
406 }
407
408 pub fn get_user_address(&self) -> Result<String> {
414 self.signer
415 .as_ref()
416 .ok_or_else(|| Error::auth("No signer configured"))?
417 .address()
418 }
419
420 #[must_use]
422 pub fn has_vault_address(&self) -> bool {
423 self.vault_address.is_some()
424 }
425
426 pub fn get_account_address(&self) -> Result<String> {
433 if let Some(vault) = &self.vault_address {
434 Ok(vault.to_hex())
435 } else {
436 self.get_user_address()
437 }
438 }
439
440 fn build_http_client(
441 timeout_secs: u64,
442 proxy_url: Option<String>,
443 ) -> std::result::Result<HttpClient, HttpClientError> {
444 HttpClient::builder()
445 .redirect_policy(HttpRedirectPolicy::Reject)
446 .headers(Self::default_headers())
447 .header_keys(vec![RETRY_AFTER_HEADER.to_string()])
448 .rate_limiters(Vec::new())
449 .timeout_secs(timeout_secs)
450 .maybe_proxy_url(proxy_url)
451 .build()
452 }
453
454 fn default_headers() -> HashMap<String, String> {
455 let mut headers: HashMap<String, String> =
456 create_standard_nautilus_headers().into_iter().collect();
457 headers.insert("Content-Type".to_string(), "application/json".to_string());
458 headers
459 }
460
461 fn signer_id(&self) -> SignerId {
462 SignerId("hyperliquid:default".into())
463 }
464
465 fn retry_after_ms(headers: &HashMap<String, String>) -> Option<u64> {
466 let retry_after = headers.get(RETRY_AFTER_HEADER)?;
467 parse_retry_after(retry_after, jiff::Timestamp::now())
468 .map(|delay| delay.as_millis().min(u128::from(u64::MAX)) as u64)
469 }
470
471 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
473 let request = InfoRequest::meta();
474 let response = self.send_info_request(&request).await?;
475 serde_json::from_value(response).map_err(Error::Serde)
476 }
477
478 pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
480 let request = InfoRequest::spot_meta();
481 let response = self.send_info_request(&request).await?;
482 serde_json::from_value(response).map_err(Error::Serde)
483 }
484
485 pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
487 let request = InfoRequest::meta_and_asset_ctxs();
488 let response = self.send_info_request(&request).await?;
489 serde_json::from_value(response).map_err(Error::Serde)
490 }
491
492 pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
494 let request = InfoRequest::spot_meta_and_asset_ctxs();
495 let response = self.send_info_request(&request).await?;
496 serde_json::from_value(response).map_err(Error::Serde)
497 }
498
499 pub async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
501 let request = InfoRequest::outcome_meta();
502 let response = self.send_info_request(&request).await?;
503 serde_json::from_value(response).map_err(Error::Serde)
504 }
505
506 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
507 let request = InfoRequest::meta();
508 let response = self.send_info_request(&request).await?;
509 serde_json::from_value(response).map_err(Error::Serde)
510 }
511
512 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
514 let request = InfoRequest::all_perp_metas();
515 let response = self.send_info_request(&request).await?;
516 serde_json::from_value(response).map_err(Error::Serde)
517 }
518
519 pub(crate) async fn load_perp_dexs(&self) -> Result<Vec<Option<PerpDex>>> {
521 let request = InfoRequest::perp_dexs();
522 let response = self.send_info_request(&request).await?;
523 serde_json::from_value(response).map_err(Error::Serde)
524 }
525
526 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
528 let request = InfoRequest::l2_book(coin);
529 let response = self.send_info_request(&request).await?;
530 serde_json::from_value(response).map_err(Error::Serde)
531 }
532
533 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
538 let request = InfoRequest::recent_trades(coin);
539 let response = self.send_info_request(&request).await?;
540 serde_json::from_value(response).map_err(Error::Serde)
541 }
542
543 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
545 let request = InfoRequest::user_fills(user);
546 let response = self.send_info_request(&request).await?;
547 serde_json::from_value(response).map_err(Error::Serde)
548 }
549
550 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
552 let request = InfoRequest::order_status(user, oid);
553 let response = self.send_info_request(&request).await?;
554 serde_json::from_value(response).map_err(Error::Serde)
555 }
556
557 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
559 let request = InfoRequest::open_orders(user);
560 self.send_info_request(&request).await
561 }
562
563 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
565 self.info_frontend_open_orders_for_dex(user, None).await
566 }
567
568 async fn info_frontend_open_orders_for_dex(
569 &self,
570 user: &str,
571 dex: Option<&str>,
572 ) -> Result<Value> {
573 let request = InfoRequest::frontend_open_orders_for_dex(user, dex);
574 self.send_info_request(&request).await
575 }
576
577 pub async fn info_historical_orders(
579 &self,
580 user: &str,
581 ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
582 let request = InfoRequest::historical_orders(user);
583 let response = self.send_info_request(&request).await?;
584 serde_json::from_value(response).map_err(Error::Serde)
585 }
586
587 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
589 self.info_clearinghouse_state_for_dex(user, None).await
590 }
591
592 async fn info_clearinghouse_state_for_dex(
593 &self,
594 user: &str,
595 dex: Option<&str>,
596 ) -> Result<Value> {
597 let request = InfoRequest::clearinghouse_state_for_dex(user, dex);
598 self.send_info_request(&request).await
599 }
600
601 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
603 let request = InfoRequest::spot_clearinghouse_state(user);
604 self.send_info_request(&request).await
605 }
606
607 pub async fn info_user_abstraction(&self, user: &str) -> Result<Value> {
609 let request = InfoRequest::user_abstraction(user);
610 self.send_info_request(&request).await
611 }
612
613 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
615 let request = InfoRequest::user_fees(user);
616 self.send_info_request(&request).await
617 }
618
619 pub async fn info_candle_snapshot(
621 &self,
622 coin: &str,
623 interval: HyperliquidBarInterval,
624 start_time: u64,
625 end_time: u64,
626 ) -> Result<HyperliquidCandleSnapshot> {
627 let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
628 let response = self.send_info_request(&request).await?;
629
630 log::trace!(
631 "Candle snapshot raw response (len={}): {:?}",
632 response.as_array().map_or(0, |a| a.len()),
633 response
634 );
635
636 serde_json::from_value(response).map_err(Error::Serde)
637 }
638
639 pub async fn info_funding_history(
644 &self,
645 coin: &str,
646 start_time: u64,
647 end_time: Option<u64>,
648 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
649 let request = InfoRequest::funding_history(coin, start_time, end_time);
650 let response = self.send_info_request(&request).await?;
651 serde_json::from_value(response).map_err(Error::Serde)
652 }
653
654 pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
656 self.send_info_request(request).await
657 }
658
659 async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
660 let base_w = info_base_weight(request);
661 let mut attempt = 0u32;
662
663 loop {
664 self.info_limiter.acquire(base_w).await;
665
666 let (e, retry_after_ms) = match self.http_roundtrip_info(request).await {
667 Ok(response) if response.status.is_success() => {
668 let val: Value =
669 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
670 let extra = info_extra_weight(request, &val);
671 if extra > 0 {
672 self.info_limiter.debit_extra(extra).await;
673 log::debug!(
674 "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
675 );
676 }
677
678 return Ok(val);
679 }
680 Ok(response) => {
681 let retry_after_ms = Self::retry_after_ms(&response.headers);
682 let error = if response.status.as_u16() == 429 {
683 Error::rate_limit("info", base_w, retry_after_ms)
684 } else {
685 let body = String::from_utf8_lossy(&response.body);
686 Error::http(response.status.as_u16(), body.to_string())
687 };
688
689 (error, retry_after_ms)
690 }
691 Err(e) => (e, None),
692 };
693
694 if !e.is_retryable() || attempt >= self.info_max_retries {
695 return Err(e);
696 }
697
698 let delay = retry_after_ms.map_or_else(
699 || {
700 backoff_full_jitter(
701 attempt,
702 self.info_retry_delay_initial,
703 self.info_retry_delay_max,
704 )
705 },
706 Duration::from_millis,
707 );
708
709 log::debug!(
710 "Info request failed; retrying: endpoint={request:?}, attempt={attempt}, wait_ms={}",
711 delay.as_millis()
712 );
713 attempt += 1;
714 tokio::time::sleep(delay).await;
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(
837 &self,
838 action: &HyperliquidExchangeAction,
839 expires_after: Option<u64>,
840 ) -> Result<HyperliquidExchangeRequest<HyperliquidExchangeAction>> {
841 if matches!(action, HyperliquidExchangeAction::UsdClassTransfer { .. }) {
842 return Err(Error::bad_request("Unsupported action: usdClassTransfer"));
843 }
844
845 let signer = self
846 .signer
847 .as_ref()
848 .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
849
850 let nonce_manager = self
851 .nonce_manager
852 .as_ref()
853 .ok_or_else(|| Error::auth("nonce manager missing"))?;
854
855 let signer_id = self.signer_id();
856 let time_nonce = nonce_manager.next(signer_id)?;
857 let action_bytes = rmp_serde::to_vec_named(action)
861 .context("serialize action with MessagePack")
862 .map_err(|e| Error::bad_request(e.to_string()))?;
863
864 let sig = signer
865 .sign(&SignRequest {
866 action: None,
867 action_bytes: Some(action_bytes),
868 time_nonce,
869 action_type: HyperliquidActionType::L1,
870 is_testnet: self.is_testnet(),
871 vault_address: self.vault_address,
872 expires_after,
873 })?
874 .signature;
875
876 let mut request = if let Some(vault) = self.vault_address {
877 HyperliquidExchangeRequest::with_vault(
878 action.clone(),
879 time_nonce.as_millis() as u64,
880 sig,
881 vault.to_string(),
882 )
883 } else {
884 HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
885 };
886 request.expires_after = expires_after;
887 Ok(request)
888 }
889
890 pub async fn post_action_exec(
895 &self,
896 action: &HyperliquidExchangeAction,
897 ) -> Result<HyperliquidExchangeResponse> {
898 let w = exec_action_weight(action);
899 self.exchange_limiter.acquire(w).await;
900
901 let request = self.sign_action_exec_request(action, None)?;
902
903 let response = self.http_roundtrip_exchange(&request).await?;
904
905 if response.status.is_success() {
906 let parsed_response: HyperliquidExchangeResponse =
907 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
908
909 match &parsed_response {
911 HyperliquidExchangeResponse::Status {
912 status,
913 response: response_data,
914 } if status == "err" => {
915 let error_msg = response_data
916 .as_str()
917 .map_or_else(|| response_data.to_string(), |s| s.to_string());
918 log::error!("Hyperliquid API returned error: {error_msg}");
919 Err(Error::bad_request(format!("API error: {error_msg}")))
920 }
921 HyperliquidExchangeResponse::Error { error } => {
922 log::error!("Hyperliquid API returned error: {error}");
923 Err(Error::bad_request(format!("API error: {error}")))
924 }
925 _ => Ok(parsed_response),
926 }
927 } else if response.status.as_u16() == 429 {
928 let ra = Self::retry_after_ms(&response.headers);
929 Err(Error::rate_limit("exchange", w, ra))
930 } else {
931 let error_body = String::from_utf8_lossy(&response.body);
932 Err(Error::http(
933 response.status.as_u16(),
934 error_body.to_string(),
935 ))
936 }
937 }
938
939 pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
941 self.info_limiter.snapshot().await
942 }
943
944 async fn http_roundtrip_exchange<T>(
945 &self,
946 request: &HyperliquidExchangeRequest<T>,
947 ) -> Result<HttpResponse>
948 where
949 T: serde::Serialize,
950 {
951 let url = &self.base_exchange;
952 let body = serde_json::to_string(&request).map_err(Error::Serde)?;
953 let body_bytes = body.into_bytes();
954
955 let response = self
956 .client
957 .request(
958 Method::POST,
959 url.clone(),
960 None,
961 None,
962 Some(body_bytes),
963 None,
964 None,
965 )
966 .await
967 .map_err(Error::from_http_client)?;
968
969 Ok(response)
970 }
971}
972
973#[derive(Debug, Clone)]
979#[cfg_attr(
980 feature = "python",
981 pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
982)]
983#[cfg_attr(
984 feature = "python",
985 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
986)]
987pub struct HyperliquidHttpClient {
988 pub(crate) inner: Arc<HyperliquidRawHttpClient>,
989 clock: &'static AtomicTime,
990 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
991 instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
992 asset_indices: Arc<AtomicMap<Ustr, u32>>,
994 spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
996 client_order_id_cloids: Arc<Mutex<AHashMap<ClientOrderId, Cloid>>>,
997 account_id: Option<AccountId>,
998 account_address: Option<String>,
1002 normalize_prices: bool,
1003 market_order_slippage_bps: u32,
1004 include_builder_attribution: bool,
1005}
1006
1007impl Default for HyperliquidHttpClient {
1008 fn default() -> Self {
1009 Self::new(HyperliquidEnvironment::Mainnet, 60, None)
1010 .expect("Failed to create default Hyperliquid HTTP client")
1011 }
1012}
1013
1014impl HyperliquidHttpClient {
1015 pub fn new(
1021 environment: HyperliquidEnvironment,
1022 timeout_secs: u64,
1023 proxy_url: Option<String>,
1024 ) -> std::result::Result<Self, HttpClientError> {
1025 let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
1026 Ok(Self::from_raw(raw_client))
1027 }
1028
1029 pub fn with_secrets(
1035 secrets: &Secrets,
1036 timeout_secs: u64,
1037 proxy_url: Option<String>,
1038 ) -> std::result::Result<Self, HttpClientError> {
1039 let raw_client =
1040 HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
1041 Ok(Self::from_raw(raw_client))
1042 }
1043
1044 fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
1045 Self {
1046 inner: Arc::new(raw_client),
1047 clock: get_atomic_clock_realtime(),
1048 instruments: Arc::new(AtomicMap::new()),
1049 instruments_by_coin: Arc::new(AtomicMap::new()),
1050 asset_indices: Arc::new(AtomicMap::new()),
1051 spot_fill_coins: Arc::new(AtomicMap::new()),
1052 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1053 account_id: None,
1054 account_address: None,
1055 normalize_prices: true,
1056 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1057 include_builder_attribution: true,
1058 }
1059 }
1060
1061 #[must_use]
1063 pub fn get_or_generate_client_order_id_cloid(&self, client_order_id: ClientOrderId) -> Cloid {
1064 let mut cloids = self.client_order_id_cloids.lock();
1065 *cloids
1066 .entry(client_order_id)
1067 .or_insert_with(|| Cloid::from_client_order_id(client_order_id))
1068 }
1069
1070 pub fn cache_client_order_id_cloid(&self, client_order_id: ClientOrderId, cloid: Cloid) {
1072 self.client_order_id_cloids
1073 .lock()
1074 .entry(client_order_id)
1075 .or_insert(cloid);
1076 }
1077
1078 #[must_use]
1080 pub fn cached_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
1081 self.client_order_id_cloids
1082 .lock()
1083 .get(client_order_id)
1084 .copied()
1085 }
1086
1087 #[must_use]
1090 pub(crate) fn unique_cached_client_order_id_cloid(
1091 &self,
1092 client_order_id: &ClientOrderId,
1093 ) -> Option<Cloid> {
1094 let cloids = self.client_order_id_cloids.lock();
1095 let cloid = cloids.get(client_order_id).copied()?;
1096 let mapping_count = cloids
1097 .values()
1098 .filter(|cached_cloid| **cached_cloid == cloid)
1099 .count();
1100
1101 (mapping_count == 1).then_some(cloid)
1102 }
1103
1104 pub fn remove_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
1106 self.client_order_id_cloids.lock().remove(client_order_id)
1107 }
1108
1109 pub fn set_base_info_url(&mut self, url: String) {
1115 Arc::get_mut(&mut self.inner)
1116 .expect("cannot override URL: Arc has multiple references")
1117 .set_base_info_url(url);
1118 }
1119
1120 pub fn set_base_exchange_url(&mut self, url: String) {
1126 Arc::get_mut(&mut self.inner)
1127 .expect("cannot override URL: Arc has multiple references")
1128 .set_base_exchange_url(url);
1129 }
1130
1131 pub(crate) fn set_info_retry_config(
1132 &mut self,
1133 max_retries: u32,
1134 delay_initial: Duration,
1135 delay_max: Duration,
1136 ) {
1137 let inner = Arc::make_mut(&mut self.inner);
1138 inner.info_max_retries = max_retries;
1139 inner.info_retry_delay_initial = delay_initial;
1140 inner.info_retry_delay_max = delay_max;
1141 }
1142
1143 pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
1149 let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
1150 Ok(Self {
1151 inner: Arc::new(raw_client),
1152 clock: get_atomic_clock_realtime(),
1153 instruments: Arc::new(AtomicMap::new()),
1154 instruments_by_coin: Arc::new(AtomicMap::new()),
1155 asset_indices: Arc::new(AtomicMap::new()),
1156 spot_fill_coins: Arc::new(AtomicMap::new()),
1157 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1158 account_id: None,
1159 account_address: None,
1160 normalize_prices: true,
1161 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1162 include_builder_attribution: true,
1163 })
1164 }
1165
1166 pub fn with_credentials(
1179 private_key: Option<String>,
1180 vault_address: Option<String>,
1181 account_address: Option<&str>,
1182 environment: HyperliquidEnvironment,
1183 timeout_secs: u64,
1184 proxy_url: Option<String>,
1185 ) -> Result<Self> {
1186 let (pk_env_var, vault_env_var) = credential_env_vars(environment);
1187
1188 let resolved_account_address = resolve_execution_account_address(
1189 private_key.as_deref(),
1190 vault_address.as_deref(),
1191 account_address,
1192 environment,
1193 )?;
1194
1195 let resolved_pk = private_key.or_else(|| std::env::var(pk_env_var).ok());
1197
1198 let resolved_vault = vault_address.or_else(|| std::env::var(vault_env_var).ok());
1200
1201 Self::from_resolved_credentials(
1202 resolved_pk,
1203 resolved_vault.as_deref(),
1204 resolved_account_address,
1205 environment,
1206 timeout_secs,
1207 proxy_url,
1208 )
1209 }
1210
1211 fn from_resolved_credentials(
1212 private_key: Option<String>,
1213 vault_address: Option<&str>,
1214 account_address: Option<String>,
1215 environment: HyperliquidEnvironment,
1216 timeout_secs: u64,
1217 proxy_url: Option<String>,
1218 ) -> Result<Self> {
1219 match private_key {
1220 Some(pk) => {
1221 let raw_client = HyperliquidRawHttpClient::from_credentials(
1222 &pk,
1223 vault_address,
1224 environment,
1225 timeout_secs,
1226 proxy_url,
1227 )?;
1228 Ok(Self {
1229 inner: Arc::new(raw_client),
1230 clock: get_atomic_clock_realtime(),
1231 instruments: Arc::new(AtomicMap::new()),
1232 instruments_by_coin: Arc::new(AtomicMap::new()),
1233 asset_indices: Arc::new(AtomicMap::new()),
1234 spot_fill_coins: Arc::new(AtomicMap::new()),
1235 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1236 account_id: None,
1237 account_address,
1238 normalize_prices: true,
1239 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1240 include_builder_attribution: true,
1241 })
1242 }
1243 None => {
1244 let mut client = Self::new(environment, timeout_secs, proxy_url)
1246 .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))?;
1247 client.set_account_address(account_address)?;
1248 Ok(client)
1249 }
1250 }
1251 }
1252
1253 pub fn from_credentials(
1259 private_key: &str,
1260 vault_address: Option<&str>,
1261 environment: HyperliquidEnvironment,
1262 timeout_secs: u64,
1263 proxy_url: Option<String>,
1264 ) -> Result<Self> {
1265 let raw_client = HyperliquidRawHttpClient::from_credentials(
1266 private_key,
1267 vault_address,
1268 environment,
1269 timeout_secs,
1270 proxy_url,
1271 )?;
1272 Ok(Self {
1273 inner: Arc::new(raw_client),
1274 clock: get_atomic_clock_realtime(),
1275 instruments: Arc::new(AtomicMap::new()),
1276 instruments_by_coin: Arc::new(AtomicMap::new()),
1277 asset_indices: Arc::new(AtomicMap::new()),
1278 spot_fill_coins: Arc::new(AtomicMap::new()),
1279 client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1280 account_id: None,
1281 account_address: None,
1282 normalize_prices: true,
1283 market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1284 include_builder_attribution: true,
1285 })
1286 }
1287
1288 #[must_use]
1290 pub fn is_testnet(&self) -> bool {
1291 self.inner.is_testnet()
1292 }
1293
1294 #[must_use]
1296 pub fn normalize_prices(&self) -> bool {
1297 self.normalize_prices
1298 }
1299
1300 pub fn set_normalize_prices(&mut self, value: bool) {
1302 self.normalize_prices = value;
1303 }
1304
1305 #[must_use]
1307 pub fn market_order_slippage_bps(&self) -> u32 {
1308 self.market_order_slippage_bps
1309 }
1310
1311 pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1313 self.market_order_slippage_bps = value;
1314 }
1315
1316 #[must_use]
1318 pub fn include_builder_attribution(&self) -> bool {
1319 self.include_builder_attribution
1320 }
1321
1322 pub fn set_include_builder_attribution(&mut self, value: bool) {
1324 self.include_builder_attribution = value;
1325 }
1326
1327 pub fn get_user_address(&self) -> Result<String> {
1333 self.inner.get_user_address()
1334 }
1335
1336 #[must_use]
1338 pub fn has_vault_address(&self) -> bool {
1339 self.inner.has_vault_address()
1340 }
1341
1342 #[must_use]
1347 pub fn builder_attribution(&self) -> Option<HyperliquidExchangeBuilderFee> {
1348 if !self.include_builder_attribution || self.has_vault_address() || self.is_testnet() {
1349 None
1350 } else {
1351 Some(HyperliquidExchangeBuilderFee {
1352 address: NAUTILUS_BUILDER_ADDRESS.to_string(),
1353 fee_tenths_bp: 0,
1354 })
1355 }
1356 }
1357
1358 pub fn get_account_address(&self) -> Result<String> {
1366 if let Some(addr) = &self.account_address {
1367 return Ok(addr.clone());
1368 }
1369 self.inner.get_account_address()
1370 }
1371
1372 pub fn set_account_address(&mut self, address: Option<String>) -> Result<()> {
1382 self.account_address = address
1383 .map(|address| normalize_account_address(&address))
1384 .transpose()?;
1385 Ok(())
1386 }
1387
1388 pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1399 self.merge_instruments(std::slice::from_ref(instrument));
1400 }
1401
1402 pub(crate) fn merge_instruments(&self, instruments: &[InstrumentAny]) {
1403 if instruments.is_empty() {
1404 return;
1405 }
1406
1407 self.cache_asset_indices(instruments);
1408 self.instruments.rcu(|m| {
1409 for instrument in instruments {
1410 m.insert(instrument.symbol().inner(), instrument.clone());
1411 m.insert(instrument.raw_symbol().inner(), instrument.clone());
1412 }
1413 });
1414
1415 let by_coin: Vec<_> = instruments
1416 .iter()
1417 .filter_map(|instrument| {
1418 let symbol = instrument.symbol();
1419 match HyperliquidProductType::from_symbol(symbol.as_str()) {
1420 Ok(product_type) => Some((instrument, product_type)),
1421 Err(_) => {
1422 log::warn!("Unable to determine product type for symbol: {symbol}");
1423 None
1424 }
1425 }
1426 })
1427 .collect();
1428
1429 if !by_coin.is_empty() {
1430 self.instruments_by_coin.rcu(|m| {
1431 for (instrument, product_type) in &by_coin {
1432 cache_coin_aliases(m, instrument, *product_type);
1433 }
1434 });
1435 }
1436 }
1437
1438 fn cache_asset_indices(&self, instruments: &[InstrumentAny]) {
1439 let cached = self.asset_indices.load();
1440 let mut pending = AHashMap::new();
1441
1442 for instrument in instruments {
1443 let full_symbol = instrument.symbol().inner();
1444 let coin = instrument.raw_symbol().inner();
1445 match instrument
1446 .info()
1447 .and_then(|info| info.get_u64(ASSET_INDEX_INFO_KEY))
1448 .and_then(|value| u32::try_from(value).ok())
1449 {
1450 Some(asset_index) => {
1451 pending.insert(full_symbol, asset_index);
1452 }
1453 None if coin.starts_with(VAULT_TOKEN_PREFIX) => {}
1456 None if !pending.contains_key(&full_symbol)
1459 && !cached.contains_key(&full_symbol) =>
1460 {
1461 log::warn!(
1462 "Instrument '{full_symbol}' carries no '{ASSET_INDEX_INFO_KEY}' info value \
1463 and has no cached asset index; orders for it will be rejected"
1464 );
1465 }
1466 None => log::warn!(
1467 "Instrument '{full_symbol}' carries no '{ASSET_INDEX_INFO_KEY}' info value; \
1468 leaving the cached asset index unchanged"
1469 ),
1470 }
1471 }
1472
1473 drop(cached);
1474
1475 if !pending.is_empty() {
1476 self.asset_indices.rcu(|m| {
1477 m.extend(pending.iter().map(|(symbol, index)| (*symbol, *index)));
1478 });
1479 }
1480 }
1481
1482 fn get_or_create_instrument(
1483 &self,
1484 coin: &Ustr,
1485 product_type: Option<HyperliquidProductType>,
1486 ) -> Option<InstrumentAny> {
1487 if let Some(pt) = product_type
1488 && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1489 {
1490 return Some(instrument.clone());
1491 }
1492
1493 if product_type.is_none() {
1497 let guard = self.instruments_by_coin.load();
1498
1499 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Outcome)) {
1500 return Some(instrument.clone());
1501 }
1502
1503 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1504 return Some(instrument.clone());
1505 }
1506
1507 if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1508 return Some(instrument.clone());
1509 }
1510 }
1511
1512 if coin.starts_with('@')
1514 && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1515 {
1516 if let Some(instrument) = self.instruments.load().get(symbol) {
1519 return Some(instrument.clone());
1520 }
1521 }
1522
1523 if let Ok(asset_id) = parse_outcome_symbol(coin)
1526 && let Ok(def) = parse_unlisted_outcome_instrument(asset_id)
1527 && let Some(instrument) = create_instrument_from_def(&def, self.clock.get_time_ns())
1528 {
1529 log::debug!("Creating instrument for unlisted outcome coin: {coin}");
1530 self.cache_instrument(&instrument);
1531 return Some(instrument);
1532 }
1533
1534 if coin.starts_with(VAULT_TOKEN_PREFIX) {
1536 log::debug!("Creating synthetic instrument for vault token: {coin}");
1537
1538 let ts_event = self.clock.get_time_ns();
1539
1540 let symbol_str = format!("{coin}-USDC-SPOT");
1542 let symbol = Symbol::new(&symbol_str);
1543 let venue = *HYPERLIQUID_VENUE;
1544 let instrument_id = InstrumentId::new(symbol, venue);
1545
1546 let base_currency = Currency::new(
1548 coin.as_str(),
1549 8, 0, coin.as_str(),
1552 CurrencyType::Crypto,
1553 );
1554
1555 let quote_currency = Currency::new(
1556 "USDC",
1557 6, 0,
1559 "USDC",
1560 CurrencyType::Crypto,
1561 );
1562
1563 let price_increment = Price::from("0.00000001");
1564 let size_increment = Quantity::from("0.00000001");
1565
1566 let instrument = InstrumentAny::CurrencyPair(
1567 CurrencyPair::builder()
1568 .instrument_id(instrument_id)
1569 .raw_symbol(symbol)
1570 .base_currency(base_currency)
1571 .quote_currency(quote_currency)
1572 .price_precision(8)
1573 .size_precision(8)
1574 .price_increment(price_increment)
1575 .size_increment(size_increment)
1576 .ts_event(ts_event)
1577 .ts_init(ts_event)
1578 .build()
1579 .unwrap(),
1580 );
1581
1582 self.cache_instrument(&instrument);
1583
1584 Some(instrument)
1585 } else {
1586 log::warn!("Instrument not found in cache: {coin}");
1588 None
1589 }
1590 }
1591
1592 pub fn set_account_id(&mut self, account_id: AccountId) {
1596 self.account_id = Some(account_id);
1597 }
1598
1599 pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1601 Ok(self.load_instrument_defs().await?.0)
1602 }
1603
1604 async fn load_instrument_defs(
1607 &self,
1608 ) -> Result<(
1609 Vec<HyperliquidInstrumentDef>,
1610 AHashSet<HyperliquidProductType>,
1611 )> {
1612 let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1613 let mut loaded = AHashSet::new();
1614 let spot_meta = match self.inner.get_spot_meta().await {
1615 Ok(spot_meta) => Some(spot_meta),
1616 Err(e) => {
1617 log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1618 None
1619 }
1620 };
1621
1622 match self.inner.load_all_perp_metas().await {
1624 Ok(all_metas) => {
1625 for (dex_index, meta) in all_metas.iter().enumerate() {
1626 let base = perp_dex_asset_index_base(dex_index);
1627 let settlement_currency = match resolve_perp_settlement_currency(
1628 meta,
1629 spot_meta.as_ref(),
1630 ) {
1631 Ok(settlement_currency) => settlement_currency,
1632 Err(e) => {
1633 return Err(Error::decode(format!(
1634 "failed to resolve perp settlement currency for dex {dex_index}: {e}",
1635 )));
1636 }
1637 };
1638
1639 let perp_defs = parse_perp_instruments_with_settlement(
1640 meta,
1641 base,
1642 settlement_currency.as_str(),
1643 );
1644 log::debug!(
1645 "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1646 perp_defs.len(),
1647 );
1648 defs.extend(perp_defs);
1649 }
1650 loaded.insert(HyperliquidProductType::Perp);
1651 }
1652 Err(e) => {
1653 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1654
1655 match self.inner.load_perp_meta().await {
1656 Ok(perp_meta) => {
1657 match resolve_perp_settlement_currency(&perp_meta, spot_meta.as_ref()) {
1658 Ok(settlement_currency) => {
1659 let perp_defs = parse_perp_instruments_with_settlement(
1660 &perp_meta,
1661 0,
1662 settlement_currency.as_str(),
1663 );
1664 log::debug!(
1665 "Loaded Hyperliquid perp defs via fallback: count={}",
1666 perp_defs.len(),
1667 );
1668 defs.extend(perp_defs);
1669 }
1670 Err(e) => {
1671 return Err(Error::decode(format!(
1672 "failed to resolve fallback perp settlement currency: {e}",
1673 )));
1674 }
1675 }
1676 }
1677 Err(e) => {
1678 log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1679 }
1680 }
1681 }
1682 }
1683
1684 if let Some(spot_meta) = spot_meta.as_ref() {
1685 match parse_spot_instruments(spot_meta) {
1686 Ok(spot_defs) => {
1687 log::debug!(
1688 "Loaded Hyperliquid spot definitions: count={}",
1689 spot_defs.len(),
1690 );
1691 defs.extend(spot_defs);
1692 loaded.insert(HyperliquidProductType::Spot);
1693 }
1694 Err(e) => {
1695 log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1696 }
1697 }
1698 }
1699
1700 match self.inner.get_outcome_meta().await {
1704 Ok(outcome_meta) => match parse_outcome_instruments(&outcome_meta) {
1705 Ok(outcome_defs) => {
1706 log::debug!(
1707 "Loaded Hyperliquid outcome definitions: count={}",
1708 outcome_defs.len(),
1709 );
1710 defs.extend(outcome_defs);
1711 loaded.insert(HyperliquidProductType::Outcome);
1712 }
1713 Err(e) => {
1714 log::warn!("Failed to parse Hyperliquid outcome instruments: {e}");
1715 }
1716 },
1717 Err(e) => {
1718 log::debug!("Skipping Hyperliquid outcome metadata: {e}");
1719 }
1720 }
1721
1722 let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1729 let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1730 for def in defs {
1731 if seen_symbols.insert(def.symbol) {
1732 deduped.push(def);
1733 } else {
1734 log::warn!(
1735 "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1736 def.symbol,
1737 def.raw_symbol,
1738 );
1739 }
1740 }
1741 let defs = deduped;
1742
1743 self.asset_indices.rcu(|m| {
1745 for def in &defs {
1746 m.insert(def.symbol, def.asset_index);
1747 }
1748 });
1749 log::debug!(
1750 "Populated asset indices map (count={})",
1751 self.asset_indices.len()
1752 );
1753
1754 Ok((defs, loaded))
1755 }
1756
1757 pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1759 let ts_init = self.clock.get_time_ns();
1760 instruments_from_defs_owned(defs, ts_init)
1761 }
1762
1763 pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1765 let defs = self.request_instrument_defs().await?;
1766 Ok(self.convert_defs(defs))
1767 }
1768
1769 pub(crate) async fn request_instruments_with_statuses(&self) -> Result<InstrumentSnapshot> {
1771 let (defs, loaded) = self.load_instrument_defs().await?;
1772 let def_statuses: AHashMap<Ustr, MarketStatusAction> = defs
1773 .iter()
1774 .map(|def| {
1775 let action = if def.active {
1776 MarketStatusAction::Trading
1777 } else {
1778 MarketStatusAction::Close
1779 };
1780 (def.symbol, action)
1781 })
1782 .collect();
1783 let instruments = self.convert_defs(defs);
1784
1785 let statuses = instruments
1787 .iter()
1788 .filter_map(|instrument| {
1789 let instrument_id = instrument.id();
1790 def_statuses
1791 .get(&instrument_id.symbol.inner())
1792 .map(|&action| (instrument_id, action))
1793 })
1794 .collect();
1795
1796 Ok(InstrumentSnapshot {
1797 instruments,
1798 statuses,
1799 loaded,
1800 })
1801 }
1802
1803 pub async fn build_all_dex_asset_ctxs_instrument_ids(
1808 &self,
1809 ) -> Result<AHashMap<String, Vec<Option<InstrumentId>>>> {
1810 let all_metas = match self.inner.load_all_perp_metas().await {
1811 Ok(all_metas) => all_metas,
1812 Err(e) => {
1813 log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1814 vec![self.inner.load_perp_meta().await?]
1815 }
1816 };
1817
1818 let perp_dexs = match self.inner.load_perp_dexs().await {
1819 Ok(dexs) => Some(dexs),
1820 Err(e) => {
1821 log::warn!("Failed to load perpDexs, inferring dex names from metadata: {e}");
1822 None
1823 }
1824 };
1825
1826 let raw_symbol_to_id =
1827 self.instruments
1828 .load()
1829 .values()
1830 .fold(AHashMap::new(), |mut acc, instrument| {
1831 acc.insert(instrument.raw_symbol().to_string(), instrument.id());
1832 acc
1833 });
1834
1835 let mut mapping = AHashMap::new();
1836
1837 for (dex_index, meta) in all_metas.iter().enumerate() {
1838 let dex_name = resolve_perp_dex_name(dex_index, meta, perp_dexs.as_deref());
1839 let mut instrument_ids = Vec::with_capacity(meta.universe.len());
1840
1841 for asset in &meta.universe {
1842 if let Some(instrument_id) = raw_symbol_to_id.get(&asset.name) {
1843 instrument_ids.push(Some(*instrument_id));
1844 } else {
1845 log::warn!(
1846 "Missing cached Hyperliquid instrument for dex='{}' raw_symbol='{}'",
1847 dex_name,
1848 asset.name
1849 );
1850 instrument_ids.push(None);
1851 }
1852 }
1853
1854 mapping.insert(dex_name, instrument_ids);
1855 }
1856
1857 Ok(mapping)
1858 }
1859
1860 pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1868 self.get_asset_index_for_symbol(Ustr::from(symbol))
1869 }
1870
1871 pub(crate) fn get_asset_index_for_symbol(&self, symbol: Ustr) -> Option<u32> {
1875 self.asset_indices.load().get(&symbol).copied()
1876 }
1877
1878 pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1880 self.get_price_precision_for_symbol(Ustr::from(symbol))
1881 }
1882
1883 pub(crate) fn get_price_precision_for_symbol(&self, symbol: Ustr) -> Option<u8> {
1885 self.instruments
1886 .load()
1887 .get(&symbol)
1888 .map(|inst| inst.price_precision())
1889 }
1890
1891 #[must_use]
1899 pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1900 const SPOT_INDEX_OFFSET: u32 = 10_000;
1901 const BUILDER_PERP_OFFSET: u32 = 100_000;
1902
1903 let guard = self.asset_indices.load();
1904
1905 let mut mapping = AHashMap::new();
1906
1907 for (symbol, &asset_index) in guard.iter() {
1908 if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1910 let pair_index = asset_index - SPOT_INDEX_OFFSET;
1911 let fill_coin = Ustr::from(&format!("@{pair_index}"));
1912 mapping.insert(fill_coin, *symbol);
1913 }
1914 }
1915
1916 self.spot_fill_coins.store(mapping.clone());
1918
1919 mapping
1920 }
1921
1922 #[allow(dead_code)]
1924 pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1925 self.inner.load_perp_meta().await
1926 }
1927
1928 #[allow(dead_code)]
1930 pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1931 self.inner.load_all_perp_metas().await
1932 }
1933
1934 #[allow(dead_code)]
1936 pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1937 self.inner.get_spot_meta().await
1938 }
1939
1940 pub(crate) async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
1942 self.inner.get_outcome_meta().await
1943 }
1944
1945 pub(crate) fn outcome_quote_currency(
1946 &self,
1947 instrument_id: InstrumentId,
1948 outcome_index: u32,
1949 meta: &OutcomeMeta,
1950 ) -> Currency {
1951 if let Some(instrument) = self.instruments.load().get(&instrument_id.symbol.inner()) {
1952 return instrument.quote_currency();
1953 }
1954
1955 let code = meta
1956 .outcomes
1957 .iter()
1958 .find(|market| market.outcome == outcome_index)
1959 .and_then(|market| market.quote_token.as_deref())
1960 .unwrap_or(DEFAULT_OUTCOME_QUOTE_CURRENCY);
1961 get_outcome_currency(code)
1962 }
1963
1964 pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1966 self.inner.info_l2_book(coin).await
1967 }
1968
1969 pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
1971 self.inner.info_recent_trades(coin).await
1972 }
1973
1974 pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1976 self.inner.info_user_fills(user).await
1977 }
1978
1979 pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1981 self.inner.info_order_status(user, oid).await
1982 }
1983
1984 pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1986 self.inner.info_open_orders(user).await
1987 }
1988
1989 pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1991 self.inner.info_frontend_open_orders(user).await
1992 }
1993
1994 async fn info_frontend_open_orders_for_dex(
1995 &self,
1996 user: &str,
1997 dex: Option<&str>,
1998 ) -> Result<Value> {
1999 self.inner
2000 .info_frontend_open_orders_for_dex(user, dex)
2001 .await
2002 }
2003
2004 pub async fn info_historical_orders(
2006 &self,
2007 user: &str,
2008 ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
2009 self.inner.info_historical_orders(user).await
2010 }
2011
2012 pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
2014 self.inner.info_clearinghouse_state(user).await
2015 }
2016
2017 async fn info_clearinghouse_state_for_dex(
2018 &self,
2019 user: &str,
2020 dex: Option<&str>,
2021 ) -> Result<Value> {
2022 self.inner.info_clearinghouse_state_for_dex(user, dex).await
2023 }
2024
2025 pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
2027 self.inner.info_spot_clearinghouse_state(user).await
2028 }
2029
2030 pub async fn info_user_abstraction(&self, user: &str) -> Result<HyperliquidAccountAbstraction> {
2040 let response = self.inner.info_user_abstraction(user).await?;
2041
2042 let abstraction = response
2046 .as_str()
2047 .and_then(|_| serde_json::from_value(response.clone()).ok())
2048 .ok_or_else(|| {
2049 Error::decode(format!(
2050 "Failed to parse user abstraction: expected a mode string, was {response}"
2051 ))
2052 })?;
2053
2054 if abstraction == HyperliquidAccountAbstraction::Unknown {
2055 log::warn!(
2056 "Unrecognized Hyperliquid account abstraction {response}, using perp clearinghouse balances"
2057 );
2058 } else {
2059 log::debug!("Hyperliquid account abstraction: {abstraction}");
2060 }
2061
2062 Ok(abstraction)
2063 }
2064
2065 pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
2067 self.inner.info_user_fees(user).await
2068 }
2069
2070 pub async fn info_candle_snapshot(
2072 &self,
2073 coin: &str,
2074 interval: HyperliquidBarInterval,
2075 start_time: u64,
2076 end_time: u64,
2077 ) -> Result<HyperliquidCandleSnapshot> {
2078 self.inner
2079 .info_candle_snapshot(coin, interval, start_time, end_time)
2080 .await
2081 }
2082
2083 pub async fn info_funding_history(
2085 &self,
2086 coin: &str,
2087 start_time: u64,
2088 end_time: Option<u64>,
2089 ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
2090 self.inner
2091 .info_funding_history(coin, start_time, end_time)
2092 .await
2093 }
2094
2095 pub async fn post_action(
2097 &self,
2098 action: &ExchangeAction,
2099 ) -> Result<HyperliquidExchangeResponse> {
2100 self.inner.post_action(action).await
2101 }
2102
2103 pub async fn post_action_exec(
2105 &self,
2106 action: &HyperliquidExchangeAction,
2107 ) -> Result<HyperliquidExchangeResponse> {
2108 self.inner.post_action_exec(action).await
2109 }
2110
2111 pub fn sign_action_exec_request(
2117 &self,
2118 action: &HyperliquidExchangeAction,
2119 expires_after: Option<u64>,
2120 ) -> Result<HyperliquidExchangeRequest<HyperliquidExchangeAction>> {
2121 self.inner.sign_action_exec_request(action, expires_after)
2122 }
2123
2124 pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
2126 self.inner.info_meta().await
2127 }
2128
2129 pub async fn cancel_order(
2139 &self,
2140 instrument_id: InstrumentId,
2141 client_order_id: Option<ClientOrderId>,
2142 venue_order_id: Option<VenueOrderId>,
2143 ) -> Result<()> {
2144 let symbol = instrument_id.symbol.inner();
2146 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
2147 Error::bad_request(format!(
2148 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2149 ))
2150 })?;
2151
2152 let action = if let Some(client_order_id) = client_order_id {
2153 if let Some(cloid) = self.cached_client_order_id_cloid(&client_order_id) {
2154 HyperliquidExchangeAction::CancelByCloid {
2155 cancels: vec![HyperliquidExchangeCancelByCloidRequest {
2156 asset: asset_id,
2157 cloid,
2158 }],
2159 fast: None,
2160 }
2161 } else if let Some(oid) = venue_order_id {
2162 let oid_u64 = oid
2163 .as_str()
2164 .parse::<u64>()
2165 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
2166 HyperliquidExchangeAction::Cancel {
2167 cancels: vec![HyperliquidExchangeCancelOrderRequest {
2168 asset: asset_id,
2169 oid: oid_u64,
2170 }],
2171 fast: None,
2172 }
2173 } else {
2174 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
2175 HyperliquidExchangeAction::CancelByCloid {
2176 cancels: vec![HyperliquidExchangeCancelByCloidRequest {
2177 asset: asset_id,
2178 cloid,
2179 }],
2180 fast: None,
2181 }
2182 }
2183 } else if let Some(oid) = venue_order_id {
2184 let oid_u64 = oid
2185 .as_str()
2186 .parse::<u64>()
2187 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
2188 HyperliquidExchangeAction::Cancel {
2189 cancels: vec![HyperliquidExchangeCancelOrderRequest {
2190 asset: asset_id,
2191 oid: oid_u64,
2192 }],
2193 fast: None,
2194 }
2195 } else {
2196 return Err(Error::bad_request(
2197 "Either client_order_id or venue_order_id must be provided",
2198 ));
2199 };
2200
2201 let response = self.inner.post_action_exec(&action).await?;
2203
2204 match response {
2206 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
2207 HyperliquidExchangeResponse::Status {
2208 status,
2209 response: error_data,
2210 } => Err(Error::bad_request(format!(
2211 "Cancel order failed: status={status}, error={error_data}"
2212 ))),
2213 HyperliquidExchangeResponse::Error { error } => {
2214 Err(Error::bad_request(format!("Cancel order error: {error}")))
2215 }
2216 }
2217 }
2218
2219 #[expect(clippy::too_many_arguments)]
2229 pub async fn modify_order(
2230 &self,
2231 instrument_id: InstrumentId,
2232 venue_order_id: Option<VenueOrderId>,
2233 order_side: OrderSide,
2234 order_type: OrderType,
2235 price: Price,
2236 quantity: Quantity,
2237 trigger_price: Option<Price>,
2238 reduce_only: bool,
2239 post_only: bool,
2240 time_in_force: TimeInForce,
2241 client_order_id: Option<ClientOrderId>,
2242 ) -> Result<()> {
2243 let symbol = instrument_id.symbol.inner();
2244 let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
2245 Error::bad_request(format!(
2246 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2247 ))
2248 })?;
2249
2250 let oid = match client_order_id
2251 .as_ref()
2252 .and_then(|id| self.unique_cached_client_order_id_cloid(id))
2253 {
2254 Some(cloid) => HyperliquidExchangeModifyTarget::Cloid(cloid),
2255 None => {
2256 let Some(venue_order_id) = venue_order_id.as_ref() else {
2257 return Err(Error::bad_request(
2258 "venue_order_id or unique cached CLOID is required for modify",
2259 ));
2260 };
2261 HyperliquidExchangeModifyTarget::from_venue_order_id(venue_order_id)
2262 .map_err(|_| Error::bad_request("Invalid venue order ID format"))?
2263 }
2264 };
2265
2266 let is_buy = matches!(order_side, OrderSide::Buy);
2267 let decimals = self.get_price_precision_for_symbol(symbol);
2268
2269 let normalized_price = normalize_or_validate_wire_price(
2270 price.as_decimal(),
2271 "Price",
2272 decimals,
2273 self.normalize_prices,
2274 )
2275 .map_err(|e| Error::bad_request(format!("{e}")))?;
2276
2277 let size = quantity.as_decimal().normalize();
2278
2279 let kind = match order_type {
2280 OrderType::Market => HyperliquidExchangeOrderKind::Limit {
2281 limit: HyperliquidExchangeLimitParams {
2282 tif: HyperliquidExchangeTif::Ioc,
2283 },
2284 },
2285 OrderType::Limit => {
2286 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
2287 .map_err(|e| Error::bad_request(format!("{e}")))?;
2288 HyperliquidExchangeOrderKind::Limit {
2289 limit: HyperliquidExchangeLimitParams { tif },
2290 }
2291 }
2292 OrderType::StopMarket
2293 | OrderType::StopLimit
2294 | OrderType::MarketIfTouched
2295 | OrderType::LimitIfTouched => {
2296 if let Some(trig_px) = trigger_price {
2297 let trigger_price_decimal = normalize_or_validate_wire_price(
2298 trig_px.as_decimal(),
2299 "Trigger price",
2300 decimals,
2301 self.normalize_prices,
2302 )
2303 .map_err(|e| Error::bad_request(format!("{e}")))?;
2304 let tpsl = match order_type {
2305 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
2306 _ => HyperliquidExchangeTpSl::Tp,
2307 };
2308 let is_market = matches!(
2309 order_type,
2310 OrderType::StopMarket | OrderType::MarketIfTouched
2311 );
2312 HyperliquidExchangeOrderKind::Trigger {
2313 trigger: HyperliquidExchangeTriggerParams {
2314 is_market,
2315 trigger_px: trigger_price_decimal,
2316 tpsl,
2317 },
2318 }
2319 } else {
2320 return Err(Error::bad_request("Trigger orders require a trigger price"));
2321 }
2322 }
2323 _ => {
2324 return Err(Error::bad_request(format!(
2325 "Order type {order_type:?} not supported for modify"
2326 )));
2327 }
2328 };
2329 let cloid = client_order_id.map(|id| self.get_or_generate_client_order_id_cloid(id));
2330
2331 let order = HyperliquidExchangePlaceOrderRequest {
2332 asset: asset_id,
2333 is_buy,
2334 price: normalized_price,
2335 size,
2336 reduce_only,
2337 kind,
2338 cloid,
2339 };
2340
2341 let action = HyperliquidExchangeAction::Modify {
2342 modify: HyperliquidExchangeModifyOrderRequest { oid, order },
2343 };
2344
2345 let response = self.inner.post_action_exec(&action).await?;
2346
2347 match response {
2348 ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
2349 if let Some(inner_error) = extract_inner_error(&response) {
2350 Err(Error::bad_request(format!(
2351 "Modify order rejected: {inner_error}",
2352 )))
2353 } else {
2354 Ok(())
2355 }
2356 }
2357 HyperliquidExchangeResponse::Status {
2358 status,
2359 response: error_data,
2360 } => Err(Error::bad_request(format!(
2361 "Modify order failed: status={status}, error={error_data}"
2362 ))),
2363 HyperliquidExchangeResponse::Error { error } => {
2364 Err(Error::bad_request(format!("Modify order error: {error}")))
2365 }
2366 }
2367 }
2368
2369 pub async fn schedule_cancel(&self, time: Option<u64>) -> Result<()> {
2387 if let Some(time) = time {
2388 validate_schedule_cancel_time(time, self.clock.get_time_ms())?;
2389 }
2390
2391 let action = HyperliquidExchangeAction::ScheduleCancel { time };
2392 let response = self.inner.post_action_exec(&action).await?;
2393 match response {
2394 HyperliquidExchangeResponse::Status { status, response }
2395 if status == RESPONSE_STATUS_OK =>
2396 {
2397 let data = serde_json::from_value::<HyperliquidExchangeResponseData>(response)
2398 .map_err(Error::Serde)?;
2399
2400 if matches!(data, HyperliquidExchangeResponseData::Default) {
2401 Ok(())
2402 } else {
2403 Err(Error::bad_request(
2404 "Unexpected schedule cancel acknowledgement",
2405 ))
2406 }
2407 }
2408 response => Err(Error::bad_request(format!(
2409 "Schedule cancel failed: {response:?}"
2410 ))),
2411 }
2412 }
2413
2414 pub async fn submit_split_outcome(
2428 &self,
2429 outcome: u32,
2430 amount: Decimal,
2431 ) -> Result<HyperliquidExchangeResponse> {
2432 let action = HyperliquidExchangeAction::UserOutcome {
2433 op: HyperliquidExchangeUserOutcomeOp::SplitOutcome(
2434 HyperliquidExchangeSplitOutcomeParams { outcome, amount },
2435 ),
2436 };
2437 self.inner.post_action_exec(&action).await
2438 }
2439
2440 pub async fn submit_merge_outcome(
2451 &self,
2452 outcome: u32,
2453 amount: Option<Decimal>,
2454 ) -> Result<HyperliquidExchangeResponse> {
2455 let action = HyperliquidExchangeAction::UserOutcome {
2456 op: HyperliquidExchangeUserOutcomeOp::MergeOutcome(
2457 HyperliquidExchangeMergeOutcomeParams { outcome, amount },
2458 ),
2459 };
2460 self.inner.post_action_exec(&action).await
2461 }
2462
2463 pub async fn submit_merge_question(
2473 &self,
2474 question: u32,
2475 amount: Option<Decimal>,
2476 ) -> Result<HyperliquidExchangeResponse> {
2477 let action = HyperliquidExchangeAction::UserOutcome {
2478 op: HyperliquidExchangeUserOutcomeOp::MergeQuestion(
2479 HyperliquidExchangeMergeQuestionParams { question, amount },
2480 ),
2481 };
2482 self.inner.post_action_exec(&action).await
2483 }
2484
2485 pub async fn submit_negate_outcome(
2495 &self,
2496 question: u32,
2497 outcome: u32,
2498 amount: Decimal,
2499 ) -> Result<HyperliquidExchangeResponse> {
2500 let action = HyperliquidExchangeAction::UserOutcome {
2501 op: HyperliquidExchangeUserOutcomeOp::NegateOutcome(
2502 HyperliquidExchangeNegateOutcomeParams {
2503 question,
2504 outcome,
2505 amount,
2506 },
2507 ),
2508 };
2509 self.inner.post_action_exec(&action).await
2510 }
2511
2512 pub async fn request_order_status_reports(
2527 &self,
2528 user: &str,
2529 instrument_id: Option<InstrumentId>,
2530 ) -> Result<Vec<OrderStatusReport>> {
2531 let dexes = self.reconciliation_dexes(instrument_id);
2532 let sweep = self
2533 .request_order_status_reports_for_dexes(user, instrument_id, &dexes)
2534 .await?;
2535
2536 if !sweep.complete {
2537 return Err(Error::bad_request(
2538 "Open-order snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2539 ));
2540 }
2541
2542 Ok(sweep.reports)
2543 }
2544
2545 pub(crate) async fn request_order_status_reports_for_dexes(
2546 &self,
2547 user: &str,
2548 instrument_id: Option<InstrumentId>,
2549 dexes: &[Option<Ustr>],
2550 ) -> Result<ReportSweep<OrderStatusReport>> {
2551 let account_id = self
2552 .account_id
2553 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2554 let mut reports = Vec::new();
2555 let mut complete = true;
2556 let ts_init = self.clock.get_time_ns();
2557
2558 for dex in dexes {
2559 let response = self
2560 .info_frontend_open_orders_for_dex(user, dex.as_deref())
2561 .await?;
2562 let orders: Vec<serde_json::Value> = serde_json::from_value(response)
2563 .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
2564
2565 for order_value in orders {
2566 let order: WsBasicOrderData = match serde_json::from_value(order_value) {
2567 Ok(order) => order,
2568 Err(e) => {
2569 log::warn!("Failed to parse order: {e}");
2570 complete = false;
2571 continue;
2572 }
2573 };
2574
2575 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2576 Some(instrument) => instrument,
2577 None => {
2579 complete = false;
2580 continue;
2581 }
2582 };
2583
2584 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2585 continue;
2586 }
2587
2588 match parse_order_status_report_from_basic(
2589 &order,
2590 &HyperliquidOrderStatusEnum::Open,
2591 &instrument,
2592 account_id,
2593 ts_init,
2594 ) {
2595 Ok(report) => reports.push(report),
2596 Err(e) => {
2597 log::error!("Failed to parse order status report: {e}");
2598 complete = false;
2599 }
2600 }
2601 }
2602 }
2603
2604 Ok(ReportSweep { reports, complete })
2605 }
2606
2607 pub async fn request_historical_order_status_reports(
2619 &self,
2620 user: &str,
2621 instrument_id: Option<InstrumentId>,
2622 ) -> Result<Vec<OrderStatusReport>> {
2623 let entries = self.info_historical_orders(user).await?;
2624 let sweep = self.historical_order_status_reports_from_response(entries, instrument_id)?;
2625
2626 if !sweep.complete {
2627 return Err(Error::bad_request(
2628 "Historical-order snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2629 ));
2630 }
2631
2632 Ok(sweep.reports)
2633 }
2634
2635 pub(crate) fn historical_order_status_reports_from_response(
2636 &self,
2637 entries: Vec<HyperliquidOrderStatusEntry>,
2638 instrument_id: Option<InstrumentId>,
2639 ) -> Result<ReportSweep<OrderStatusReport>> {
2640 let account_id = self
2641 .account_id
2642 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2643 let mut reports = Vec::new();
2644 let mut complete = true;
2645 let ts_init = self.clock.get_time_ns();
2646
2647 for entry in entries {
2648 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2649 Some(instrument) => instrument,
2651 None => {
2652 complete = false;
2653 continue;
2654 }
2655 };
2656
2657 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2658 continue;
2659 }
2660
2661 let label = entry.order.order_type.as_deref().unwrap_or_default();
2662 let tpsl = parse_trigger_order_type_label(label).map(|(tpsl, _)| tpsl);
2663 let is_market = label.ends_with("Market");
2664 let historical_order_type = match tpsl.as_ref() {
2665 Some(tpsl) => parse_trigger_order_type(is_market, tpsl),
2666 None if is_market => OrderType::Market,
2667 None => OrderType::Limit,
2668 };
2669 let order = WsBasicOrderData {
2670 coin: entry.order.coin,
2671 side: entry.order.side,
2672 limit_px: entry.order.limit_px,
2673 sz: entry.order.sz,
2674 oid: entry.order.oid,
2675 timestamp: entry.order.timestamp,
2676 orig_sz: entry.order.orig_sz,
2677 cloid: entry.order.cloid,
2678 tif: entry.order.tif,
2679 reduce_only: entry.order.reduce_only,
2680 trigger_px: entry
2681 .order
2682 .trigger_px
2683 .filter(|price| *price != Decimal::ZERO),
2684 is_market: tpsl.is_some().then_some(is_market),
2685 tpsl,
2686 trigger_activated: None,
2687 trailing_stop: None,
2688 order_type: None,
2689 };
2690
2691 match parse_order_status_report_from_basic(
2692 &order,
2693 &entry.status,
2694 &instrument,
2695 account_id,
2696 ts_init,
2697 ) {
2698 Ok(mut report) => {
2699 report.order_type = historical_order_type;
2700 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2701 reports.push(report);
2702 }
2703 Err(e) => {
2704 log::error!("Failed to parse historical order status report: {e}");
2705 complete = false;
2706 }
2707 }
2708 }
2709
2710 Ok(ReportSweep {
2711 reports: deduplicate_historical_order_reports(reports),
2712 complete,
2713 })
2714 }
2715
2716 pub async fn request_order_status_report(
2728 &self,
2729 user: &str,
2730 oid: u64,
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 orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
2743 Ok(response) => match serde_json::from_value(response) {
2744 Ok(v) => v,
2745 Err(e) => {
2746 log::warn!("Failed to parse frontend open orders response: {e}");
2747 Vec::new()
2748 }
2749 },
2750 Err(e) => {
2751 log::warn!(
2752 "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
2753 );
2754 Vec::new()
2755 }
2756 };
2757
2758 if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
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 oid {oid} with 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 return parse_order_status_report_from_basic(
2776 &order,
2777 &status,
2778 &instrument,
2779 account_id,
2780 ts_init,
2781 )
2782 .map(Some)
2783 .map_err(|e| {
2784 Error::bad_request(format!(
2785 "Failed to parse order status report for oid {oid}: {e}"
2786 ))
2787 });
2788 }
2789
2790 let response = self.info_order_status(user, oid).await?;
2792 let entry = match response.into_order() {
2793 Some(e) => e,
2794 None => return Ok(None),
2795 };
2796
2797 let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2798 Some(inst) => inst,
2799 None => {
2800 return Err(Error::bad_request(format!(
2801 "Failed to resolve instrument for order oid {oid} with coin {}",
2802 entry.order.coin,
2803 )));
2804 }
2805 };
2806
2807 let basic = WsBasicOrderData {
2814 coin: entry.order.coin,
2815 side: entry.order.side,
2816 limit_px: entry.order.limit_px,
2817 sz: entry.order.sz,
2818 oid: entry.order.oid,
2819 timestamp: entry.order.timestamp,
2820 orig_sz: entry.order.orig_sz,
2821 cloid: entry.order.cloid,
2822 tif: None,
2823 reduce_only: entry.order.reduce_only,
2824 trigger_px: None,
2825 is_market: None,
2826 tpsl: None,
2827 trigger_activated: None,
2828 trailing_stop: None,
2829 order_type: None,
2830 };
2831
2832 let mut report = parse_order_status_report_from_basic(
2833 &basic,
2834 &entry.status,
2835 &instrument,
2836 account_id,
2837 ts_init,
2838 )
2839 .map_err(|e| {
2840 Error::bad_request(format!(
2841 "Failed to parse order status report for oid {oid}: {e}"
2842 ))
2843 })?;
2844
2845 if entry.status_timestamp > 0 {
2848 report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2849 }
2850
2851 Ok(Some(report))
2852 }
2853
2854 pub async fn request_order_status_report_by_client_order_id(
2865 &self,
2866 user: &str,
2867 client_order_id: &ClientOrderId,
2868 ) -> Result<Option<OrderStatusReport>> {
2869 let account_id = self
2870 .account_id
2871 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2872
2873 let ts_init = self.clock.get_time_ns();
2874
2875 let cached_cloid_hex = self
2876 .cached_client_order_id_cloid(client_order_id)
2877 .map(|cloid| cloid.to_hex());
2878 let cloid = Cloid::from_client_order_id(*client_order_id);
2879 let cloid_hex = cloid.to_hex();
2880
2881 let response = self.info_frontend_open_orders(user).await?;
2882
2883 let orders: Vec<WsBasicOrderData> = serde_json::from_value(response).map_err(|e| {
2884 Error::bad_request(format!("Failed to parse open orders response: {e}"))
2885 })?;
2886
2887 let order = match orders.into_iter().find(|o| {
2888 o.cloid
2889 .as_ref()
2890 .is_some_and(|c| cached_cloid_hex.as_ref() == Some(c) || c == &cloid_hex)
2891 }) {
2892 Some(o) => o,
2893 None => return Ok(None),
2894 };
2895
2896 let instrument = match self.get_or_create_instrument(&order.coin, None) {
2897 Some(inst) => inst,
2898 None => {
2899 return Err(Error::bad_request(format!(
2900 "Failed to resolve instrument for open order with cloid {cloid_hex} and coin {}",
2901 order.coin,
2902 )));
2903 }
2904 };
2905
2906 let status = if order.trigger_activated == Some(true) {
2907 HyperliquidOrderStatusEnum::Triggered
2908 } else {
2909 HyperliquidOrderStatusEnum::Open
2910 };
2911
2912 let mut report =
2913 parse_order_status_report_from_basic(&order, &status, &instrument, account_id, ts_init)
2914 .map_err(|e| {
2915 Error::bad_request(format!(
2916 "Failed to parse order status report for cloid {cloid_hex}: {e}"
2917 ))
2918 })?;
2919
2920 report.client_order_id = Some(*client_order_id);
2921 Ok(Some(report))
2922 }
2923
2924 pub async fn request_fill_reports(
2940 &self,
2941 user: &str,
2942 instrument_id: Option<InstrumentId>,
2943 ) -> Result<Vec<FillReport>> {
2944 let fills_response = self.info_user_fills(user).await?;
2945 let sweep = self.fill_reports_from_response(fills_response, instrument_id)?;
2946
2947 if !sweep.complete {
2948 return Err(Error::bad_request(
2949 "Fill snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2950 ));
2951 }
2952
2953 Ok(sweep.reports)
2954 }
2955
2956 pub(crate) fn fill_reports_from_response(
2957 &self,
2958 fills_response: HyperliquidFills,
2959 instrument_id: Option<InstrumentId>,
2960 ) -> Result<ReportSweep<FillReport>> {
2961 let account_id = self
2962 .account_id
2963 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2964
2965 let mut reports = Vec::new();
2966 let mut complete = true;
2967 let ts_init = self.clock.get_time_ns();
2968
2969 for fill in fills_response {
2970 let instrument = match self.get_or_create_instrument(&fill.coin, None) {
2972 Some(inst) => inst,
2973 None => {
2975 complete = false;
2976 continue;
2977 }
2978 };
2979
2980 if let Some(filter_id) = instrument_id
2982 && instrument.id() != filter_id
2983 {
2984 continue;
2985 }
2986
2987 match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2989 Ok(report) => reports.push(report),
2990 Err(e) => {
2991 log::error!("Failed to parse fill report: {e}");
2992 complete = false;
2993 }
2994 }
2995 }
2996
2997 Ok(ReportSweep { reports, complete })
2998 }
2999
3000 pub async fn request_position_status_reports(
3024 &self,
3025 user: &str,
3026 instrument_id: Option<InstrumentId>,
3027 ) -> Result<Vec<PositionStatusReport>> {
3028 let dexes = self.reconciliation_dexes(instrument_id);
3029 let sweep = self
3030 .request_position_status_reports_for_dexes(user, instrument_id, &dexes)
3031 .await?;
3032
3033 if !sweep.complete {
3034 return Err(Error::bad_request(
3035 "Position snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
3036 ));
3037 }
3038
3039 Ok(sweep.reports)
3040 }
3041
3042 pub(crate) async fn request_position_status_reports_for_dexes(
3043 &self,
3044 user: &str,
3045 instrument_id: Option<InstrumentId>,
3046 dexes: &[Option<Ustr>],
3047 ) -> Result<ReportSweep<PositionStatusReport>> {
3048 let account_id = self
3049 .account_id
3050 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3051
3052 let filter_product = instrument_id
3053 .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
3054
3055 let fetch_perp = !matches!(
3056 filter_product,
3057 Some(HyperliquidProductType::Spot | HyperliquidProductType::Outcome)
3058 );
3059 let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
3060
3061 let mut reports = Vec::new();
3062 let mut complete = true;
3063 let ts_init = self.clock.get_time_ns();
3064
3065 if !fetch_perp {
3066 return self
3067 .request_spot_position_status_reports_sweep(user, instrument_id)
3068 .await;
3069 }
3070
3071 for dex in dexes {
3072 let state_response = self
3073 .info_clearinghouse_state_for_dex(user, dex.as_deref())
3074 .await?;
3075 let asset_positions: Vec<serde_json::Value> = state_response
3076 .get("assetPositions")
3077 .and_then(|value| value.as_array())
3078 .ok_or_else(|| {
3079 Error::bad_request("assetPositions not found in clearinghouse state")
3080 })?
3081 .clone();
3082
3083 for position_value in asset_positions {
3084 let coin = position_value
3085 .get("position")
3086 .and_then(|position| position.get("coin"))
3087 .and_then(|coin| coin.as_str())
3088 .ok_or_else(|| Error::bad_request("coin not found in position"))?;
3089
3090 let instrument = match self.get_or_create_instrument(&Ustr::from(coin), None) {
3091 Some(instrument) => instrument,
3092 None => {
3094 complete = false;
3095 continue;
3096 }
3097 };
3098
3099 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
3100 continue;
3101 }
3102
3103 match parse_position_status_report(
3104 &position_value,
3105 &instrument,
3106 account_id,
3107 ts_init,
3108 ) {
3109 Ok(report) => reports.push(report),
3110 Err(e) => {
3111 log::error!("Failed to parse position status report: {e}");
3112 complete = false;
3113 }
3114 }
3115 }
3116 }
3117
3118 if fetch_spot {
3121 let spot_sweep = self
3122 .request_spot_position_status_reports_sweep(user, instrument_id)
3123 .await?;
3124 reports.extend(spot_sweep.reports);
3125 complete &= spot_sweep.complete;
3126 }
3127
3128 Ok(ReportSweep { reports, complete })
3129 }
3130
3131 pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
3150 let account_id = self
3151 .account_id
3152 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3153 let state_response = self.info_clearinghouse_state(user).await?;
3154 let ts_init = self.clock.get_time_ns();
3155
3156 log::trace!("Clearinghouse state response: {state_response}");
3157
3158 let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
3159 .map_err(|e| {
3160 log::error!("Failed to parse clearinghouse state: {e}");
3161 log::debug!("Raw response: {state_response}");
3162 Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
3163 })?;
3164
3165 let spot_response = self.info_spot_clearinghouse_state(user).await?;
3168 let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
3169 .map_err(|e| {
3170 log::error!("Failed to parse spot clearinghouse state: {e}");
3171 log::debug!("Raw spot response: {spot_response}");
3172 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
3173 })?;
3174
3175 let abstraction = self.info_user_abstraction(user).await?;
3177
3178 let (balances, margins) =
3179 parse_combined_account_balances_and_margins(&perp_state, &spot_state, abstraction)
3180 .map_err(|e| Error::decode(e.to_string()))?;
3181
3182 Ok(AccountState::new(
3183 account_id,
3184 AccountType::Margin,
3185 balances,
3186 margins,
3187 true, UUID4::new(),
3189 ts_init,
3190 ts_init,
3191 None,
3192 ))
3193 }
3194
3195 pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
3206 let response = self.info_spot_clearinghouse_state(user).await?;
3207
3208 log::trace!("Spot clearinghouse state response: {response}");
3209
3210 let state: SpotClearinghouseState =
3211 serde_json::from_value(response.clone()).map_err(|e| {
3212 log::error!("Failed to parse spot clearinghouse state: {e}");
3213 log::debug!("Raw response: {response}");
3214 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
3215 })?;
3216
3217 parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
3218 }
3219
3220 pub async fn request_spot_position_status_reports(
3236 &self,
3237 user: &str,
3238 instrument_id: Option<InstrumentId>,
3239 ) -> Result<Vec<PositionStatusReport>> {
3240 let sweep = self
3241 .request_spot_position_status_reports_sweep(user, instrument_id)
3242 .await?;
3243
3244 if !sweep.complete {
3245 return Err(Error::bad_request(
3246 "Spot position snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
3247 ));
3248 }
3249
3250 Ok(sweep.reports)
3251 }
3252
3253 pub(crate) async fn request_spot_position_status_reports_sweep(
3254 &self,
3255 user: &str,
3256 instrument_id: Option<InstrumentId>,
3257 ) -> Result<ReportSweep<PositionStatusReport>> {
3258 let account_id = self
3259 .account_id
3260 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3261 let response = self.info_spot_clearinghouse_state(user).await?;
3262
3263 let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
3264 log::error!("Failed to parse spot clearinghouse state: {e}");
3265 Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
3266 })?;
3267
3268 let ts_init = self.clock.get_time_ns();
3269 let mut reports = Vec::with_capacity(state.balances.len());
3270 let mut complete = true;
3271
3272 for balance in &state.balances {
3273 if balance.total.is_zero() {
3274 continue;
3275 }
3276
3277 if balance.coin == "USDC" {
3282 continue;
3283 }
3284
3285 let product_type = match HyperliquidProductType::from_symbol(balance.coin.as_str()) {
3286 Ok(HyperliquidProductType::Outcome) => HyperliquidProductType::Outcome,
3287 _ => HyperliquidProductType::Spot,
3288 };
3289
3290 let instrument = match self.get_or_create_instrument(&balance.coin, Some(product_type))
3291 {
3292 Some(inst) => inst,
3293 None => {
3295 complete = false;
3296 continue;
3297 }
3298 };
3299
3300 if let Some(filter_id) = instrument_id
3301 && instrument.id() != filter_id
3302 {
3303 continue;
3304 }
3305
3306 match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
3307 Ok(report) => reports.push(report),
3308 Err(e) => {
3309 log::error!(
3310 "Failed to parse spot position status report for {}: {e}",
3311 balance.coin,
3312 );
3313 complete = false;
3314 }
3315 }
3316 }
3317
3318 Ok(ReportSweep { reports, complete })
3319 }
3320
3321 pub async fn request_bars(
3338 &self,
3339 bar_type: BarType,
3340 start: Option<jiff::Timestamp>,
3341 end: Option<jiff::Timestamp>,
3342 limit: Option<u32>,
3343 ) -> Result<Vec<Bar>> {
3344 let instrument_id = bar_type.instrument_id();
3345 let symbol = instrument_id.symbol;
3346
3347 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
3348
3349 let alias = cache_alias_for_symbol(symbol.as_str())
3353 .map(|alias| Ustr::from(alias.as_str()))
3354 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
3355
3356 let instrument = self
3357 .get_or_create_instrument(&alias, product_type)
3358 .ok_or_else(|| {
3359 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3360 })?;
3361
3362 let coin = instrument.raw_symbol().inner();
3367
3368 let price_precision = instrument.price_precision();
3369 let size_precision = instrument.size_precision();
3370
3371 let interval =
3372 bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
3373
3374 let now = jiff::Timestamp::now();
3376 let end_time = end.unwrap_or(now).as_millisecond() as u64;
3377 let start_time = if let Some(start) = start {
3378 start.as_millisecond() as u64
3379 } else {
3380 let spec = bar_type.spec();
3382 let step_ms = match spec.aggregation {
3383 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
3384 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
3385 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
3386 BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
3387 BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
3388 _ => 60_000,
3389 };
3390 end_time.saturating_sub(1000 * step_ms)
3391 };
3392
3393 let candles = self
3394 .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
3395 .await?;
3396
3397 let now_ms = now.as_millisecond() as u64;
3399
3400 let mut bars: Vec<Bar> = candles
3401 .iter()
3402 .filter(|candle| candle.end_timestamp < now_ms)
3403 .enumerate()
3404 .filter_map(|(i, candle)| {
3405 candle_to_bar(candle, bar_type, price_precision, size_precision)
3406 .map_err(|e| {
3407 log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
3408 e
3409 })
3410 .ok()
3411 })
3412 .collect();
3413
3414 if let Some(limit) = limit
3416 && limit > 0
3417 && bars.len() > limit as usize
3418 {
3419 bars.truncate(limit as usize);
3420 }
3421
3422 log::debug!(
3423 "Received {} bars for {} (filtered {} incomplete)",
3424 bars.len(),
3425 bar_type,
3426 candles.len() - bars.len()
3427 );
3428 Ok(bars)
3429 }
3430
3431 pub async fn request_public_trades(
3440 &self,
3441 instrument_id: InstrumentId,
3442 start: Option<jiff::Timestamp>,
3443 end: Option<jiff::Timestamp>,
3444 limit: Option<usize>,
3445 ) -> Result<Vec<HyperliquidPublicTrade>> {
3446 let symbol = instrument_id.symbol;
3447 let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
3448 let alias = cache_alias_for_symbol(symbol.as_str())
3449 .map(|alias| Ustr::from(alias.as_str()))
3450 .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
3451 let instrument = self
3452 .get_or_create_instrument(&alias, product_type)
3453 .ok_or_else(|| {
3454 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3455 })?;
3456
3457 let raw_trades = match self
3458 .info_recent_trades(instrument.raw_symbol().as_ref())
3459 .await
3460 {
3461 Ok(trades) => trades,
3462 Err(e) if e.is_unprocessable_entity() => {
3463 log::warn!(
3464 "Recent public trades endpoint unavailable for {instrument_id} \
3465 (requires the Hyperliquid indexer); returning empty response"
3466 );
3467 Vec::new()
3468 }
3469 Err(e) => return Err(e),
3470 };
3471
3472 let mut trades: Vec<HyperliquidPublicTrade> = raw_trades
3473 .iter()
3474 .filter_map(|raw| match parse_recent_public_trade(raw, &instrument) {
3475 Ok(trade) => Some(trade),
3476 Err(e) => {
3477 log::warn!("Skipping recent public trade for {instrument_id}: {e}");
3478 None
3479 }
3480 })
3481 .collect();
3482 trades.sort_by_key(|trade| trade.ts_event);
3483
3484 Ok(filter_recent_public_trades(
3485 trades,
3486 datetime_to_unix_nanos(start),
3487 datetime_to_unix_nanos(end),
3488 limit.filter(|limit| *limit > 0),
3489 instrument_id,
3490 ))
3491 }
3492
3493 pub async fn request_funding_rates(
3504 &self,
3505 instrument: &InstrumentAny,
3506 start: Option<jiff::Timestamp>,
3507 end: Option<jiff::Timestamp>,
3508 limit: Option<usize>,
3509 ) -> Result<Vec<FundingRateUpdate>> {
3510 let instrument_id = instrument.id();
3511
3512 if !matches!(instrument, InstrumentAny::CryptoPerpetual(_)) {
3513 return Err(Error::bad_request(format!(
3514 "funding rates are only available for perpetual instruments: {instrument_id}",
3515 )));
3516 }
3517
3518 let coin = instrument.raw_symbol().inner();
3519
3520 if let (Some(start), Some(end)) = (start, end)
3521 && start >= end
3522 {
3523 return Err(Error::bad_request(format!(
3524 "invalid funding request range: start={start}, end={end}",
3525 )));
3526 }
3527
3528 let now = jiff::Timestamp::now();
3529 let end = end.unwrap_or(now).min(now);
3530
3531 if let Some(start) = start
3532 && start >= end
3533 {
3534 return Ok(Vec::new());
3535 }
3536
3537 let requested_limit = limit.filter(|n| *n > 0);
3538 let target_limit = requested_limit.unwrap_or(DEFAULT_FUNDING_RATES_LIMIT);
3539 let start_was_unspecified = start.is_none();
3540 let end_ms = end.as_millisecond().max(0) as u64;
3541
3542 let start_ms = start.map_or_else(
3543 || {
3544 let lookback_rows = target_limit.saturating_add(1) as u64;
3545 end_ms.saturating_sub((60 * 60 * 1_000_u64).saturating_mul(lookback_rows))
3547 },
3548 |dt| dt.as_millisecond().max(0) as u64,
3549 );
3550
3551 let mut funding_rates = Vec::new();
3552 let mut cursor_ms = start_ms;
3553
3554 'pages: while cursor_ms <= end_ms {
3555 let entries = self
3556 .info_funding_history(coin.as_str(), cursor_ms, Some(end_ms))
3557 .await?;
3558 let Some(last_time_ms) = entries.iter().map(|entry| entry.time).max() else {
3559 break;
3560 };
3561
3562 let mut page: Vec<FundingRateUpdate> = entries
3563 .iter()
3564 .filter(|entry| (cursor_ms..=end_ms).contains(&entry.time))
3565 .map(|entry| funding_entry_to_update(entry, instrument_id))
3566 .collect();
3567 page.sort_by_key(|rate| rate.ts_event);
3568
3569 for update in page {
3570 if funding_rates
3571 .last()
3572 .is_some_and(|last: &FundingRateUpdate| last.ts_event == update.ts_event)
3573 {
3574 continue;
3575 }
3576 funding_rates.push(update);
3577
3578 if !start_was_unspecified
3579 && requested_limit.is_some_and(|limit| funding_rates.len() >= limit)
3580 {
3581 break 'pages;
3582 }
3583 }
3584
3585 if entries.len() < HYPERLIQUID_FUNDING_HISTORY_MAX_LIMIT {
3586 break;
3587 }
3588
3589 let next_cursor_ms = last_time_ms.saturating_add(1);
3591 if next_cursor_ms <= cursor_ms {
3592 break;
3593 }
3594 cursor_ms = next_cursor_ms;
3595 }
3596
3597 if start_was_unspecified && funding_rates.len() > target_limit {
3598 funding_rates = funding_rates.split_off(funding_rates.len() - target_limit);
3599 }
3600
3601 Ok(funding_rates)
3602 }
3603
3604 #[expect(clippy::too_many_arguments)]
3611 pub async fn submit_order(
3612 &self,
3613 instrument_id: InstrumentId,
3614 client_order_id: ClientOrderId,
3615 order_side: OrderSide,
3616 order_type: OrderType,
3617 quantity: Quantity,
3618 time_in_force: TimeInForce,
3619 price: Option<Price>,
3620 trigger_price: Option<Price>,
3621 post_only: bool,
3622 reduce_only: bool,
3623 ) -> Result<OrderStatusReport> {
3624 let symbol = instrument_id.symbol.inner();
3625 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3626 Error::bad_request(format!(
3627 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3628 ))
3629 })?;
3630
3631 let is_buy = matches!(order_side, OrderSide::Buy);
3632 let price_precision = self.get_price_precision_for_symbol(symbol);
3633
3634 let price_decimal = match price {
3635 Some(px) => normalize_or_validate_wire_price(
3636 px.as_decimal(),
3637 "Price",
3638 price_precision,
3639 self.normalize_prices,
3640 )
3641 .map_err(|e| Error::bad_request(format!("{e}")))?,
3642 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
3643 None if matches!(
3644 order_type,
3645 OrderType::StopMarket | OrderType::MarketIfTouched
3646 ) =>
3647 {
3648 match trigger_price {
3649 Some(tp) => {
3650 let derived = derive_limit_from_trigger(
3651 tp.as_decimal().normalize(),
3652 is_buy,
3653 self.market_order_slippage_bps,
3654 );
3655 let sig_rounded = round_to_sig_figs(derived, 5);
3656 clamp_price_to_precision(sig_rounded, price_precision.unwrap_or(2), is_buy)
3657 .normalize()
3658 }
3659 None => Decimal::ZERO,
3660 }
3661 }
3662 None => return Err(Error::bad_request("Limit orders require a price")),
3663 };
3664
3665 let size_decimal = quantity.as_decimal().normalize();
3666
3667 let kind = match order_type {
3668 OrderType::Market => HyperliquidExchangeOrderKind::Limit {
3669 limit: HyperliquidExchangeLimitParams {
3670 tif: HyperliquidExchangeTif::Ioc,
3671 },
3672 },
3673 OrderType::Limit => {
3674 let tif = if post_only {
3675 HyperliquidExchangeTif::Alo
3676 } else {
3677 match time_in_force {
3678 TimeInForce::Gtc => HyperliquidExchangeTif::Gtc,
3679 TimeInForce::Ioc => HyperliquidExchangeTif::Ioc,
3680 TimeInForce::Fok
3681 | TimeInForce::Day
3682 | TimeInForce::Gtd
3683 | TimeInForce::AtTheOpen
3684 | TimeInForce::AtTheClose => {
3685 return Err(Error::bad_request(format!(
3686 "Time in force {time_in_force:?} not supported"
3687 )));
3688 }
3689 }
3690 };
3691 HyperliquidExchangeOrderKind::Limit {
3692 limit: HyperliquidExchangeLimitParams { tif },
3693 }
3694 }
3695 OrderType::StopMarket
3696 | OrderType::StopLimit
3697 | OrderType::MarketIfTouched
3698 | OrderType::LimitIfTouched => {
3699 if let Some(trig_px) = trigger_price {
3700 let trigger_price_decimal = normalize_or_validate_wire_price(
3701 trig_px.as_decimal(),
3702 "Trigger price",
3703 price_precision,
3704 self.normalize_prices,
3705 )
3706 .map_err(|e| Error::bad_request(format!("{e}")))?;
3707
3708 let tpsl = match order_type {
3712 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
3713 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
3714 HyperliquidExchangeTpSl::Tp
3715 }
3716 _ => unreachable!(),
3717 };
3718
3719 let is_market = matches!(
3720 order_type,
3721 OrderType::StopMarket | OrderType::MarketIfTouched
3722 );
3723
3724 HyperliquidExchangeOrderKind::Trigger {
3725 trigger: HyperliquidExchangeTriggerParams {
3726 is_market,
3727 trigger_px: trigger_price_decimal,
3728 tpsl,
3729 },
3730 }
3731 } else {
3732 return Err(Error::bad_request("Trigger orders require a trigger price"));
3733 }
3734 }
3735 _ => {
3736 return Err(Error::bad_request(format!(
3737 "Order type {order_type:?} not supported"
3738 )));
3739 }
3740 };
3741
3742 let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
3743 let hyperliquid_order = HyperliquidExchangePlaceOrderRequest {
3744 asset,
3745 is_buy,
3746 price: price_decimal,
3747 size: size_decimal,
3748 reduce_only,
3749 kind,
3750 cloid: Some(cloid),
3751 };
3752
3753 let builder = self.builder_attribution();
3754
3755 let action = HyperliquidExchangeAction::Order {
3756 orders: vec![hyperliquid_order],
3757 grouping: HyperliquidExchangeGrouping::Na,
3758 builder,
3759 };
3760
3761 let response = self.inner.post_action_exec(&action).await?;
3762
3763 self.build_submit_order_report(
3766 instrument_id,
3767 client_order_id,
3768 order_side,
3769 order_type,
3770 quantity,
3771 time_in_force,
3772 price,
3773 trigger_price,
3774 response,
3775 )?
3776 .ok_or_else(|| {
3777 Error::bad_request(
3778 "Single-order submission returned no actionable status (deferred trigger child)",
3779 )
3780 })
3781 }
3782
3783 pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
3793 if order.is_quote_quantity() {
3794 return Err(Error::bad_request(
3795 "Quote-denominated quantity orders must submit through the execution client \
3796 for quote-to-base conversion",
3797 ));
3798 }
3799
3800 self.submit_order(
3801 order.instrument_id(),
3802 order.client_order_id(),
3803 order.order_side(),
3804 order.order_type(),
3805 order.quantity(),
3806 order.time_in_force(),
3807 order.price(),
3808 order.trigger_price(),
3809 order.is_post_only(),
3810 order.is_reduce_only(),
3811 )
3812 .await
3813 }
3814
3815 #[expect(clippy::too_many_arguments)]
3816 fn create_order_status_report(
3817 &self,
3818 instrument_id: InstrumentId,
3819 client_order_id: Option<ClientOrderId>,
3820 venue_order_id: VenueOrderId,
3821 order_side: OrderSide,
3822 order_type: OrderType,
3823 quantity: Quantity,
3824 time_in_force: TimeInForce,
3825 price: Option<Price>,
3826 trigger_price: Option<Price>,
3827 order_status: OrderStatus,
3828 filled_qty: Quantity,
3829 _instrument: &InstrumentAny,
3830 account_id: AccountId,
3831 ts_init: UnixNanos,
3832 ) -> OrderStatusReport {
3833 let ts_accepted = self.clock.get_time_ns();
3834 let ts_last = ts_accepted;
3835 let report_id = UUID4::new();
3836
3837 let mut report = OrderStatusReport::new(
3838 account_id,
3839 instrument_id,
3840 client_order_id,
3841 venue_order_id,
3842 order_side.into(),
3843 order_type,
3844 time_in_force,
3845 order_status,
3846 quantity,
3847 filled_qty,
3848 ts_accepted,
3849 ts_last,
3850 ts_init,
3851 Some(report_id),
3852 );
3853
3854 if let Some(px) = price {
3855 report = report.with_price(px);
3856 }
3857
3858 if let Some(trig_px) = trigger_price {
3859 report = report
3860 .with_trigger_price(trig_px)
3861 .with_trigger_type(TriggerType::Default);
3862 }
3863
3864 report
3865 }
3866
3867 pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
3876 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
3878 let mut client_order_ids = Vec::with_capacity(orders.len());
3879
3880 for order in orders {
3881 if order.is_quote_quantity() {
3882 return Err(Error::bad_request(format!(
3883 "Quote-denominated quantity order {} must submit through the execution \
3884 client for quote-to-base conversion",
3885 order.client_order_id()
3886 )));
3887 }
3888
3889 let instrument_id = order.instrument_id();
3890 let symbol = instrument_id.symbol.inner();
3891 let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3892 Error::bad_request(format!(
3893 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3894 ))
3895 })?;
3896 let price_decimals = self.get_price_precision_for_symbol(symbol);
3897 let request = order_to_hyperliquid_request_with_optional_decimals(
3898 order,
3899 asset,
3900 price_decimals,
3901 self.normalize_prices,
3902 self.market_order_slippage_bps,
3903 None,
3904 )
3905 .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
3906 client_order_ids.push(order.client_order_id());
3907 hyperliquid_orders.push(request);
3908 }
3909
3910 for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
3911 request.cloid = Some(self.get_or_generate_client_order_id_cloid(client_order_id));
3912 }
3913
3914 let builder = self.builder_attribution();
3915
3916 let grouping =
3917 determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
3918
3919 let action = HyperliquidExchangeAction::Order {
3920 orders: hyperliquid_orders,
3921 grouping,
3922 builder,
3923 };
3924
3925 let response = self.inner.post_action_exec(&action).await?;
3927
3928 self.build_submit_orders_reports(orders, grouping, response)
3929 }
3930
3931 #[expect(clippy::too_many_arguments)]
3947 pub fn build_submit_order_report(
3948 &self,
3949 instrument_id: InstrumentId,
3950 client_order_id: ClientOrderId,
3951 order_side: OrderSide,
3952 order_type: OrderType,
3953 quantity: Quantity,
3954 time_in_force: TimeInForce,
3955 price: Option<Price>,
3956 trigger_price: Option<Price>,
3957 response: HyperliquidExchangeResponse,
3958 ) -> Result<Option<OrderStatusReport>> {
3959 let order_response = parse_order_response(response)?;
3960
3961 let Some(order_status) = order_response.statuses.first() else {
3962 return Ok(None);
3963 };
3964
3965 let account_id = self
3966 .account_id
3967 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3968 let ts_init = self.clock.get_time_ns();
3969
3970 self.build_status_report(
3971 instrument_id,
3972 client_order_id,
3973 order_side,
3974 order_type,
3975 quantity,
3976 time_in_force,
3977 price,
3978 trigger_price,
3979 order_status,
3980 account_id,
3981 ts_init,
3982 )
3983 }
3984
3985 pub fn build_submit_orders_reports(
4000 &self,
4001 orders: &[&OrderAny],
4002 grouping: HyperliquidExchangeGrouping,
4003 response: HyperliquidExchangeResponse,
4004 ) -> Result<Vec<OrderStatusReport>> {
4005 let order_response = parse_order_response(response)?;
4006
4007 let account_id = self
4008 .account_id
4009 .ok_or_else(|| Error::bad_request("Account ID not set"))?;
4010 let ts_init = self.clock.get_time_ns();
4011
4012 if grouping == HyperliquidExchangeGrouping::Na
4016 && order_response.statuses.len() != orders.len()
4017 {
4018 return Err(Error::bad_request(format!(
4019 "Mismatch between submitted orders ({}) and response statuses ({})",
4020 orders.len(),
4021 order_response.statuses.len()
4022 )));
4023 }
4024
4025 let mut reports = Vec::with_capacity(order_response.statuses.len());
4028 for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
4029 if let Some(report) = self.build_status_report(
4030 order.instrument_id(),
4031 order.client_order_id(),
4032 order.order_side(),
4033 order.order_type(),
4034 order.quantity(),
4035 order.time_in_force(),
4036 order.price(),
4037 order.trigger_price(),
4038 order_status,
4039 account_id,
4040 ts_init,
4041 )? {
4042 reports.push(report);
4043 }
4044 }
4045
4046 Ok(reports)
4047 }
4048
4049 #[expect(clippy::too_many_arguments)]
4057 fn build_status_report(
4058 &self,
4059 instrument_id: InstrumentId,
4060 client_order_id: ClientOrderId,
4061 order_side: OrderSide,
4062 order_type: OrderType,
4063 quantity: Quantity,
4064 time_in_force: TimeInForce,
4065 price: Option<Price>,
4066 trigger_price: Option<Price>,
4067 order_status: &HyperliquidExchangeOrderStatus,
4068 account_id: AccountId,
4069 ts_init: UnixNanos,
4070 ) -> Result<Option<OrderStatusReport>> {
4071 if matches!(order_status, HyperliquidExchangeOrderStatus::Tag(_)) {
4072 return Ok(None);
4073 }
4074
4075 let symbol = instrument_id.symbol.as_str();
4076 let product_type = HyperliquidProductType::from_symbol(symbol).ok();
4077
4078 let asset = cache_alias_for_symbol(symbol).unwrap_or_else(|| symbol.to_string());
4081 let instrument = self
4082 .get_or_create_instrument(&Ustr::from(asset.as_str()), product_type)
4083 .ok_or_else(|| {
4084 Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
4085 })?;
4086
4087 let report = match order_status {
4088 HyperliquidExchangeOrderStatus::Resting { resting } => self.create_order_status_report(
4089 instrument_id,
4090 Some(client_order_id),
4091 VenueOrderId::new(resting.oid.to_string()),
4092 order_side,
4093 order_type,
4094 quantity,
4095 time_in_force,
4096 price,
4097 trigger_price,
4098 OrderStatus::Accepted,
4099 Quantity::zero(instrument.size_precision()),
4100 &instrument,
4101 account_id,
4102 ts_init,
4103 ),
4104 HyperliquidExchangeOrderStatus::Filled { filled } => {
4105 let filled_qty =
4106 Quantity::from_decimal_dp(filled.total_sz, instrument.size_precision())
4107 .map_err(|e| {
4108 Error::bad_request(format!(
4109 "Invalid filled size {}: {e}",
4110 filled.total_sz
4111 ))
4112 })?;
4113 self.create_order_status_report(
4114 instrument_id,
4115 Some(client_order_id),
4116 VenueOrderId::new(filled.oid.to_string()),
4117 order_side,
4118 order_type,
4119 quantity,
4120 time_in_force,
4121 price,
4122 trigger_price,
4123 OrderStatus::Filled,
4124 filled_qty,
4125 &instrument,
4126 account_id,
4127 ts_init,
4128 )
4129 }
4130 HyperliquidExchangeOrderStatus::Error { error } => {
4131 return Err(Error::bad_request(format!(
4132 "Order {client_order_id} rejected: {error}"
4133 )));
4134 }
4135 HyperliquidExchangeOrderStatus::Tag(_) => unreachable!("handled above"),
4136 };
4137
4138 Ok(Some(report))
4139 }
4140
4141 fn reconciliation_dexes(&self, instrument_id: Option<InstrumentId>) -> Vec<Option<Ustr>> {
4142 if let Some(instrument_id) = instrument_id {
4143 return vec![perp_dex_from_symbol(instrument_id.symbol.as_str())];
4144 }
4145
4146 let cached = self.instruments.load();
4147 reconciliation_dexes_from_builders(
4148 cached
4149 .keys()
4150 .filter_map(|symbol| perp_dex_from_symbol(symbol.as_str())),
4151 )
4152 }
4153
4154 pub(crate) async fn reconciliation_dexes_from_activity(
4155 &self,
4156 historical_orders: &[HyperliquidOrderStatusEntry],
4157 fills: &HyperliquidFills,
4158 ) -> Result<Vec<Option<Ustr>>> {
4159 if historical_orders.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT
4160 || fills.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT
4161 {
4162 let builder_dexes = self
4163 .inner
4164 .load_perp_dexs()
4165 .await?
4166 .into_iter()
4167 .flatten()
4168 .filter(|dex| !dex.name.is_empty())
4169 .map(|dex| Ustr::from(dex.name.as_str()));
4170 return Ok(reconciliation_dexes_from_builders(builder_dexes));
4171 }
4172
4173 let builder_dexes = historical_orders
4174 .iter()
4175 .map(|entry| entry.order.coin.as_str())
4176 .chain(fills.iter().map(|fill| fill.coin.as_str()))
4177 .filter_map(perp_dex_from_activity_coin);
4178
4179 Ok(reconciliation_dexes_from_builders(builder_dexes))
4180 }
4181}
4182
4183#[derive(Debug)]
4189pub(crate) struct ReportSweep<T> {
4190 pub reports: Vec<T>,
4191 pub complete: bool,
4192}
4193
4194#[derive(Debug)]
4196pub(crate) struct InstrumentSnapshot {
4197 pub instruments: Vec<InstrumentAny>,
4198 pub statuses: AHashMap<InstrumentId, MarketStatusAction>,
4199 pub loaded: AHashSet<HyperliquidProductType>,
4201}
4202
4203fn reconciliation_dexes_from_builders(
4204 builder_dexes: impl IntoIterator<Item = Ustr>,
4205) -> Vec<Option<Ustr>> {
4206 let mut builder_dexes = builder_dexes.into_iter().collect::<Vec<_>>();
4207 builder_dexes.sort_unstable();
4208 builder_dexes.dedup();
4209
4210 let mut dexes = Vec::with_capacity(builder_dexes.len() + 1);
4211 dexes.push(None);
4212 dexes.extend(builder_dexes.into_iter().map(Some));
4213 dexes
4214}
4215
4216fn perp_dex_from_activity_coin(coin: &str) -> Option<Ustr> {
4217 if coin.starts_with(VAULT_TOKEN_PREFIX) {
4218 return None;
4219 }
4220
4221 let (dex, _) = coin.split_once(':')?;
4222 (!dex.is_empty()).then(|| Ustr::from(dex))
4223}
4224
4225fn perp_dex_from_symbol(symbol: &str) -> Option<Ustr> {
4226 symbol
4227 .strip_suffix("-PERP")?
4228 .split_once(':')
4229 .map(|(dex, _)| Ustr::from(dex))
4230}
4231
4232fn parse_order_response(
4237 response: HyperliquidExchangeResponse,
4238) -> Result<HyperliquidExchangeOrderResponseData> {
4239 let response_data = match response {
4240 HyperliquidExchangeResponse::Status {
4241 status,
4242 response: response_data,
4243 } if status == RESPONSE_STATUS_OK => response_data,
4244 HyperliquidExchangeResponse::Error { error } => {
4245 return Err(Error::bad_request(format!(
4246 "Order submission failed: {error}"
4247 )));
4248 }
4249 _ => return Err(Error::bad_request("Unexpected response format")),
4250 };
4251
4252 let data_value = if let Some(data) = response_data.get("data") {
4253 data.clone()
4254 } else {
4255 response_data
4256 };
4257
4258 serde_json::from_value(data_value)
4259 .map_err(|e| Error::bad_request(format!("Failed to parse order response: {e}")))
4260}
4261
4262fn resolve_perp_dex_name(
4263 dex_index: usize,
4264 meta: &PerpMeta,
4265 perp_dexs: Option<&[Option<PerpDex>]>,
4266) -> String {
4267 if dex_index == 0 {
4268 return String::new();
4269 }
4270
4271 if let Some(dex_name) = perp_dexs
4272 .and_then(|dexs| dexs.get(dex_index))
4273 .and_then(|dex| dex.as_ref())
4274 .map(|dex| dex.name.clone())
4275 {
4276 return dex_name;
4277 }
4278
4279 meta.universe
4280 .iter()
4281 .find_map(|asset| asset.name.split_once(':').map(|(dex, _)| dex.to_string()))
4282 .unwrap_or_default()
4283}
4284
4285fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
4290 if dex_index == 0 {
4291 0
4292 } else {
4293 100_000 + dex_index as u32 * 10_000
4294 }
4295}
4296
4297fn cache_coin_aliases(
4298 cache: &mut AHashMap<(Ustr, HyperliquidProductType), InstrumentAny>,
4299 instrument: &InstrumentAny,
4300 product_type: HyperliquidProductType,
4301) {
4302 let full_symbol = instrument.symbol().inner();
4303 let coin = instrument.raw_symbol().inner();
4304
4305 cache.insert((coin, product_type), instrument.clone());
4307
4308 if let Some(alias_ustr) =
4330 cache_alias_for_symbol(full_symbol.as_str()).map(|alias| Ustr::from(alias.as_str()))
4331 {
4332 let key = (alias_ustr, product_type);
4333 if alias_ustr != coin
4334 && cache
4335 .get(&key)
4336 .is_none_or(|cached| cached.raw_symbol() == instrument.raw_symbol())
4337 {
4338 cache.insert(key, instrument.clone());
4339 }
4340 }
4341}
4342
4343fn validate_schedule_cancel_time(time: u64, now: u64) -> Result<()> {
4344 if time.checked_sub(now).is_none_or(|delay| delay < 5_000) {
4345 return Err(Error::bad_request(
4346 "Schedule cancel time must be at least 5000 ms after the current time",
4347 ));
4348 }
4349
4350 Ok(())
4351}
4352
4353#[cfg(test)]
4354mod tests {
4355 use std::{
4356 collections::HashMap,
4357 net::SocketAddr,
4358 sync::{
4359 Arc,
4360 atomic::{AtomicUsize, Ordering},
4361 },
4362 time::Duration,
4363 };
4364
4365 use axum::{
4366 Router,
4367 extract::State,
4368 http::StatusCode,
4369 response::{IntoResponse, Json, Response},
4370 routing::post,
4371 };
4372 use nautilus_core::{Params, UUID4, UnixNanos, hex, time::get_atomic_clock_realtime};
4373 use nautilus_model::{
4374 currencies::CURRENCY_MAP,
4375 enums::{CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType},
4376 events::{OrderEventAny, OrderInitialized},
4377 identifiers::{
4378 AccountId, ClientOrderId, InstrumentId, StrategyId, Symbol, TraderId, VenueOrderId,
4379 },
4380 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
4381 orders::OrderAny,
4382 reports::OrderStatusReport,
4383 types::{Currency, Price, Quantity},
4384 };
4385 use nautilus_testkit::http::assert_http_redirect_rejected;
4386 use rstest::rstest;
4387 use rust_decimal_macros::dec;
4388 use serde_json::{Value, json};
4389 use ustr::Ustr;
4390
4391 use super::{
4392 HyperliquidHttpClient, HyperliquidRawHttpClient, RETRY_AFTER_HEADER, resolve_perp_dex_name,
4393 validate_schedule_cancel_time,
4394 };
4395 use crate::{
4396 common::{
4397 consts::{ASSET_INDEX_INFO_KEY, HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS},
4398 credential::EvmPrivateKey,
4399 enums::{HyperliquidEnvironment, HyperliquidProductType},
4400 },
4401 http::{
4402 error::Error,
4403 models::{
4404 Cloid, HyperliquidExchangeAction, HyperliquidExchangeResponse,
4405 HyperliquidOrderStatusEntry, PerpAsset, PerpDex, PerpMeta,
4406 },
4407 query::InfoRequest,
4408 },
4409 signing::{HyperliquidActionType, HyperliquidEip712Signer, SignRequest, nonce::TimeNonce},
4410 };
4411
4412 const TEST_PRIVATE_KEY: &str =
4413 "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
4414
4415 #[rstest]
4416 #[case(10_000, 9_999, false)]
4417 #[case(10_000, 10_000, false)]
4418 #[case(10_000, 14_999, false)]
4419 #[case(10_000, 15_000, true)]
4420 #[case(10_000, 15_001, true)]
4421 #[case(u64::MAX - 4_999, u64::MAX, false)]
4422 #[case(u64::MAX - 5_000, u64::MAX, true)]
4423 fn test_schedule_cancel_timestamp(#[case] now: u64, #[case] time: u64, #[case] valid: bool) {
4424 assert_eq!(validate_schedule_cancel_time(time, now).is_ok(), valid);
4425 }
4426
4427 #[rstest]
4428 fn test_usd_class_transfer_rejected_without_credentials(
4429 #[values(HyperliquidEnvironment::Mainnet, HyperliquidEnvironment::Testnet)]
4430 environment: HyperliquidEnvironment,
4431 ) {
4432 let client = HyperliquidRawHttpClient::new(environment, 5, None).unwrap();
4433
4434 let action = HyperliquidExchangeAction::UsdClassTransfer {
4435 from: "spot".to_string(),
4436 to: "perp".to_string(),
4437 amount: dec!(1.25),
4438 };
4439
4440 let error = client.sign_action_exec_request(&action, None).unwrap_err();
4441
4442 assert!(matches!(error, Error::BadRequest(_)));
4443 assert_eq!(
4444 error.to_string(),
4445 "bad request: Unsupported action: usdClassTransfer"
4446 );
4447 }
4448
4449 #[rstest]
4450 fn test_usd_class_transfer_rejected_before_nonce_allocation(
4451 #[values(HyperliquidEnvironment::Mainnet, HyperliquidEnvironment::Testnet)]
4452 environment: HyperliquidEnvironment,
4453 #[values(None, Some("0x1234567890123456789012345678901234567890"))] vault: Option<&str>,
4454 #[values(None, Some(1_900_000_000_000))] expires_after: Option<u64>,
4455 ) {
4456 let mut client =
4457 HyperliquidHttpClient::from_credentials(TEST_PRIVATE_KEY, vault, environment, 5, None)
4458 .unwrap();
4459 Arc::get_mut(&mut client.inner).unwrap().nonce_manager = None;
4460
4461 let action = HyperliquidExchangeAction::UsdClassTransfer {
4462 from: "perp".to_string(),
4463 to: "spot".to_string(),
4464 amount: dec!(2.75),
4465 };
4466
4467 let error = client
4468 .sign_action_exec_request(&action, expires_after)
4469 .unwrap_err();
4470
4471 assert!(matches!(error, Error::BadRequest(_)));
4472 assert_eq!(
4473 error.to_string(),
4474 "bad request: Unsupported action: usdClassTransfer"
4475 );
4476 }
4477
4478 #[rstest]
4479 #[tokio::test]
4480 async fn test_usd_class_transfer_rejected_without_dispatch(
4481 #[values(HyperliquidEnvironment::Mainnet, HyperliquidEnvironment::Testnet)]
4482 environment: HyperliquidEnvironment,
4483 ) {
4484 let (addr, count, server) =
4485 start_retry_server(RetryFailure::Response(400), usize::MAX, None).await;
4486 let mut client =
4487 HyperliquidHttpClient::from_credentials(TEST_PRIVATE_KEY, None, environment, 5, None)
4488 .unwrap();
4489 client.set_base_exchange_url(format!("http://{addr}/exchange"));
4490
4491 let action = HyperliquidExchangeAction::UsdClassTransfer {
4492 from: "spot".to_string(),
4493 to: "perp".to_string(),
4494 amount: dec!(3.125),
4495 };
4496
4497 let error = client.post_action_exec(&action).await.unwrap_err();
4498
4499 server.abort();
4500
4501 assert!(matches!(error, Error::BadRequest(_)));
4502 assert_eq!(
4503 error.to_string(),
4504 "bad request: Unsupported action: usdClassTransfer"
4505 );
4506 assert_eq!(count.load(Ordering::Relaxed), 0);
4507 }
4508
4509 #[rstest]
4510 #[case(0)]
4511 #[case(1)]
4512 #[case(2)]
4513 #[case(3)]
4514 fn test_leverage_margin_signed_request(
4515 #[case] case_index: usize,
4516 #[values(HyperliquidEnvironment::Mainnet, HyperliquidEnvironment::Testnet)]
4517 environment: HyperliquidEnvironment,
4518 ) {
4519 let fixture: Value =
4520 serde_json::from_str(include_str!("../../test_data/leverage_margin_actions.json"))
4521 .unwrap();
4522 let case = &fixture["cases"][case_index];
4523 let action: HyperliquidExchangeAction =
4524 serde_json::from_str(case["json"].as_str().unwrap()).unwrap();
4525 let client =
4526 HyperliquidHttpClient::from_credentials(TEST_PRIVATE_KEY, None, environment, 60, None)
4527 .unwrap();
4528 let expires_after = Some(1_900_000_000_000);
4529 let request = client
4530 .sign_action_exec_request(&action, expires_after)
4531 .unwrap();
4532 let key = EvmPrivateKey::new(TEST_PRIVATE_KEY).unwrap();
4533 let signer = HyperliquidEip712Signer::new(&key).unwrap();
4534 let expected_bytes = hex::decode(case["msgpack"].as_str().unwrap()).unwrap();
4535
4536 let expected_signature = signer
4537 .sign(&SignRequest {
4538 action: None,
4539 action_bytes: Some(expected_bytes),
4540 time_nonce: TimeNonce::from_millis(i128::from(request.nonce)),
4541 action_type: HyperliquidActionType::L1,
4542 is_testnet: environment == HyperliquidEnvironment::Testnet,
4543 vault_address: None,
4544 expires_after,
4545 })
4546 .unwrap()
4547 .signature;
4548
4549 let expected_action: Value = serde_json::from_str(case["json"].as_str().unwrap()).unwrap();
4550
4551 assert_eq!(
4552 serde_json::to_value(&request).unwrap(),
4553 json!({
4554 "action": expected_action,
4555 "nonce": request.nonce,
4556 "signature": expected_signature,
4557 "expiresAfter": expires_after,
4558 })
4559 );
4560 }
4561
4562 #[tokio::test]
4563 async fn test_authenticated_client_rejects_redirects() {
4564 let client = HyperliquidRawHttpClient::build_http_client(3, None).unwrap();
4565 assert_http_redirect_rejected(|url| async move {
4566 client
4567 .get(url, None, None, Some(3), None)
4568 .await
4569 .unwrap()
4570 .status
4571 .as_u16()
4572 })
4573 .await;
4574 }
4575
4576 #[rstest]
4577 fn raw_clients_share_rest_limit_for_one_route() {
4578 let mut first =
4579 HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
4580 let mut second =
4581 HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
4582 first.set_base_info_url("https://shared-http-limit.test/info".to_string());
4583 second.set_base_exchange_url("https://shared-http-limit.test/exchange".to_string());
4584
4585 assert!(Arc::ptr_eq(&first.info_limiter, &second.exchange_limiter));
4586 }
4587
4588 #[rstest]
4589 #[case::seconds("3", Some(3_000))]
4590 #[case::zero("0", Some(0))]
4591 #[case::past_date("Sun, 06 Nov 1994 08:49:36 GMT", Some(0))]
4592 #[case::overflow("18446744073709551615", Some(u64::MAX))]
4593 #[case::invalid("soon", None)]
4594 #[case::negative("-1", None)]
4595 fn retry_after_ms_parses_header(#[case] value: &str, #[case] expected: Option<u64>) {
4596 let headers = HashMap::from([(RETRY_AFTER_HEADER.to_string(), value.to_string())]);
4597
4598 assert_eq!(HyperliquidRawHttpClient::retry_after_ms(&headers), expected);
4599 }
4600
4601 #[rstest]
4602 #[case::transport(RetryFailure::Transport)]
4603 #[case::timeout(RetryFailure::Timeout)]
4604 #[case::request_timeout(RetryFailure::Response(408))]
4605 #[case::rate_limit(RetryFailure::Response(429))]
4606 #[case::server_error(RetryFailure::Response(500))]
4607 #[case::unavailable(RetryFailure::Response(503))]
4608 #[tokio::test]
4609 async fn info_retry_recovers(#[case] failure: RetryFailure) {
4610 let (addr, count, server) = start_retry_server(failure, 1, None).await;
4611 let mut client =
4612 HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 1, None).unwrap();
4613 client.set_base_info_url(format!("http://{addr}/info"));
4614
4615 let result = client
4616 .send_info_request_raw(&InfoRequest::meta())
4617 .await
4618 .unwrap();
4619 let expected: Value =
4620 serde_json::from_str(include_str!("../../test_data/http_meta_perp_sample.json"))
4621 .unwrap();
4622
4623 server.abort();
4624 assert_eq!(result, expected);
4625 assert_eq!(count.load(Ordering::Relaxed), 2);
4626 }
4627
4628 #[rstest]
4629 #[case::transport(RetryFailure::Transport)]
4630 #[case::timeout(RetryFailure::Timeout)]
4631 #[case::request_timeout(RetryFailure::Response(408))]
4632 #[case::rate_limit(RetryFailure::Response(429))]
4633 #[case::server_error(RetryFailure::Response(503))]
4634 #[tokio::test]
4635 async fn info_retry_exhausts_budget(#[case] failure: RetryFailure) {
4636 let (addr, count, server) = start_retry_server(failure, usize::MAX, Some("0")).await;
4637 let mut client =
4638 HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 1, None).unwrap();
4639 client.set_base_info_url(format!("http://{addr}/info"));
4640
4641 let error = client.info_meta().await.unwrap_err();
4642
4643 server.abort();
4644 assert_retry_failure(error, failure, ("info", 20, Some(0)));
4645 assert_eq!(count.load(Ordering::Relaxed), 4);
4646 }
4647
4648 #[rstest]
4649 #[case::bad_request(RetryFailure::Response(400))]
4650 #[case::unauthorized(RetryFailure::Response(401))]
4651 #[case::forbidden(RetryFailure::Response(403))]
4652 #[case::unprocessable(RetryFailure::Response(422))]
4653 #[case::invalid_json(RetryFailure::InvalidJson)]
4654 #[tokio::test]
4655 async fn info_retry_rejects_permanent_failures(#[case] failure: RetryFailure) {
4656 let (addr, count, server) = start_retry_server(failure, usize::MAX, Some("0")).await;
4657 let mut client =
4658 HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 1, None).unwrap();
4659 client.set_base_info_url(format!("http://{addr}/info"));
4660
4661 let error = client.info_meta().await.unwrap_err();
4662
4663 server.abort();
4664 assert_retry_failure(error, failure, ("info", 20, Some(0)));
4665 assert_eq!(count.load(Ordering::Relaxed), 1);
4666 }
4667
4668 #[rstest]
4669 #[case(0)]
4670 #[case(1)]
4671 #[case(2)]
4672 #[tokio::test]
4673 async fn info_retry_uses_configured_budget(#[case] max_retries: u32) {
4674 let failure = RetryFailure::Response(503);
4675 let (addr, count, server) = start_retry_server(failure, usize::MAX, Some("invalid")).await;
4676 let mut client =
4677 HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 1, None).unwrap();
4678 client.set_base_info_url(format!("http://{addr}/info"));
4679 client.set_info_retry_config(max_retries, Duration::ZERO, Duration::ZERO);
4680
4681 let error = client.info_meta().await.unwrap_err();
4682
4683 server.abort();
4684 assert_retry_failure(error, failure, ("info", 20, None));
4685 assert_eq!(count.load(Ordering::Relaxed), max_retries as usize + 1);
4686 }
4687
4688 #[rstest]
4689 #[case::rate_limit(429)]
4690 #[case::unavailable(503)]
4691 #[tokio::test]
4692 async fn info_retry_honors_retry_after_above_backoff_cap(#[case] status: u16) {
4693 let (addr, count, server) =
4694 start_retry_server(RetryFailure::Response(status), 1, Some("1")).await;
4695 let mut client =
4696 HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 2, None).unwrap();
4697 client.set_base_info_url(format!("http://{addr}/info"));
4698 client.set_info_retry_config(1, Duration::ZERO, Duration::ZERO);
4699 let started = tokio::time::Instant::now();
4700
4701 let result = client.info_meta().await;
4702 let elapsed = started.elapsed();
4703
4704 server.abort();
4705 assert!(result.is_ok(), "{result:?}");
4706 assert_eq!(count.load(Ordering::Relaxed), 2);
4707 assert!(elapsed >= Duration::from_secs(1), "{elapsed:?}");
4708 }
4709
4710 #[rstest]
4711 #[case::transport(RetryFailure::Transport)]
4712 #[case::timeout(RetryFailure::Timeout)]
4713 #[case::rate_limit(RetryFailure::Response(429))]
4714 #[case::server_error(RetryFailure::Response(503))]
4715 #[tokio::test]
4716 async fn exchange_does_not_retry_unknown_outcome(
4717 #[case] failure: RetryFailure,
4718 #[values("submit", "modify", "cancel")] operation: &str,
4719 ) {
4720 let (addr, count, server) = start_retry_server(failure, usize::MAX, Some("0")).await;
4721 let mut client = HyperliquidHttpClient::from_credentials(
4722 TEST_PRIVATE_KEY,
4723 None,
4724 HyperliquidEnvironment::Testnet,
4725 1,
4726 None,
4727 )
4728 .unwrap();
4729 client.set_base_exchange_url(format!("http://{addr}/exchange"));
4730 client.set_info_retry_config(9, Duration::ZERO, Duration::ZERO);
4731 let signing: Value =
4732 serde_json::from_str(include_str!("../../test_data/order_decimal_signing.json"))
4733 .unwrap();
4734
4735 let action = match operation {
4736 "submit" => serde_json::from_value(signing["cases"][0]["action"].clone()).unwrap(),
4737 "modify" => HyperliquidExchangeAction::Modify {
4738 modify: serde_json::from_value(
4739 signing["cases"][2]["action"]["modifies"][0].clone(),
4740 )
4741 .unwrap(),
4742 },
4743 "cancel" => {
4744 let cancels: Value =
4745 serde_json::from_str(include_str!("../../test_data/cancel_actions.json"))
4746 .unwrap();
4747 serde_json::from_value(cancels["cancel"]["normal"]["json"].clone()).unwrap()
4748 }
4749 _ => unreachable!(),
4750 };
4751
4752 let error = client.post_action_exec(&action).await.unwrap_err();
4753
4754 server.abort();
4755
4756 assert_retry_failure(error, failure, ("exchange", 1, Some(0)));
4757 assert_eq!(count.load(Ordering::Relaxed), 1);
4758 }
4759
4760 #[derive(Debug, Clone, Copy)]
4761 enum RetryFailure {
4762 Transport,
4763 Timeout,
4764 Response(u16),
4765 InvalidJson,
4766 }
4767
4768 async fn start_retry_server(
4769 failure: RetryFailure,
4770 failures: usize,
4771 retry_after: Option<&'static str>,
4772 ) -> (SocketAddr, Arc<AtomicUsize>, tokio::task::JoinHandle<()>) {
4773 let count = Arc::new(AtomicUsize::new(0));
4774 let requests = count.clone();
4775 let handler = post(move |_body: axum::body::Bytes| {
4776 let count = requests.clone();
4777 async move {
4778 if count.fetch_add(1, Ordering::Relaxed) >= failures {
4779 return include_str!("../../test_data/http_meta_perp_sample.json")
4780 .into_response();
4781 }
4782
4783 let mut response = match failure {
4784 RetryFailure::Transport => {
4785 let stream = futures_util::stream::once(async {
4786 Err::<axum::body::Bytes, _>(std::io::Error::other("connection dropped"))
4787 });
4788
4789 Response::new(axum::body::Body::from_stream(stream))
4790 }
4791 RetryFailure::Timeout => {
4792 tokio::time::sleep(Duration::from_secs(2)).await;
4793 "delayed response".into_response()
4794 }
4795 RetryFailure::Response(status) => {
4796 (StatusCode::from_u16(status).unwrap(), "retry failure").into_response()
4797 }
4798 RetryFailure::InvalidJson => "invalid json".into_response(),
4799 };
4800
4801 if let Some(value) = retry_after {
4802 response
4803 .headers_mut()
4804 .insert(RETRY_AFTER_HEADER, value.parse().unwrap());
4805 }
4806
4807 response
4808 }
4809 });
4810
4811 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4812 let addr = listener.local_addr().unwrap();
4813 let router = Router::new()
4814 .route("/info", handler.clone())
4815 .route("/exchange", handler);
4816
4817 let server = tokio::spawn(async move {
4818 axum::serve(listener, router).await.unwrap();
4819 });
4820
4821 (addr, count, server)
4822 }
4823
4824 fn assert_retry_failure(
4825 error: Error,
4826 failure: RetryFailure,
4827 rate_limit: (&str, u32, Option<u64>),
4828 ) {
4829 match (failure, error) {
4830 (RetryFailure::Transport, Error::Transport(_))
4831 | (RetryFailure::Timeout, Error::Timeout)
4832 | (RetryFailure::InvalidJson, Error::Serde(_)) => {}
4833 (
4834 RetryFailure::Response(429),
4835 Error::RateLimit {
4836 scope,
4837 weight,
4838 retry_after_ms,
4839 },
4840 ) => {
4841 assert_eq!((scope, weight, retry_after_ms), rate_limit);
4842 }
4843 (
4844 RetryFailure::Response(status),
4845 Error::Http {
4846 status: actual,
4847 message,
4848 },
4849 ) => {
4850 assert_eq!((actual, message.as_str()), (status, "retry failure"));
4851 }
4852 (_, e) => panic!("unexpected info failure: {e:?}"),
4853 }
4854 }
4855
4856 fn perp_meta_with_assets(names: &[&str]) -> PerpMeta {
4857 PerpMeta {
4858 universe: names
4859 .iter()
4860 .map(|name| PerpAsset {
4861 name: (*name).to_string(),
4862 ..Default::default()
4863 })
4864 .collect(),
4865 margin_tables: Vec::new(),
4866 collateral_token: None,
4867 }
4868 }
4869
4870 #[rstest]
4871 fn resolve_perp_dex_name_uses_empty_string_for_default_dex() {
4872 let meta = perp_meta_with_assets(&["BTC", "ETH"]);
4873 assert_eq!(resolve_perp_dex_name(0, &meta, None), "");
4874 }
4875
4876 #[rstest]
4877 fn resolve_perp_dex_name_prefers_perp_dexs_entry() {
4878 let meta = perp_meta_with_assets(&["xyz:TSLA"]);
4879 let perp_dexs = vec![
4880 None,
4881 Some(PerpDex {
4882 name: "xyz".to_string(),
4883 }),
4884 ];
4885 assert_eq!(resolve_perp_dex_name(1, &meta, Some(&perp_dexs)), "xyz");
4886 }
4887
4888 #[rstest]
4889 fn resolve_perp_dex_name_infers_from_asset_name_when_perp_dexs_missing() {
4890 let meta = perp_meta_with_assets(&["abc:TSLA", "abc:NVDA"]);
4891 assert_eq!(resolve_perp_dex_name(1, &meta, None), "abc");
4892 }
4893
4894 #[rstest]
4895 fn test_build_submit_order_report_elides_waiting_for_fill_tag() {
4896 let mut client =
4900 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4901 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
4902
4903 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
4904 "status": "ok",
4905 "response": {
4906 "type": "order",
4907 "data": {
4908 "statuses": ["waitingForFill"]
4909 }
4910 }
4911 }))
4912 .unwrap();
4913
4914 let result = client
4915 .build_submit_order_report(
4916 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
4917 ClientOrderId::from("O-WAITING-CHILD"),
4918 OrderSide::Buy,
4919 OrderType::StopMarket,
4920 Quantity::from("100"),
4921 TimeInForce::Gtc,
4922 None,
4923 Some(Price::from("0.16136")),
4924 response,
4925 )
4926 .unwrap();
4927
4928 assert!(
4929 result.is_none(),
4930 "Tag status must elide so the order stays SUBMITTED, was {result:?}"
4931 );
4932 }
4933
4934 #[rstest]
4935 fn test_build_submit_order_report_filled_uses_total_sz_decimal() {
4936 let mut client =
4940 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4941 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
4942
4943 let base = Currency::new("ARB", 8, 0, "ARB", CurrencyType::Crypto);
4944 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4945 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4946 let clock = get_atomic_clock_realtime();
4947 let ts = clock.get_time_ns();
4948 let perp = InstrumentAny::CryptoPerpetual(
4949 CryptoPerpetual::builder()
4950 .instrument_id(InstrumentId::new(
4951 Symbol::new("ARB-USD-PERP"),
4952 *HYPERLIQUID_VENUE,
4953 ))
4954 .raw_symbol(Symbol::new("ARB"))
4955 .base_currency(base)
4956 .quote_currency(usd)
4957 .settlement_currency(usdc)
4958 .is_inverse(false)
4959 .price_precision(5)
4960 .size_precision(2)
4961 .price_increment(Price::from("0.00001"))
4962 .size_increment(Quantity::from("0.01"))
4963 .ts_event(ts)
4964 .ts_init(ts)
4965 .build()
4966 .unwrap(),
4967 );
4968 client.cache_instrument(&perp);
4969
4970 let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
4971 "status": "ok",
4972 "response": {
4973 "type": "order",
4974 "data": {
4975 "statuses": [{
4976 "filled": {"totalSz": "0.5", "avgPx": "1.2345", "oid": 778899}
4977 }]
4978 }
4979 }
4980 }))
4981 .unwrap();
4982
4983 let report = client
4984 .build_submit_order_report(
4985 InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
4986 ClientOrderId::from("O-FILLED-001"),
4987 OrderSide::Buy,
4988 OrderType::Market,
4989 Quantity::from("0.5"),
4990 TimeInForce::Ioc,
4991 None,
4992 None,
4993 response,
4994 )
4995 .unwrap()
4996 .expect("filled status must produce a report");
4997
4998 assert_eq!(report.order_status, OrderStatus::Filled);
4999 assert_eq!(report.venue_order_id.as_str(), "778899");
5000 assert_eq!(report.filled_qty.as_decimal(), dec!(0.5));
5001 }
5002
5003 #[derive(Clone, Default)]
5004 struct OutcomeMetaServerState {
5005 last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
5006 }
5007
5008 async fn handle_outcome_meta_info(
5009 State(state): State<OutcomeMetaServerState>,
5010 body: axum::body::Bytes,
5011 ) -> Response {
5012 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
5013 return (
5014 StatusCode::BAD_REQUEST,
5015 Json(json!({"error": "Invalid JSON body"})),
5016 )
5017 .into_response();
5018 };
5019
5020 *state.last_request_body.lock().await = Some(request_body.clone());
5021
5022 if request_body.get("type").and_then(|value| value.as_str()) != Some("outcomeMeta") {
5023 return (
5024 StatusCode::BAD_REQUEST,
5025 Json(json!({"error": "Expected outcomeMeta request"})),
5026 )
5027 .into_response();
5028 }
5029
5030 Json(json!({
5031 "outcomes": [
5032 {
5033 "outcome": 123,
5034 "quoteToken": "USDC",
5035 "name": "Recurring",
5036 "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m",
5037 "sideSpecs": [
5038 {"name": "Yes"},
5039 {"name": "No"}
5040 ]
5041 }
5042 ]
5043 }))
5044 .into_response()
5045 }
5046
5047 async fn start_outcome_meta_server(state: OutcomeMetaServerState) -> SocketAddr {
5048 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5049 let addr = listener.local_addr().unwrap();
5050 let router = Router::new()
5051 .route("/info", post(handle_outcome_meta_info))
5052 .with_state(state);
5053
5054 tokio::spawn(async move {
5055 axum::serve(listener, router).await.unwrap();
5056 });
5057
5058 addr
5059 }
5060
5061 async fn handle_unresolved_collateral_info(body: axum::body::Bytes) -> Response {
5062 let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
5063 return (
5064 StatusCode::BAD_REQUEST,
5065 Json(json!({"error": "Invalid JSON body"})),
5066 )
5067 .into_response();
5068 };
5069
5070 match request_body.get("type").and_then(|value| value.as_str()) {
5071 Some("spotMeta") => (
5072 StatusCode::INTERNAL_SERVER_ERROR,
5073 Json(json!({"error": "spot metadata unavailable"})),
5074 )
5075 .into_response(),
5076 Some("allPerpMetas") => Json(json!([
5077 {
5078 "collateralToken": 360,
5079 "marginTables": [],
5080 "universe": [
5081 {
5082 "maxLeverage": 20,
5083 "name": "km:US500",
5084 "szDecimals": 3
5085 }
5086 ]
5087 }
5088 ]))
5089 .into_response(),
5090 _ => Json(json!({"universe": [], "marginTables": []})).into_response(),
5091 }
5092 }
5093
5094 async fn start_unresolved_collateral_server() -> SocketAddr {
5095 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5096 let addr = listener.local_addr().unwrap();
5097 let router = Router::new().route("/info", post(handle_unresolved_collateral_info));
5098
5099 tokio::spawn(async move {
5100 axum::serve(listener, router).await.unwrap();
5101 });
5102
5103 addr
5104 }
5105
5106 #[rstest]
5107 fn stable_json_roundtrips() {
5108 let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
5109 let s = serde_json::to_string(&v).unwrap();
5110 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
5112 assert_eq!(parsed["type"], "l2Book");
5113 assert_eq!(parsed["coin"], "BTC");
5114 assert_eq!(parsed, v);
5115 }
5116
5117 #[rstest]
5118 fn info_pretty_shape() {
5119 let r = InfoRequest::l2_book("BTC");
5120 let val = serde_json::to_value(&r).unwrap();
5121 let pretty = serde_json::to_string_pretty(&val).unwrap();
5122 assert!(pretty.contains("\"type\": \"l2Book\""));
5123 assert!(pretty.contains("\"coin\": \"BTC\""));
5124 }
5125
5126 #[rstest]
5127 fn test_client_order_id_cloid_cache_is_stable_and_first_write_wins() {
5128 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5129 let client_order_id = ClientOrderId::new("O-CLOID-CACHE");
5130 let other_client_order_id = ClientOrderId::new("O-CLOID-CACHE-OTHER");
5131 let duplicate_client_order_id = ClientOrderId::new("O-CLOID-CACHE-DUPLICATE");
5132 let explicit_cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
5133
5134 let first = client.get_or_generate_client_order_id_cloid(client_order_id);
5135 let second = client.get_or_generate_client_order_id_cloid(client_order_id);
5136 client.cache_client_order_id_cloid(client_order_id, explicit_cloid);
5137 client.cache_client_order_id_cloid(other_client_order_id, explicit_cloid);
5138 client.cache_client_order_id_cloid(duplicate_client_order_id, explicit_cloid);
5139
5140 assert_eq!(first, Cloid::from_client_order_id(client_order_id));
5141 assert_eq!(first, second);
5142 assert_eq!(
5143 client.cached_client_order_id_cloid(&client_order_id),
5144 Some(first),
5145 "cache insert must not overwrite an existing generated CLOID",
5146 );
5147 assert_eq!(
5148 client.unique_cached_client_order_id_cloid(&client_order_id),
5149 Some(first),
5150 );
5151 assert_eq!(
5152 client.cached_client_order_id_cloid(&other_client_order_id),
5153 Some(explicit_cloid),
5154 );
5155 assert_eq!(
5156 client.unique_cached_client_order_id_cloid(&other_client_order_id),
5157 None,
5158 "duplicate CLOID mappings are not safe modify targets",
5159 );
5160 assert_eq!(
5161 client.remove_client_order_id_cloid(&client_order_id),
5162 Some(first),
5163 );
5164 assert_eq!(client.cached_client_order_id_cloid(&client_order_id), None);
5165 }
5166
5167 #[rstest]
5168 fn test_builder_attribution_defaults_to_mainnet_builder() {
5169 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5170 let builder = client
5171 .builder_attribution()
5172 .expect("mainnet client should include builder attribution by default");
5173
5174 assert!(client.include_builder_attribution());
5175 assert_eq!(builder.address, NAUTILUS_BUILDER_ADDRESS);
5176 assert_eq!(builder.fee_tenths_bp, 0);
5177 }
5178
5179 #[rstest]
5180 fn test_builder_attribution_disabled_returns_none() {
5181 let mut client =
5182 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5183 client.set_include_builder_attribution(false);
5184
5185 assert!(!client.include_builder_attribution());
5186 assert!(client.builder_attribution().is_none());
5187 }
5188
5189 #[rstest]
5190 fn test_builder_attribution_omitted_on_testnet() {
5191 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
5192
5193 assert!(client.include_builder_attribution());
5194 assert!(client.builder_attribution().is_none());
5195 }
5196
5197 #[rstest]
5198 #[tokio::test]
5199 async fn test_production_client_get_outcome_meta_uses_outcome_meta_request() {
5200 let state = OutcomeMetaServerState::default();
5201 let addr = start_outcome_meta_server(state.clone()).await;
5202 let mut client =
5203 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5204 client.set_base_info_url(format!("http://{addr}/info"));
5205
5206 let meta = client.get_outcome_meta().await.unwrap();
5207 let request_body = state.last_request_body.lock().await.clone().unwrap();
5208
5209 assert_eq!(request_body, json!({"type": "outcomeMeta"}));
5210 assert_eq!(meta.outcomes.len(), 1);
5211 assert_eq!(meta.outcomes[0].outcome, 123);
5212 assert_eq!(meta.outcomes[0].quote_token.as_deref(), Some("USDC"));
5213 assert_eq!(meta.outcomes[0].name, "Recurring");
5214 assert_eq!(meta.outcomes[0].side_specs.len(), 2);
5215 assert_eq!(meta.outcomes[0].side_specs[0].name, "Yes");
5216 assert_eq!(meta.outcomes[0].side_specs[1].name, "No");
5217 }
5218
5219 #[rstest]
5220 #[tokio::test]
5221 async fn test_request_instrument_defs_errors_when_non_usdc_collateral_unresolved() {
5222 let addr = start_unresolved_collateral_server().await;
5223 let mut client =
5224 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5225 client.set_base_info_url(format!("http://{addr}/info"));
5226
5227 let err = client.request_instrument_defs().await.unwrap_err();
5228
5229 assert_eq!(
5230 err.to_string(),
5231 "decode error: failed to resolve perp settlement currency for dex 0: \
5232 Spot metadata required to resolve perp collateral token 360",
5233 );
5234 }
5235
5236 #[rstest]
5237 fn test_with_credentials_preserves_explicit_account_address() {
5238 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
5239 let client = HyperliquidHttpClient::with_credentials(
5240 Some(TEST_PRIVATE_KEY.to_string()),
5241 None,
5242 Some(account_address),
5243 HyperliquidEnvironment::Mainnet,
5244 60,
5245 None,
5246 )
5247 .unwrap();
5248
5249 assert_eq!(client.get_account_address().unwrap(), account_address);
5250 }
5251
5252 #[rstest]
5253 fn test_from_resolved_credentials_preserves_account_address_without_private_key() {
5254 let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
5255 let client = HyperliquidHttpClient::from_resolved_credentials(
5256 None,
5257 None,
5258 Some(account_address.to_string()),
5259 HyperliquidEnvironment::Mainnet,
5260 60,
5261 None,
5262 )
5263 .unwrap();
5264
5265 assert_eq!(client.get_account_address().unwrap(), account_address);
5266 }
5267
5268 #[rstest]
5269 fn test_with_credentials_rejects_invalid_account_address() {
5270 let e = HyperliquidHttpClient::with_credentials(
5271 Some(TEST_PRIVATE_KEY.to_string()),
5272 None,
5273 Some("0xinvalid"),
5274 HyperliquidEnvironment::Mainnet,
5275 60,
5276 Some("invalid proxy URL".to_string()),
5277 )
5278 .unwrap_err();
5279
5280 assert_eq!(
5281 e.to_string(),
5282 "bad request: Account address must be 20 bytes of valid hex"
5283 );
5284 }
5285
5286 #[rstest]
5287 fn test_set_account_address_normalizes_and_preserves_override_on_error() {
5288 let mut client =
5289 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5290 client
5291 .set_account_address(Some(
5292 " \t0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 ".to_string(),
5293 ))
5294 .unwrap();
5295 let e = client
5296 .set_account_address(Some("0xinvalid".to_string()))
5297 .unwrap_err();
5298
5299 assert_eq!(
5300 e.to_string(),
5301 "bad request: Account address must be 20 bytes of valid hex"
5302 );
5303 assert_eq!(
5304 client.get_account_address().unwrap(),
5305 "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"
5306 );
5307 }
5308
5309 #[rstest]
5310 #[case(None)]
5311 #[case(Some("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"))]
5312 fn test_set_account_address_clears_override(#[case] vault_address: Option<&str>) {
5313 let mut client = HyperliquidHttpClient::from_resolved_credentials(
5314 Some(TEST_PRIVATE_KEY.to_string()),
5315 vault_address,
5316 Some("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
5317 HyperliquidEnvironment::Mainnet,
5318 60,
5319 None,
5320 )
5321 .unwrap();
5322
5323 let expected_address = match vault_address {
5324 Some(vault) => vault.to_string(),
5325 None => client.get_user_address().unwrap(),
5326 };
5327
5328 client.set_account_address(None).unwrap();
5329
5330 assert_eq!(client.account_address, None);
5331 assert_eq!(client.get_account_address().unwrap(), expected_address);
5332 }
5333
5334 #[rstest]
5335 fn test_cache_instrument_by_raw_symbol() {
5336 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5337
5338 let base_code = "vntls:vCURSOR";
5340 let quote_code = "USDC";
5341
5342 {
5344 let mut currency_map = CURRENCY_MAP.lock();
5345 if !currency_map.contains_key(base_code) {
5346 currency_map.insert(
5347 base_code.to_string(),
5348 Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
5349 );
5350 }
5351 }
5352
5353 let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
5354 let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
5355
5356 let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
5358 let venue = *HYPERLIQUID_VENUE;
5359 let instrument_id = InstrumentId::new(symbol, venue);
5360
5361 let raw_symbol = Symbol::new(base_code);
5363
5364 let clock = get_atomic_clock_realtime();
5365 let ts = clock.get_time_ns();
5366
5367 let instrument = InstrumentAny::CurrencyPair(
5368 CurrencyPair::builder()
5369 .instrument_id(instrument_id)
5370 .raw_symbol(raw_symbol)
5371 .base_currency(base_currency)
5372 .quote_currency(quote_currency)
5373 .price_precision(8)
5374 .size_precision(8)
5375 .price_increment(Price::from("0.00000001"))
5376 .size_increment(Quantity::from("0.00000001"))
5377 .ts_event(ts)
5378 .ts_init(ts)
5379 .build()
5380 .unwrap(),
5381 );
5382
5383 client.cache_instrument(&instrument);
5385
5386 let instruments = client.instruments.load();
5388 let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
5389 assert!(
5390 by_full_symbol.is_some(),
5391 "Instrument should be accessible by full symbol"
5392 );
5393 assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
5394
5395 let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
5397 assert!(
5398 by_raw_symbol.is_some(),
5399 "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
5400 );
5401 assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
5402 drop(instruments);
5403
5404 let instruments_by_coin = client.instruments_by_coin.load();
5406 let by_coin =
5407 instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
5408 assert!(
5409 by_coin.is_some(),
5410 "Instrument should be accessible by coin and product type"
5411 );
5412 assert_eq!(by_coin.unwrap().id(), instrument.id());
5413 drop(instruments_by_coin);
5414
5415 let retrieved_with_type = client.get_or_create_instrument(
5417 &Ustr::from("vntls:vCURSOR"),
5418 Some(HyperliquidProductType::Spot),
5419 );
5420 assert!(retrieved_with_type.is_some());
5421 assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
5422
5423 let retrieved_without_type =
5425 client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
5426 assert!(retrieved_without_type.is_some());
5427 assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
5428 }
5429
5430 #[rstest]
5431 fn test_get_or_create_instrument_outcome_fallback_no_product_type() {
5432 use nautilus_core::time::get_atomic_clock_realtime;
5438 use nautilus_model::{
5439 enums::AssetClass,
5440 identifiers::{InstrumentId, Symbol},
5441 instruments::{BinaryOption, InstrumentAny},
5442 types::{Currency, Price, Quantity},
5443 };
5444
5445 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5446 let coin = "#500";
5447 let token = "+500";
5448
5449 let usdh = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
5450 let symbol = Symbol::new(token);
5451 let raw_symbol = Symbol::new(coin);
5452 let venue = *HYPERLIQUID_VENUE;
5453 let instrument_id = InstrumentId::new(symbol, venue);
5454
5455 let clock = get_atomic_clock_realtime();
5456 let ts = clock.get_time_ns();
5457
5458 let binary = InstrumentAny::BinaryOption(
5459 BinaryOption::builder()
5460 .instrument_id(instrument_id)
5461 .raw_symbol(raw_symbol)
5462 .asset_class(AssetClass::Alternative)
5463 .currency(usdh)
5464 .activation_ns(Default::default())
5465 .expiration_ns(Default::default())
5466 .price_precision(4)
5467 .size_precision(2)
5468 .price_increment(Price::from("0.0001"))
5469 .size_increment(Quantity::from("0.01"))
5470 .ts_event(ts)
5471 .ts_init(ts)
5472 .build()
5473 .unwrap(),
5474 );
5475
5476 client.cache_instrument(&binary);
5477
5478 let with_type = client
5479 .get_or_create_instrument(&Ustr::from(coin), Some(HyperliquidProductType::Outcome));
5480 assert!(with_type.is_some());
5481 let with_type = with_type.unwrap();
5482 assert_eq!(with_type.id(), instrument_id);
5483 assert_eq!(with_type.quote_currency(), usdh);
5484
5485 let no_type = client.get_or_create_instrument(&Ustr::from(coin), None);
5486 assert!(
5487 no_type.is_some(),
5488 "Outcome coin must resolve through the no-product fallback",
5489 );
5490 let no_type = no_type.unwrap();
5491 assert_eq!(no_type.id(), instrument_id);
5492 assert_eq!(no_type.quote_currency(), usdh);
5493
5494 let missing = client.get_or_create_instrument(&Ustr::from("#9999"), None);
5495 assert!(missing.is_none());
5496 }
5497
5498 #[rstest]
5499 #[case::coin_form("#200", None, "20-YES-OUTCOME", "#200", 100_000_200)]
5500 #[case::token_form(
5501 "+201",
5502 Some(HyperliquidProductType::Outcome),
5503 "20-NO-OUTCOME",
5504 "#201",
5505 100_000_201
5506 )]
5507 fn test_get_or_create_instrument_resolves_unlisted_outcome(
5508 #[case] coin: &str,
5509 #[case] product_type: Option<HyperliquidProductType>,
5510 #[case] symbol: &str,
5511 #[case] raw_symbol: &str,
5512 #[case] asset_index: u32,
5513 ) {
5514 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5517
5518 let instrument = client
5519 .get_or_create_instrument(&Ustr::from(coin), product_type)
5520 .expect("unlisted outcome coin must resolve from its encoding");
5521
5522 assert_eq!(
5523 instrument.id(),
5524 InstrumentId::new(Symbol::new(symbol), *HYPERLIQUID_VENUE)
5525 );
5526 assert_eq!(instrument.raw_symbol(), Symbol::new(raw_symbol));
5527 assert_eq!(instrument.price_precision(), 5);
5528 assert_eq!(instrument.size_precision(), 0);
5529 assert_eq!(instrument.quote_currency(), Currency::USDC());
5530 assert_eq!(instrument.settlement_currency(), Currency::USDC());
5531 assert_eq!(client.get_asset_index(symbol), Some(asset_index));
5532 }
5533
5534 #[rstest]
5535 fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
5536 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5541
5542 let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
5543 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
5544 let clock = get_atomic_clock_realtime();
5545 let ts = clock.get_time_ns();
5546
5547 let canonical = InstrumentAny::CurrencyPair(
5548 CurrencyPair::builder()
5549 .instrument_id(InstrumentId::new(
5550 Symbol::new("HYPE-USDC-SPOT"),
5551 *HYPERLIQUID_VENUE,
5552 ))
5553 .raw_symbol(Symbol::new("@107"))
5554 .base_currency(hype)
5555 .quote_currency(usdc)
5556 .price_precision(5)
5557 .size_precision(2)
5558 .price_increment(Price::from("0.00001"))
5559 .size_increment(Quantity::from("0.01"))
5560 .ts_event(ts)
5561 .ts_init(ts)
5562 .build()
5563 .unwrap(),
5564 );
5565
5566 let non_canonical = InstrumentAny::CurrencyPair(
5567 CurrencyPair::builder()
5568 .instrument_id(InstrumentId::new(
5569 Symbol::new("HYPE-USDC-SPOT"),
5570 *HYPERLIQUID_VENUE,
5571 ))
5572 .raw_symbol(Symbol::new("@999"))
5573 .base_currency(hype)
5574 .quote_currency(usdc)
5575 .price_precision(5)
5576 .size_precision(2)
5577 .price_increment(Price::from("0.00001"))
5578 .size_increment(Quantity::from("0.01"))
5579 .ts_event(ts)
5580 .ts_init(ts)
5581 .build()
5582 .unwrap(),
5583 );
5584
5585 client.merge_instruments(&[canonical.clone(), non_canonical.clone()]);
5586
5587 let instruments_by_coin = client.instruments_by_coin.load();
5588 let by_base = instruments_by_coin
5589 .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
5590 .expect("base alias must resolve");
5591 assert_eq!(
5592 by_base.raw_symbol().inner().as_str(),
5593 "@107",
5594 "base alias must point to the canonical pair, not the one cached later",
5595 );
5596 drop(instruments_by_coin);
5597
5598 let InstrumentAny::CurrencyPair(mut refreshed) = canonical else {
5599 unreachable!();
5600 };
5601
5602 refreshed.price_precision = 4;
5603 refreshed.price_increment = Price::from("0.0001");
5604 refreshed.size_precision = 3;
5605 refreshed.size_increment = Quantity::from("0.001");
5606 let refreshed = InstrumentAny::CurrencyPair(refreshed);
5607 client.merge_instruments(&[refreshed.clone(), non_canonical]);
5608
5609 let resolved = client
5610 .get_or_create_instrument(&Ustr::from("HYPE"), Some(HyperliquidProductType::Spot))
5611 .unwrap();
5612 assert_eq!(
5613 serde_json::to_value(&resolved).unwrap(),
5614 serde_json::to_value(&refreshed).unwrap(),
5615 );
5616 }
5617
5618 #[rstest]
5619 fn test_cache_instrument_perp_aliases_sanitized_base() {
5620 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5626
5627 let base_currency = Currency::new(
5628 "dex:STREAMABCD****",
5629 8,
5630 0,
5631 "dex:STREAMABCD****",
5632 CurrencyType::Crypto,
5633 );
5634 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
5635 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
5636 let clock = get_atomic_clock_realtime();
5637 let ts = clock.get_time_ns();
5638
5639 let hip3 = InstrumentAny::CryptoPerpetual(
5640 CryptoPerpetual::builder()
5641 .instrument_id(InstrumentId::new(
5642 Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
5643 *HYPERLIQUID_VENUE,
5644 ))
5645 .raw_symbol(Symbol::new("dex:STREAMABCD****"))
5646 .base_currency(base_currency)
5647 .quote_currency(usd)
5648 .settlement_currency(usdc)
5649 .is_inverse(false)
5650 .price_precision(6)
5651 .size_precision(3)
5652 .price_increment(Price::from("0.000001"))
5653 .size_increment(Quantity::from("0.001"))
5654 .ts_event(ts)
5655 .ts_init(ts)
5656 .build()
5657 .unwrap(),
5658 );
5659
5660 client.cache_instrument(&hip3);
5661
5662 let instruments_by_coin = client.instruments_by_coin.load();
5663 let by_raw = instruments_by_coin
5664 .get(&(
5665 Ustr::from("dex:STREAMABCD****"),
5666 HyperliquidProductType::Perp,
5667 ))
5668 .expect("venue coin lookup must resolve");
5669 assert_eq!(by_raw.id(), hip3.id());
5670
5671 let by_sanitized = instruments_by_coin
5672 .get(&(
5673 Ustr::from("dex:STREAMABCDxxxx"),
5674 HyperliquidProductType::Perp,
5675 ))
5676 .expect("sanitized base lookup must resolve");
5677 assert_eq!(by_sanitized.id(), hip3.id());
5678 drop(instruments_by_coin);
5679
5680 let resolved = client
5682 .get_or_create_instrument(
5683 &Ustr::from("dex:STREAMABCDxxxx"),
5684 Some(HyperliquidProductType::Perp),
5685 )
5686 .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
5687 assert_eq!(resolved.id(), hip3.id());
5688
5689 let InstrumentAny::CryptoPerpetual(mut refreshed) = hip3 else {
5690 unreachable!();
5691 };
5692
5693 refreshed.price_precision = 4;
5694 refreshed.price_increment = Price::from("0.0001");
5695 refreshed.size_precision = 2;
5696 refreshed.size_increment = Quantity::from("0.01");
5697 let refreshed = InstrumentAny::CryptoPerpetual(refreshed);
5698 client.cache_instrument(&refreshed);
5699
5700 let resolved = client
5701 .get_or_create_instrument(
5702 &Ustr::from("dex:STREAMABCDxxxx"),
5703 Some(HyperliquidProductType::Perp),
5704 )
5705 .unwrap();
5706 assert_eq!(
5707 serde_json::to_value(&resolved).unwrap(),
5708 serde_json::to_value(&refreshed).unwrap(),
5709 );
5710 }
5711
5712 #[tokio::test]
5713 #[ignore = "connects to Hyperliquid mainnet public instrument metadata"]
5714 async fn test_live_mainnet_instrument_refresh_updates_aliases() {
5715 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5716 let original = client.request_instruments().await.unwrap();
5717 assert!(!original.is_empty());
5718
5719 for instrument in original {
5720 client.cache_instrument(&instrument);
5721 }
5722
5723 let refreshed = client.request_instruments().await.unwrap();
5724 let mut spot_aliases = 0;
5725 let mut perp_aliases = 0;
5726
5727 for instrument in &refreshed {
5728 client.cache_instrument(instrument);
5729 let symbol = instrument.symbol();
5730 let product = HyperliquidProductType::from_symbol(symbol.as_str()).unwrap();
5731 let alias = super::cache_alias_for_symbol(symbol.as_str()).unwrap();
5732 let resolved = client
5733 .get_or_create_instrument(&Ustr::from(alias.as_str()), Some(product))
5734 .unwrap();
5735
5736 if resolved.raw_symbol() != instrument.raw_symbol() {
5737 continue;
5738 }
5739
5740 assert_eq!(
5741 serde_json::to_value(&resolved).unwrap(),
5742 serde_json::to_value(instrument).unwrap(),
5743 );
5744 assert_eq!(
5745 client.get_price_precision(symbol.as_str()),
5746 Some(instrument.price_precision()),
5747 );
5748
5749 if alias != instrument.raw_symbol().as_str() {
5750 match product {
5751 HyperliquidProductType::Spot => spot_aliases += 1,
5752 HyperliquidProductType::Perp => perp_aliases += 1,
5753 HyperliquidProductType::Outcome => {}
5754 }
5755 }
5756 }
5757
5758 assert!(spot_aliases > 0);
5759 println!(
5760 "Validated {} refreshed definitions, {spot_aliases} spot aliases, and {perp_aliases} perp aliases",
5761 refreshed.len()
5762 );
5763 }
5764
5765 fn perp_with_asset_index(symbol: &str, asset_index: Option<u32>) -> InstrumentAny {
5766 let base_currency = Currency::new("NEW", 8, 0, "NEW", CurrencyType::Crypto);
5767 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
5768 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
5769 let ts = get_atomic_clock_realtime().get_time_ns();
5770
5771 let info = asset_index.map(|asset_index| {
5772 Params::new([(ASSET_INDEX_INFO_KEY.to_string(), asset_index.into())].into())
5773 });
5774
5775 InstrumentAny::CryptoPerpetual(
5776 CryptoPerpetual::builder()
5777 .instrument_id(InstrumentId::new(Symbol::new(symbol), *HYPERLIQUID_VENUE))
5778 .raw_symbol(Symbol::new("NEW"))
5779 .base_currency(base_currency)
5780 .quote_currency(usd)
5781 .settlement_currency(usdc)
5782 .is_inverse(false)
5783 .price_precision(6)
5784 .size_precision(3)
5785 .price_increment(Price::from("0.000001"))
5786 .size_increment(Quantity::from("0.001"))
5787 .maybe_info(info)
5788 .ts_event(ts)
5789 .ts_init(ts)
5790 .build()
5791 .unwrap(),
5792 )
5793 }
5794
5795 #[rstest]
5796 fn test_cache_instrument_registers_asset_index_from_info() {
5797 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5798
5799 client.cache_instrument(&perp_with_asset_index("NEW-USD-PERP", Some(42)));
5800
5801 assert_eq!(client.get_asset_index("NEW-USD-PERP"), Some(42));
5802 }
5803
5804 #[rstest]
5805 fn test_cache_instrument_without_asset_index_info_retains_existing_index() {
5806 let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5809
5810 client.cache_instrument(&perp_with_asset_index("NEW-USD-PERP", Some(42)));
5811 let asset_indices = client.asset_indices.load();
5812 client.cache_instrument(&perp_with_asset_index("NEW-USD-PERP", None));
5813 client.cache_instrument(&perp_with_asset_index("OTHER-USD-PERP", None));
5814
5815 assert_eq!(client.get_asset_index("NEW-USD-PERP"), Some(42));
5816 assert_eq!(client.get_asset_index("OTHER-USD-PERP"), None);
5817 assert!(Arc::ptr_eq(&asset_indices, &client.asset_indices.load()));
5818 }
5819
5820 fn btc_history_client() -> HyperliquidHttpClient {
5821 let mut client =
5822 HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5823 client.set_account_id(AccountId::from("HYPERLIQUID-001"));
5824
5825 let base = Currency::new("BTC", 8, 0, "BTC", CurrencyType::Crypto);
5826 let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
5827 let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
5828 let ts = get_atomic_clock_realtime().get_time_ns();
5829 let perp = InstrumentAny::CryptoPerpetual(
5830 CryptoPerpetual::builder()
5831 .instrument_id(InstrumentId::new(
5832 Symbol::new("BTC-USD-PERP"),
5833 *HYPERLIQUID_VENUE,
5834 ))
5835 .raw_symbol(Symbol::new("BTC"))
5836 .base_currency(base)
5837 .quote_currency(usd)
5838 .settlement_currency(usdc)
5839 .is_inverse(false)
5840 .price_precision(1)
5841 .size_precision(5)
5842 .price_increment(Price::from("0.1"))
5843 .size_increment(Quantity::from("0.00001"))
5844 .ts_event(ts)
5845 .ts_init(ts)
5846 .build()
5847 .unwrap(),
5848 );
5849 client.cache_instrument(&perp);
5850 client
5851 }
5852
5853 fn history_rows(rows: Value) -> Vec<HyperliquidOrderStatusEntry> {
5854 serde_json::from_value(rows).unwrap()
5855 }
5856
5857 fn assert_initializes_order(report: &OrderStatusReport) -> OrderAny {
5859 let initialized = OrderInitialized::new_checked(
5860 TraderId::from("TESTER-001"),
5861 StrategyId::from("EXTERNAL"),
5862 report.instrument_id,
5863 ClientOrderId::from(report.venue_order_id.as_str()),
5864 report.order_side.expect("report has an order side"),
5865 report.order_type,
5866 report.quantity,
5867 report.time_in_force,
5868 report.post_only,
5869 report.reduce_only,
5870 false,
5871 true,
5872 UUID4::new(),
5873 UnixNanos::default(),
5874 UnixNanos::default(),
5875 report.price,
5876 report.activation_price,
5877 report.trigger_price,
5878 report.trigger_type,
5879 report.limit_offset,
5880 report.trailing_offset,
5881 report.trailing_offset_type,
5882 report.expire_time,
5883 report.display_qty,
5884 None,
5885 None,
5886 report.contingency_type,
5887 report.order_list_id,
5888 report.linked_order_ids.clone(),
5889 report.parent_order_id,
5890 None,
5891 None,
5892 None,
5893 None,
5894 )
5895 .expect("report must initialize an order");
5896 OrderAny::from_events(vec![OrderEventAny::Initialized(initialized)])
5897 .expect("order must replay from its initialization")
5898 }
5899
5900 fn triggered_stop_rows() -> Value {
5903 let trigger_row = |status: &str, ts: u64| {
5904 json!({
5905 "order": {
5906 "coin": "BTC", "side": "A", "limitPx": "58800.0", "sz": "0.0",
5907 "oid": 900000001_u64, "timestamp": 1700000000000_u64,
5908 "triggerCondition": "Price below 59500", "isTrigger": true,
5909 "triggerPx": "59500.0", "children": [], "isPositionTpsl": true,
5910 "reduceOnly": true, "orderType": "Stop Market", "origSz": "0.0", "tif": null,
5911 "cloid": "0x00000000000000000000000000000a01"
5912 },
5913 "status": status,
5914 "statusTimestamp": ts
5915 })
5916 };
5917 json!([
5918 filled_stop_row(),
5919 trigger_row("triggered", 1700000600000),
5920 trigger_row("open", 1700000000000),
5921 ])
5922 }
5923
5924 fn filled_stop_row() -> Value {
5925 json!({
5926 "order": {
5927 "coin": "BTC", "side": "A", "limitPx": "58200.0", "sz": "0.0",
5928 "oid": 900000001_u64, "timestamp": 1700000600000_u64,
5929 "triggerCondition": "Triggered", "isTrigger": false, "triggerPx": "0.0",
5930 "children": [], "isPositionTpsl": true, "reduceOnly": true,
5931 "orderType": "Stop Market", "origSz": "0.02", "tif": "Gtc",
5932 "cloid": "0x00000000000000000000000000000a01"
5933 },
5934 "status": "filled",
5935 "statusTimestamp": 1700000600000_u64
5936 })
5937 }
5938
5939 #[rstest]
5940 fn test_historical_reports_triggered_stop_keeps_trigger_from_earlier_rows() {
5941 let client = btc_history_client();
5942
5943 let sweep = client
5944 .historical_order_status_reports_from_response(
5945 history_rows(triggered_stop_rows()),
5946 None,
5947 )
5948 .unwrap();
5949
5950 assert!(sweep.complete);
5951 assert_eq!(sweep.reports.len(), 1);
5952 let report = &sweep.reports[0];
5953 assert_eq!(report.venue_order_id, VenueOrderId::new("900000001"));
5954 assert_eq!(report.order_type, OrderType::StopMarket);
5955 assert_eq!(report.order_status, OrderStatus::Filled);
5956 assert_eq!(report.filled_qty, Quantity::from("0.02"));
5957 assert_eq!(report.trigger_price, Some(Price::from("59500.0")));
5958 assert_eq!(report.trigger_type, Some(TriggerType::Default));
5959 assert_initializes_order(report);
5960 }
5961
5962 #[rstest]
5963 fn test_historical_reports_triggered_stop_limit_keeps_trigger_and_price() {
5964 let client = btc_history_client();
5965 let mut rows = triggered_stop_rows();
5966 for row in rows.as_array_mut().unwrap() {
5967 row["order"]["orderType"] = json!("Stop Limit");
5968 }
5969
5970 let sweep = client
5971 .historical_order_status_reports_from_response(history_rows(rows), None)
5972 .unwrap();
5973
5974 assert_eq!(sweep.reports.len(), 1);
5975 let report = &sweep.reports[0];
5976 assert_eq!(report.order_type, OrderType::StopLimit);
5977 assert_eq!(report.price, Some(Price::from("58800.0")));
5979 assert_eq!(report.trigger_price, Some(Price::from("59500.0")));
5980 assert_eq!(report.trigger_type, Some(TriggerType::Default));
5981 assert_initializes_order(report);
5982 }
5983
5984 #[rstest]
5985 #[case("Stop Market", "filled", OrderType::Market)]
5986 #[case("Take Profit Market", "filled", OrderType::Market)]
5987 #[case("Stop Limit", "filled", OrderType::Market)]
5988 #[case("Take Profit Limit", "filled", OrderType::Market)]
5989 #[case("Stop Market", "canceled", OrderType::Market)]
5990 #[case("Take Profit Market", "canceled", OrderType::Market)]
5991 #[case("Stop Limit", "canceled", OrderType::Limit)]
5992 #[case("Take Profit Limit", "canceled", OrderType::Limit)]
5993 fn test_historical_reports_closed_trigger_without_trigger_rows_is_untriggered(
5994 #[case] label: &str,
5995 #[case] status: &str,
5996 #[case] expected: OrderType,
5997 ) {
5998 let client = btc_history_client();
6001 let mut row = filled_stop_row();
6002 row["order"]["orderType"] = json!(label);
6003 row["status"] = json!(status);
6004 let expected_filled = if status == "canceled" {
6006 row["order"]["sz"] = json!("0.02");
6007 "0"
6008 } else {
6009 "0.02"
6010 };
6011
6012 let sweep = client
6013 .historical_order_status_reports_from_response(history_rows(json!([row])), None)
6014 .unwrap();
6015
6016 assert_eq!(sweep.reports.len(), 1);
6017 let report = &sweep.reports[0];
6018 assert_eq!(report.order_type, expected);
6019 assert_eq!(report.filled_qty, Quantity::from(expected_filled));
6020 assert!(report.trigger_price.is_none());
6021 assert!(report.trigger_type.is_none());
6022 assert_eq!(report.price.is_some(), expected == OrderType::Limit);
6023 assert!(report.reduce_only);
6024 assert_initializes_order(report);
6025 }
6026
6027 #[rstest]
6028 fn test_historical_reports_triggered_stop_filled_with_remainder() {
6029 let client = btc_history_client();
6031 let mut row = filled_stop_row();
6032 row["order"]["sz"] = json!("0.0008");
6033
6034 let sweep = client
6035 .historical_order_status_reports_from_response(history_rows(json!([row])), None)
6036 .unwrap();
6037
6038 assert_eq!(sweep.reports.len(), 1);
6039 let report = &sweep.reports[0];
6040 assert_eq!(report.order_status, OrderStatus::Canceled);
6041 assert_eq!(report.order_type, OrderType::Market);
6042 assert_eq!(report.filled_qty, Quantity::from("0.0192"));
6043 assert!(report.price.is_none());
6044 assert_initializes_order(report);
6045 }
6046
6047 #[rstest]
6048 fn test_historical_reports_open_trigger_without_trigger_price_keeps_order_type() {
6049 let client = btc_history_client();
6052 let mut row = filled_stop_row();
6053 row["status"] = json!("triggered");
6054
6055 let sweep = client
6056 .historical_order_status_reports_from_response(history_rows(json!([row])), None)
6057 .unwrap();
6058
6059 assert_eq!(sweep.reports.len(), 1);
6060 assert_eq!(sweep.reports[0].order_type, OrderType::StopMarket);
6061 }
6062
6063 #[rstest]
6066 fn test_historical_reports_partially_filled_ioc_is_canceled() {
6067 let client = btc_history_client();
6068 let row = |status: &str, sz: &str| {
6069 json!({
6070 "order": {
6071 "coin": "BTC", "side": "A", "limitPx": "60100.0", "sz": sz,
6072 "oid": 900000002_u64, "timestamp": 1700001000000_u64,
6073 "triggerCondition": "N/A", "isTrigger": false, "triggerPx": "0.0",
6074 "children": [], "isPositionTpsl": false, "reduceOnly": false,
6075 "orderType": "Limit", "origSz": "0.015", "tif": "Ioc",
6076 "cloid": "0x00000000000000000000000000000a02"
6077 },
6078 "status": status,
6079 "statusTimestamp": 1700001000000_u64
6080 })
6081 };
6082
6083 let sweep = client
6084 .historical_order_status_reports_from_response(
6085 history_rows(json!([row("filled", "0.0005"), row("open", "0.015")])),
6086 None,
6087 )
6088 .unwrap();
6089
6090 assert_eq!(sweep.reports.len(), 1);
6091 let report = &sweep.reports[0];
6092 assert_eq!(report.order_status, OrderStatus::Canceled);
6093 assert_eq!(report.order_type, OrderType::Limit);
6094 assert_eq!(report.quantity, Quantity::from("0.015"));
6095 assert_eq!(report.filled_qty, Quantity::from("0.0145"));
6096 assert_eq!(report.price, Some(Price::from("60100.0")));
6098 assert_eq!(
6099 report.cancel_reason.as_deref(),
6100 Some("Unfilled remainder canceled")
6101 );
6102 assert_initializes_order(report);
6103 }
6104}