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
141pub async fn user_fills_by_time(
161 info_client: &hyperliquid_rust_sdk::InfoClient,
162 address: ethers::types::H160,
163 start_ms: u64,
164 end_ms: u64,
165) -> Result<Vec<UserFill>, UnindexedClientError> {
166 let mut fills: Vec<UserFill> = Vec::new();
167 let mut from = start_ms;
168 loop {
169 let body = format!(
171 r#"{{"type":"userFillsByTime","user":"{address:?}","startTime":{from},"endTime":{end_ms}}}"#
172 );
173 let page: Vec<UserFill> = info(info_client, "userFillsByTime", body).await?;
174 let full = page.len() >= USER_FILLS_PER_RESPONSE;
175 let Some(last_time) = page.last().map(|fill| fill.time) else {
176 break;
177 };
178 let seen: Vec<(String, u64)> = fills
181 .iter()
182 .rev()
183 .take_while(|fill| fill.time == from)
184 .map(|fill| (fill.coin.clone(), fill.tid))
185 .collect();
186 fills.extend(page.into_iter().filter(|fill| {
187 fill.time != from
188 || !seen
189 .iter()
190 .any(|(coin, tid)| *tid == fill.tid && *coin == fill.coin)
191 }));
192 if !full {
193 break;
194 }
195 if last_time <= from {
196 return Err(UnindexedClientError::Truncated {
197 fills_read: fills.len(),
198 });
199 }
200 from = last_time;
201 }
202 Ok(fills)
203}
204
205#[derive(Debug, Clone, serde::Deserialize)]
215#[serde(rename_all = "camelCase")]
216pub struct OpenOrder {
217 pub coin: String,
218 pub side: String,
219 pub limit_px: String,
220 pub sz: String,
222 pub oid: u64,
223 pub timestamp: u64,
224
225 #[serde(default)]
231 pub orig_sz: Option<String>,
232
233 #[serde(default)]
236 pub cloid: Option<String>,
237}
238
239pub async fn open_orders(
247 info_client: &hyperliquid_rust_sdk::InfoClient,
248 address: ethers::types::H160,
249) -> Result<Vec<OpenOrder>, UnindexedClientError> {
250 user_info(info_client, "openOrders", address, None).await
251}
252
253pub(super) async fn user_info<T: DeserializeOwned>(
260 info_client: &hyperliquid_rust_sdk::InfoClient,
261 kind: &str,
262 address: ethers::types::H160,
263 dex: Option<&str>,
264) -> Result<T, UnindexedClientError> {
265 let mut body = serde_json::json!({"type": kind, "user": format!("{address:?}")});
268 if let Some(dex) = dex {
269 body["dex"] = dex.into();
270 }
271 info(info_client, kind, body.to_string()).await
272}
273
274pub(super) async fn info<T: DeserializeOwned>(
276 info_client: &hyperliquid_rust_sdk::InfoClient,
277 kind: &str,
278 body: String,
279) -> Result<T, UnindexedClientError> {
280 let raw = info_client
281 .http_client
282 .post("/info", body)
283 .await
284 .map_err(map_sdk_error)?;
285
286 serde_json::from_str(&raw).map_err(|e| {
289 UnindexedClientError::Internal(format!("Hyperliquid {kind} response did not parse: {e}"))
290 })
291}
292
293pub(super) fn spot_balances<'a>(
295 balances: impl IntoIterator<Item = &'a hyperliquid_rust_sdk::UserTokenBalance>,
296 now: DateTime<Utc>,
297) -> impl Iterator<Item = AssetBalance<AssetNameExchange>> {
298 balances.into_iter().map(move |balance| {
299 let total = parse_decimal(&balance.total, "total").unwrap_or(Decimal::ZERO);
300 let hold = parse_decimal(&balance.hold, "hold").unwrap_or(Decimal::ZERO);
301 let free = (total - hold).max(Decimal::ZERO);
302 AssetBalance::new(
303 AssetNameExchange::from(balance.coin.as_str()),
304 Balance::new(total, free),
305 now,
306 )
307 })
308}
309
310pub(super) fn open_order_to_order(
315 row: &OpenOrder,
316 exchange: ExchangeId,
317 instrument: InstrumentNameExchange,
318) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
319 let parsed = (|| {
320 let cid = record_cid(row.cloid.as_deref(), row.oid)?;
321 let side = parse_side(&row.side)?;
322 let price = parse_decimal(&row.limit_px, "limitPx")?;
323 let remaining = parse_decimal(&row.sz, "sz")?;
324 let quantity = match &row.orig_sz {
325 Some(orig_sz) => parse_decimal(orig_sz, "origSz")?,
326 None => remaining,
327 };
328 let time_exchange = millis_to_datetime(row.timestamp)?;
329 Some((cid, side, price, quantity, remaining, time_exchange))
330 })();
331
332 let Some((cid, side, price, quantity, remaining, time_exchange)) = parsed else {
333 warn!(
334 %exchange,
335 %instrument,
336 oid = row.oid,
337 order = ?row,
338 "Hyperliquid open order did not convert - leaving it out, so this instrument's order \
339 list is not complete"
340 );
341 return None;
342 };
343
344 Some(Order {
345 key: OrderKey {
346 exchange,
347 instrument,
348 strategy: StrategyId::unknown(),
349 cid,
350 },
351 side,
352 price: Some(price),
353 quantity,
354 kind: OrderKind::Limit,
356 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
357 state: Open {
358 id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", row.oid))),
359 time_exchange,
360 filled_quantity: (quantity - remaining).max(Decimal::ZERO),
361 },
362 })
363}
364
365pub(super) struct InstrumentFilter<'a>(Option<FnvHashSet<&'a InstrumentNameExchange>>);
368
369impl<'a> InstrumentFilter<'a> {
370 pub(super) fn new(instruments: &'a [InstrumentNameExchange]) -> Self {
371 Self((!instruments.is_empty()).then(|| instruments.iter().collect()))
372 }
373
374 pub(super) fn matches(&self, instrument: &InstrumentNameExchange) -> bool {
376 self.0.as_ref().is_none_or(|set| set.contains(instrument))
377 }
378}
379
380type UnindexedInstrumentAccountSnapshot =
381 InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
382
383pub(super) struct OpenOrderListing(
385 HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
386);
387
388impl OpenOrderListing {
389 pub(super) fn new(
402 rows: &[OpenOrder],
403 exchange: ExchangeId,
404 requested: &[InstrumentNameExchange],
405 to_instrument: impl Fn(&str) -> Option<InstrumentNameExchange>,
406 ) -> Self {
407 fn entry<'a>(
408 listing: &'a mut HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
409 instrument: &InstrumentNameExchange,
410 ) -> &'a mut UnindexedInstrumentAccountSnapshot {
411 listing
412 .entry(instrument.clone())
413 .or_insert_with(|| InstrumentAccountSnapshot {
414 instrument: instrument.clone(),
415 orders: Vec::new(),
416 orders_complete: true,
417 position: PositionReport::Unreported,
418 isolated: None,
419 })
420 }
421
422 let filter = InstrumentFilter::new(requested);
423 let mut listing = HashMap::with_capacity(requested.len());
424
425 for instrument in requested {
426 entry(&mut listing, instrument);
427 }
428
429 for row in rows {
430 let Some(instrument) = to_instrument(&row.coin) else {
431 continue;
432 };
433 if !filter.matches(&instrument) {
434 continue;
435 }
436 let order = open_order_to_order(row, exchange, instrument.clone());
437 let snapshot = entry(&mut listing, &instrument);
438 match order {
439 Some(order) => snapshot.orders.push(order.into()),
440 None => snapshot.orders_complete = false,
441 }
442 }
443
444 Self(listing)
445 }
446
447 pub(super) fn remove(
449 &mut self,
450 instrument: &InstrumentNameExchange,
451 ) -> Option<UnindexedInstrumentAccountSnapshot> {
452 self.0.remove(instrument)
453 }
454
455 pub(super) fn into_snapshots(self) -> impl Iterator<Item = UnindexedInstrumentAccountSnapshot> {
457 self.0.into_values()
458 }
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463enum OrderStatus {
464 Open,
465 Filled,
466 Cancelled,
467 Rejected,
468}
469
470impl OrderStatus {
471 fn classify(status: &str) -> Option<Self> {
482 match status {
483 "open" | "triggered" => Some(Self::Open),
484 "filled" => Some(Self::Filled),
485 "canceled" | "scheduledCancel" => Some(Self::Cancelled),
486 "rejected" => Some(Self::Rejected),
487 status if status.ends_with("Canceled") => Some(Self::Cancelled),
488 status if status.ends_with("Rejected") => Some(Self::Rejected),
489 _ => None,
490 }
491 }
492}
493
494pub(super) fn order_update_to_account_event(
500 update: &hyperliquid_rust_sdk::OrderUpdate,
501 exchange: ExchangeId,
502 instrument: InstrumentNameExchange,
503) -> Option<UnindexedAccountEvent> {
504 let snapshot = order_update_to_order(update, exchange, instrument)?;
505 Some(crate::AccountEvent::new(
506 exchange,
507 crate::AccountEventKind::OrderSnapshot(
508 rustrade_integration::collection::snapshot::Snapshot(snapshot),
509 ),
510 ))
511}
512
513pub(super) fn order_update_to_order(
519 update: &hyperliquid_rust_sdk::OrderUpdate,
520 exchange: ExchangeId,
521 instrument: InstrumentNameExchange,
522) -> Option<UnindexedOrderSnapshot> {
523 let order = &update.order;
524 let Some(status) = OrderStatus::classify(&update.status) else {
525 warn!(%exchange, status = %update.status, oid = order.oid, "Unknown Hyperliquid order status - ignoring the update");
526 return None;
527 };
528 let cid = record_cid(order.cloid.as_deref(), order.oid)?;
529 let side = parse_side(&order.side)?;
530 let price = parse_decimal(&order.limit_px, "order.limitPx")?;
531 let orig_sz = parse_decimal(&order.orig_sz, "order.origSz")?;
532 let Some(time_exchange) = millis_to_datetime(update.status_timestamp) else {
533 warn!(%exchange, oid = order.oid, status_timestamp = update.status_timestamp, "Invalid Hyperliquid order update timestamp - ignoring the update");
534 return None;
535 };
536 let order_id = OrderId(format_smolstr!("{}", order.oid));
537 let filled_quantity = || {
538 parse_decimal(&order.sz, "order.sz")
539 .map(|remaining| (orig_sz - remaining).max(Decimal::ZERO))
540 };
541
542 let state = match status {
543 OrderStatus::Open => OrderState::active(Open {
544 id: VenueOrderId::Assigned(order_id),
545 time_exchange,
546 filled_quantity: filled_quantity()?,
547 }),
548 OrderStatus::Filled => OrderState::fully_filled(Filled::new(
549 order_id,
550 time_exchange,
551 orig_sz,
552 None, )),
554 OrderStatus::Cancelled => {
556 OrderState::inactive(Cancelled::new(order_id, time_exchange, filled_quantity()))
557 }
558 OrderStatus::Rejected => {
559 OrderState::inactive(OrderError::Rejected(order_rejection(update.status.clone())))
560 }
561 };
562
563 Some(Order {
565 key: OrderKey {
566 exchange,
567 instrument,
568 strategy: StrategyId::unknown(),
569 cid,
570 },
571 side,
572 price: Some(price),
573 quantity: orig_sz,
574 kind: OrderKind::Limit,
575 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
576 state,
577 })
578}
579
580pub fn parse_decimal(value: &str, field: &str) -> Option<Decimal> {
581 Decimal::from_str(value)
582 .map_err(|e| warn!(%field, %value, %e, "Failed to parse decimal"))
583 .ok()
584}
585
586pub fn parse_side(side: &str) -> Option<Side> {
591 match side {
592 "B" | "b" | "BUY" | "Buy" | "buy" => Some(Side::Buy),
593 "A" | "a" | "S" | "s" | "SELL" | "Sell" | "sell" => Some(Side::Sell),
594 _ => {
595 warn!(%side, "Unknown side string");
596 None
597 }
598 }
599}
600
601pub fn millis_to_datetime(millis: u64) -> Option<DateTime<Utc>> {
605 Utc.timestamp_millis_opt(i64::try_from(millis).ok()?)
606 .single()
607}
608
609pub fn map_tif(tif: &TimeInForce) -> &'static str {
611 match tif {
612 TimeInForce::GoodUntilCancelled { post_only: true } => "Alo",
613 TimeInForce::GoodUntilCancelled { post_only: false } => "Gtc",
614 TimeInForce::ImmediateOrCancel => "Ioc",
615 TimeInForce::FillOrKill => "Ioc", TimeInForce::GoodUntilEndOfDay => "Gtc", TimeInForce::GoodTillDate { .. } | TimeInForce::AtOpen | TimeInForce::AtClose => {
621 warn!(time_in_force = ?tif, "Hyperliquid does not support this TimeInForce; coercing to Gtc");
622 "Gtc"
623 }
624 }
625}
626
627pub(super) const CLOID_REQUIRED: &str = "Hyperliquid requires the client order id to be a UUID in \
629 canonical form, as ClientOrderId::uuid() makes: the venue reports each order under that id, \
630 and an order under any other id could not be matched to its own updates";
631
632pub fn cid_to_cloid(cid: &ClientOrderId) -> Option<Uuid> {
640 let uuid = Uuid::try_parse(cid.0.as_str()).ok()?;
641 let mut canonical = Uuid::encode_buffer();
642 (uuid.hyphenated().encode_lower(&mut canonical) == cid.0.as_str()).then_some(uuid)
643}
644
645pub fn cloid_to_cid(cloid: &str) -> Option<ClientOrderId> {
649 let hex = cloid.strip_prefix("0x")?;
650 if hex.len() != 32 {
651 return None;
652 }
653 let uuid = Uuid::try_parse(hex).ok()?;
655 Some(ClientOrderId::new(format_smolstr!("{}", uuid.hyphenated())))
656}
657
658pub(super) fn cancel_outcome(response: ExchangeResponseStatus) -> Result<(), String> {
671 let response = match response {
672 ExchangeResponseStatus::Ok(response) => response,
673 ExchangeResponseStatus::Err(reason) => return Err(reason),
674 };
675 match response
676 .data
677 .and_then(|data| data.statuses.into_iter().next())
678 {
679 Some(ExchangeDataStatus::Success) => Ok(()),
680 Some(ExchangeDataStatus::Error(reason)) => Err(reason),
681 Some(status) => Err(format!("cancel answered with status {status:?}")),
682 None => Err("cancel answered without a status for the order".to_owned()),
683 }
684}
685
686pub(super) fn record_cid(cloid: Option<&str>, oid: u64) -> Option<ClientOrderId> {
696 match cloid {
697 None => Some(ClientOrderId::new(format_smolstr!("{oid}"))),
698 Some(cloid) => {
699 let cid = cloid_to_cid(cloid);
700 if cid.is_none() {
701 warn!(%cloid, oid, "Hyperliquid order carries a cloid that is not 0x and 32 hex digits");
702 }
703 cid
704 }
705 }
706}
707
708fn is_unknown_coin(coin: &str) -> bool {
710 !matches!(CoinKind::of(coin), CoinKind::Perp | CoinKind::Spot)
711}
712
713pub(super) fn warn_unknown_coins<'a>(coins: impl IntoIterator<Item = &'a str>) {
716 let unknown: BTreeSet<&str> = coins
717 .into_iter()
718 .filter(|coin| is_unknown_coin(coin))
719 .collect();
720 if !unknown.is_empty() {
721 warn!(
722 ?unknown,
723 "Hyperliquid coins name markets of an unrecognised kind; leaving them out"
724 );
725 }
726}
727
728#[derive(Debug, Default)]
731pub(super) struct UnknownCoins(HashSet<String>);
732
733impl UnknownCoins {
734 pub(super) fn warn_once(&mut self, coin: &str) {
736 if is_unknown_coin(coin) && !self.0.contains(coin) {
737 warn!(
738 %coin,
739 "Hyperliquid coin names a market of an unrecognised kind; leaving it out \
740 (logged once per stream)"
741 );
742 self.0.insert(coin.to_owned());
743 }
744 }
745}
746
747pub fn spot_pair_to_instrument(pair: &SpotPair) -> InstrumentNameExchange {
750 InstrumentNameExchange::from(format_smolstr!("{}-{}-SPOT", pair.base(), pair.quote()))
751}
752
753pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
764 let (base, quote) = spot_base_quote(instrument)?;
765 Some(format!("{base}/{quote}"))
766}
767
768pub(super) fn spot_base_quote(instrument: &InstrumentNameExchange) -> Option<(&str, &str)> {
771 instrument.as_ref().strip_suffix("-SPOT")?.split_once('-')
772}
773
774#[cfg(test)]
775#[allow(clippy::unwrap_used)]
777mod tests {
778 use super::*;
779 use rust_decimal_macros::dec;
780
781 #[test]
782 fn an_instrument_filter_keeps_what_it_was_given_or_everything_when_given_nothing() {
783 let (btc, eth) = (
784 InstrumentNameExchange::new("BTC"),
785 InstrumentNameExchange::new("ETH"),
786 );
787
788 let all = InstrumentFilter::new(&[]);
789 assert!(all.matches(&btc) && all.matches(ð));
790
791 let given = [btc.clone()];
792 let one = InstrumentFilter::new(&given);
793 assert!(one.matches(&btc));
794 assert!(!one.matches(ð));
795 }
796
797 #[test]
798 fn test_parse_decimal_valid() {
799 assert_eq!(parse_decimal("123.456", "test"), Some(dec!(123.456)));
800 assert_eq!(parse_decimal("0", "test"), Some(dec!(0)));
801 assert_eq!(parse_decimal("-50.5", "test"), Some(dec!(-50.5)));
802 }
803
804 #[test]
805 fn test_parse_decimal_invalid() {
806 assert_eq!(parse_decimal("", "test"), None);
807 assert_eq!(parse_decimal("abc", "test"), None);
808 assert_eq!(parse_decimal("12.34.56", "test"), None);
809 }
810
811 #[test]
812 fn test_parse_side() {
813 assert_eq!(parse_side("B"), Some(Side::Buy));
814 assert_eq!(parse_side("BUY"), Some(Side::Buy));
815 assert_eq!(parse_side("buy"), Some(Side::Buy));
816 assert_eq!(parse_side("A"), Some(Side::Sell));
817 assert_eq!(parse_side("S"), Some(Side::Sell));
818 assert_eq!(parse_side("SELL"), Some(Side::Sell));
819 assert_eq!(parse_side("sell"), Some(Side::Sell));
820 assert_eq!(parse_side("X"), None);
821 assert_eq!(parse_side(""), None);
822 }
823
824 #[test]
825 fn test_spot_pair_to_instrument() {
826 let pairs = super::super::spot_coins::test_spot_pairs();
827 let instrument = |coin| spot_pair_to_instrument(pairs.get(coin).unwrap());
828
829 assert_eq!(instrument("PURR/USDC").as_ref(), "PURR-USDC-SPOT");
830 assert_eq!(instrument("@107").as_ref(), "HYPE-USDC-SPOT");
831 assert_eq!(instrument("@207").as_ref(), "HYPE-USDT0-SPOT");
832 }
833
834 #[test]
835 fn unknown_coins_are_recorded_once_and_known_ones_never() {
836 let mut unknown = UnknownCoins::default();
837 for coin in ["#12", "#12", "BTC", "@107", "PURR/USDC", "+3"] {
838 unknown.warn_once(coin);
839 }
840 assert_eq!(
841 unknown.0,
842 HashSet::from(["#12".to_owned(), "+3".to_owned()])
843 );
844 }
845
846 fn cancel_answer(statuses: serde_json::Value) -> ExchangeResponseStatus {
848 serde_json::from_value(info_tests::cancel_answer(statuses)).unwrap()
849 }
850
851 #[test]
852 fn only_a_success_status_is_a_cancel() {
853 assert_eq!(
854 cancel_outcome(cancel_answer(serde_json::json!(["success"]))),
855 Ok(())
856 );
857 }
858
859 #[test]
861 fn an_error_status_under_a_top_level_ok_is_not_a_cancel() {
862 let answer = cancel_answer(serde_json::json!([
863 {"error": info_tests::CANCEL_NOT_APPLIED}
864 ]));
865 assert_eq!(
866 cancel_outcome(answer),
867 Err(info_tests::CANCEL_NOT_APPLIED.to_owned())
868 );
869 }
870
871 #[test]
872 fn an_answer_without_a_success_status_is_not_a_cancel() {
873 let no_data: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
874 "status": "ok",
875 "response": {"type": "cancel"},
876 }))
877 .unwrap();
878 let answers = [
879 no_data,
880 cancel_answer(serde_json::json!([])),
881 cancel_answer(serde_json::json!([{"resting": {"oid": 1}}])),
882 ];
883 for answer in answers {
884 let outcome = cancel_outcome(answer.clone());
885 assert!(outcome.is_err(), "{answer:?} read as {outcome:?}");
886 }
887 }
888
889 #[test]
890 fn a_top_level_error_is_not_a_cancel() {
891 let answer: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
892 "status": "err",
893 "response": "User or API Wallet does not exist.",
894 }))
895 .unwrap();
896 assert_eq!(
897 cancel_outcome(answer),
898 Err("User or API Wallet does not exist.".to_owned())
899 );
900 }
901
902 #[test]
903 fn test_instrument_to_spot_coin() {
904 let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("PURR-USDC-SPOT"));
905 assert_eq!(coin, Some("PURR/USDC".to_string()));
906
907 let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("HYPE-USDC-SPOT"));
908 assert_eq!(coin, Some("HYPE/USDC".to_string()));
909
910 assert_eq!(
912 instrument_to_spot_coin(&InstrumentNameExchange::from("BTC-USDC-PERP")),
913 None
914 );
915 assert_eq!(
916 instrument_to_spot_coin(&InstrumentNameExchange::from("INVALID")),
917 None
918 );
919 }
920
921 #[test]
922 fn test_map_tif() {
923 assert_eq!(
924 map_tif(&TimeInForce::GoodUntilCancelled { post_only: false }),
925 "Gtc"
926 );
927 assert_eq!(
928 map_tif(&TimeInForce::GoodUntilCancelled { post_only: true }),
929 "Alo"
930 );
931 assert_eq!(map_tif(&TimeInForce::ImmediateOrCancel), "Ioc");
932 assert_eq!(map_tif(&TimeInForce::FillOrKill), "Ioc");
933 assert_eq!(map_tif(&TimeInForce::GoodUntilEndOfDay), "Gtc");
934 }
935
936 #[test]
937 fn test_millis_to_datetime() {
938 let dt = millis_to_datetime(1714100000000).unwrap();
939 assert_eq!(dt.timestamp_millis(), 1714100000000);
940
941 assert!(millis_to_datetime(0).is_some());
943 }
944}
945
946#[cfg(test)]
947#[allow(clippy::unwrap_used)]
949pub(super) mod info_tests {
950 use super::*;
951 use crate::error::ApiError;
952 use rust_decimal_macros::dec;
953 use wiremock::matchers::{method, path};
954 use wiremock::{Mock, MockServer, ResponseTemplate};
955
956 const DOCUMENTED_FILL: &str = r#"{
959 "closedPnl": "0.0",
960 "coin": "AVAX",
961 "crossed": false,
962 "dir": "Open Long",
963 "hash": "0xa166e3fa63c25663024b03f2e0da011a00307e4017465df020210d3d432e7cb8",
964 "oid": 90542681,
965 "px": "18.435",
966 "side": "B",
967 "startPosition": "26.86",
968 "sz": "93.53",
969 "time": 1681222254710,
970 "fee": "0.01",
971 "feeToken": "USDC",
972 "builderFee": "0.01",
973 "tid": 118906512037719
974 }"#;
975
976 pub(in crate::client::hyperliquid) async fn info_client_against(
979 uri: String,
980 ) -> hyperliquid_rust_sdk::InfoClient {
981 let mut client = hyperliquid_rust_sdk::InfoClient::new(
982 None,
983 Some(hyperliquid_rust_sdk::BaseUrl::Localhost),
984 )
985 .await
986 .unwrap();
987 client.http_client.base_url = uri;
988 client
989 }
990
991 pub(in crate::client::hyperliquid) fn test_wallet() -> ethers::signers::LocalWallet {
993 "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
994 .parse()
995 .unwrap()
996 }
997
998 pub(in crate::client::hyperliquid) async fn exchange_client_against(
1001 uri: String,
1002 coin_to_asset: HashMap<String, u32>,
1003 ) -> hyperliquid_rust_sdk::ExchangeClient {
1004 hyperliquid_rust_sdk::ExchangeClient {
1005 http_client: info_client_against(uri).await.http_client,
1006 wallet: test_wallet(),
1007 meta: hyperliquid_rust_sdk::Meta {
1008 universe: Vec::new(),
1009 },
1010 vault_address: None,
1011 coin_to_asset,
1012 }
1013 }
1014
1015 pub(in crate::client::hyperliquid) fn cancel_answer(
1017 statuses: serde_json::Value,
1018 ) -> serde_json::Value {
1019 serde_json::json!({
1020 "status": "ok",
1021 "response": {"type": "cancel", "data": {"statuses": statuses}},
1022 })
1023 }
1024
1025 pub(in crate::client::hyperliquid) const CANCEL_NOT_APPLIED: &str =
1027 "Order was never placed, already canceled, or filled.";
1028
1029 async fn serve(body: String) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
1030 let server = MockServer::start().await;
1031 Mock::given(method("POST"))
1032 .and(path("/info"))
1033 .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
1034 .mount(&server)
1035 .await;
1036 let client = info_client_against(server.uri()).await;
1037 (server, client)
1038 }
1039
1040 #[tokio::test]
1041 async fn a_fill_carries_the_tid_and_fee_token_the_sdk_type_drops() {
1042 let (_server, client) = serve(format!("[{DOCUMENTED_FILL}]")).await;
1043
1044 let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1045 .await
1046 .unwrap();
1047
1048 assert_eq!(fills.len(), 1);
1049 assert_eq!(fills[0].tid, 118_906_512_037_719);
1050 assert_eq!(fills[0].fee_token.as_deref(), Some("USDC"));
1051 assert_eq!(fills[0].oid, 90_542_681);
1052 assert_eq!(fills[0].coin, "AVAX");
1053 }
1054
1055 #[tokio::test]
1056 async fn one_transaction_can_carry_several_fills_and_they_are_told_apart_by_tid() {
1057 let sweep = r#"[
1060 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1061 "hash":"0xsweep","oid":1,"px":"18.40","side":"B","startPosition":"0",
1062 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":111},
1063 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1064 "hash":"0xsweep","oid":1,"px":"18.45","side":"B","startPosition":"10",
1065 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":222},
1066 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1067 "hash":"0xsweep","oid":1,"px":"18.50","side":"B","startPosition":"20",
1068 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":333}
1069 ]"#;
1070 let (_server, client) = serve(sweep.to_string()).await;
1071
1072 let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1073 .await
1074 .unwrap();
1075
1076 let tids: Vec<u64> = fills.iter().map(|f| f.tid).collect();
1077 assert_eq!(tids, vec![111, 222, 333]);
1078 }
1079
1080 #[tokio::test]
1081 async fn a_missing_fee_token_falls_back_rather_than_failing() {
1082 let without = DOCUMENTED_FILL.replace(r#""feeToken": "USDC","#, "");
1085 let (_server, client) = serve(format!("[{without}]")).await;
1086
1087 let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1088 .await
1089 .unwrap();
1090
1091 assert_eq!(fills.len(), 1);
1092 assert!(fills[0].fee_token.is_none());
1093 }
1094
1095 #[tokio::test]
1096 async fn a_missing_tid_fails_loudly_instead_of_falling_back_to_the_hash() {
1097 let without = DOCUMENTED_FILL.replace(r#""tid": 118906512037719"#, r#""unused": 0"#);
1100 let (_server, client) = serve(format!("[{without}]")).await;
1101
1102 let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1103 .await
1104 .unwrap_err();
1105
1106 assert!(
1107 matches!(&error, UnindexedClientError::Internal(msg) if msg.contains("userFills")),
1108 "expected a parse failure naming the endpoint, got {error:?}"
1109 );
1110 }
1111
1112 #[tokio::test]
1113 async fn the_request_names_the_address_in_full() {
1114 let server = MockServer::start().await;
1117 Mock::given(method("POST"))
1118 .and(path("/info"))
1119 .and(wiremock::matchers::body_string_contains(
1120 "0x000000000000000000000000000000000000dead",
1121 ))
1122 .and(wiremock::matchers::body_string_contains("userFills"))
1123 .respond_with(ResponseTemplate::new(200).set_body_raw("[]", "application/json"))
1124 .mount(&server)
1125 .await;
1126 let client = info_client_against(server.uri()).await;
1127
1128 let address = "0x000000000000000000000000000000000000dEaD"
1129 .parse::<ethers::types::H160>()
1130 .unwrap();
1131
1132 assert!(
1135 user_fills_by_time(&client, address, 0, u64::MAX)
1136 .await
1137 .unwrap()
1138 .is_empty()
1139 );
1140 }
1141
1142 struct FillsByTime {
1145 fills: Vec<serde_json::Value>,
1146 }
1147
1148 impl wiremock::Respond for FillsByTime {
1149 fn respond(&self, request: &wiremock::Request) -> ResponseTemplate {
1150 let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
1151 assert_eq!(body["type"], "userFillsByTime");
1152 let bound = |name: &str| body[name].as_u64().unwrap();
1153 let (start, end) = (bound("startTime"), bound("endTime"));
1154 let page: Vec<_> = self
1155 .fills
1156 .iter()
1157 .filter(|fill| (start..=end).contains(&fill["time"].as_u64().unwrap()))
1158 .take(USER_FILLS_PER_RESPONSE)
1159 .cloned()
1160 .collect();
1161 ResponseTemplate::new(200).set_body_json(page)
1162 }
1163 }
1164
1165 fn fill(tid: u64, time: u64) -> serde_json::Value {
1167 let mut fill: serde_json::Value = serde_json::from_str(DOCUMENTED_FILL).unwrap();
1168 fill["tid"] = tid.into();
1169 fill["time"] = time.into();
1170 fill
1171 }
1172
1173 async fn serve_fills_by_time(
1174 fills: Vec<serde_json::Value>,
1175 ) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
1176 let server = MockServer::start().await;
1177 Mock::given(method("POST"))
1178 .and(path("/info"))
1179 .respond_with(FillsByTime { fills })
1180 .mount(&server)
1181 .await;
1182 let client = info_client_against(server.uri()).await;
1183 (server, client)
1184 }
1185
1186 #[tokio::test]
1190 async fn a_span_with_more_fills_than_one_response_is_read_whole() {
1191 let fills = (0..4_500).map(|tid| fill(tid, 1_000 + tid / 3)).collect();
1192 let (server, client) = serve_fills_by_time(fills).await;
1193
1194 let read = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1195 .await
1196 .unwrap();
1197 let tids: Vec<u64> = read.iter().map(|fill| fill.tid).collect();
1198 assert_eq!(tids, (0..4_500).collect::<Vec<_>>());
1199 assert_eq!(server.received_requests().await.map(|r| r.len()), Some(3));
1200
1201 let read = user_fills_by_time(&client, ethers::types::H160::zero(), 1_010, 1_019)
1203 .await
1204 .unwrap();
1205 assert_eq!(read.len(), 30);
1206 }
1207
1208 #[tokio::test]
1210 async fn a_read_that_cannot_advance_is_truncated() {
1211 let fills = (0..USER_FILLS_PER_RESPONSE as u64)
1212 .map(|tid| fill(tid, 5))
1213 .collect();
1214 let (_server, client) = serve_fills_by_time(fills).await;
1215
1216 let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1217 .await
1218 .unwrap_err();
1219 assert!(
1220 matches!(error, UnindexedClientError::Truncated { fills_read: 2_000 }),
1221 "got {error:?}"
1222 );
1223 }
1224
1225 #[test]
1226 fn a_span_is_read_in_whole_milliseconds_and_an_empty_one_not_at_all() {
1227 let at = |s: &str| s.parse::<DateTime<Utc>>().unwrap();
1228 assert_eq!(
1229 span_millis(
1230 at("1970-01-01T00:00:01.000900Z"),
1231 at("1970-01-01T00:00:02.000900Z")
1232 ),
1233 Some((1_000, 2_000))
1234 );
1235 assert_eq!(
1236 span_millis(at("1969-12-31T23:59:59Z"), at("1970-01-01T00:00:01Z")),
1237 Some((0, 1_000))
1238 );
1239 assert_eq!(
1240 span_millis(at("1970-01-01T00:00:02Z"), at("1970-01-01T00:00:01Z")),
1241 None
1242 );
1243 assert_eq!(
1244 span_millis(at("1969-01-01T00:00:00Z"), at("1969-12-31T23:59:59Z")),
1245 None
1246 );
1247 }
1248
1249 const CID: &str = "85b60bff-64d5-49ec-95af-6268d7d1df63";
1252 const CLOID: &str = "0x85b60bff64d549ec95af6268d7d1df63";
1254
1255 #[test]
1257 fn a_user_fill_reads_its_cloid() {
1258 let fill = |cloid: &str| {
1259 serde_json::from_str::<UserFill>(&format!(
1260 r#"{{"coin": "BTC", "side": "B", "px": "1", "sz": "1", "time": 1, "oid": 7,
1261 "fee": "0", "tid": 9{cloid}}}"#
1262 ))
1263 .unwrap()
1264 };
1265
1266 let with = fill(&format!(r#", "cloid": "{CLOID}""#));
1267 assert_eq!(with.cloid.as_deref(), Some(CLOID));
1268 assert!(cloid_to_cid(CLOID).is_some());
1269 assert_eq!(fill("").cloid, None);
1270 assert_eq!(fill(r#", "cloid": null"#).cloid, None);
1271 }
1272
1273 #[test]
1274 fn only_the_canonical_uuid_form_can_be_a_cloid() {
1275 let cloid = cid_to_cloid(&ClientOrderId::new(CID)).unwrap();
1276 assert_eq!(cloid.to_string(), CID);
1277
1278 for other in [
1280 "85B60BFF-64D5-49EC-95AF-6268D7D1DF63",
1281 "85b60bff64d549ec95af6268d7d1df63",
1282 "{85b60bff-64d5-49ec-95af-6268d7d1df63}",
1283 "urn:uuid:85b60bff-64d5-49ec-95af-6268d7d1df63",
1284 "example-1714100000000",
1285 "",
1286 ] {
1287 assert_eq!(cid_to_cloid(&ClientOrderId::new(other)), None, "{other}");
1288 }
1289 }
1290
1291 #[test]
1292 fn a_generated_uuid_client_id_is_always_a_cloid() {
1293 for _ in 0..32 {
1294 let cid = ClientOrderId::uuid();
1295 assert!(cid_to_cloid(&cid).is_some(), "{cid}");
1296 }
1297 }
1298
1299 #[test]
1300 fn the_reported_cloid_is_the_client_id_the_order_was_placed_under() {
1301 assert_eq!(cloid_to_cid(CLOID), Some(ClientOrderId::new(CID)));
1302
1303 let cid = ClientOrderId::uuid();
1305 let sent = format!("0x{}", cid_to_cloid(&cid).unwrap().simple());
1306 assert_eq!(cloid_to_cid(&sent), Some(cid));
1307 }
1308
1309 #[test]
1310 fn a_cloid_not_in_the_venue_form_names_no_client_id() {
1311 for malformed in [
1312 CID,
1313 "85b60bff64d549ec95af6268d7d1df63",
1314 "0x85b60bff64d549ec95af6268d7d1df",
1315 "0x85b60bff64d549ec95af6268d7d1df6300",
1316 "0x85b60bff-64d5-49ec-95af-6268d7d1df63",
1317 "0xzzb60bff64d549ec95af6268d7d1df63",
1318 "0x",
1319 ] {
1320 assert_eq!(cloid_to_cid(malformed), None, "{malformed}");
1321 }
1322 }
1323
1324 #[test]
1325 fn a_record_without_a_cloid_is_reported_under_its_oid() {
1326 assert_eq!(record_cid(None, 42), Some(ClientOrderId::new("42")));
1327 assert_eq!(record_cid(Some(CLOID), 42), Some(ClientOrderId::new(CID)));
1328 assert_eq!(record_cid(Some("0x1234"), 42), None);
1329 }
1330
1331 #[test]
1334 fn every_documented_order_status_is_classified() {
1335 use OrderStatus::*;
1336 for (status, expected) in [
1337 ("open", Open),
1338 ("triggered", Open),
1339 ("filled", Filled),
1340 ("canceled", Cancelled),
1341 ("marginCanceled", Cancelled),
1342 ("vaultWithdrawalCanceled", Cancelled),
1343 ("openInterestCapCanceled", Cancelled),
1344 ("selfTradeCanceled", Cancelled),
1345 ("reduceOnlyCanceled", Cancelled),
1346 ("siblingFilledCanceled", Cancelled),
1347 ("delistedCanceled", Cancelled),
1348 ("liquidatedCanceled", Cancelled),
1349 ("scheduledCancel", Cancelled),
1350 ("rejected", Rejected),
1351 ("tickRejected", Rejected),
1352 ("minTradeNtlRejected", Rejected),
1353 ("perpMarginRejected", Rejected),
1354 ("reduceOnlyRejected", Rejected),
1355 ("badAloPxRejected", Rejected),
1356 ("iocCancelRejected", Rejected),
1357 ("badTriggerPxRejected", Rejected),
1358 ("marketOrderNoLiquidityRejected", Rejected),
1359 ("positionIncreaseAtOpenInterestCapRejected", Rejected),
1360 ("positionFlipAtOpenInterestCapRejected", Rejected),
1361 ("tooAggressiveAtOpenInterestCapRejected", Rejected),
1362 ("openInterestIncreaseRejected", Rejected),
1363 ("insufficientSpotBalanceRejected", Rejected),
1364 ("oracleRejected", Rejected),
1365 ("perpMaxPositionRejected", Rejected),
1366 ] {
1367 assert_eq!(OrderStatus::classify(status), Some(expected), "{status}");
1368 }
1369 assert_eq!(OrderStatus::classify("unknown_status"), None);
1370 }
1371
1372 fn order_update(status: &str, cloid: &str, sz: &str) -> hyperliquid_rust_sdk::OrderUpdate {
1373 serde_json::from_str(&format!(
1374 r#"{{"order": {{"coin": "ETH", "side": "B", "limitPx": "1605.0", "sz": "{sz}",
1375 "oid": 7001, "timestamp": 1714100000000, "origSz": "0.0075", "cloid": {cloid}}},
1376 "status": "{status}", "statusTimestamp": 1714100001000}}"#
1377 ))
1378 .unwrap()
1379 }
1380
1381 fn snapshot_of(event: UnindexedAccountEvent) -> UnindexedOrderSnapshot {
1382 match event.kind {
1383 crate::AccountEventKind::OrderSnapshot(snapshot) => snapshot.0,
1384 other => panic!("expected an order snapshot, got {other:?}"),
1385 }
1386 }
1387
1388 fn eth() -> InstrumentNameExchange {
1389 InstrumentNameExchange::from("ETH-USDC-PERP")
1390 }
1391
1392 #[test]
1393 fn an_order_update_names_the_order_by_the_id_it_was_placed_under() {
1394 let update = order_update("open", &format!(r#""{CLOID}""#), "0.005");
1395 let order = snapshot_of(
1396 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1397 );
1398
1399 assert_eq!(order.key.cid, ClientOrderId::new(CID));
1400 assert_eq!(order.quantity, dec!(0.0075));
1401 match order.state {
1402 OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) => {
1403 assert_eq!(open.id, VenueOrderId::Assigned(OrderId::new("7001")));
1404 assert_eq!(open.filled_quantity, dec!(0.0025));
1405 }
1406 other => panic!("expected Open, got {other:?}"),
1407 }
1408 }
1409
1410 #[test]
1411 fn an_order_update_without_a_cloid_is_named_by_its_oid() {
1412 let update = order_update("open", "null", "0.0075");
1413 let order = snapshot_of(
1414 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1415 );
1416 assert_eq!(order.key.cid, ClientOrderId::new("7001"));
1417 }
1418
1419 #[test]
1420 fn an_order_update_with_a_malformed_cloid_is_dropped() {
1421 let update = order_update("open", r#""0x1234""#, "0.0075");
1422 assert!(
1423 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).is_none()
1424 );
1425 }
1426
1427 #[test]
1428 fn a_cancellation_for_any_reason_ends_the_order() {
1429 let update = order_update("selfTradeCanceled", &format!(r#""{CLOID}""#), "0.005");
1430 let order = snapshot_of(
1431 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1432 );
1433 match order.state {
1434 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1435 assert_eq!(cancelled.filled_quantity, Some(dec!(0.0025)));
1436 }
1437 other => panic!("expected Cancelled, got {other:?}"),
1438 }
1439 }
1440
1441 #[test]
1444 fn a_cancellation_with_an_unparseable_size_ends_the_order_with_its_fill_unknown() {
1445 let update = order_update("canceled", &format!(r#""{CLOID}""#), "not a number");
1446 let order = snapshot_of(
1447 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1448 );
1449 match order.state {
1450 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1451 assert_eq!(cancelled.filled_quantity, None);
1452 }
1453 other => panic!("expected Cancelled, got {other:?}"),
1454 }
1455 }
1456
1457 #[test]
1458 fn a_status_saying_funds_ran_short_ends_the_order_as_balance_insufficient() {
1459 for status in ["perpMarginRejected", "insufficientSpotBalanceRejected"] {
1460 let update = order_update(status, &format!(r#""{CLOID}""#), "0.0075");
1461 let order = snapshot_of(
1462 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1463 );
1464 match order.state {
1465 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
1466 OrderError::Rejected(ApiError::BalanceInsufficient(None, reason)),
1467 )) => assert_eq!(reason, status),
1468 other => panic!("{status}: expected BalanceInsufficient, got {other:?}"),
1469 }
1470 }
1471 }
1472
1473 #[test]
1474 fn a_rejection_ends_the_order_and_names_the_reason() {
1475 let update = order_update("badAloPxRejected", &format!(r#""{CLOID}""#), "0.0075");
1476 let order = snapshot_of(
1477 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1478 );
1479 match order.state {
1480 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
1481 OrderError::Rejected(ApiError::OrderRejected(reason)),
1482 )) => assert_eq!(reason, "badAloPxRejected"),
1483 other => panic!("expected a rejection, got {other:?}"),
1484 }
1485 }
1486
1487 const OPEN_ORDERS: &str = r#"[
1492 {"coin":"ETH","side":"B","limitPx":"1605.0","sz":"0.005","oid":7001,
1493 "timestamp":1714100000000,"origSz":"0.0075","cloid":"0x85b60bff64d549ec95af6268d7d1df63"},
1494 {"coin":"ETH","side":"A","limitPx":"1700.0","sz":"0.01","oid":7002,
1495 "timestamp":1714100000000,"origSz":"0.01"},
1496 {"coin":"BTC","side":"A","limitPx":"50000.0","sz":"0.001","oid":7003,
1497 "timestamp":1714100000000}
1498 ]"#;
1499
1500 #[tokio::test]
1501 async fn open_orders_carry_the_cloid_and_original_size_the_sdk_type_drops() {
1502 let (_server, client) = serve(OPEN_ORDERS.to_string()).await;
1503
1504 let rows = open_orders(&client, ethers::types::H160::zero())
1505 .await
1506 .unwrap();
1507
1508 assert_eq!(rows.len(), 3);
1509 assert_eq!(rows[0].cloid.as_deref(), Some(CLOID));
1510 assert_eq!(rows[0].orig_sz.as_deref(), Some("0.0075"));
1511 assert_eq!(rows[1].cloid, None);
1512 assert_eq!(rows[2].orig_sz, None);
1513 }
1514
1515 fn rows() -> Vec<OpenOrder> {
1516 serde_json::from_str(OPEN_ORDERS).unwrap()
1517 }
1518
1519 fn perp(coin: &str) -> Option<InstrumentNameExchange> {
1520 Some(InstrumentNameExchange::from(format!("{coin}-USDC-PERP")))
1521 }
1522
1523 #[test]
1524 fn an_open_order_is_listed_under_its_client_id_with_its_fill_so_far() {
1525 let order = open_order_to_order(&rows()[0], ExchangeId::HyperliquidPerp, eth()).unwrap();
1526
1527 assert_eq!(order.key.cid, ClientOrderId::new(CID));
1528 assert_eq!(order.quantity, dec!(0.0075));
1529 assert_eq!(order.state.filled_quantity, dec!(0.0025));
1530 assert_eq!(order.state.id, VenueOrderId::Assigned(OrderId::new("7001")));
1531 }
1532
1533 #[test]
1534 fn an_open_order_without_its_original_size_is_its_remainder_with_nothing_filled() {
1535 let row = &rows()[2];
1536 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1537 let order = open_order_to_order(row, ExchangeId::HyperliquidPerp, btc).unwrap();
1538
1539 assert_eq!(order.key.cid, ClientOrderId::new("7003"));
1540 assert_eq!(order.quantity, dec!(0.001));
1541 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
1542 }
1543
1544 #[test]
1545 fn every_requested_instrument_is_listed_complete_even_with_nothing_open() {
1546 let sol = InstrumentNameExchange::from("SOL-USDC-PERP");
1547 let mut listing = OpenOrderListing::new(
1548 &rows(),
1549 ExchangeId::HyperliquidPerp,
1550 &[eth(), sol.clone()],
1551 perp,
1552 );
1553
1554 let eth_entry = listing.remove(ð()).unwrap();
1555 assert!(eth_entry.orders_complete);
1556 assert_eq!(eth_entry.orders.len(), 2);
1557
1558 let sol_entry = listing.remove(&sol).unwrap();
1560 assert!(sol_entry.orders_complete);
1561 assert!(sol_entry.orders.is_empty());
1562
1563 assert_eq!(listing.into_snapshots().count(), 0);
1565 }
1566
1567 #[test]
1568 fn an_unfiltered_listing_covers_every_instrument_with_open_orders() {
1569 let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidPerp, &[], perp);
1570 let mut instruments = listing
1571 .into_snapshots()
1572 .map(|entry| entry.instrument.to_string())
1573 .collect::<Vec<_>>();
1574 instruments.sort();
1575 assert_eq!(instruments, ["BTC-USDC-PERP", "ETH-USDC-PERP"]);
1576 }
1577
1578 #[test]
1579 fn an_order_that_does_not_convert_leaves_only_its_instrument_incomplete() {
1580 let mut rows = rows();
1581 rows[1].side = "?".to_string();
1582 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1583 let mut listing = OpenOrderListing::new(
1584 &rows,
1585 ExchangeId::HyperliquidPerp,
1586 &[eth(), btc.clone()],
1587 perp,
1588 );
1589
1590 let eth_entry = listing.remove(ð()).unwrap();
1591 assert!(!eth_entry.orders_complete);
1592 assert_eq!(eth_entry.orders.len(), 1);
1593 assert!(listing.remove(&btc).unwrap().orders_complete);
1594 }
1595
1596 #[test]
1597 fn an_order_with_a_malformed_cloid_leaves_its_instrument_incomplete() {
1598 let mut rows = rows();
1599 rows[0].cloid = Some("0x1234".to_string());
1600 let mut listing = OpenOrderListing::new(&rows, ExchangeId::HyperliquidPerp, &[eth()], perp);
1601
1602 let eth_entry = listing.remove(ð()).unwrap();
1603 assert!(!eth_entry.orders_complete);
1604 assert_eq!(eth_entry.orders.len(), 1);
1605 }
1606
1607 #[test]
1608 fn coins_the_client_does_not_trade_are_left_out() {
1609 let pairs = super::super::spot_coins::test_spot_pairs();
1610 let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidSpot, &[], |coin| {
1611 pairs.get(coin).map(spot_pair_to_instrument)
1612 });
1613 assert_eq!(listing.into_snapshots().count(), 0);
1614 }
1615}