1use crate::client::hyperliquid::error::map_sdk_error;
9use crate::error::{ApiError, 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 futures::Stream;
22use hyperliquid_rust_sdk::{ExchangeDataStatus, ExchangeResponseStatus};
23use rust_decimal::Decimal;
24use rustrade_instrument::{
25 Side,
26 asset::name::AssetNameExchange,
27 exchange::ExchangeId,
28 hyperliquid::{CoinKind, SpotPair},
29 instrument::name::InstrumentNameExchange,
30};
31use serde::de::DeserializeOwned;
32use smol_str::format_smolstr;
33use std::collections::{BTreeSet, HashMap, HashSet};
34use std::pin::Pin;
35use std::str::FromStr;
36use std::task::{Context, Poll};
37use tokio_util::sync::CancellationToken;
38use tracing::warn;
39use uuid::Uuid;
40
41#[derive(Debug)]
46pub struct CancelOnDropStream<S> {
47 inner: S,
48 cancel_token: CancellationToken,
49}
50
51impl<S> CancelOnDropStream<S> {
52 pub(crate) fn new(inner: S, cancel_token: CancellationToken) -> Self {
54 Self {
55 inner,
56 cancel_token,
57 }
58 }
59}
60
61impl<S: Stream + Unpin> Stream for CancelOnDropStream<S> {
62 type Item = S::Item;
63
64 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
65 Pin::new(&mut self.inner).poll_next(cx)
66 }
67}
68
69impl<S> Drop for CancelOnDropStream<S> {
70 fn drop(&mut self) {
71 self.cancel_token.cancel();
72 }
73}
74
75#[derive(Debug, Clone, serde::Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct UserFill {
94 pub coin: String,
95 pub side: String,
96 pub px: String,
97 pub sz: String,
98 pub time: u64,
99 pub oid: u64,
100 pub fee: String,
101
102 pub tid: u64,
113
114 pub fee_token: Option<String>,
120}
121
122pub(super) fn span_millis(start: DateTime<Utc>, end: DateTime<Utc>) -> Option<(u64, u64)> {
126 if start > end {
127 return None;
128 }
129 let end_ms = u64::try_from(end.timestamp_millis()).ok()?;
130 let start_ms = u64::try_from(start.timestamp_millis()).unwrap_or(0);
131 Some((start_ms, end_ms))
132}
133
134const USER_FILLS_PER_RESPONSE: usize = 2_000;
136
137pub async fn user_fills_by_time(
157 info_client: &hyperliquid_rust_sdk::InfoClient,
158 address: ethers::types::H160,
159 start_ms: u64,
160 end_ms: u64,
161) -> Result<Vec<UserFill>, UnindexedClientError> {
162 let mut fills: Vec<UserFill> = Vec::new();
163 let mut from = start_ms;
164 loop {
165 let body = format!(
167 r#"{{"type":"userFillsByTime","user":"{address:?}","startTime":{from},"endTime":{end_ms}}}"#
168 );
169 let page: Vec<UserFill> = info(info_client, "userFillsByTime", body).await?;
170 let full = page.len() >= USER_FILLS_PER_RESPONSE;
171 let Some(last_time) = page.last().map(|fill| fill.time) else {
172 break;
173 };
174 let seen: Vec<(String, u64)> = fills
177 .iter()
178 .rev()
179 .take_while(|fill| fill.time == from)
180 .map(|fill| (fill.coin.clone(), fill.tid))
181 .collect();
182 fills.extend(page.into_iter().filter(|fill| {
183 fill.time != from
184 || !seen
185 .iter()
186 .any(|(coin, tid)| *tid == fill.tid && *coin == fill.coin)
187 }));
188 if !full {
189 break;
190 }
191 if last_time <= from {
192 return Err(UnindexedClientError::Truncated {
193 fills_read: fills.len(),
194 });
195 }
196 from = last_time;
197 }
198 Ok(fills)
199}
200
201#[derive(Debug, Clone, serde::Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct OpenOrder {
213 pub coin: String,
214 pub side: String,
215 pub limit_px: String,
216 pub sz: String,
218 pub oid: u64,
219 pub timestamp: u64,
220
221 #[serde(default)]
227 pub orig_sz: Option<String>,
228
229 #[serde(default)]
232 pub cloid: Option<String>,
233}
234
235pub async fn open_orders(
243 info_client: &hyperliquid_rust_sdk::InfoClient,
244 address: ethers::types::H160,
245) -> Result<Vec<OpenOrder>, UnindexedClientError> {
246 user_info(info_client, "openOrders", address, None).await
247}
248
249pub(super) async fn user_info<T: DeserializeOwned>(
256 info_client: &hyperliquid_rust_sdk::InfoClient,
257 kind: &str,
258 address: ethers::types::H160,
259 dex: Option<&str>,
260) -> Result<T, UnindexedClientError> {
261 let mut body = serde_json::json!({"type": kind, "user": format!("{address:?}")});
264 if let Some(dex) = dex {
265 body["dex"] = dex.into();
266 }
267 info(info_client, kind, body.to_string()).await
268}
269
270pub(super) async fn info<T: DeserializeOwned>(
272 info_client: &hyperliquid_rust_sdk::InfoClient,
273 kind: &str,
274 body: String,
275) -> Result<T, UnindexedClientError> {
276 let raw = info_client
277 .http_client
278 .post("/info", body)
279 .await
280 .map_err(map_sdk_error)?;
281
282 serde_json::from_str(&raw).map_err(|e| {
285 UnindexedClientError::Internal(format!("Hyperliquid {kind} response did not parse: {e}"))
286 })
287}
288
289pub(super) fn spot_balances<'a>(
291 balances: impl IntoIterator<Item = &'a hyperliquid_rust_sdk::UserTokenBalance>,
292 now: DateTime<Utc>,
293) -> impl Iterator<Item = AssetBalance<AssetNameExchange>> {
294 balances.into_iter().map(move |balance| {
295 let total = parse_decimal(&balance.total, "total").unwrap_or(Decimal::ZERO);
296 let hold = parse_decimal(&balance.hold, "hold").unwrap_or(Decimal::ZERO);
297 let free = (total - hold).max(Decimal::ZERO);
298 AssetBalance::new(
299 AssetNameExchange::from(balance.coin.as_str()),
300 Balance::new(total, free),
301 now,
302 )
303 })
304}
305
306pub(super) fn open_order_to_order(
311 row: &OpenOrder,
312 exchange: ExchangeId,
313 instrument: InstrumentNameExchange,
314) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
315 let parsed = (|| {
316 let cid = record_cid(row.cloid.as_deref(), row.oid)?;
317 let side = parse_side(&row.side)?;
318 let price = parse_decimal(&row.limit_px, "limitPx")?;
319 let remaining = parse_decimal(&row.sz, "sz")?;
320 let quantity = match &row.orig_sz {
321 Some(orig_sz) => parse_decimal(orig_sz, "origSz")?,
322 None => remaining,
323 };
324 let time_exchange = millis_to_datetime(row.timestamp)?;
325 Some((cid, side, price, quantity, remaining, time_exchange))
326 })();
327
328 let Some((cid, side, price, quantity, remaining, time_exchange)) = parsed else {
329 warn!(
330 %exchange,
331 %instrument,
332 oid = row.oid,
333 order = ?row,
334 "Hyperliquid open order did not convert - leaving it out, so this instrument's order \
335 list is not complete"
336 );
337 return None;
338 };
339
340 Some(Order {
341 key: OrderKey {
342 exchange,
343 instrument,
344 strategy: StrategyId::unknown(),
345 cid,
346 },
347 side,
348 price: Some(price),
349 quantity,
350 kind: OrderKind::Limit,
352 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
353 state: Open {
354 id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", row.oid))),
355 time_exchange,
356 filled_quantity: (quantity - remaining).max(Decimal::ZERO),
357 },
358 })
359}
360
361type UnindexedInstrumentAccountSnapshot =
362 InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
363
364pub(super) struct OpenOrderListing(
366 HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
367);
368
369impl OpenOrderListing {
370 pub(super) fn new(
383 rows: &[OpenOrder],
384 exchange: ExchangeId,
385 requested: &[InstrumentNameExchange],
386 to_instrument: impl Fn(&str) -> Option<InstrumentNameExchange>,
387 ) -> Self {
388 fn entry<'a>(
389 listing: &'a mut HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
390 instrument: &InstrumentNameExchange,
391 ) -> &'a mut UnindexedInstrumentAccountSnapshot {
392 listing
393 .entry(instrument.clone())
394 .or_insert_with(|| InstrumentAccountSnapshot {
395 instrument: instrument.clone(),
396 orders: Vec::new(),
397 orders_complete: true,
398 position: PositionReport::Unreported,
399 isolated: None,
400 })
401 }
402
403 let requested_set = requested.iter().collect::<HashSet<_>>();
404 let mut listing = HashMap::with_capacity(requested.len());
405
406 for instrument in requested {
407 entry(&mut listing, instrument);
408 }
409
410 for row in rows {
411 let Some(instrument) = to_instrument(&row.coin) else {
412 continue;
413 };
414 if !requested_set.is_empty() && !requested_set.contains(&instrument) {
415 continue;
416 }
417 let order = open_order_to_order(row, exchange, instrument.clone());
418 let snapshot = entry(&mut listing, &instrument);
419 match order {
420 Some(order) => snapshot.orders.push(order.into()),
421 None => snapshot.orders_complete = false,
422 }
423 }
424
425 Self(listing)
426 }
427
428 pub(super) fn remove(
430 &mut self,
431 instrument: &InstrumentNameExchange,
432 ) -> Option<UnindexedInstrumentAccountSnapshot> {
433 self.0.remove(instrument)
434 }
435
436 pub(super) fn into_snapshots(self) -> impl Iterator<Item = UnindexedInstrumentAccountSnapshot> {
438 self.0.into_values()
439 }
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
444enum OrderStatus {
445 Open,
446 Filled,
447 Cancelled,
448 Rejected,
449}
450
451impl OrderStatus {
452 fn classify(status: &str) -> Option<Self> {
463 match status {
464 "open" | "triggered" => Some(Self::Open),
465 "filled" => Some(Self::Filled),
466 "canceled" | "scheduledCancel" => Some(Self::Cancelled),
467 "rejected" => Some(Self::Rejected),
468 status if status.ends_with("Canceled") => Some(Self::Cancelled),
469 status if status.ends_with("Rejected") => Some(Self::Rejected),
470 _ => None,
471 }
472 }
473}
474
475pub(super) fn order_update_to_account_event(
481 update: &hyperliquid_rust_sdk::OrderUpdate,
482 exchange: ExchangeId,
483 instrument: InstrumentNameExchange,
484) -> Option<UnindexedAccountEvent> {
485 let snapshot = order_update_to_order(update, exchange, instrument)?;
486 Some(crate::AccountEvent::new(
487 exchange,
488 crate::AccountEventKind::OrderSnapshot(
489 rustrade_integration::collection::snapshot::Snapshot(snapshot),
490 ),
491 ))
492}
493
494pub(super) fn order_update_to_order(
500 update: &hyperliquid_rust_sdk::OrderUpdate,
501 exchange: ExchangeId,
502 instrument: InstrumentNameExchange,
503) -> Option<UnindexedOrderSnapshot> {
504 let order = &update.order;
505 let Some(status) = OrderStatus::classify(&update.status) else {
506 warn!(%exchange, status = %update.status, oid = order.oid, "Unknown Hyperliquid order status - ignoring the update");
507 return None;
508 };
509 let cid = record_cid(order.cloid.as_deref(), order.oid)?;
510 let side = parse_side(&order.side)?;
511 let price = parse_decimal(&order.limit_px, "order.limitPx")?;
512 let orig_sz = parse_decimal(&order.orig_sz, "order.origSz")?;
513 let Some(time_exchange) = millis_to_datetime(update.status_timestamp) else {
514 warn!(%exchange, oid = order.oid, status_timestamp = update.status_timestamp, "Invalid Hyperliquid order update timestamp - ignoring the update");
515 return None;
516 };
517 let order_id = OrderId(format_smolstr!("{}", order.oid));
518 let filled_quantity = || {
519 parse_decimal(&order.sz, "order.sz")
520 .map(|remaining| (orig_sz - remaining).max(Decimal::ZERO))
521 };
522
523 let state = match status {
524 OrderStatus::Open => OrderState::active(Open {
525 id: VenueOrderId::Assigned(order_id),
526 time_exchange,
527 filled_quantity: filled_quantity()?,
528 }),
529 OrderStatus::Filled => OrderState::fully_filled(Filled::new(
530 order_id,
531 time_exchange,
532 orig_sz,
533 None, )),
535 OrderStatus::Cancelled => {
537 OrderState::inactive(Cancelled::new(order_id, time_exchange, filled_quantity()))
538 }
539 OrderStatus::Rejected => OrderState::inactive(OrderError::Rejected(
540 ApiError::OrderRejected(update.status.clone()),
541 )),
542 };
543
544 Some(Order {
546 key: OrderKey {
547 exchange,
548 instrument,
549 strategy: StrategyId::unknown(),
550 cid,
551 },
552 side,
553 price: Some(price),
554 quantity: orig_sz,
555 kind: OrderKind::Limit,
556 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
557 state,
558 })
559}
560
561pub fn parse_decimal(value: &str, field: &str) -> Option<Decimal> {
562 Decimal::from_str(value)
563 .map_err(|e| warn!(%field, %value, %e, "Failed to parse decimal"))
564 .ok()
565}
566
567pub fn parse_side(side: &str) -> Option<Side> {
572 match side {
573 "B" | "b" | "BUY" | "Buy" | "buy" => Some(Side::Buy),
574 "A" | "a" | "S" | "s" | "SELL" | "Sell" | "sell" => Some(Side::Sell),
575 _ => {
576 warn!(%side, "Unknown side string");
577 None
578 }
579 }
580}
581
582pub fn millis_to_datetime(millis: u64) -> Option<DateTime<Utc>> {
586 Utc.timestamp_millis_opt(i64::try_from(millis).ok()?)
587 .single()
588}
589
590pub fn map_tif(tif: &TimeInForce) -> &'static str {
592 match tif {
593 TimeInForce::GoodUntilCancelled { post_only: true } => "Alo",
594 TimeInForce::GoodUntilCancelled { post_only: false } => "Gtc",
595 TimeInForce::ImmediateOrCancel => "Ioc",
596 TimeInForce::FillOrKill => "Ioc", TimeInForce::GoodUntilEndOfDay => "Gtc", TimeInForce::GoodTillDate { .. } | TimeInForce::AtOpen | TimeInForce::AtClose => {
602 warn!(time_in_force = ?tif, "Hyperliquid does not support this TimeInForce; coercing to Gtc");
603 "Gtc"
604 }
605 }
606}
607
608pub(super) const CLOID_REQUIRED: &str = "Hyperliquid requires the client order id to be a UUID in \
610 canonical form, as ClientOrderId::uuid() makes: the venue reports each order under that id, \
611 and an order under any other id could not be matched to its own updates";
612
613pub fn cid_to_cloid(cid: &ClientOrderId) -> Option<Uuid> {
621 let uuid = Uuid::try_parse(cid.0.as_str()).ok()?;
622 let mut canonical = Uuid::encode_buffer();
623 (uuid.hyphenated().encode_lower(&mut canonical) == cid.0.as_str()).then_some(uuid)
624}
625
626pub fn cloid_to_cid(cloid: &str) -> Option<ClientOrderId> {
630 let hex = cloid.strip_prefix("0x")?;
631 if hex.len() != 32 {
632 return None;
633 }
634 let uuid = Uuid::try_parse(hex).ok()?;
636 Some(ClientOrderId::new(format_smolstr!("{}", uuid.hyphenated())))
637}
638
639pub(super) fn cancel_outcome(response: ExchangeResponseStatus) -> Result<(), String> {
652 let response = match response {
653 ExchangeResponseStatus::Ok(response) => response,
654 ExchangeResponseStatus::Err(reason) => return Err(reason),
655 };
656 match response
657 .data
658 .and_then(|data| data.statuses.into_iter().next())
659 {
660 Some(ExchangeDataStatus::Success) => Ok(()),
661 Some(ExchangeDataStatus::Error(reason)) => Err(reason),
662 Some(status) => Err(format!("cancel answered with status {status:?}")),
663 None => Err("cancel answered without a status for the order".to_owned()),
664 }
665}
666
667pub(super) fn record_cid(cloid: Option<&str>, oid: u64) -> Option<ClientOrderId> {
677 match cloid {
678 None => Some(ClientOrderId::new(format_smolstr!("{oid}"))),
679 Some(cloid) => {
680 let cid = cloid_to_cid(cloid);
681 if cid.is_none() {
682 warn!(%cloid, oid, "Hyperliquid order carries a cloid that is not 0x and 32 hex digits");
683 }
684 cid
685 }
686 }
687}
688
689fn is_unknown_coin(coin: &str) -> bool {
691 !matches!(CoinKind::of(coin), CoinKind::Perp | CoinKind::Spot)
692}
693
694pub(super) fn warn_unknown_coins<'a>(coins: impl IntoIterator<Item = &'a str>) {
697 let unknown: BTreeSet<&str> = coins
698 .into_iter()
699 .filter(|coin| is_unknown_coin(coin))
700 .collect();
701 if !unknown.is_empty() {
702 warn!(
703 ?unknown,
704 "Hyperliquid coins name markets of an unrecognised kind; leaving them out"
705 );
706 }
707}
708
709#[derive(Debug, Default)]
712pub(super) struct UnknownCoins(HashSet<String>);
713
714impl UnknownCoins {
715 pub(super) fn warn_once(&mut self, coin: &str) {
717 if is_unknown_coin(coin) && !self.0.contains(coin) {
718 warn!(
719 %coin,
720 "Hyperliquid coin names a market of an unrecognised kind; leaving it out \
721 (logged once per stream)"
722 );
723 self.0.insert(coin.to_owned());
724 }
725 }
726}
727
728pub fn spot_pair_to_instrument(pair: &SpotPair) -> InstrumentNameExchange {
731 InstrumentNameExchange::from(format_smolstr!("{}-{}-SPOT", pair.base(), pair.quote()))
732}
733
734pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
743 let (base, quote) = spot_base_quote(instrument)?;
744 Some(format!("{base}/{quote}"))
745}
746
747pub(super) fn spot_base_quote(instrument: &InstrumentNameExchange) -> Option<(&str, &str)> {
750 instrument.as_ref().strip_suffix("-SPOT")?.split_once('-')
751}
752
753#[cfg(test)]
754#[allow(clippy::unwrap_used)]
756mod tests {
757 use super::*;
758 use rust_decimal_macros::dec;
759
760 #[test]
761 fn test_parse_decimal_valid() {
762 assert_eq!(parse_decimal("123.456", "test"), Some(dec!(123.456)));
763 assert_eq!(parse_decimal("0", "test"), Some(dec!(0)));
764 assert_eq!(parse_decimal("-50.5", "test"), Some(dec!(-50.5)));
765 }
766
767 #[test]
768 fn test_parse_decimal_invalid() {
769 assert_eq!(parse_decimal("", "test"), None);
770 assert_eq!(parse_decimal("abc", "test"), None);
771 assert_eq!(parse_decimal("12.34.56", "test"), None);
772 }
773
774 #[test]
775 fn test_parse_side() {
776 assert_eq!(parse_side("B"), Some(Side::Buy));
777 assert_eq!(parse_side("BUY"), Some(Side::Buy));
778 assert_eq!(parse_side("buy"), Some(Side::Buy));
779 assert_eq!(parse_side("A"), Some(Side::Sell));
780 assert_eq!(parse_side("S"), Some(Side::Sell));
781 assert_eq!(parse_side("SELL"), Some(Side::Sell));
782 assert_eq!(parse_side("sell"), Some(Side::Sell));
783 assert_eq!(parse_side("X"), None);
784 assert_eq!(parse_side(""), None);
785 }
786
787 #[test]
788 fn test_spot_pair_to_instrument() {
789 let pairs = super::super::spot_coins::test_spot_pairs();
790 let instrument = |coin| spot_pair_to_instrument(pairs.get(coin).unwrap());
791
792 assert_eq!(instrument("PURR/USDC").as_ref(), "PURR-USDC-SPOT");
793 assert_eq!(instrument("@107").as_ref(), "HYPE-USDC-SPOT");
794 assert_eq!(instrument("@207").as_ref(), "HYPE-USDT0-SPOT");
795 }
796
797 #[test]
798 fn unknown_coins_are_recorded_once_and_known_ones_never() {
799 let mut unknown = UnknownCoins::default();
800 for coin in ["#12", "#12", "BTC", "@107", "PURR/USDC", "+3"] {
801 unknown.warn_once(coin);
802 }
803 assert_eq!(
804 unknown.0,
805 HashSet::from(["#12".to_owned(), "+3".to_owned()])
806 );
807 }
808
809 fn cancel_answer(statuses: serde_json::Value) -> ExchangeResponseStatus {
811 serde_json::from_value(info_tests::cancel_answer(statuses)).unwrap()
812 }
813
814 #[test]
815 fn only_a_success_status_is_a_cancel() {
816 assert_eq!(
817 cancel_outcome(cancel_answer(serde_json::json!(["success"]))),
818 Ok(())
819 );
820 }
821
822 #[test]
824 fn an_error_status_under_a_top_level_ok_is_not_a_cancel() {
825 let answer = cancel_answer(serde_json::json!([
826 {"error": info_tests::CANCEL_NOT_APPLIED}
827 ]));
828 assert_eq!(
829 cancel_outcome(answer),
830 Err(info_tests::CANCEL_NOT_APPLIED.to_owned())
831 );
832 }
833
834 #[test]
835 fn an_answer_without_a_success_status_is_not_a_cancel() {
836 let no_data: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
837 "status": "ok",
838 "response": {"type": "cancel"},
839 }))
840 .unwrap();
841 let answers = [
842 no_data,
843 cancel_answer(serde_json::json!([])),
844 cancel_answer(serde_json::json!([{"resting": {"oid": 1}}])),
845 ];
846 for answer in answers {
847 let outcome = cancel_outcome(answer.clone());
848 assert!(outcome.is_err(), "{answer:?} read as {outcome:?}");
849 }
850 }
851
852 #[test]
853 fn a_top_level_error_is_not_a_cancel() {
854 let answer: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
855 "status": "err",
856 "response": "User or API Wallet does not exist.",
857 }))
858 .unwrap();
859 assert_eq!(
860 cancel_outcome(answer),
861 Err("User or API Wallet does not exist.".to_owned())
862 );
863 }
864
865 #[test]
866 fn test_instrument_to_spot_coin() {
867 let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("PURR-USDC-SPOT"));
868 assert_eq!(coin, Some("PURR/USDC".to_string()));
869
870 let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("HYPE-USDC-SPOT"));
871 assert_eq!(coin, Some("HYPE/USDC".to_string()));
872
873 assert_eq!(
875 instrument_to_spot_coin(&InstrumentNameExchange::from("BTC-USDC-PERP")),
876 None
877 );
878 assert_eq!(
879 instrument_to_spot_coin(&InstrumentNameExchange::from("INVALID")),
880 None
881 );
882 }
883
884 #[test]
885 fn test_map_tif() {
886 assert_eq!(
887 map_tif(&TimeInForce::GoodUntilCancelled { post_only: false }),
888 "Gtc"
889 );
890 assert_eq!(
891 map_tif(&TimeInForce::GoodUntilCancelled { post_only: true }),
892 "Alo"
893 );
894 assert_eq!(map_tif(&TimeInForce::ImmediateOrCancel), "Ioc");
895 assert_eq!(map_tif(&TimeInForce::FillOrKill), "Ioc");
896 assert_eq!(map_tif(&TimeInForce::GoodUntilEndOfDay), "Gtc");
897 }
898
899 #[test]
900 fn test_millis_to_datetime() {
901 let dt = millis_to_datetime(1714100000000).unwrap();
902 assert_eq!(dt.timestamp_millis(), 1714100000000);
903
904 assert!(millis_to_datetime(0).is_some());
906 }
907}
908
909#[cfg(test)]
910#[allow(clippy::unwrap_used)]
912pub(super) mod info_tests {
913 use super::*;
914 use rust_decimal_macros::dec;
915 use wiremock::matchers::{method, path};
916 use wiremock::{Mock, MockServer, ResponseTemplate};
917
918 const DOCUMENTED_FILL: &str = r#"{
921 "closedPnl": "0.0",
922 "coin": "AVAX",
923 "crossed": false,
924 "dir": "Open Long",
925 "hash": "0xa166e3fa63c25663024b03f2e0da011a00307e4017465df020210d3d432e7cb8",
926 "oid": 90542681,
927 "px": "18.435",
928 "side": "B",
929 "startPosition": "26.86",
930 "sz": "93.53",
931 "time": 1681222254710,
932 "fee": "0.01",
933 "feeToken": "USDC",
934 "builderFee": "0.01",
935 "tid": 118906512037719
936 }"#;
937
938 pub(in crate::client::hyperliquid) async fn info_client_against(
941 uri: String,
942 ) -> hyperliquid_rust_sdk::InfoClient {
943 let mut client = hyperliquid_rust_sdk::InfoClient::new(
944 None,
945 Some(hyperliquid_rust_sdk::BaseUrl::Localhost),
946 )
947 .await
948 .unwrap();
949 client.http_client.base_url = uri;
950 client
951 }
952
953 pub(in crate::client::hyperliquid) fn test_wallet() -> ethers::signers::LocalWallet {
955 "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
956 .parse()
957 .unwrap()
958 }
959
960 pub(in crate::client::hyperliquid) async fn exchange_client_against(
963 uri: String,
964 coin_to_asset: HashMap<String, u32>,
965 ) -> hyperliquid_rust_sdk::ExchangeClient {
966 hyperliquid_rust_sdk::ExchangeClient {
967 http_client: info_client_against(uri).await.http_client,
968 wallet: test_wallet(),
969 meta: hyperliquid_rust_sdk::Meta {
970 universe: Vec::new(),
971 },
972 vault_address: None,
973 coin_to_asset,
974 }
975 }
976
977 pub(in crate::client::hyperliquid) fn cancel_answer(
979 statuses: serde_json::Value,
980 ) -> serde_json::Value {
981 serde_json::json!({
982 "status": "ok",
983 "response": {"type": "cancel", "data": {"statuses": statuses}},
984 })
985 }
986
987 pub(in crate::client::hyperliquid) const CANCEL_NOT_APPLIED: &str =
989 "Order was never placed, already canceled, or filled.";
990
991 async fn serve(body: String) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
992 let server = MockServer::start().await;
993 Mock::given(method("POST"))
994 .and(path("/info"))
995 .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
996 .mount(&server)
997 .await;
998 let client = info_client_against(server.uri()).await;
999 (server, client)
1000 }
1001
1002 #[tokio::test]
1003 async fn a_fill_carries_the_tid_and_fee_token_the_sdk_type_drops() {
1004 let (_server, client) = serve(format!("[{DOCUMENTED_FILL}]")).await;
1005
1006 let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1007 .await
1008 .unwrap();
1009
1010 assert_eq!(fills.len(), 1);
1011 assert_eq!(fills[0].tid, 118_906_512_037_719);
1012 assert_eq!(fills[0].fee_token.as_deref(), Some("USDC"));
1013 assert_eq!(fills[0].oid, 90_542_681);
1014 assert_eq!(fills[0].coin, "AVAX");
1015 }
1016
1017 #[tokio::test]
1018 async fn one_transaction_can_carry_several_fills_and_they_are_told_apart_by_tid() {
1019 let sweep = r#"[
1022 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1023 "hash":"0xsweep","oid":1,"px":"18.40","side":"B","startPosition":"0",
1024 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":111},
1025 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1026 "hash":"0xsweep","oid":1,"px":"18.45","side":"B","startPosition":"10",
1027 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":222},
1028 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1029 "hash":"0xsweep","oid":1,"px":"18.50","side":"B","startPosition":"20",
1030 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":333}
1031 ]"#;
1032 let (_server, client) = serve(sweep.to_string()).await;
1033
1034 let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1035 .await
1036 .unwrap();
1037
1038 let tids: Vec<u64> = fills.iter().map(|f| f.tid).collect();
1039 assert_eq!(tids, vec![111, 222, 333]);
1040 }
1041
1042 #[tokio::test]
1043 async fn a_missing_fee_token_falls_back_rather_than_failing() {
1044 let without = DOCUMENTED_FILL.replace(r#""feeToken": "USDC","#, "");
1047 let (_server, client) = serve(format!("[{without}]")).await;
1048
1049 let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1050 .await
1051 .unwrap();
1052
1053 assert_eq!(fills.len(), 1);
1054 assert!(fills[0].fee_token.is_none());
1055 }
1056
1057 #[tokio::test]
1058 async fn a_missing_tid_fails_loudly_instead_of_falling_back_to_the_hash() {
1059 let without = DOCUMENTED_FILL.replace(r#""tid": 118906512037719"#, r#""unused": 0"#);
1062 let (_server, client) = serve(format!("[{without}]")).await;
1063
1064 let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1065 .await
1066 .unwrap_err();
1067
1068 assert!(
1069 matches!(&error, UnindexedClientError::Internal(msg) if msg.contains("userFills")),
1070 "expected a parse failure naming the endpoint, got {error:?}"
1071 );
1072 }
1073
1074 #[tokio::test]
1075 async fn the_request_names_the_address_in_full() {
1076 let server = MockServer::start().await;
1079 Mock::given(method("POST"))
1080 .and(path("/info"))
1081 .and(wiremock::matchers::body_string_contains(
1082 "0x000000000000000000000000000000000000dead",
1083 ))
1084 .and(wiremock::matchers::body_string_contains("userFills"))
1085 .respond_with(ResponseTemplate::new(200).set_body_raw("[]", "application/json"))
1086 .mount(&server)
1087 .await;
1088 let client = info_client_against(server.uri()).await;
1089
1090 let address = "0x000000000000000000000000000000000000dEaD"
1091 .parse::<ethers::types::H160>()
1092 .unwrap();
1093
1094 assert!(
1097 user_fills_by_time(&client, address, 0, u64::MAX)
1098 .await
1099 .unwrap()
1100 .is_empty()
1101 );
1102 }
1103
1104 struct FillsByTime {
1107 fills: Vec<serde_json::Value>,
1108 }
1109
1110 impl wiremock::Respond for FillsByTime {
1111 fn respond(&self, request: &wiremock::Request) -> ResponseTemplate {
1112 let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
1113 assert_eq!(body["type"], "userFillsByTime");
1114 let bound = |name: &str| body[name].as_u64().unwrap();
1115 let (start, end) = (bound("startTime"), bound("endTime"));
1116 let page: Vec<_> = self
1117 .fills
1118 .iter()
1119 .filter(|fill| (start..=end).contains(&fill["time"].as_u64().unwrap()))
1120 .take(USER_FILLS_PER_RESPONSE)
1121 .cloned()
1122 .collect();
1123 ResponseTemplate::new(200).set_body_json(page)
1124 }
1125 }
1126
1127 fn fill(tid: u64, time: u64) -> serde_json::Value {
1129 let mut fill: serde_json::Value = serde_json::from_str(DOCUMENTED_FILL).unwrap();
1130 fill["tid"] = tid.into();
1131 fill["time"] = time.into();
1132 fill
1133 }
1134
1135 async fn serve_fills_by_time(
1136 fills: Vec<serde_json::Value>,
1137 ) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
1138 let server = MockServer::start().await;
1139 Mock::given(method("POST"))
1140 .and(path("/info"))
1141 .respond_with(FillsByTime { fills })
1142 .mount(&server)
1143 .await;
1144 let client = info_client_against(server.uri()).await;
1145 (server, client)
1146 }
1147
1148 #[tokio::test]
1152 async fn a_span_with_more_fills_than_one_response_is_read_whole() {
1153 let fills = (0..4_500).map(|tid| fill(tid, 1_000 + tid / 3)).collect();
1154 let (server, client) = serve_fills_by_time(fills).await;
1155
1156 let read = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1157 .await
1158 .unwrap();
1159 let tids: Vec<u64> = read.iter().map(|fill| fill.tid).collect();
1160 assert_eq!(tids, (0..4_500).collect::<Vec<_>>());
1161 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(3));
1162
1163 let read = user_fills_by_time(&client, ethers::types::H160::zero(), 1_010, 1_019)
1165 .await
1166 .unwrap();
1167 assert_eq!(read.len(), 30);
1168 }
1169
1170 #[tokio::test]
1172 async fn a_read_that_cannot_advance_is_truncated() {
1173 let fills = (0..USER_FILLS_PER_RESPONSE as u64)
1174 .map(|tid| fill(tid, 5))
1175 .collect();
1176 let (_server, client) = serve_fills_by_time(fills).await;
1177
1178 let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1179 .await
1180 .unwrap_err();
1181 assert!(
1182 matches!(error, UnindexedClientError::Truncated { fills_read: 2_000 }),
1183 "got {error:?}"
1184 );
1185 }
1186
1187 #[test]
1188 fn a_span_is_read_in_whole_milliseconds_and_an_empty_one_not_at_all() {
1189 let at = |s: &str| s.parse::<DateTime<Utc>>().unwrap();
1190 assert_eq!(
1191 span_millis(
1192 at("1970-01-01T00:00:01.000900Z"),
1193 at("1970-01-01T00:00:02.000900Z")
1194 ),
1195 Some((1_000, 2_000))
1196 );
1197 assert_eq!(
1198 span_millis(at("1969-12-31T23:59:59Z"), at("1970-01-01T00:00:01Z")),
1199 Some((0, 1_000))
1200 );
1201 assert_eq!(
1202 span_millis(at("1970-01-01T00:00:02Z"), at("1970-01-01T00:00:01Z")),
1203 None
1204 );
1205 assert_eq!(
1206 span_millis(at("1969-01-01T00:00:00Z"), at("1969-12-31T23:59:59Z")),
1207 None
1208 );
1209 }
1210
1211 const CID: &str = "85b60bff-64d5-49ec-95af-6268d7d1df63";
1214 const CLOID: &str = "0x85b60bff64d549ec95af6268d7d1df63";
1216
1217 #[test]
1218 fn only_the_canonical_uuid_form_can_be_a_cloid() {
1219 let cloid = cid_to_cloid(&ClientOrderId::new(CID)).unwrap();
1220 assert_eq!(cloid.to_string(), CID);
1221
1222 for other in [
1224 "85B60BFF-64D5-49EC-95AF-6268D7D1DF63",
1225 "85b60bff64d549ec95af6268d7d1df63",
1226 "{85b60bff-64d5-49ec-95af-6268d7d1df63}",
1227 "urn:uuid:85b60bff-64d5-49ec-95af-6268d7d1df63",
1228 "example-1714100000000",
1229 "",
1230 ] {
1231 assert_eq!(cid_to_cloid(&ClientOrderId::new(other)), None, "{other}");
1232 }
1233 }
1234
1235 #[test]
1236 fn a_generated_uuid_client_id_is_always_a_cloid() {
1237 for _ in 0..32 {
1238 let cid = ClientOrderId::uuid();
1239 assert!(cid_to_cloid(&cid).is_some(), "{cid}");
1240 }
1241 }
1242
1243 #[test]
1244 fn the_reported_cloid_is_the_client_id_the_order_was_placed_under() {
1245 assert_eq!(cloid_to_cid(CLOID), Some(ClientOrderId::new(CID)));
1246
1247 let cid = ClientOrderId::uuid();
1249 let sent = format!("0x{}", cid_to_cloid(&cid).unwrap().simple());
1250 assert_eq!(cloid_to_cid(&sent), Some(cid));
1251 }
1252
1253 #[test]
1254 fn a_cloid_not_in_the_venue_form_names_no_client_id() {
1255 for malformed in [
1256 CID,
1257 "85b60bff64d549ec95af6268d7d1df63",
1258 "0x85b60bff64d549ec95af6268d7d1df",
1259 "0x85b60bff64d549ec95af6268d7d1df6300",
1260 "0x85b60bff-64d5-49ec-95af-6268d7d1df63",
1261 "0xzzb60bff64d549ec95af6268d7d1df63",
1262 "0x",
1263 ] {
1264 assert_eq!(cloid_to_cid(malformed), None, "{malformed}");
1265 }
1266 }
1267
1268 #[test]
1269 fn a_record_without_a_cloid_is_reported_under_its_oid() {
1270 assert_eq!(record_cid(None, 42), Some(ClientOrderId::new("42")));
1271 assert_eq!(record_cid(Some(CLOID), 42), Some(ClientOrderId::new(CID)));
1272 assert_eq!(record_cid(Some("0x1234"), 42), None);
1273 }
1274
1275 #[test]
1278 fn every_documented_order_status_is_classified() {
1279 use OrderStatus::*;
1280 for (status, expected) in [
1281 ("open", Open),
1282 ("triggered", Open),
1283 ("filled", Filled),
1284 ("canceled", Cancelled),
1285 ("marginCanceled", Cancelled),
1286 ("vaultWithdrawalCanceled", Cancelled),
1287 ("openInterestCapCanceled", Cancelled),
1288 ("selfTradeCanceled", Cancelled),
1289 ("reduceOnlyCanceled", Cancelled),
1290 ("siblingFilledCanceled", Cancelled),
1291 ("delistedCanceled", Cancelled),
1292 ("liquidatedCanceled", Cancelled),
1293 ("scheduledCancel", Cancelled),
1294 ("rejected", Rejected),
1295 ("tickRejected", Rejected),
1296 ("minTradeNtlRejected", Rejected),
1297 ("perpMarginRejected", Rejected),
1298 ("reduceOnlyRejected", Rejected),
1299 ("badAloPxRejected", Rejected),
1300 ("iocCancelRejected", Rejected),
1301 ("badTriggerPxRejected", Rejected),
1302 ("marketOrderNoLiquidityRejected", Rejected),
1303 ("positionIncreaseAtOpenInterestCapRejected", Rejected),
1304 ("positionFlipAtOpenInterestCapRejected", Rejected),
1305 ("tooAggressiveAtOpenInterestCapRejected", Rejected),
1306 ("openInterestIncreaseRejected", Rejected),
1307 ("insufficientSpotBalanceRejected", Rejected),
1308 ("oracleRejected", Rejected),
1309 ("perpMaxPositionRejected", Rejected),
1310 ] {
1311 assert_eq!(OrderStatus::classify(status), Some(expected), "{status}");
1312 }
1313 assert_eq!(OrderStatus::classify("unknown_status"), None);
1314 }
1315
1316 fn order_update(status: &str, cloid: &str, sz: &str) -> hyperliquid_rust_sdk::OrderUpdate {
1317 serde_json::from_str(&format!(
1318 r#"{{"order": {{"coin": "ETH", "side": "B", "limitPx": "1605.0", "sz": "{sz}",
1319 "oid": 7001, "timestamp": 1714100000000, "origSz": "0.0075", "cloid": {cloid}}},
1320 "status": "{status}", "statusTimestamp": 1714100001000}}"#
1321 ))
1322 .unwrap()
1323 }
1324
1325 fn snapshot_of(event: UnindexedAccountEvent) -> UnindexedOrderSnapshot {
1326 match event.kind {
1327 crate::AccountEventKind::OrderSnapshot(snapshot) => snapshot.0,
1328 other => panic!("expected an order snapshot, got {other:?}"),
1329 }
1330 }
1331
1332 fn eth() -> InstrumentNameExchange {
1333 InstrumentNameExchange::from("ETH-USDC-PERP")
1334 }
1335
1336 #[test]
1337 fn an_order_update_names_the_order_by_the_id_it_was_placed_under() {
1338 let update = order_update("open", &format!(r#""{CLOID}""#), "0.005");
1339 let order = snapshot_of(
1340 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1341 );
1342
1343 assert_eq!(order.key.cid, ClientOrderId::new(CID));
1344 assert_eq!(order.quantity, dec!(0.0075));
1345 match order.state {
1346 OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) => {
1347 assert_eq!(open.id, VenueOrderId::Assigned(OrderId::new("7001")));
1348 assert_eq!(open.filled_quantity, dec!(0.0025));
1349 }
1350 other => panic!("expected Open, got {other:?}"),
1351 }
1352 }
1353
1354 #[test]
1355 fn an_order_update_without_a_cloid_is_named_by_its_oid() {
1356 let update = order_update("open", "null", "0.0075");
1357 let order = snapshot_of(
1358 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1359 );
1360 assert_eq!(order.key.cid, ClientOrderId::new("7001"));
1361 }
1362
1363 #[test]
1364 fn an_order_update_with_a_malformed_cloid_is_dropped() {
1365 let update = order_update("open", r#""0x1234""#, "0.0075");
1366 assert!(
1367 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).is_none()
1368 );
1369 }
1370
1371 #[test]
1372 fn a_cancellation_for_any_reason_ends_the_order() {
1373 let update = order_update("selfTradeCanceled", &format!(r#""{CLOID}""#), "0.005");
1374 let order = snapshot_of(
1375 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1376 );
1377 match order.state {
1378 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1379 assert_eq!(cancelled.filled_quantity, Some(dec!(0.0025)));
1380 }
1381 other => panic!("expected Cancelled, got {other:?}"),
1382 }
1383 }
1384
1385 #[test]
1388 fn a_cancellation_with_an_unparseable_size_ends_the_order_with_its_fill_unknown() {
1389 let update = order_update("canceled", &format!(r#""{CLOID}""#), "not a number");
1390 let order = snapshot_of(
1391 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1392 );
1393 match order.state {
1394 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1395 assert_eq!(cancelled.filled_quantity, None);
1396 }
1397 other => panic!("expected Cancelled, got {other:?}"),
1398 }
1399 }
1400
1401 #[test]
1402 fn a_rejection_ends_the_order_and_names_the_reason() {
1403 let update = order_update("badAloPxRejected", &format!(r#""{CLOID}""#), "0.0075");
1404 let order = snapshot_of(
1405 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1406 );
1407 match order.state {
1408 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
1409 OrderError::Rejected(ApiError::OrderRejected(reason)),
1410 )) => assert_eq!(reason, "badAloPxRejected"),
1411 other => panic!("expected a rejection, got {other:?}"),
1412 }
1413 }
1414
1415 const OPEN_ORDERS: &str = r#"[
1420 {"coin":"ETH","side":"B","limitPx":"1605.0","sz":"0.005","oid":7001,
1421 "timestamp":1714100000000,"origSz":"0.0075","cloid":"0x85b60bff64d549ec95af6268d7d1df63"},
1422 {"coin":"ETH","side":"A","limitPx":"1700.0","sz":"0.01","oid":7002,
1423 "timestamp":1714100000000,"origSz":"0.01"},
1424 {"coin":"BTC","side":"A","limitPx":"50000.0","sz":"0.001","oid":7003,
1425 "timestamp":1714100000000}
1426 ]"#;
1427
1428 #[tokio::test]
1429 async fn open_orders_carry_the_cloid_and_original_size_the_sdk_type_drops() {
1430 let (_server, client) = serve(OPEN_ORDERS.to_string()).await;
1431
1432 let rows = open_orders(&client, ethers::types::H160::zero())
1433 .await
1434 .unwrap();
1435
1436 assert_eq!(rows.len(), 3);
1437 assert_eq!(rows[0].cloid.as_deref(), Some(CLOID));
1438 assert_eq!(rows[0].orig_sz.as_deref(), Some("0.0075"));
1439 assert_eq!(rows[1].cloid, None);
1440 assert_eq!(rows[2].orig_sz, None);
1441 }
1442
1443 fn rows() -> Vec<OpenOrder> {
1444 serde_json::from_str(OPEN_ORDERS).unwrap()
1445 }
1446
1447 fn perp(coin: &str) -> Option<InstrumentNameExchange> {
1448 Some(InstrumentNameExchange::from(format!("{coin}-USDC-PERP")))
1449 }
1450
1451 #[test]
1452 fn an_open_order_is_listed_under_its_client_id_with_its_fill_so_far() {
1453 let order = open_order_to_order(&rows()[0], ExchangeId::HyperliquidPerp, eth()).unwrap();
1454
1455 assert_eq!(order.key.cid, ClientOrderId::new(CID));
1456 assert_eq!(order.quantity, dec!(0.0075));
1457 assert_eq!(order.state.filled_quantity, dec!(0.0025));
1458 assert_eq!(order.state.id, VenueOrderId::Assigned(OrderId::new("7001")));
1459 }
1460
1461 #[test]
1462 fn an_open_order_without_its_original_size_is_its_remainder_with_nothing_filled() {
1463 let row = &rows()[2];
1464 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1465 let order = open_order_to_order(row, ExchangeId::HyperliquidPerp, btc).unwrap();
1466
1467 assert_eq!(order.key.cid, ClientOrderId::new("7003"));
1468 assert_eq!(order.quantity, dec!(0.001));
1469 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
1470 }
1471
1472 #[test]
1473 fn every_requested_instrument_is_listed_complete_even_with_nothing_open() {
1474 let sol = InstrumentNameExchange::from("SOL-USDC-PERP");
1475 let mut listing = OpenOrderListing::new(
1476 &rows(),
1477 ExchangeId::HyperliquidPerp,
1478 &[eth(), sol.clone()],
1479 perp,
1480 );
1481
1482 let eth_entry = listing.remove(ð()).unwrap();
1483 assert!(eth_entry.orders_complete);
1484 assert_eq!(eth_entry.orders.len(), 2);
1485
1486 let sol_entry = listing.remove(&sol).unwrap();
1488 assert!(sol_entry.orders_complete);
1489 assert!(sol_entry.orders.is_empty());
1490
1491 assert_eq!(listing.into_snapshots().count(), 0);
1493 }
1494
1495 #[test]
1496 fn an_unfiltered_listing_covers_every_instrument_with_open_orders() {
1497 let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidPerp, &[], perp);
1498 let mut instruments = listing
1499 .into_snapshots()
1500 .map(|entry| entry.instrument.to_string())
1501 .collect::<Vec<_>>();
1502 instruments.sort();
1503 assert_eq!(instruments, ["BTC-USDC-PERP", "ETH-USDC-PERP"]);
1504 }
1505
1506 #[test]
1507 fn an_order_that_does_not_convert_leaves_only_its_instrument_incomplete() {
1508 let mut rows = rows();
1509 rows[1].side = "?".to_string();
1510 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1511 let mut listing = OpenOrderListing::new(
1512 &rows,
1513 ExchangeId::HyperliquidPerp,
1514 &[eth(), btc.clone()],
1515 perp,
1516 );
1517
1518 let eth_entry = listing.remove(ð()).unwrap();
1519 assert!(!eth_entry.orders_complete);
1520 assert_eq!(eth_entry.orders.len(), 1);
1521 assert!(listing.remove(&btc).unwrap().orders_complete);
1522 }
1523
1524 #[test]
1525 fn an_order_with_a_malformed_cloid_leaves_its_instrument_incomplete() {
1526 let mut rows = rows();
1527 rows[0].cloid = Some("0x1234".to_string());
1528 let mut listing = OpenOrderListing::new(&rows, ExchangeId::HyperliquidPerp, &[eth()], perp);
1529
1530 let eth_entry = listing.remove(ð()).unwrap();
1531 assert!(!eth_entry.orders_complete);
1532 assert_eq!(eth_entry.orders.len(), 1);
1533 }
1534
1535 #[test]
1536 fn coins_the_client_does_not_trade_are_left_out() {
1537 let pairs = super::super::spot_coins::test_spot_pairs();
1538 let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidSpot, &[], |coin| {
1539 pairs.get(coin).map(spot_pair_to_instrument)
1540 });
1541 assert_eq!(listing.into_snapshots().count(), 0);
1542 }
1543}