1use crate::client::hyperliquid::error::{map_sdk_error, order_rejection};
9use crate::error::{OrderError, UnindexedClientError};
10use crate::order::{
11 Order, OrderKey, OrderKind, TimeInForce, UnindexedOrderSnapshot,
12 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
13 state::{Cancelled, Filled, Open, OrderState},
14};
15use crate::{
16 InstrumentAccountSnapshot, UnindexedAccountEvent,
17 balance::{AssetBalance, Balance},
18 position::PositionReport,
19};
20use chrono::{DateTime, TimeZone, Utc};
21use fnv::FnvHashSet;
22use futures::Stream;
23use hyperliquid_rust_sdk::{ExchangeDataStatus, ExchangeResponseStatus};
24use rust_decimal::Decimal;
25use rustrade_instrument::{
26 Side,
27 asset::name::AssetNameExchange,
28 exchange::ExchangeId,
29 hyperliquid::{CoinKind, SpotPair},
30 instrument::name::InstrumentNameExchange,
31};
32use serde::de::DeserializeOwned;
33use smol_str::format_smolstr;
34use std::collections::{BTreeSet, HashMap, HashSet};
35use std::pin::Pin;
36use std::str::FromStr;
37use std::task::{Context, Poll};
38use tokio_util::sync::CancellationToken;
39use tracing::warn;
40use uuid::Uuid;
41
42#[derive(Debug)]
47pub struct CancelOnDropStream<S> {
48 inner: S,
49 cancel_token: CancellationToken,
50}
51
52impl<S> CancelOnDropStream<S> {
53 pub(crate) fn new(inner: S, cancel_token: CancellationToken) -> Self {
55 Self {
56 inner,
57 cancel_token,
58 }
59 }
60}
61
62impl<S: Stream + Unpin> Stream for CancelOnDropStream<S> {
63 type Item = S::Item;
64
65 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
66 Pin::new(&mut self.inner).poll_next(cx)
67 }
68}
69
70impl<S> Drop for CancelOnDropStream<S> {
71 fn drop(&mut self) {
72 self.cancel_token.cancel();
73 }
74}
75
76#[derive(Debug, Clone, serde::Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct UserFill {
95 pub coin: String,
96 pub side: String,
97 pub px: String,
98 pub sz: String,
99 pub time: u64,
100 pub oid: u64,
101 pub fee: String,
102
103 pub tid: u64,
114
115 pub fee_token: Option<String>,
121 pub cloid: Option<String>,
124}
125
126pub(super) fn span_millis(start: DateTime<Utc>, end: DateTime<Utc>) -> Option<(u64, u64)> {
130 if start > end {
131 return None;
132 }
133 let end_ms = u64::try_from(end.timestamp_millis()).ok()?;
134 let start_ms = u64::try_from(start.timestamp_millis()).unwrap_or(0);
135 Some((start_ms, end_ms))
136}
137
138const USER_FILLS_PER_RESPONSE: usize = 2_000;
140
141const FILLS_RETAINED: usize = 10_000;
143
144pub(super) fn held_since(fills: &[UserFill], start_ms: u64) -> Option<DateTime<Utc>> {
149 if fills.len() < FILLS_RETAINED {
150 return None;
151 }
152 let oldest = fills.iter().map(|fill| fill.time).min()?;
153 (oldest > start_ms).then(|| {
154 Utc.timestamp_millis_opt(i64::try_from(oldest).ok()?)
155 .single()
156 })?
157}
158
159pub async fn user_fills_by_time(
179 info_client: &hyperliquid_rust_sdk::InfoClient,
180 address: ethers::types::H160,
181 start_ms: u64,
182 end_ms: u64,
183) -> Result<Vec<UserFill>, UnindexedClientError> {
184 let mut fills: Vec<UserFill> = Vec::new();
185 let mut from = start_ms;
186 loop {
187 let body = format!(
189 r#"{{"type":"userFillsByTime","user":"{address:?}","startTime":{from},"endTime":{end_ms}}}"#
190 );
191 let page: Vec<UserFill> = info(info_client, "userFillsByTime", body).await?;
192 let full = page.len() >= USER_FILLS_PER_RESPONSE;
193 let Some(last_time) = page.last().map(|fill| fill.time) else {
194 break;
195 };
196 let seen: Vec<(String, u64)> = fills
199 .iter()
200 .rev()
201 .take_while(|fill| fill.time == from)
202 .map(|fill| (fill.coin.clone(), fill.tid))
203 .collect();
204 fills.extend(page.into_iter().filter(|fill| {
205 fill.time != from
206 || !seen
207 .iter()
208 .any(|(coin, tid)| *tid == fill.tid && *coin == fill.coin)
209 }));
210 if !full {
211 break;
212 }
213 if last_time <= from {
214 return Err(UnindexedClientError::Truncated {
215 fills_read: fills.len(),
216 });
217 }
218 from = last_time;
219 }
220 Ok(fills)
221}
222
223#[derive(Debug, Clone, serde::Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct OpenOrder {
235 pub coin: String,
236 pub side: String,
237 pub limit_px: String,
238 pub sz: String,
240 pub oid: u64,
241 pub timestamp: u64,
242
243 #[serde(default)]
249 pub orig_sz: Option<String>,
250
251 #[serde(default)]
254 pub cloid: Option<String>,
255}
256
257pub async fn open_orders(
265 info_client: &hyperliquid_rust_sdk::InfoClient,
266 address: ethers::types::H160,
267) -> Result<Vec<OpenOrder>, UnindexedClientError> {
268 user_info(info_client, "openOrders", address, None).await
269}
270
271pub(super) async fn user_info<T: DeserializeOwned>(
278 info_client: &hyperliquid_rust_sdk::InfoClient,
279 kind: &str,
280 address: ethers::types::H160,
281 dex: Option<&str>,
282) -> Result<T, UnindexedClientError> {
283 let mut body = serde_json::json!({"type": kind, "user": format!("{address:?}")});
286 if let Some(dex) = dex {
287 body["dex"] = dex.into();
288 }
289 info(info_client, kind, body.to_string()).await
290}
291
292pub(super) async fn info<T: DeserializeOwned>(
294 info_client: &hyperliquid_rust_sdk::InfoClient,
295 kind: &str,
296 body: String,
297) -> Result<T, UnindexedClientError> {
298 let raw = info_client
299 .http_client
300 .post("/info", body)
301 .await
302 .map_err(map_sdk_error)?;
303
304 serde_json::from_str(&raw).map_err(|e| {
307 UnindexedClientError::Internal(format!("Hyperliquid {kind} response did not parse: {e}"))
308 })
309}
310
311pub(super) fn spot_balances<'a>(
313 balances: impl IntoIterator<Item = &'a hyperliquid_rust_sdk::UserTokenBalance>,
314 now: DateTime<Utc>,
315) -> impl Iterator<Item = AssetBalance<AssetNameExchange>> {
316 balances.into_iter().map(move |balance| {
317 let total = parse_decimal(&balance.total, "total").unwrap_or(Decimal::ZERO);
318 let hold = parse_decimal(&balance.hold, "hold").unwrap_or(Decimal::ZERO);
319 let free = (total - hold).max(Decimal::ZERO);
320 AssetBalance::new(
321 AssetNameExchange::from(balance.coin.as_str()),
322 Balance::new(total, free),
323 now,
324 )
325 })
326}
327
328pub(super) fn open_order_to_order(
333 row: &OpenOrder,
334 exchange: ExchangeId,
335 instrument: InstrumentNameExchange,
336) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
337 let parsed = (|| {
338 let cid = record_cid(row.cloid.as_deref(), row.oid)?;
339 let side = parse_side(&row.side)?;
340 let price = parse_decimal(&row.limit_px, "limitPx")?;
341 let remaining = parse_decimal(&row.sz, "sz")?;
342 let quantity = match &row.orig_sz {
343 Some(orig_sz) => parse_decimal(orig_sz, "origSz")?,
344 None => remaining,
345 };
346 let time_exchange = millis_to_datetime(row.timestamp)?;
347 Some((cid, side, price, quantity, remaining, time_exchange))
348 })();
349
350 let Some((cid, side, price, quantity, remaining, time_exchange)) = parsed else {
351 warn!(
352 %exchange,
353 %instrument,
354 oid = row.oid,
355 order = ?row,
356 "Hyperliquid open order did not convert - leaving it out, so this instrument's order \
357 list is not complete"
358 );
359 return None;
360 };
361
362 Some(Order {
363 key: OrderKey {
364 exchange,
365 instrument,
366 strategy: StrategyId::unknown(),
367 cid,
368 },
369 side,
370 price: Some(price),
371 quantity,
372 kind: OrderKind::Limit,
374 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
375 state: Open {
376 id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", row.oid))),
377 time_exchange,
378 filled_quantity: (quantity - remaining).max(Decimal::ZERO),
379 },
380 })
381}
382
383pub(super) struct InstrumentFilter<'a>(Option<FnvHashSet<&'a InstrumentNameExchange>>);
386
387impl<'a> InstrumentFilter<'a> {
388 pub(super) fn new(instruments: &'a [InstrumentNameExchange]) -> Self {
389 Self((!instruments.is_empty()).then(|| instruments.iter().collect()))
390 }
391
392 pub(super) fn matches(&self, instrument: &InstrumentNameExchange) -> bool {
394 self.0.as_ref().is_none_or(|set| set.contains(instrument))
395 }
396}
397
398type UnindexedInstrumentAccountSnapshot =
399 InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
400
401pub(super) struct OpenOrderListing(
403 HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
404);
405
406impl OpenOrderListing {
407 pub(super) fn new(
420 rows: &[OpenOrder],
421 exchange: ExchangeId,
422 requested: &[InstrumentNameExchange],
423 to_instrument: impl Fn(&str) -> Option<InstrumentNameExchange>,
424 ) -> Self {
425 fn entry<'a>(
426 listing: &'a mut HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
427 instrument: &InstrumentNameExchange,
428 ) -> &'a mut UnindexedInstrumentAccountSnapshot {
429 listing
430 .entry(instrument.clone())
431 .or_insert_with(|| InstrumentAccountSnapshot {
432 instrument: instrument.clone(),
433 orders: Vec::new(),
434 orders_complete: true,
435 position: PositionReport::Unreported,
436 isolated: None,
437 })
438 }
439
440 let filter = InstrumentFilter::new(requested);
441 let mut listing = HashMap::with_capacity(requested.len());
442
443 for instrument in requested {
444 entry(&mut listing, instrument);
445 }
446
447 for row in rows {
448 let Some(instrument) = to_instrument(&row.coin) else {
449 continue;
450 };
451 if !filter.matches(&instrument) {
452 continue;
453 }
454 let order = open_order_to_order(row, exchange, instrument.clone());
455 let snapshot = entry(&mut listing, &instrument);
456 match order {
457 Some(order) => snapshot.orders.push(order.into()),
458 None => snapshot.orders_complete = false,
459 }
460 }
461
462 Self(listing)
463 }
464
465 pub(super) fn remove(
467 &mut self,
468 instrument: &InstrumentNameExchange,
469 ) -> Option<UnindexedInstrumentAccountSnapshot> {
470 self.0.remove(instrument)
471 }
472
473 pub(super) fn into_snapshots(self) -> impl Iterator<Item = UnindexedInstrumentAccountSnapshot> {
475 self.0.into_values()
476 }
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481enum OrderStatus {
482 Open,
483 Filled,
484 Cancelled,
485 Rejected,
486}
487
488impl OrderStatus {
489 fn classify(status: &str) -> Option<Self> {
500 match status {
501 "open" | "triggered" => Some(Self::Open),
502 "filled" => Some(Self::Filled),
503 "canceled" | "scheduledCancel" => Some(Self::Cancelled),
504 "rejected" => Some(Self::Rejected),
505 status if status.ends_with("Canceled") => Some(Self::Cancelled),
506 status if status.ends_with("Rejected") => Some(Self::Rejected),
507 _ => None,
508 }
509 }
510}
511
512pub(super) fn order_update_to_account_event(
518 update: &hyperliquid_rust_sdk::OrderUpdate,
519 exchange: ExchangeId,
520 instrument: InstrumentNameExchange,
521) -> Option<UnindexedAccountEvent> {
522 let snapshot = order_update_to_order(update, exchange, instrument)?;
523 Some(crate::AccountEvent::new(
524 exchange,
525 crate::AccountEventKind::OrderSnapshot(
526 rustrade_integration::collection::snapshot::Snapshot(snapshot),
527 ),
528 ))
529}
530
531pub(super) fn order_update_to_order(
537 update: &hyperliquid_rust_sdk::OrderUpdate,
538 exchange: ExchangeId,
539 instrument: InstrumentNameExchange,
540) -> Option<UnindexedOrderSnapshot> {
541 let order = &update.order;
542 let Some(status) = OrderStatus::classify(&update.status) else {
543 warn!(%exchange, status = %update.status, oid = order.oid, "Unknown Hyperliquid order status - ignoring the update");
544 return None;
545 };
546 let cid = record_cid(order.cloid.as_deref(), order.oid)?;
547 let side = parse_side(&order.side)?;
548 let price = parse_decimal(&order.limit_px, "order.limitPx")?;
549 let orig_sz = parse_decimal(&order.orig_sz, "order.origSz")?;
550 let Some(time_exchange) = millis_to_datetime(update.status_timestamp) else {
551 warn!(%exchange, oid = order.oid, status_timestamp = update.status_timestamp, "Invalid Hyperliquid order update timestamp - ignoring the update");
552 return None;
553 };
554 let order_id = OrderId(format_smolstr!("{}", order.oid));
555 let filled_quantity = || {
556 parse_decimal(&order.sz, "order.sz")
557 .map(|remaining| (orig_sz - remaining).max(Decimal::ZERO))
558 };
559
560 let state = match status {
561 OrderStatus::Open => OrderState::active(Open {
562 id: VenueOrderId::Assigned(order_id),
563 time_exchange,
564 filled_quantity: filled_quantity()?,
565 }),
566 OrderStatus::Filled => OrderState::fully_filled(Filled::new(
567 order_id,
568 time_exchange,
569 orig_sz,
570 None, )),
572 OrderStatus::Cancelled => {
574 OrderState::inactive(Cancelled::new(order_id, time_exchange, filled_quantity()))
575 }
576 OrderStatus::Rejected => {
577 OrderState::inactive(OrderError::Rejected(order_rejection(update.status.clone())))
578 }
579 };
580
581 Some(Order {
583 key: OrderKey {
584 exchange,
585 instrument,
586 strategy: StrategyId::unknown(),
587 cid,
588 },
589 side,
590 price: Some(price),
591 quantity: orig_sz,
592 kind: OrderKind::Limit,
593 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
594 state,
595 })
596}
597
598pub fn parse_decimal(value: &str, field: &str) -> Option<Decimal> {
599 Decimal::from_str(value)
600 .map_err(|e| warn!(%field, %value, %e, "Failed to parse decimal"))
601 .ok()
602}
603
604pub fn parse_side(side: &str) -> Option<Side> {
609 match side {
610 "B" | "b" | "BUY" | "Buy" | "buy" => Some(Side::Buy),
611 "A" | "a" | "S" | "s" | "SELL" | "Sell" | "sell" => Some(Side::Sell),
612 _ => {
613 warn!(%side, "Unknown side string");
614 None
615 }
616 }
617}
618
619pub fn millis_to_datetime(millis: u64) -> Option<DateTime<Utc>> {
623 Utc.timestamp_millis_opt(i64::try_from(millis).ok()?)
624 .single()
625}
626
627pub fn map_tif(tif: &TimeInForce) -> &'static str {
629 match tif {
630 TimeInForce::GoodUntilCancelled { post_only: true } => "Alo",
631 TimeInForce::GoodUntilCancelled { post_only: false } => "Gtc",
632 TimeInForce::ImmediateOrCancel => "Ioc",
633 TimeInForce::FillOrKill => "Ioc", TimeInForce::GoodUntilEndOfDay => "Gtc", TimeInForce::GoodTillDate { .. } | TimeInForce::AtOpen | TimeInForce::AtClose => {
639 warn!(time_in_force = ?tif, "Hyperliquid does not support this TimeInForce; coercing to Gtc");
640 "Gtc"
641 }
642 }
643}
644
645pub(super) const CLOID_REQUIRED: &str = "Hyperliquid requires the client order id to be a UUID in \
647 canonical form, as ClientOrderId::uuid() makes: the venue reports each order under that id, \
648 and an order under any other id could not be matched to its own updates";
649
650pub fn cid_to_cloid(cid: &ClientOrderId) -> Option<Uuid> {
658 let uuid = Uuid::try_parse(cid.0.as_str()).ok()?;
659 let mut canonical = Uuid::encode_buffer();
660 (uuid.hyphenated().encode_lower(&mut canonical) == cid.0.as_str()).then_some(uuid)
661}
662
663pub fn cloid_to_cid(cloid: &str) -> Option<ClientOrderId> {
667 let hex = cloid.strip_prefix("0x")?;
668 if hex.len() != 32 {
669 return None;
670 }
671 let uuid = Uuid::try_parse(hex).ok()?;
673 Some(ClientOrderId::new(format_smolstr!("{}", uuid.hyphenated())))
674}
675
676pub(super) fn cancel_outcome(response: ExchangeResponseStatus) -> Result<(), String> {
689 let response = match response {
690 ExchangeResponseStatus::Ok(response) => response,
691 ExchangeResponseStatus::Err(reason) => return Err(reason),
692 };
693 match response
694 .data
695 .and_then(|data| data.statuses.into_iter().next())
696 {
697 Some(ExchangeDataStatus::Success) => Ok(()),
698 Some(ExchangeDataStatus::Error(reason)) => Err(reason),
699 Some(status) => Err(format!("cancel answered with status {status:?}")),
700 None => Err("cancel answered without a status for the order".to_owned()),
701 }
702}
703
704pub(super) fn record_cid(cloid: Option<&str>, oid: u64) -> Option<ClientOrderId> {
714 match cloid {
715 None => Some(ClientOrderId::new(format_smolstr!("{oid}"))),
716 Some(cloid) => {
717 let cid = cloid_to_cid(cloid);
718 if cid.is_none() {
719 warn!(%cloid, oid, "Hyperliquid order carries a cloid that is not 0x and 32 hex digits");
720 }
721 cid
722 }
723 }
724}
725
726fn is_unknown_coin(coin: &str) -> bool {
728 !matches!(CoinKind::of(coin), CoinKind::Perp | CoinKind::Spot)
729}
730
731pub(super) fn warn_unknown_coins<'a>(coins: impl IntoIterator<Item = &'a str>) {
734 let unknown: BTreeSet<&str> = coins
735 .into_iter()
736 .filter(|coin| is_unknown_coin(coin))
737 .collect();
738 if !unknown.is_empty() {
739 warn!(
740 ?unknown,
741 "Hyperliquid coins name markets of an unrecognised kind; leaving them out"
742 );
743 }
744}
745
746#[derive(Debug, Default)]
749pub(super) struct UnknownCoins(HashSet<String>);
750
751impl UnknownCoins {
752 pub(super) fn warn_once(&mut self, coin: &str) {
754 if is_unknown_coin(coin) && !self.0.contains(coin) {
755 warn!(
756 %coin,
757 "Hyperliquid coin names a market of an unrecognised kind; leaving it out \
758 (logged once per stream)"
759 );
760 self.0.insert(coin.to_owned());
761 }
762 }
763}
764
765pub fn spot_pair_to_instrument(pair: &SpotPair) -> InstrumentNameExchange {
768 InstrumentNameExchange::from(format_smolstr!("{}-{}-SPOT", pair.base(), pair.quote()))
769}
770
771pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
782 let (base, quote) = spot_base_quote(instrument)?;
783 Some(format!("{base}/{quote}"))
784}
785
786pub(super) fn spot_base_quote(instrument: &InstrumentNameExchange) -> Option<(&str, &str)> {
789 instrument.as_ref().strip_suffix("-SPOT")?.split_once('-')
790}
791
792#[cfg(test)]
793#[allow(clippy::unwrap_used)]
795mod tests {
796 use super::*;
797 use rust_decimal_macros::dec;
798
799 fn fills_at(times: impl IntoIterator<Item = u64>) -> Vec<UserFill> {
800 times
801 .into_iter()
802 .map(|time| {
803 serde_json::from_value(serde_json::json!({
804 "coin": "BTC", "side": "B", "px": "1", "sz": "1", "time": time,
805 "oid": 1, "fee": "0", "tid": time
806 }))
807 .unwrap()
808 })
809 .collect()
810 }
811
812 #[test]
813 fn held_since_is_the_oldest_fill_only_when_the_retention_limit_is_reached() {
814 let full = fills_at((0..FILLS_RETAINED as u64).map(|i| 5_000 + i));
815 assert_eq!(
816 held_since(&full, 1_000),
817 Some(Utc.timestamp_millis_opt(5_000).unwrap())
818 );
819
820 let short = fills_at((0..FILLS_RETAINED as u64 - 1).map(|i| 5_000 + i));
821 assert_eq!(held_since(&short, 1_000), None);
822 }
823
824 #[test]
825 fn held_since_is_none_when_the_oldest_fill_is_not_after_the_span_start() {
826 let full = fills_at((0..FILLS_RETAINED as u64).map(|i| 5_000 + i));
827
828 assert_eq!(held_since(&full, 5_000), None);
829 assert_eq!(held_since(&full, 9_000), None);
830 }
831
832 #[test]
833 fn an_instrument_filter_keeps_what_it_was_given_or_everything_when_given_nothing() {
834 let (btc, eth) = (
835 InstrumentNameExchange::new("BTC"),
836 InstrumentNameExchange::new("ETH"),
837 );
838
839 let all = InstrumentFilter::new(&[]);
840 assert!(all.matches(&btc) && all.matches(ð));
841
842 let given = [btc.clone()];
843 let one = InstrumentFilter::new(&given);
844 assert!(one.matches(&btc));
845 assert!(!one.matches(ð));
846 }
847
848 #[test]
849 fn test_parse_decimal_valid() {
850 assert_eq!(parse_decimal("123.456", "test"), Some(dec!(123.456)));
851 assert_eq!(parse_decimal("0", "test"), Some(dec!(0)));
852 assert_eq!(parse_decimal("-50.5", "test"), Some(dec!(-50.5)));
853 }
854
855 #[test]
856 fn test_parse_decimal_invalid() {
857 assert_eq!(parse_decimal("", "test"), None);
858 assert_eq!(parse_decimal("abc", "test"), None);
859 assert_eq!(parse_decimal("12.34.56", "test"), None);
860 }
861
862 #[test]
863 fn test_parse_side() {
864 assert_eq!(parse_side("B"), Some(Side::Buy));
865 assert_eq!(parse_side("BUY"), Some(Side::Buy));
866 assert_eq!(parse_side("buy"), Some(Side::Buy));
867 assert_eq!(parse_side("A"), Some(Side::Sell));
868 assert_eq!(parse_side("S"), Some(Side::Sell));
869 assert_eq!(parse_side("SELL"), Some(Side::Sell));
870 assert_eq!(parse_side("sell"), Some(Side::Sell));
871 assert_eq!(parse_side("X"), None);
872 assert_eq!(parse_side(""), None);
873 }
874
875 #[test]
876 fn test_spot_pair_to_instrument() {
877 let pairs = super::super::spot_coins::test_spot_pairs();
878 let instrument = |coin| spot_pair_to_instrument(pairs.get(coin).unwrap());
879
880 assert_eq!(instrument("PURR/USDC").as_ref(), "PURR-USDC-SPOT");
881 assert_eq!(instrument("@107").as_ref(), "HYPE-USDC-SPOT");
882 assert_eq!(instrument("@207").as_ref(), "HYPE-USDT0-SPOT");
883 }
884
885 #[test]
886 fn unknown_coins_are_recorded_once_and_known_ones_never() {
887 let mut unknown = UnknownCoins::default();
888 for coin in ["#12", "#12", "BTC", "@107", "PURR/USDC", "+3"] {
889 unknown.warn_once(coin);
890 }
891 assert_eq!(
892 unknown.0,
893 HashSet::from(["#12".to_owned(), "+3".to_owned()])
894 );
895 }
896
897 fn cancel_answer(statuses: serde_json::Value) -> ExchangeResponseStatus {
899 serde_json::from_value(info_tests::cancel_answer(statuses)).unwrap()
900 }
901
902 #[test]
903 fn only_a_success_status_is_a_cancel() {
904 assert_eq!(
905 cancel_outcome(cancel_answer(serde_json::json!(["success"]))),
906 Ok(())
907 );
908 }
909
910 #[test]
912 fn an_error_status_under_a_top_level_ok_is_not_a_cancel() {
913 let answer = cancel_answer(serde_json::json!([
914 {"error": info_tests::CANCEL_NOT_APPLIED}
915 ]));
916 assert_eq!(
917 cancel_outcome(answer),
918 Err(info_tests::CANCEL_NOT_APPLIED.to_owned())
919 );
920 }
921
922 #[test]
923 fn an_answer_without_a_success_status_is_not_a_cancel() {
924 let no_data: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
925 "status": "ok",
926 "response": {"type": "cancel"},
927 }))
928 .unwrap();
929 let answers = [
930 no_data,
931 cancel_answer(serde_json::json!([])),
932 cancel_answer(serde_json::json!([{"resting": {"oid": 1}}])),
933 ];
934 for answer in answers {
935 let outcome = cancel_outcome(answer.clone());
936 assert!(outcome.is_err(), "{answer:?} read as {outcome:?}");
937 }
938 }
939
940 #[test]
941 fn a_top_level_error_is_not_a_cancel() {
942 let answer: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
943 "status": "err",
944 "response": "User or API Wallet does not exist.",
945 }))
946 .unwrap();
947 assert_eq!(
948 cancel_outcome(answer),
949 Err("User or API Wallet does not exist.".to_owned())
950 );
951 }
952
953 #[test]
954 fn test_instrument_to_spot_coin() {
955 let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("PURR-USDC-SPOT"));
956 assert_eq!(coin, Some("PURR/USDC".to_string()));
957
958 let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("HYPE-USDC-SPOT"));
959 assert_eq!(coin, Some("HYPE/USDC".to_string()));
960
961 assert_eq!(
963 instrument_to_spot_coin(&InstrumentNameExchange::from("BTC-USDC-PERP")),
964 None
965 );
966 assert_eq!(
967 instrument_to_spot_coin(&InstrumentNameExchange::from("INVALID")),
968 None
969 );
970 }
971
972 #[test]
973 fn test_map_tif() {
974 assert_eq!(
975 map_tif(&TimeInForce::GoodUntilCancelled { post_only: false }),
976 "Gtc"
977 );
978 assert_eq!(
979 map_tif(&TimeInForce::GoodUntilCancelled { post_only: true }),
980 "Alo"
981 );
982 assert_eq!(map_tif(&TimeInForce::ImmediateOrCancel), "Ioc");
983 assert_eq!(map_tif(&TimeInForce::FillOrKill), "Ioc");
984 assert_eq!(map_tif(&TimeInForce::GoodUntilEndOfDay), "Gtc");
985 }
986
987 #[test]
988 fn test_millis_to_datetime() {
989 let dt = millis_to_datetime(1714100000000).unwrap();
990 assert_eq!(dt.timestamp_millis(), 1714100000000);
991
992 assert!(millis_to_datetime(0).is_some());
994 }
995}
996
997#[cfg(test)]
998#[allow(clippy::unwrap_used)]
1000pub(super) mod info_tests {
1001 use super::*;
1002 use crate::error::ApiError;
1003 use rust_decimal_macros::dec;
1004 use wiremock::matchers::{method, path};
1005 use wiremock::{Mock, MockServer, ResponseTemplate};
1006
1007 const DOCUMENTED_FILL: &str = r#"{
1010 "closedPnl": "0.0",
1011 "coin": "AVAX",
1012 "crossed": false,
1013 "dir": "Open Long",
1014 "hash": "0xa166e3fa63c25663024b03f2e0da011a00307e4017465df020210d3d432e7cb8",
1015 "oid": 90542681,
1016 "px": "18.435",
1017 "side": "B",
1018 "startPosition": "26.86",
1019 "sz": "93.53",
1020 "time": 1681222254710,
1021 "fee": "0.01",
1022 "feeToken": "USDC",
1023 "builderFee": "0.01",
1024 "tid": 118906512037719
1025 }"#;
1026
1027 pub(in crate::client::hyperliquid) async fn info_client_against(
1030 uri: String,
1031 ) -> hyperliquid_rust_sdk::InfoClient {
1032 let mut client = hyperliquid_rust_sdk::InfoClient::new(
1033 None,
1034 Some(hyperliquid_rust_sdk::BaseUrl::Localhost),
1035 )
1036 .await
1037 .unwrap();
1038 client.http_client.base_url = uri;
1039 client
1040 }
1041
1042 pub(in crate::client::hyperliquid) fn test_wallet() -> ethers::signers::LocalWallet {
1044 "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
1045 .parse()
1046 .unwrap()
1047 }
1048
1049 pub(in crate::client::hyperliquid) async fn exchange_client_against(
1052 uri: String,
1053 coin_to_asset: HashMap<String, u32>,
1054 ) -> hyperliquid_rust_sdk::ExchangeClient {
1055 hyperliquid_rust_sdk::ExchangeClient {
1056 http_client: info_client_against(uri).await.http_client,
1057 wallet: test_wallet(),
1058 meta: hyperliquid_rust_sdk::Meta {
1059 universe: Vec::new(),
1060 },
1061 vault_address: None,
1062 coin_to_asset,
1063 }
1064 }
1065
1066 pub(in crate::client::hyperliquid) fn cancel_answer(
1068 statuses: serde_json::Value,
1069 ) -> serde_json::Value {
1070 serde_json::json!({
1071 "status": "ok",
1072 "response": {"type": "cancel", "data": {"statuses": statuses}},
1073 })
1074 }
1075
1076 pub(in crate::client::hyperliquid) const CANCEL_NOT_APPLIED: &str =
1078 "Order was never placed, already canceled, or filled.";
1079
1080 async fn serve(body: String) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
1081 let server = MockServer::start().await;
1082 Mock::given(method("POST"))
1083 .and(path("/info"))
1084 .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
1085 .mount(&server)
1086 .await;
1087 let client = info_client_against(server.uri()).await;
1088 (server, client)
1089 }
1090
1091 #[tokio::test]
1092 async fn a_fill_carries_the_tid_and_fee_token_the_sdk_type_drops() {
1093 let (_server, client) = serve(format!("[{DOCUMENTED_FILL}]")).await;
1094
1095 let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1096 .await
1097 .unwrap();
1098
1099 assert_eq!(fills.len(), 1);
1100 assert_eq!(fills[0].tid, 118_906_512_037_719);
1101 assert_eq!(fills[0].fee_token.as_deref(), Some("USDC"));
1102 assert_eq!(fills[0].oid, 90_542_681);
1103 assert_eq!(fills[0].coin, "AVAX");
1104 }
1105
1106 #[tokio::test]
1107 async fn one_transaction_can_carry_several_fills_and_they_are_told_apart_by_tid() {
1108 let sweep = r#"[
1111 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1112 "hash":"0xsweep","oid":1,"px":"18.40","side":"B","startPosition":"0",
1113 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":111},
1114 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1115 "hash":"0xsweep","oid":1,"px":"18.45","side":"B","startPosition":"10",
1116 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":222},
1117 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1118 "hash":"0xsweep","oid":1,"px":"18.50","side":"B","startPosition":"20",
1119 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":333}
1120 ]"#;
1121 let (_server, client) = serve(sweep.to_string()).await;
1122
1123 let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1124 .await
1125 .unwrap();
1126
1127 let tids: Vec<u64> = fills.iter().map(|f| f.tid).collect();
1128 assert_eq!(tids, vec![111, 222, 333]);
1129 }
1130
1131 #[tokio::test]
1132 async fn a_missing_fee_token_falls_back_rather_than_failing() {
1133 let without = DOCUMENTED_FILL.replace(r#""feeToken": "USDC","#, "");
1136 let (_server, client) = serve(format!("[{without}]")).await;
1137
1138 let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1139 .await
1140 .unwrap();
1141
1142 assert_eq!(fills.len(), 1);
1143 assert!(fills[0].fee_token.is_none());
1144 }
1145
1146 #[tokio::test]
1147 async fn a_missing_tid_fails_loudly_instead_of_falling_back_to_the_hash() {
1148 let without = DOCUMENTED_FILL.replace(r#""tid": 118906512037719"#, r#""unused": 0"#);
1151 let (_server, client) = serve(format!("[{without}]")).await;
1152
1153 let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1154 .await
1155 .unwrap_err();
1156
1157 assert!(
1158 matches!(&error, UnindexedClientError::Internal(msg) if msg.contains("userFills")),
1159 "expected a parse failure naming the endpoint, got {error:?}"
1160 );
1161 }
1162
1163 #[tokio::test]
1164 async fn the_request_names_the_address_in_full() {
1165 let server = MockServer::start().await;
1168 Mock::given(method("POST"))
1169 .and(path("/info"))
1170 .and(wiremock::matchers::body_string_contains(
1171 "0x000000000000000000000000000000000000dead",
1172 ))
1173 .and(wiremock::matchers::body_string_contains("userFills"))
1174 .respond_with(ResponseTemplate::new(200).set_body_raw("[]", "application/json"))
1175 .mount(&server)
1176 .await;
1177 let client = info_client_against(server.uri()).await;
1178
1179 let address = "0x000000000000000000000000000000000000dEaD"
1180 .parse::<ethers::types::H160>()
1181 .unwrap();
1182
1183 assert!(
1186 user_fills_by_time(&client, address, 0, u64::MAX)
1187 .await
1188 .unwrap()
1189 .is_empty()
1190 );
1191 }
1192
1193 struct FillsByTime {
1196 fills: Vec<serde_json::Value>,
1197 }
1198
1199 impl wiremock::Respond for FillsByTime {
1200 fn respond(&self, request: &wiremock::Request) -> ResponseTemplate {
1201 let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
1202 assert_eq!(body["type"], "userFillsByTime");
1203 let bound = |name: &str| body[name].as_u64().unwrap();
1204 let (start, end) = (bound("startTime"), bound("endTime"));
1205 let page: Vec<_> = self
1206 .fills
1207 .iter()
1208 .filter(|fill| (start..=end).contains(&fill["time"].as_u64().unwrap()))
1209 .take(USER_FILLS_PER_RESPONSE)
1210 .cloned()
1211 .collect();
1212 ResponseTemplate::new(200).set_body_json(page)
1213 }
1214 }
1215
1216 fn fill(tid: u64, time: u64) -> serde_json::Value {
1218 let mut fill: serde_json::Value = serde_json::from_str(DOCUMENTED_FILL).unwrap();
1219 fill["tid"] = tid.into();
1220 fill["time"] = time.into();
1221 fill
1222 }
1223
1224 async fn serve_fills_by_time(
1225 fills: Vec<serde_json::Value>,
1226 ) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
1227 let server = MockServer::start().await;
1228 Mock::given(method("POST"))
1229 .and(path("/info"))
1230 .respond_with(FillsByTime { fills })
1231 .mount(&server)
1232 .await;
1233 let client = info_client_against(server.uri()).await;
1234 (server, client)
1235 }
1236
1237 #[tokio::test]
1241 async fn a_span_with_more_fills_than_one_response_is_read_whole() {
1242 let fills = (0..4_500).map(|tid| fill(tid, 1_000 + tid / 3)).collect();
1243 let (server, client) = serve_fills_by_time(fills).await;
1244
1245 let read = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1246 .await
1247 .unwrap();
1248 let tids: Vec<u64> = read.iter().map(|fill| fill.tid).collect();
1249 assert_eq!(tids, (0..4_500).collect::<Vec<_>>());
1250 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(3));
1251
1252 let read = user_fills_by_time(&client, ethers::types::H160::zero(), 1_010, 1_019)
1254 .await
1255 .unwrap();
1256 assert_eq!(read.len(), 30);
1257 }
1258
1259 #[tokio::test]
1261 async fn a_read_that_cannot_advance_is_truncated() {
1262 let fills = (0..USER_FILLS_PER_RESPONSE as u64)
1263 .map(|tid| fill(tid, 5))
1264 .collect();
1265 let (_server, client) = serve_fills_by_time(fills).await;
1266
1267 let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1268 .await
1269 .unwrap_err();
1270 assert!(
1271 matches!(error, UnindexedClientError::Truncated { fills_read: 2_000 }),
1272 "got {error:?}"
1273 );
1274 }
1275
1276 #[test]
1277 fn a_span_is_read_in_whole_milliseconds_and_an_empty_one_not_at_all() {
1278 let at = |s: &str| s.parse::<DateTime<Utc>>().unwrap();
1279 assert_eq!(
1280 span_millis(
1281 at("1970-01-01T00:00:01.000900Z"),
1282 at("1970-01-01T00:00:02.000900Z")
1283 ),
1284 Some((1_000, 2_000))
1285 );
1286 assert_eq!(
1287 span_millis(at("1969-12-31T23:59:59Z"), at("1970-01-01T00:00:01Z")),
1288 Some((0, 1_000))
1289 );
1290 assert_eq!(
1291 span_millis(at("1970-01-01T00:00:02Z"), at("1970-01-01T00:00:01Z")),
1292 None
1293 );
1294 assert_eq!(
1295 span_millis(at("1969-01-01T00:00:00Z"), at("1969-12-31T23:59:59Z")),
1296 None
1297 );
1298 }
1299
1300 const CID: &str = "85b60bff-64d5-49ec-95af-6268d7d1df63";
1303 const CLOID: &str = "0x85b60bff64d549ec95af6268d7d1df63";
1305
1306 #[test]
1308 fn a_user_fill_reads_its_cloid() {
1309 let fill = |cloid: &str| {
1310 serde_json::from_str::<UserFill>(&format!(
1311 r#"{{"coin": "BTC", "side": "B", "px": "1", "sz": "1", "time": 1, "oid": 7,
1312 "fee": "0", "tid": 9{cloid}}}"#
1313 ))
1314 .unwrap()
1315 };
1316
1317 let with = fill(&format!(r#", "cloid": "{CLOID}""#));
1318 assert_eq!(with.cloid.as_deref(), Some(CLOID));
1319 assert!(cloid_to_cid(CLOID).is_some());
1320 assert_eq!(fill("").cloid, None);
1321 assert_eq!(fill(r#", "cloid": null"#).cloid, None);
1322 }
1323
1324 #[test]
1325 fn only_the_canonical_uuid_form_can_be_a_cloid() {
1326 let cloid = cid_to_cloid(&ClientOrderId::new(CID)).unwrap();
1327 assert_eq!(cloid.to_string(), CID);
1328
1329 for other in [
1331 "85B60BFF-64D5-49EC-95AF-6268D7D1DF63",
1332 "85b60bff64d549ec95af6268d7d1df63",
1333 "{85b60bff-64d5-49ec-95af-6268d7d1df63}",
1334 "urn:uuid:85b60bff-64d5-49ec-95af-6268d7d1df63",
1335 "example-1714100000000",
1336 "",
1337 ] {
1338 assert_eq!(cid_to_cloid(&ClientOrderId::new(other)), None, "{other}");
1339 }
1340 }
1341
1342 #[test]
1343 fn a_generated_uuid_client_id_is_always_a_cloid() {
1344 for _ in 0..32 {
1345 let cid = ClientOrderId::uuid();
1346 assert!(cid_to_cloid(&cid).is_some(), "{cid}");
1347 }
1348 }
1349
1350 #[test]
1351 fn the_reported_cloid_is_the_client_id_the_order_was_placed_under() {
1352 assert_eq!(cloid_to_cid(CLOID), Some(ClientOrderId::new(CID)));
1353
1354 let cid = ClientOrderId::uuid();
1356 let sent = format!("0x{}", cid_to_cloid(&cid).unwrap().simple());
1357 assert_eq!(cloid_to_cid(&sent), Some(cid));
1358 }
1359
1360 #[test]
1361 fn a_cloid_not_in_the_venue_form_names_no_client_id() {
1362 for malformed in [
1363 CID,
1364 "85b60bff64d549ec95af6268d7d1df63",
1365 "0x85b60bff64d549ec95af6268d7d1df",
1366 "0x85b60bff64d549ec95af6268d7d1df6300",
1367 "0x85b60bff-64d5-49ec-95af-6268d7d1df63",
1368 "0xzzb60bff64d549ec95af6268d7d1df63",
1369 "0x",
1370 ] {
1371 assert_eq!(cloid_to_cid(malformed), None, "{malformed}");
1372 }
1373 }
1374
1375 #[test]
1376 fn a_record_without_a_cloid_is_reported_under_its_oid() {
1377 assert_eq!(record_cid(None, 42), Some(ClientOrderId::new("42")));
1378 assert_eq!(record_cid(Some(CLOID), 42), Some(ClientOrderId::new(CID)));
1379 assert_eq!(record_cid(Some("0x1234"), 42), None);
1380 }
1381
1382 #[test]
1385 fn every_documented_order_status_is_classified() {
1386 use OrderStatus::*;
1387 for (status, expected) in [
1388 ("open", Open),
1389 ("triggered", Open),
1390 ("filled", Filled),
1391 ("canceled", Cancelled),
1392 ("marginCanceled", Cancelled),
1393 ("vaultWithdrawalCanceled", Cancelled),
1394 ("openInterestCapCanceled", Cancelled),
1395 ("selfTradeCanceled", Cancelled),
1396 ("reduceOnlyCanceled", Cancelled),
1397 ("siblingFilledCanceled", Cancelled),
1398 ("delistedCanceled", Cancelled),
1399 ("liquidatedCanceled", Cancelled),
1400 ("scheduledCancel", Cancelled),
1401 ("rejected", Rejected),
1402 ("tickRejected", Rejected),
1403 ("minTradeNtlRejected", Rejected),
1404 ("perpMarginRejected", Rejected),
1405 ("reduceOnlyRejected", Rejected),
1406 ("badAloPxRejected", Rejected),
1407 ("iocCancelRejected", Rejected),
1408 ("badTriggerPxRejected", Rejected),
1409 ("marketOrderNoLiquidityRejected", Rejected),
1410 ("positionIncreaseAtOpenInterestCapRejected", Rejected),
1411 ("positionFlipAtOpenInterestCapRejected", Rejected),
1412 ("tooAggressiveAtOpenInterestCapRejected", Rejected),
1413 ("openInterestIncreaseRejected", Rejected),
1414 ("insufficientSpotBalanceRejected", Rejected),
1415 ("oracleRejected", Rejected),
1416 ("perpMaxPositionRejected", Rejected),
1417 ] {
1418 assert_eq!(OrderStatus::classify(status), Some(expected), "{status}");
1419 }
1420 assert_eq!(OrderStatus::classify("unknown_status"), None);
1421 }
1422
1423 fn order_update(status: &str, cloid: &str, sz: &str) -> hyperliquid_rust_sdk::OrderUpdate {
1424 serde_json::from_str(&format!(
1425 r#"{{"order": {{"coin": "ETH", "side": "B", "limitPx": "1605.0", "sz": "{sz}",
1426 "oid": 7001, "timestamp": 1714100000000, "origSz": "0.0075", "cloid": {cloid}}},
1427 "status": "{status}", "statusTimestamp": 1714100001000}}"#
1428 ))
1429 .unwrap()
1430 }
1431
1432 fn snapshot_of(event: UnindexedAccountEvent) -> UnindexedOrderSnapshot {
1433 match event.kind {
1434 crate::AccountEventKind::OrderSnapshot(snapshot) => snapshot.0,
1435 other => panic!("expected an order snapshot, got {other:?}"),
1436 }
1437 }
1438
1439 fn eth() -> InstrumentNameExchange {
1440 InstrumentNameExchange::from("ETH-USDC-PERP")
1441 }
1442
1443 #[test]
1444 fn an_order_update_names_the_order_by_the_id_it_was_placed_under() {
1445 let update = order_update("open", &format!(r#""{CLOID}""#), "0.005");
1446 let order = snapshot_of(
1447 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1448 );
1449
1450 assert_eq!(order.key.cid, ClientOrderId::new(CID));
1451 assert_eq!(order.quantity, dec!(0.0075));
1452 match order.state {
1453 OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) => {
1454 assert_eq!(open.id, VenueOrderId::Assigned(OrderId::new("7001")));
1455 assert_eq!(open.filled_quantity, dec!(0.0025));
1456 }
1457 other => panic!("expected Open, got {other:?}"),
1458 }
1459 }
1460
1461 #[test]
1462 fn an_order_update_without_a_cloid_is_named_by_its_oid() {
1463 let update = order_update("open", "null", "0.0075");
1464 let order = snapshot_of(
1465 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1466 );
1467 assert_eq!(order.key.cid, ClientOrderId::new("7001"));
1468 }
1469
1470 #[test]
1471 fn an_order_update_with_a_malformed_cloid_is_dropped() {
1472 let update = order_update("open", r#""0x1234""#, "0.0075");
1473 assert!(
1474 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).is_none()
1475 );
1476 }
1477
1478 #[test]
1479 fn a_cancellation_for_any_reason_ends_the_order() {
1480 let update = order_update("selfTradeCanceled", &format!(r#""{CLOID}""#), "0.005");
1481 let order = snapshot_of(
1482 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1483 );
1484 match order.state {
1485 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1486 assert_eq!(cancelled.filled_quantity, Some(dec!(0.0025)));
1487 }
1488 other => panic!("expected Cancelled, got {other:?}"),
1489 }
1490 }
1491
1492 #[test]
1495 fn a_cancellation_with_an_unparseable_size_ends_the_order_with_its_fill_unknown() {
1496 let update = order_update("canceled", &format!(r#""{CLOID}""#), "not a number");
1497 let order = snapshot_of(
1498 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1499 );
1500 match order.state {
1501 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1502 assert_eq!(cancelled.filled_quantity, None);
1503 }
1504 other => panic!("expected Cancelled, got {other:?}"),
1505 }
1506 }
1507
1508 #[test]
1509 fn a_status_saying_funds_ran_short_ends_the_order_as_balance_insufficient() {
1510 for status in ["perpMarginRejected", "insufficientSpotBalanceRejected"] {
1511 let update = order_update(status, &format!(r#""{CLOID}""#), "0.0075");
1512 let order = snapshot_of(
1513 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1514 );
1515 match order.state {
1516 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
1517 OrderError::Rejected(ApiError::BalanceInsufficient(None, reason)),
1518 )) => assert_eq!(reason, status),
1519 other => panic!("{status}: expected BalanceInsufficient, got {other:?}"),
1520 }
1521 }
1522 }
1523
1524 #[test]
1525 fn a_rejection_ends_the_order_and_names_the_reason() {
1526 let update = order_update("badAloPxRejected", &format!(r#""{CLOID}""#), "0.0075");
1527 let order = snapshot_of(
1528 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1529 );
1530 match order.state {
1531 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
1532 OrderError::Rejected(ApiError::OrderRejected(reason)),
1533 )) => assert_eq!(reason, "badAloPxRejected"),
1534 other => panic!("expected a rejection, got {other:?}"),
1535 }
1536 }
1537
1538 const OPEN_ORDERS: &str = r#"[
1543 {"coin":"ETH","side":"B","limitPx":"1605.0","sz":"0.005","oid":7001,
1544 "timestamp":1714100000000,"origSz":"0.0075","cloid":"0x85b60bff64d549ec95af6268d7d1df63"},
1545 {"coin":"ETH","side":"A","limitPx":"1700.0","sz":"0.01","oid":7002,
1546 "timestamp":1714100000000,"origSz":"0.01"},
1547 {"coin":"BTC","side":"A","limitPx":"50000.0","sz":"0.001","oid":7003,
1548 "timestamp":1714100000000}
1549 ]"#;
1550
1551 #[tokio::test]
1552 async fn open_orders_carry_the_cloid_and_original_size_the_sdk_type_drops() {
1553 let (_server, client) = serve(OPEN_ORDERS.to_string()).await;
1554
1555 let rows = open_orders(&client, ethers::types::H160::zero())
1556 .await
1557 .unwrap();
1558
1559 assert_eq!(rows.len(), 3);
1560 assert_eq!(rows[0].cloid.as_deref(), Some(CLOID));
1561 assert_eq!(rows[0].orig_sz.as_deref(), Some("0.0075"));
1562 assert_eq!(rows[1].cloid, None);
1563 assert_eq!(rows[2].orig_sz, None);
1564 }
1565
1566 fn rows() -> Vec<OpenOrder> {
1567 serde_json::from_str(OPEN_ORDERS).unwrap()
1568 }
1569
1570 fn perp(coin: &str) -> Option<InstrumentNameExchange> {
1571 Some(InstrumentNameExchange::from(format!("{coin}-USDC-PERP")))
1572 }
1573
1574 #[test]
1575 fn an_open_order_is_listed_under_its_client_id_with_its_fill_so_far() {
1576 let order = open_order_to_order(&rows()[0], ExchangeId::HyperliquidPerp, eth()).unwrap();
1577
1578 assert_eq!(order.key.cid, ClientOrderId::new(CID));
1579 assert_eq!(order.quantity, dec!(0.0075));
1580 assert_eq!(order.state.filled_quantity, dec!(0.0025));
1581 assert_eq!(order.state.id, VenueOrderId::Assigned(OrderId::new("7001")));
1582 }
1583
1584 #[test]
1585 fn an_open_order_without_its_original_size_is_its_remainder_with_nothing_filled() {
1586 let row = &rows()[2];
1587 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1588 let order = open_order_to_order(row, ExchangeId::HyperliquidPerp, btc).unwrap();
1589
1590 assert_eq!(order.key.cid, ClientOrderId::new("7003"));
1591 assert_eq!(order.quantity, dec!(0.001));
1592 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
1593 }
1594
1595 #[test]
1596 fn every_requested_instrument_is_listed_complete_even_with_nothing_open() {
1597 let sol = InstrumentNameExchange::from("SOL-USDC-PERP");
1598 let mut listing = OpenOrderListing::new(
1599 &rows(),
1600 ExchangeId::HyperliquidPerp,
1601 &[eth(), sol.clone()],
1602 perp,
1603 );
1604
1605 let eth_entry = listing.remove(ð()).unwrap();
1606 assert!(eth_entry.orders_complete);
1607 assert_eq!(eth_entry.orders.len(), 2);
1608
1609 let sol_entry = listing.remove(&sol).unwrap();
1611 assert!(sol_entry.orders_complete);
1612 assert!(sol_entry.orders.is_empty());
1613
1614 assert_eq!(listing.into_snapshots().count(), 0);
1616 }
1617
1618 #[test]
1619 fn an_unfiltered_listing_covers_every_instrument_with_open_orders() {
1620 let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidPerp, &[], perp);
1621 let mut instruments = listing
1622 .into_snapshots()
1623 .map(|entry| entry.instrument.to_string())
1624 .collect::<Vec<_>>();
1625 instruments.sort();
1626 assert_eq!(instruments, ["BTC-USDC-PERP", "ETH-USDC-PERP"]);
1627 }
1628
1629 #[test]
1630 fn an_order_that_does_not_convert_leaves_only_its_instrument_incomplete() {
1631 let mut rows = rows();
1632 rows[1].side = "?".to_string();
1633 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1634 let mut listing = OpenOrderListing::new(
1635 &rows,
1636 ExchangeId::HyperliquidPerp,
1637 &[eth(), btc.clone()],
1638 perp,
1639 );
1640
1641 let eth_entry = listing.remove(ð()).unwrap();
1642 assert!(!eth_entry.orders_complete);
1643 assert_eq!(eth_entry.orders.len(), 1);
1644 assert!(listing.remove(&btc).unwrap().orders_complete);
1645 }
1646
1647 #[test]
1648 fn an_order_with_a_malformed_cloid_leaves_its_instrument_incomplete() {
1649 let mut rows = rows();
1650 rows[0].cloid = Some("0x1234".to_string());
1651 let mut listing = OpenOrderListing::new(&rows, ExchangeId::HyperliquidPerp, &[eth()], perp);
1652
1653 let eth_entry = listing.remove(ð()).unwrap();
1654 assert!(!eth_entry.orders_complete);
1655 assert_eq!(eth_entry.orders.len(), 1);
1656 }
1657
1658 #[test]
1659 fn coins_the_client_does_not_trade_are_left_out() {
1660 let pairs = super::super::spot_coins::test_spot_pairs();
1661 let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidSpot, &[], |coin| {
1662 pairs.get(coin).map(spot_pair_to_instrument)
1663 });
1664 assert_eq!(listing.into_snapshots().count(), 0);
1665 }
1666}