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> {
745 let (base, quote) = spot_base_quote(instrument)?;
746 Some(format!("{base}/{quote}"))
747}
748
749pub(super) fn spot_base_quote(instrument: &InstrumentNameExchange) -> Option<(&str, &str)> {
752 instrument.as_ref().strip_suffix("-SPOT")?.split_once('-')
753}
754
755#[cfg(test)]
756#[allow(clippy::unwrap_used)]
758mod tests {
759 use super::*;
760 use rust_decimal_macros::dec;
761
762 #[test]
763 fn test_parse_decimal_valid() {
764 assert_eq!(parse_decimal("123.456", "test"), Some(dec!(123.456)));
765 assert_eq!(parse_decimal("0", "test"), Some(dec!(0)));
766 assert_eq!(parse_decimal("-50.5", "test"), Some(dec!(-50.5)));
767 }
768
769 #[test]
770 fn test_parse_decimal_invalid() {
771 assert_eq!(parse_decimal("", "test"), None);
772 assert_eq!(parse_decimal("abc", "test"), None);
773 assert_eq!(parse_decimal("12.34.56", "test"), None);
774 }
775
776 #[test]
777 fn test_parse_side() {
778 assert_eq!(parse_side("B"), Some(Side::Buy));
779 assert_eq!(parse_side("BUY"), Some(Side::Buy));
780 assert_eq!(parse_side("buy"), Some(Side::Buy));
781 assert_eq!(parse_side("A"), Some(Side::Sell));
782 assert_eq!(parse_side("S"), Some(Side::Sell));
783 assert_eq!(parse_side("SELL"), Some(Side::Sell));
784 assert_eq!(parse_side("sell"), Some(Side::Sell));
785 assert_eq!(parse_side("X"), None);
786 assert_eq!(parse_side(""), None);
787 }
788
789 #[test]
790 fn test_spot_pair_to_instrument() {
791 let pairs = super::super::spot_coins::test_spot_pairs();
792 let instrument = |coin| spot_pair_to_instrument(pairs.get(coin).unwrap());
793
794 assert_eq!(instrument("PURR/USDC").as_ref(), "PURR-USDC-SPOT");
795 assert_eq!(instrument("@107").as_ref(), "HYPE-USDC-SPOT");
796 assert_eq!(instrument("@207").as_ref(), "HYPE-USDT0-SPOT");
797 }
798
799 #[test]
800 fn unknown_coins_are_recorded_once_and_known_ones_never() {
801 let mut unknown = UnknownCoins::default();
802 for coin in ["#12", "#12", "BTC", "@107", "PURR/USDC", "+3"] {
803 unknown.warn_once(coin);
804 }
805 assert_eq!(
806 unknown.0,
807 HashSet::from(["#12".to_owned(), "+3".to_owned()])
808 );
809 }
810
811 fn cancel_answer(statuses: serde_json::Value) -> ExchangeResponseStatus {
813 serde_json::from_value(info_tests::cancel_answer(statuses)).unwrap()
814 }
815
816 #[test]
817 fn only_a_success_status_is_a_cancel() {
818 assert_eq!(
819 cancel_outcome(cancel_answer(serde_json::json!(["success"]))),
820 Ok(())
821 );
822 }
823
824 #[test]
826 fn an_error_status_under_a_top_level_ok_is_not_a_cancel() {
827 let answer = cancel_answer(serde_json::json!([
828 {"error": info_tests::CANCEL_NOT_APPLIED}
829 ]));
830 assert_eq!(
831 cancel_outcome(answer),
832 Err(info_tests::CANCEL_NOT_APPLIED.to_owned())
833 );
834 }
835
836 #[test]
837 fn an_answer_without_a_success_status_is_not_a_cancel() {
838 let no_data: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
839 "status": "ok",
840 "response": {"type": "cancel"},
841 }))
842 .unwrap();
843 let answers = [
844 no_data,
845 cancel_answer(serde_json::json!([])),
846 cancel_answer(serde_json::json!([{"resting": {"oid": 1}}])),
847 ];
848 for answer in answers {
849 let outcome = cancel_outcome(answer.clone());
850 assert!(outcome.is_err(), "{answer:?} read as {outcome:?}");
851 }
852 }
853
854 #[test]
855 fn a_top_level_error_is_not_a_cancel() {
856 let answer: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
857 "status": "err",
858 "response": "User or API Wallet does not exist.",
859 }))
860 .unwrap();
861 assert_eq!(
862 cancel_outcome(answer),
863 Err("User or API Wallet does not exist.".to_owned())
864 );
865 }
866
867 #[test]
868 fn test_instrument_to_spot_coin() {
869 let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("PURR-USDC-SPOT"));
870 assert_eq!(coin, Some("PURR/USDC".to_string()));
871
872 let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("HYPE-USDC-SPOT"));
873 assert_eq!(coin, Some("HYPE/USDC".to_string()));
874
875 assert_eq!(
877 instrument_to_spot_coin(&InstrumentNameExchange::from("BTC-USDC-PERP")),
878 None
879 );
880 assert_eq!(
881 instrument_to_spot_coin(&InstrumentNameExchange::from("INVALID")),
882 None
883 );
884 }
885
886 #[test]
887 fn test_map_tif() {
888 assert_eq!(
889 map_tif(&TimeInForce::GoodUntilCancelled { post_only: false }),
890 "Gtc"
891 );
892 assert_eq!(
893 map_tif(&TimeInForce::GoodUntilCancelled { post_only: true }),
894 "Alo"
895 );
896 assert_eq!(map_tif(&TimeInForce::ImmediateOrCancel), "Ioc");
897 assert_eq!(map_tif(&TimeInForce::FillOrKill), "Ioc");
898 assert_eq!(map_tif(&TimeInForce::GoodUntilEndOfDay), "Gtc");
899 }
900
901 #[test]
902 fn test_millis_to_datetime() {
903 let dt = millis_to_datetime(1714100000000).unwrap();
904 assert_eq!(dt.timestamp_millis(), 1714100000000);
905
906 assert!(millis_to_datetime(0).is_some());
908 }
909}
910
911#[cfg(test)]
912#[allow(clippy::unwrap_used)]
914pub(super) mod info_tests {
915 use super::*;
916 use rust_decimal_macros::dec;
917 use wiremock::matchers::{method, path};
918 use wiremock::{Mock, MockServer, ResponseTemplate};
919
920 const DOCUMENTED_FILL: &str = r#"{
923 "closedPnl": "0.0",
924 "coin": "AVAX",
925 "crossed": false,
926 "dir": "Open Long",
927 "hash": "0xa166e3fa63c25663024b03f2e0da011a00307e4017465df020210d3d432e7cb8",
928 "oid": 90542681,
929 "px": "18.435",
930 "side": "B",
931 "startPosition": "26.86",
932 "sz": "93.53",
933 "time": 1681222254710,
934 "fee": "0.01",
935 "feeToken": "USDC",
936 "builderFee": "0.01",
937 "tid": 118906512037719
938 }"#;
939
940 pub(in crate::client::hyperliquid) async fn info_client_against(
943 uri: String,
944 ) -> hyperliquid_rust_sdk::InfoClient {
945 let mut client = hyperliquid_rust_sdk::InfoClient::new(
946 None,
947 Some(hyperliquid_rust_sdk::BaseUrl::Localhost),
948 )
949 .await
950 .unwrap();
951 client.http_client.base_url = uri;
952 client
953 }
954
955 pub(in crate::client::hyperliquid) fn test_wallet() -> ethers::signers::LocalWallet {
957 "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
958 .parse()
959 .unwrap()
960 }
961
962 pub(in crate::client::hyperliquid) async fn exchange_client_against(
965 uri: String,
966 coin_to_asset: HashMap<String, u32>,
967 ) -> hyperliquid_rust_sdk::ExchangeClient {
968 hyperliquid_rust_sdk::ExchangeClient {
969 http_client: info_client_against(uri).await.http_client,
970 wallet: test_wallet(),
971 meta: hyperliquid_rust_sdk::Meta {
972 universe: Vec::new(),
973 },
974 vault_address: None,
975 coin_to_asset,
976 }
977 }
978
979 pub(in crate::client::hyperliquid) fn cancel_answer(
981 statuses: serde_json::Value,
982 ) -> serde_json::Value {
983 serde_json::json!({
984 "status": "ok",
985 "response": {"type": "cancel", "data": {"statuses": statuses}},
986 })
987 }
988
989 pub(in crate::client::hyperliquid) const CANCEL_NOT_APPLIED: &str =
991 "Order was never placed, already canceled, or filled.";
992
993 async fn serve(body: String) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
994 let server = MockServer::start().await;
995 Mock::given(method("POST"))
996 .and(path("/info"))
997 .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
998 .mount(&server)
999 .await;
1000 let client = info_client_against(server.uri()).await;
1001 (server, client)
1002 }
1003
1004 #[tokio::test]
1005 async fn a_fill_carries_the_tid_and_fee_token_the_sdk_type_drops() {
1006 let (_server, client) = serve(format!("[{DOCUMENTED_FILL}]")).await;
1007
1008 let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1009 .await
1010 .unwrap();
1011
1012 assert_eq!(fills.len(), 1);
1013 assert_eq!(fills[0].tid, 118_906_512_037_719);
1014 assert_eq!(fills[0].fee_token.as_deref(), Some("USDC"));
1015 assert_eq!(fills[0].oid, 90_542_681);
1016 assert_eq!(fills[0].coin, "AVAX");
1017 }
1018
1019 #[tokio::test]
1020 async fn one_transaction_can_carry_several_fills_and_they_are_told_apart_by_tid() {
1021 let sweep = r#"[
1024 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1025 "hash":"0xsweep","oid":1,"px":"18.40","side":"B","startPosition":"0",
1026 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":111},
1027 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1028 "hash":"0xsweep","oid":1,"px":"18.45","side":"B","startPosition":"10",
1029 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":222},
1030 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1031 "hash":"0xsweep","oid":1,"px":"18.50","side":"B","startPosition":"20",
1032 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":333}
1033 ]"#;
1034 let (_server, client) = serve(sweep.to_string()).await;
1035
1036 let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1037 .await
1038 .unwrap();
1039
1040 let tids: Vec<u64> = fills.iter().map(|f| f.tid).collect();
1041 assert_eq!(tids, vec![111, 222, 333]);
1042 }
1043
1044 #[tokio::test]
1045 async fn a_missing_fee_token_falls_back_rather_than_failing() {
1046 let without = DOCUMENTED_FILL.replace(r#""feeToken": "USDC","#, "");
1049 let (_server, client) = serve(format!("[{without}]")).await;
1050
1051 let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1052 .await
1053 .unwrap();
1054
1055 assert_eq!(fills.len(), 1);
1056 assert!(fills[0].fee_token.is_none());
1057 }
1058
1059 #[tokio::test]
1060 async fn a_missing_tid_fails_loudly_instead_of_falling_back_to_the_hash() {
1061 let without = DOCUMENTED_FILL.replace(r#""tid": 118906512037719"#, r#""unused": 0"#);
1064 let (_server, client) = serve(format!("[{without}]")).await;
1065
1066 let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1067 .await
1068 .unwrap_err();
1069
1070 assert!(
1071 matches!(&error, UnindexedClientError::Internal(msg) if msg.contains("userFills")),
1072 "expected a parse failure naming the endpoint, got {error:?}"
1073 );
1074 }
1075
1076 #[tokio::test]
1077 async fn the_request_names_the_address_in_full() {
1078 let server = MockServer::start().await;
1081 Mock::given(method("POST"))
1082 .and(path("/info"))
1083 .and(wiremock::matchers::body_string_contains(
1084 "0x000000000000000000000000000000000000dead",
1085 ))
1086 .and(wiremock::matchers::body_string_contains("userFills"))
1087 .respond_with(ResponseTemplate::new(200).set_body_raw("[]", "application/json"))
1088 .mount(&server)
1089 .await;
1090 let client = info_client_against(server.uri()).await;
1091
1092 let address = "0x000000000000000000000000000000000000dEaD"
1093 .parse::<ethers::types::H160>()
1094 .unwrap();
1095
1096 assert!(
1099 user_fills_by_time(&client, address, 0, u64::MAX)
1100 .await
1101 .unwrap()
1102 .is_empty()
1103 );
1104 }
1105
1106 struct FillsByTime {
1109 fills: Vec<serde_json::Value>,
1110 }
1111
1112 impl wiremock::Respond for FillsByTime {
1113 fn respond(&self, request: &wiremock::Request) -> ResponseTemplate {
1114 let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
1115 assert_eq!(body["type"], "userFillsByTime");
1116 let bound = |name: &str| body[name].as_u64().unwrap();
1117 let (start, end) = (bound("startTime"), bound("endTime"));
1118 let page: Vec<_> = self
1119 .fills
1120 .iter()
1121 .filter(|fill| (start..=end).contains(&fill["time"].as_u64().unwrap()))
1122 .take(USER_FILLS_PER_RESPONSE)
1123 .cloned()
1124 .collect();
1125 ResponseTemplate::new(200).set_body_json(page)
1126 }
1127 }
1128
1129 fn fill(tid: u64, time: u64) -> serde_json::Value {
1131 let mut fill: serde_json::Value = serde_json::from_str(DOCUMENTED_FILL).unwrap();
1132 fill["tid"] = tid.into();
1133 fill["time"] = time.into();
1134 fill
1135 }
1136
1137 async fn serve_fills_by_time(
1138 fills: Vec<serde_json::Value>,
1139 ) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
1140 let server = MockServer::start().await;
1141 Mock::given(method("POST"))
1142 .and(path("/info"))
1143 .respond_with(FillsByTime { fills })
1144 .mount(&server)
1145 .await;
1146 let client = info_client_against(server.uri()).await;
1147 (server, client)
1148 }
1149
1150 #[tokio::test]
1154 async fn a_span_with_more_fills_than_one_response_is_read_whole() {
1155 let fills = (0..4_500).map(|tid| fill(tid, 1_000 + tid / 3)).collect();
1156 let (server, client) = serve_fills_by_time(fills).await;
1157
1158 let read = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1159 .await
1160 .unwrap();
1161 let tids: Vec<u64> = read.iter().map(|fill| fill.tid).collect();
1162 assert_eq!(tids, (0..4_500).collect::<Vec<_>>());
1163 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(3));
1164
1165 let read = user_fills_by_time(&client, ethers::types::H160::zero(), 1_010, 1_019)
1167 .await
1168 .unwrap();
1169 assert_eq!(read.len(), 30);
1170 }
1171
1172 #[tokio::test]
1174 async fn a_read_that_cannot_advance_is_truncated() {
1175 let fills = (0..USER_FILLS_PER_RESPONSE as u64)
1176 .map(|tid| fill(tid, 5))
1177 .collect();
1178 let (_server, client) = serve_fills_by_time(fills).await;
1179
1180 let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1181 .await
1182 .unwrap_err();
1183 assert!(
1184 matches!(error, UnindexedClientError::Truncated { fills_read: 2_000 }),
1185 "got {error:?}"
1186 );
1187 }
1188
1189 #[test]
1190 fn a_span_is_read_in_whole_milliseconds_and_an_empty_one_not_at_all() {
1191 let at = |s: &str| s.parse::<DateTime<Utc>>().unwrap();
1192 assert_eq!(
1193 span_millis(
1194 at("1970-01-01T00:00:01.000900Z"),
1195 at("1970-01-01T00:00:02.000900Z")
1196 ),
1197 Some((1_000, 2_000))
1198 );
1199 assert_eq!(
1200 span_millis(at("1969-12-31T23:59:59Z"), at("1970-01-01T00:00:01Z")),
1201 Some((0, 1_000))
1202 );
1203 assert_eq!(
1204 span_millis(at("1970-01-01T00:00:02Z"), at("1970-01-01T00:00:01Z")),
1205 None
1206 );
1207 assert_eq!(
1208 span_millis(at("1969-01-01T00:00:00Z"), at("1969-12-31T23:59:59Z")),
1209 None
1210 );
1211 }
1212
1213 const CID: &str = "85b60bff-64d5-49ec-95af-6268d7d1df63";
1216 const CLOID: &str = "0x85b60bff64d549ec95af6268d7d1df63";
1218
1219 #[test]
1220 fn only_the_canonical_uuid_form_can_be_a_cloid() {
1221 let cloid = cid_to_cloid(&ClientOrderId::new(CID)).unwrap();
1222 assert_eq!(cloid.to_string(), CID);
1223
1224 for other in [
1226 "85B60BFF-64D5-49EC-95AF-6268D7D1DF63",
1227 "85b60bff64d549ec95af6268d7d1df63",
1228 "{85b60bff-64d5-49ec-95af-6268d7d1df63}",
1229 "urn:uuid:85b60bff-64d5-49ec-95af-6268d7d1df63",
1230 "example-1714100000000",
1231 "",
1232 ] {
1233 assert_eq!(cid_to_cloid(&ClientOrderId::new(other)), None, "{other}");
1234 }
1235 }
1236
1237 #[test]
1238 fn a_generated_uuid_client_id_is_always_a_cloid() {
1239 for _ in 0..32 {
1240 let cid = ClientOrderId::uuid();
1241 assert!(cid_to_cloid(&cid).is_some(), "{cid}");
1242 }
1243 }
1244
1245 #[test]
1246 fn the_reported_cloid_is_the_client_id_the_order_was_placed_under() {
1247 assert_eq!(cloid_to_cid(CLOID), Some(ClientOrderId::new(CID)));
1248
1249 let cid = ClientOrderId::uuid();
1251 let sent = format!("0x{}", cid_to_cloid(&cid).unwrap().simple());
1252 assert_eq!(cloid_to_cid(&sent), Some(cid));
1253 }
1254
1255 #[test]
1256 fn a_cloid_not_in_the_venue_form_names_no_client_id() {
1257 for malformed in [
1258 CID,
1259 "85b60bff64d549ec95af6268d7d1df63",
1260 "0x85b60bff64d549ec95af6268d7d1df",
1261 "0x85b60bff64d549ec95af6268d7d1df6300",
1262 "0x85b60bff-64d5-49ec-95af-6268d7d1df63",
1263 "0xzzb60bff64d549ec95af6268d7d1df63",
1264 "0x",
1265 ] {
1266 assert_eq!(cloid_to_cid(malformed), None, "{malformed}");
1267 }
1268 }
1269
1270 #[test]
1271 fn a_record_without_a_cloid_is_reported_under_its_oid() {
1272 assert_eq!(record_cid(None, 42), Some(ClientOrderId::new("42")));
1273 assert_eq!(record_cid(Some(CLOID), 42), Some(ClientOrderId::new(CID)));
1274 assert_eq!(record_cid(Some("0x1234"), 42), None);
1275 }
1276
1277 #[test]
1280 fn every_documented_order_status_is_classified() {
1281 use OrderStatus::*;
1282 for (status, expected) in [
1283 ("open", Open),
1284 ("triggered", Open),
1285 ("filled", Filled),
1286 ("canceled", Cancelled),
1287 ("marginCanceled", Cancelled),
1288 ("vaultWithdrawalCanceled", Cancelled),
1289 ("openInterestCapCanceled", Cancelled),
1290 ("selfTradeCanceled", Cancelled),
1291 ("reduceOnlyCanceled", Cancelled),
1292 ("siblingFilledCanceled", Cancelled),
1293 ("delistedCanceled", Cancelled),
1294 ("liquidatedCanceled", Cancelled),
1295 ("scheduledCancel", Cancelled),
1296 ("rejected", Rejected),
1297 ("tickRejected", Rejected),
1298 ("minTradeNtlRejected", Rejected),
1299 ("perpMarginRejected", Rejected),
1300 ("reduceOnlyRejected", Rejected),
1301 ("badAloPxRejected", Rejected),
1302 ("iocCancelRejected", Rejected),
1303 ("badTriggerPxRejected", Rejected),
1304 ("marketOrderNoLiquidityRejected", Rejected),
1305 ("positionIncreaseAtOpenInterestCapRejected", Rejected),
1306 ("positionFlipAtOpenInterestCapRejected", Rejected),
1307 ("tooAggressiveAtOpenInterestCapRejected", Rejected),
1308 ("openInterestIncreaseRejected", Rejected),
1309 ("insufficientSpotBalanceRejected", Rejected),
1310 ("oracleRejected", Rejected),
1311 ("perpMaxPositionRejected", Rejected),
1312 ] {
1313 assert_eq!(OrderStatus::classify(status), Some(expected), "{status}");
1314 }
1315 assert_eq!(OrderStatus::classify("unknown_status"), None);
1316 }
1317
1318 fn order_update(status: &str, cloid: &str, sz: &str) -> hyperliquid_rust_sdk::OrderUpdate {
1319 serde_json::from_str(&format!(
1320 r#"{{"order": {{"coin": "ETH", "side": "B", "limitPx": "1605.0", "sz": "{sz}",
1321 "oid": 7001, "timestamp": 1714100000000, "origSz": "0.0075", "cloid": {cloid}}},
1322 "status": "{status}", "statusTimestamp": 1714100001000}}"#
1323 ))
1324 .unwrap()
1325 }
1326
1327 fn snapshot_of(event: UnindexedAccountEvent) -> UnindexedOrderSnapshot {
1328 match event.kind {
1329 crate::AccountEventKind::OrderSnapshot(snapshot) => snapshot.0,
1330 other => panic!("expected an order snapshot, got {other:?}"),
1331 }
1332 }
1333
1334 fn eth() -> InstrumentNameExchange {
1335 InstrumentNameExchange::from("ETH-USDC-PERP")
1336 }
1337
1338 #[test]
1339 fn an_order_update_names_the_order_by_the_id_it_was_placed_under() {
1340 let update = order_update("open", &format!(r#""{CLOID}""#), "0.005");
1341 let order = snapshot_of(
1342 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1343 );
1344
1345 assert_eq!(order.key.cid, ClientOrderId::new(CID));
1346 assert_eq!(order.quantity, dec!(0.0075));
1347 match order.state {
1348 OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) => {
1349 assert_eq!(open.id, VenueOrderId::Assigned(OrderId::new("7001")));
1350 assert_eq!(open.filled_quantity, dec!(0.0025));
1351 }
1352 other => panic!("expected Open, got {other:?}"),
1353 }
1354 }
1355
1356 #[test]
1357 fn an_order_update_without_a_cloid_is_named_by_its_oid() {
1358 let update = order_update("open", "null", "0.0075");
1359 let order = snapshot_of(
1360 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1361 );
1362 assert_eq!(order.key.cid, ClientOrderId::new("7001"));
1363 }
1364
1365 #[test]
1366 fn an_order_update_with_a_malformed_cloid_is_dropped() {
1367 let update = order_update("open", r#""0x1234""#, "0.0075");
1368 assert!(
1369 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).is_none()
1370 );
1371 }
1372
1373 #[test]
1374 fn a_cancellation_for_any_reason_ends_the_order() {
1375 let update = order_update("selfTradeCanceled", &format!(r#""{CLOID}""#), "0.005");
1376 let order = snapshot_of(
1377 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1378 );
1379 match order.state {
1380 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1381 assert_eq!(cancelled.filled_quantity, Some(dec!(0.0025)));
1382 }
1383 other => panic!("expected Cancelled, got {other:?}"),
1384 }
1385 }
1386
1387 #[test]
1390 fn a_cancellation_with_an_unparseable_size_ends_the_order_with_its_fill_unknown() {
1391 let update = order_update("canceled", &format!(r#""{CLOID}""#), "not a number");
1392 let order = snapshot_of(
1393 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1394 );
1395 match order.state {
1396 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1397 assert_eq!(cancelled.filled_quantity, None);
1398 }
1399 other => panic!("expected Cancelled, got {other:?}"),
1400 }
1401 }
1402
1403 #[test]
1404 fn a_rejection_ends_the_order_and_names_the_reason() {
1405 let update = order_update("badAloPxRejected", &format!(r#""{CLOID}""#), "0.0075");
1406 let order = snapshot_of(
1407 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1408 );
1409 match order.state {
1410 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
1411 OrderError::Rejected(ApiError::OrderRejected(reason)),
1412 )) => assert_eq!(reason, "badAloPxRejected"),
1413 other => panic!("expected a rejection, got {other:?}"),
1414 }
1415 }
1416
1417 const OPEN_ORDERS: &str = r#"[
1422 {"coin":"ETH","side":"B","limitPx":"1605.0","sz":"0.005","oid":7001,
1423 "timestamp":1714100000000,"origSz":"0.0075","cloid":"0x85b60bff64d549ec95af6268d7d1df63"},
1424 {"coin":"ETH","side":"A","limitPx":"1700.0","sz":"0.01","oid":7002,
1425 "timestamp":1714100000000,"origSz":"0.01"},
1426 {"coin":"BTC","side":"A","limitPx":"50000.0","sz":"0.001","oid":7003,
1427 "timestamp":1714100000000}
1428 ]"#;
1429
1430 #[tokio::test]
1431 async fn open_orders_carry_the_cloid_and_original_size_the_sdk_type_drops() {
1432 let (_server, client) = serve(OPEN_ORDERS.to_string()).await;
1433
1434 let rows = open_orders(&client, ethers::types::H160::zero())
1435 .await
1436 .unwrap();
1437
1438 assert_eq!(rows.len(), 3);
1439 assert_eq!(rows[0].cloid.as_deref(), Some(CLOID));
1440 assert_eq!(rows[0].orig_sz.as_deref(), Some("0.0075"));
1441 assert_eq!(rows[1].cloid, None);
1442 assert_eq!(rows[2].orig_sz, None);
1443 }
1444
1445 fn rows() -> Vec<OpenOrder> {
1446 serde_json::from_str(OPEN_ORDERS).unwrap()
1447 }
1448
1449 fn perp(coin: &str) -> Option<InstrumentNameExchange> {
1450 Some(InstrumentNameExchange::from(format!("{coin}-USDC-PERP")))
1451 }
1452
1453 #[test]
1454 fn an_open_order_is_listed_under_its_client_id_with_its_fill_so_far() {
1455 let order = open_order_to_order(&rows()[0], ExchangeId::HyperliquidPerp, eth()).unwrap();
1456
1457 assert_eq!(order.key.cid, ClientOrderId::new(CID));
1458 assert_eq!(order.quantity, dec!(0.0075));
1459 assert_eq!(order.state.filled_quantity, dec!(0.0025));
1460 assert_eq!(order.state.id, VenueOrderId::Assigned(OrderId::new("7001")));
1461 }
1462
1463 #[test]
1464 fn an_open_order_without_its_original_size_is_its_remainder_with_nothing_filled() {
1465 let row = &rows()[2];
1466 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1467 let order = open_order_to_order(row, ExchangeId::HyperliquidPerp, btc).unwrap();
1468
1469 assert_eq!(order.key.cid, ClientOrderId::new("7003"));
1470 assert_eq!(order.quantity, dec!(0.001));
1471 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
1472 }
1473
1474 #[test]
1475 fn every_requested_instrument_is_listed_complete_even_with_nothing_open() {
1476 let sol = InstrumentNameExchange::from("SOL-USDC-PERP");
1477 let mut listing = OpenOrderListing::new(
1478 &rows(),
1479 ExchangeId::HyperliquidPerp,
1480 &[eth(), sol.clone()],
1481 perp,
1482 );
1483
1484 let eth_entry = listing.remove(ð()).unwrap();
1485 assert!(eth_entry.orders_complete);
1486 assert_eq!(eth_entry.orders.len(), 2);
1487
1488 let sol_entry = listing.remove(&sol).unwrap();
1490 assert!(sol_entry.orders_complete);
1491 assert!(sol_entry.orders.is_empty());
1492
1493 assert_eq!(listing.into_snapshots().count(), 0);
1495 }
1496
1497 #[test]
1498 fn an_unfiltered_listing_covers_every_instrument_with_open_orders() {
1499 let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidPerp, &[], perp);
1500 let mut instruments = listing
1501 .into_snapshots()
1502 .map(|entry| entry.instrument.to_string())
1503 .collect::<Vec<_>>();
1504 instruments.sort();
1505 assert_eq!(instruments, ["BTC-USDC-PERP", "ETH-USDC-PERP"]);
1506 }
1507
1508 #[test]
1509 fn an_order_that_does_not_convert_leaves_only_its_instrument_incomplete() {
1510 let mut rows = rows();
1511 rows[1].side = "?".to_string();
1512 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1513 let mut listing = OpenOrderListing::new(
1514 &rows,
1515 ExchangeId::HyperliquidPerp,
1516 &[eth(), btc.clone()],
1517 perp,
1518 );
1519
1520 let eth_entry = listing.remove(ð()).unwrap();
1521 assert!(!eth_entry.orders_complete);
1522 assert_eq!(eth_entry.orders.len(), 1);
1523 assert!(listing.remove(&btc).unwrap().orders_complete);
1524 }
1525
1526 #[test]
1527 fn an_order_with_a_malformed_cloid_leaves_its_instrument_incomplete() {
1528 let mut rows = rows();
1529 rows[0].cloid = Some("0x1234".to_string());
1530 let mut listing = OpenOrderListing::new(&rows, ExchangeId::HyperliquidPerp, &[eth()], perp);
1531
1532 let eth_entry = listing.remove(ð()).unwrap();
1533 assert!(!eth_entry.orders_complete);
1534 assert_eq!(eth_entry.orders.len(), 1);
1535 }
1536
1537 #[test]
1538 fn coins_the_client_does_not_trade_are_left_out() {
1539 let pairs = super::super::spot_coins::test_spot_pairs();
1540 let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidSpot, &[], |coin| {
1541 pairs.get(coin).map(spot_pair_to_instrument)
1542 });
1543 assert_eq!(listing.into_snapshots().count(), 0);
1544 }
1545}