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::{InstrumentAccountSnapshot, UnindexedAccountEvent};
16use chrono::{DateTime, TimeZone, Utc};
17use futures::Stream;
18use rust_decimal::Decimal;
19use rustrade_instrument::{
20 Side, asset::name::AssetNameExchange, exchange::ExchangeId,
21 instrument::name::InstrumentNameExchange,
22};
23use serde::de::DeserializeOwned;
24use smol_str::format_smolstr;
25use std::collections::{HashMap, HashSet};
26use std::pin::Pin;
27use std::str::FromStr;
28use std::task::{Context, Poll};
29use tokio_util::sync::CancellationToken;
30use tracing::warn;
31use uuid::Uuid;
32
33#[derive(Debug)]
38pub struct CancelOnDropStream<S> {
39 inner: S,
40 cancel_token: CancellationToken,
41}
42
43impl<S> CancelOnDropStream<S> {
44 pub(crate) fn new(inner: S, cancel_token: CancellationToken) -> Self {
46 Self {
47 inner,
48 cancel_token,
49 }
50 }
51}
52
53impl<S: Stream + Unpin> Stream for CancelOnDropStream<S> {
54 type Item = S::Item;
55
56 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
57 Pin::new(&mut self.inner).poll_next(cx)
58 }
59}
60
61impl<S> Drop for CancelOnDropStream<S> {
62 fn drop(&mut self) {
63 self.cancel_token.cancel();
64 }
65}
66
67#[derive(Debug, Clone, serde::Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct UserFill {
86 pub coin: String,
87 pub side: String,
88 pub px: String,
89 pub sz: String,
90 pub time: u64,
91 pub oid: u64,
92 pub fee: String,
93
94 pub tid: u64,
105
106 pub fee_token: Option<String>,
112}
113
114pub async fn user_fills(
125 info_client: &hyperliquid_rust_sdk::InfoClient,
126 address: ethers::types::H160,
127) -> Result<Vec<UserFill>, UnindexedClientError> {
128 user_info(info_client, "userFills", address).await
129}
130
131#[derive(Debug, Clone, serde::Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct OpenOrder {
143 pub coin: String,
144 pub side: String,
145 pub limit_px: String,
146 pub sz: String,
148 pub oid: u64,
149 pub timestamp: u64,
150
151 #[serde(default)]
157 pub orig_sz: Option<String>,
158
159 #[serde(default)]
162 pub cloid: Option<String>,
163}
164
165pub async fn open_orders(
173 info_client: &hyperliquid_rust_sdk::InfoClient,
174 address: ethers::types::H160,
175) -> Result<Vec<OpenOrder>, UnindexedClientError> {
176 user_info(info_client, "openOrders", address).await
177}
178
179async fn user_info<T: DeserializeOwned>(
185 info_client: &hyperliquid_rust_sdk::InfoClient,
186 kind: &str,
187 address: ethers::types::H160,
188) -> Result<T, UnindexedClientError> {
189 let body = format!(r#"{{"type":"{kind}","user":"{address:?}"}}"#);
192
193 let raw = info_client
194 .http_client
195 .post("/info", body)
196 .await
197 .map_err(map_sdk_error)?;
198
199 serde_json::from_str(&raw).map_err(|e| {
202 UnindexedClientError::Internal(format!("Hyperliquid {kind} response did not parse: {e}"))
203 })
204}
205
206pub(super) fn open_order_to_order(
211 row: &OpenOrder,
212 exchange: ExchangeId,
213 instrument: InstrumentNameExchange,
214) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
215 let parsed = (|| {
216 let cid = record_cid(row.cloid.as_deref(), row.oid)?;
217 let side = parse_side(&row.side)?;
218 let price = parse_decimal(&row.limit_px, "limitPx")?;
219 let remaining = parse_decimal(&row.sz, "sz")?;
220 let quantity = match &row.orig_sz {
221 Some(orig_sz) => parse_decimal(orig_sz, "origSz")?,
222 None => remaining,
223 };
224 let time_exchange = millis_to_datetime(row.timestamp)?;
225 Some((cid, side, price, quantity, remaining, time_exchange))
226 })();
227
228 let Some((cid, side, price, quantity, remaining, time_exchange)) = parsed else {
229 warn!(
230 %exchange,
231 %instrument,
232 oid = row.oid,
233 order = ?row,
234 "Hyperliquid open order did not convert - leaving it out, so this instrument's order \
235 list is not complete"
236 );
237 return None;
238 };
239
240 Some(Order {
241 key: OrderKey {
242 exchange,
243 instrument,
244 strategy: StrategyId::unknown(),
245 cid,
246 },
247 side,
248 price: Some(price),
249 quantity,
250 kind: OrderKind::Limit,
252 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
253 state: Open {
254 id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", row.oid))),
255 time_exchange,
256 filled_quantity: (quantity - remaining).max(Decimal::ZERO),
257 },
258 })
259}
260
261type UnindexedInstrumentAccountSnapshot =
262 InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
263
264pub(super) struct OpenOrderListing(
266 HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
267);
268
269impl OpenOrderListing {
270 pub(super) fn new(
283 rows: &[OpenOrder],
284 exchange: ExchangeId,
285 requested: &[InstrumentNameExchange],
286 to_instrument: impl Fn(&str) -> Option<InstrumentNameExchange>,
287 ) -> Self {
288 fn entry<'a>(
289 listing: &'a mut HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
290 instrument: &InstrumentNameExchange,
291 ) -> &'a mut UnindexedInstrumentAccountSnapshot {
292 listing
293 .entry(instrument.clone())
294 .or_insert_with(|| InstrumentAccountSnapshot {
295 instrument: instrument.clone(),
296 orders: Vec::new(),
297 orders_complete: true,
298 position: None,
299 isolated: None,
300 })
301 }
302
303 let requested_set = requested.iter().collect::<HashSet<_>>();
304 let mut listing = HashMap::with_capacity(requested.len());
305
306 for instrument in requested {
307 entry(&mut listing, instrument);
308 }
309
310 for row in rows {
311 let Some(instrument) = to_instrument(&row.coin) else {
312 continue;
313 };
314 if !requested_set.is_empty() && !requested_set.contains(&instrument) {
315 continue;
316 }
317 let order = open_order_to_order(row, exchange, instrument.clone());
318 let snapshot = entry(&mut listing, &instrument);
319 match order {
320 Some(order) => snapshot.orders.push(order.into()),
321 None => snapshot.orders_complete = false,
322 }
323 }
324
325 Self(listing)
326 }
327
328 pub(super) fn remove(
330 &mut self,
331 instrument: &InstrumentNameExchange,
332 ) -> Option<UnindexedInstrumentAccountSnapshot> {
333 self.0.remove(instrument)
334 }
335
336 pub(super) fn into_snapshots(self) -> impl Iterator<Item = UnindexedInstrumentAccountSnapshot> {
338 self.0.into_values()
339 }
340}
341
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344enum OrderStatus {
345 Open,
346 Filled,
347 Cancelled,
348 Rejected,
349}
350
351impl OrderStatus {
352 fn classify(status: &str) -> Option<Self> {
363 match status {
364 "open" | "triggered" => Some(Self::Open),
365 "filled" => Some(Self::Filled),
366 "canceled" | "scheduledCancel" => Some(Self::Cancelled),
367 "rejected" => Some(Self::Rejected),
368 status if status.ends_with("Canceled") => Some(Self::Cancelled),
369 status if status.ends_with("Rejected") => Some(Self::Rejected),
370 _ => None,
371 }
372 }
373}
374
375pub(super) fn order_update_to_account_event(
381 update: &hyperliquid_rust_sdk::OrderUpdate,
382 exchange: ExchangeId,
383 instrument: InstrumentNameExchange,
384) -> Option<UnindexedAccountEvent> {
385 let order = &update.order;
386 let Some(status) = OrderStatus::classify(&update.status) else {
387 warn!(%exchange, status = %update.status, oid = order.oid, "Unknown Hyperliquid order status - ignoring the update");
388 return None;
389 };
390 let cid = record_cid(order.cloid.as_deref(), order.oid)?;
391 let side = parse_side(&order.side)?;
392 let price = parse_decimal(&order.limit_px, "order.limitPx")?;
393 let orig_sz = parse_decimal(&order.orig_sz, "order.origSz")?;
394 let Some(time_exchange) = millis_to_datetime(update.status_timestamp) else {
395 warn!(%exchange, oid = order.oid, status_timestamp = update.status_timestamp, "Invalid Hyperliquid order update timestamp - ignoring the update");
396 return None;
397 };
398 let order_id = OrderId(format_smolstr!("{}", order.oid));
399 let filled_quantity = || {
400 parse_decimal(&order.sz, "order.sz")
401 .map(|remaining| (orig_sz - remaining).max(Decimal::ZERO))
402 };
403
404 let state = match status {
405 OrderStatus::Open => OrderState::active(Open {
406 id: VenueOrderId::Assigned(order_id),
407 time_exchange,
408 filled_quantity: filled_quantity()?,
409 }),
410 OrderStatus::Filled => OrderState::fully_filled(Filled::new(
411 order_id,
412 time_exchange,
413 orig_sz,
414 None, )),
416 OrderStatus::Cancelled => {
417 OrderState::inactive(Cancelled::new(order_id, time_exchange, filled_quantity()?))
418 }
419 OrderStatus::Rejected => OrderState::inactive(OrderError::Rejected(
420 ApiError::OrderRejected(update.status.clone()),
421 )),
422 };
423
424 let snapshot: UnindexedOrderSnapshot = Order {
426 key: OrderKey {
427 exchange,
428 instrument,
429 strategy: StrategyId::unknown(),
430 cid,
431 },
432 side,
433 price: Some(price),
434 quantity: orig_sz,
435 kind: OrderKind::Limit,
436 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
437 state,
438 };
439
440 Some(crate::AccountEvent::new(
441 exchange,
442 crate::AccountEventKind::OrderSnapshot(
443 rustrade_integration::collection::snapshot::Snapshot(snapshot),
444 ),
445 ))
446}
447
448pub fn parse_decimal(value: &str, field: &str) -> Option<Decimal> {
449 Decimal::from_str(value)
450 .map_err(|e| warn!(%field, %value, %e, "Failed to parse decimal"))
451 .ok()
452}
453
454pub fn parse_side(side: &str) -> Option<Side> {
459 match side {
460 "B" | "b" | "BUY" | "Buy" | "buy" => Some(Side::Buy),
461 "A" | "a" | "S" | "s" | "SELL" | "Sell" | "sell" => Some(Side::Sell),
462 _ => {
463 warn!(%side, "Unknown side string");
464 None
465 }
466 }
467}
468
469pub fn millis_to_datetime(millis: u64) -> Option<DateTime<Utc>> {
473 Utc.timestamp_millis_opt(i64::try_from(millis).ok()?)
474 .single()
475}
476
477pub fn round_to_5_sig_figs(value: Decimal) -> f64 {
482 use rust_decimal::prelude::ToPrimitive;
483
484 if value.is_zero() {
485 return 0.0;
486 }
487
488 let abs_f = value.abs().to_f64().unwrap_or(0.0);
491 if abs_f == 0.0 {
492 return 0.0;
493 }
494
495 #[allow(clippy::cast_possible_truncation)]
497 let magnitude = abs_f.log10().floor().clamp(-30.0, 30.0) as i32;
498
499 let rounded = if magnitude >= 4 {
501 #[allow(clippy::cast_sign_loss)]
504 let factor = Decimal::from(10i64.pow((magnitude - 4) as u32));
505 (value / factor).round() * factor
506 } else {
507 #[allow(clippy::cast_sign_loss)]
509 let dp = (4 - magnitude) as u32;
510 value.round_dp(dp)
511 };
512
513 rounded.to_f64().unwrap_or(0.0)
515}
516
517pub fn map_tif(tif: &TimeInForce) -> &'static str {
519 match tif {
520 TimeInForce::GoodUntilCancelled { post_only: true } => "Alo",
521 TimeInForce::GoodUntilCancelled { post_only: false } => "Gtc",
522 TimeInForce::ImmediateOrCancel => "Ioc",
523 TimeInForce::FillOrKill => "Ioc", TimeInForce::GoodUntilEndOfDay => "Gtc", TimeInForce::GoodTillDate { .. } | TimeInForce::AtOpen | TimeInForce::AtClose => {
529 warn!(time_in_force = ?tif, "Hyperliquid does not support this TimeInForce; coercing to Gtc");
530 "Gtc"
531 }
532 }
533}
534
535pub(super) const CLOID_REQUIRED: &str = "Hyperliquid requires the client order id to be a UUID in \
537 canonical form, as ClientOrderId::uuid() makes: the venue reports each order under that id, \
538 and an order under any other id could not be matched to its own updates";
539
540pub fn cid_to_cloid(cid: &ClientOrderId) -> Option<Uuid> {
548 let uuid = Uuid::try_parse(cid.0.as_str()).ok()?;
549 let mut canonical = Uuid::encode_buffer();
550 (uuid.hyphenated().encode_lower(&mut canonical) == cid.0.as_str()).then_some(uuid)
551}
552
553pub fn cloid_to_cid(cloid: &str) -> Option<ClientOrderId> {
557 let hex = cloid.strip_prefix("0x")?;
558 if hex.len() != 32 {
559 return None;
560 }
561 let uuid = Uuid::try_parse(hex).ok()?;
563 Some(ClientOrderId::new(format_smolstr!("{}", uuid.hyphenated())))
564}
565
566pub(super) fn record_cid(cloid: Option<&str>, oid: u64) -> Option<ClientOrderId> {
576 match cloid {
577 None => Some(ClientOrderId::new(format_smolstr!("{oid}"))),
578 Some(cloid) => {
579 let cid = cloid_to_cid(cloid);
580 if cid.is_none() {
581 warn!(%cloid, oid, "Hyperliquid order carries a cloid that is not 0x and 32 hex digits");
582 }
583 cid
584 }
585 }
586}
587
588pub fn perp_coin_to_instrument(coin: &str) -> InstrumentNameExchange {
590 InstrumentNameExchange::from(format_smolstr!("{}-USD-PERP", coin))
591}
592
593pub fn instrument_to_perp_coin(instrument: &InstrumentNameExchange) -> String {
597 let s = instrument.as_ref();
598 match s.split_once('-') {
600 Some((coin, _)) => coin.to_string(),
601 None => s.to_string(),
602 }
603}
604
605pub fn spot_coin_to_instrument(coin: &str) -> InstrumentNameExchange {
612 match coin.split_once('/') {
613 Some((base, quote)) => {
614 InstrumentNameExchange::from(format_smolstr!("{}-{}-SPOT", base, quote))
615 }
616 None => {
617 debug_assert!(
618 false,
619 "spot_coin_to_instrument called with non-spot coin: {coin}"
620 );
621 InstrumentNameExchange::from(format_smolstr!("{}-SPOT", coin))
622 }
623 }
624}
625
626pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
632 let s = instrument.as_ref();
633 let without_suffix = s.strip_suffix("-SPOT")?;
635 let (base, quote) = without_suffix.split_once('-')?;
636 Some(format!("{}/{}", base, quote))
637}
638
639pub fn is_spot_coin(coin: &str) -> bool {
646 coin.contains('/')
647}
648
649#[cfg(test)]
650#[allow(clippy::unwrap_used)]
652mod tests {
653 use super::*;
654 use rust_decimal_macros::dec;
655
656 #[test]
657 fn test_parse_decimal_valid() {
658 assert_eq!(parse_decimal("123.456", "test"), Some(dec!(123.456)));
659 assert_eq!(parse_decimal("0", "test"), Some(dec!(0)));
660 assert_eq!(parse_decimal("-50.5", "test"), Some(dec!(-50.5)));
661 }
662
663 #[test]
664 fn test_parse_decimal_invalid() {
665 assert_eq!(parse_decimal("", "test"), None);
666 assert_eq!(parse_decimal("abc", "test"), None);
667 assert_eq!(parse_decimal("12.34.56", "test"), None);
668 }
669
670 #[test]
671 fn test_parse_side() {
672 assert_eq!(parse_side("B"), Some(Side::Buy));
673 assert_eq!(parse_side("BUY"), Some(Side::Buy));
674 assert_eq!(parse_side("buy"), Some(Side::Buy));
675 assert_eq!(parse_side("A"), Some(Side::Sell));
676 assert_eq!(parse_side("S"), Some(Side::Sell));
677 assert_eq!(parse_side("SELL"), Some(Side::Sell));
678 assert_eq!(parse_side("sell"), Some(Side::Sell));
679 assert_eq!(parse_side("X"), None);
680 assert_eq!(parse_side(""), None);
681 }
682
683 #[test]
684 fn test_perp_coin_to_instrument() {
685 let inst = perp_coin_to_instrument("BTC");
686 assert_eq!(inst.as_ref(), "BTC-USD-PERP");
687
688 let inst = perp_coin_to_instrument("ETH");
689 assert_eq!(inst.as_ref(), "ETH-USD-PERP");
690 }
691
692 #[test]
693 fn test_instrument_to_perp_coin() {
694 let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("BTC-USD-PERP"));
695 assert_eq!(coin, "BTC");
696
697 let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("ETH-USD-PERP"));
698 assert_eq!(coin, "ETH");
699
700 let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("SOL"));
702 assert_eq!(coin, "SOL");
703 }
704
705 #[test]
706 fn test_spot_coin_to_instrument() {
707 let inst = spot_coin_to_instrument("PURR/USDC");
708 assert_eq!(inst.as_ref(), "PURR-USDC-SPOT");
709
710 let inst = spot_coin_to_instrument("HYPE/USDC");
711 assert_eq!(inst.as_ref(), "HYPE-USDC-SPOT");
712 }
713
714 #[test]
715 fn test_instrument_to_spot_coin() {
716 let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("PURR-USDC-SPOT"));
717 assert_eq!(coin, Some("PURR/USDC".to_string()));
718
719 let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("HYPE-USDC-SPOT"));
720 assert_eq!(coin, Some("HYPE/USDC".to_string()));
721
722 assert_eq!(
724 instrument_to_spot_coin(&InstrumentNameExchange::from("BTC-USD-PERP")),
725 None
726 );
727 assert_eq!(
728 instrument_to_spot_coin(&InstrumentNameExchange::from("INVALID")),
729 None
730 );
731 }
732
733 #[test]
734 fn test_is_spot_coin() {
735 assert!(is_spot_coin("PURR/USDC"));
736 assert!(is_spot_coin("HYPE/USDC"));
737 assert!(!is_spot_coin("BTC"));
738 assert!(!is_spot_coin("ETH"));
739 }
740
741 #[test]
742 fn test_round_to_5_sig_figs() {
743 assert_eq!(round_to_5_sig_figs(dec!(0)), 0.0);
744 assert_eq!(round_to_5_sig_figs(dec!(12345)), 12345.0);
745 assert_eq!(round_to_5_sig_figs(dec!(123456)), 123460.0);
746 assert_eq!(round_to_5_sig_figs(dec!(0.00012345)), 0.00012345);
747 assert_eq!(round_to_5_sig_figs(dec!(0.000123456)), 0.00012346);
748 assert_eq!(round_to_5_sig_figs(dec!(1.23456789)), 1.2346);
749 }
750
751 #[test]
752 fn test_map_tif() {
753 assert_eq!(
754 map_tif(&TimeInForce::GoodUntilCancelled { post_only: false }),
755 "Gtc"
756 );
757 assert_eq!(
758 map_tif(&TimeInForce::GoodUntilCancelled { post_only: true }),
759 "Alo"
760 );
761 assert_eq!(map_tif(&TimeInForce::ImmediateOrCancel), "Ioc");
762 assert_eq!(map_tif(&TimeInForce::FillOrKill), "Ioc");
763 assert_eq!(map_tif(&TimeInForce::GoodUntilEndOfDay), "Gtc");
764 }
765
766 #[test]
767 fn test_millis_to_datetime() {
768 let dt = millis_to_datetime(1714100000000).unwrap();
769 assert_eq!(dt.timestamp_millis(), 1714100000000);
770
771 assert!(millis_to_datetime(0).is_some());
773 }
774}
775
776#[cfg(test)]
777#[allow(clippy::unwrap_used)]
779mod info_tests {
780 use super::*;
781 use rust_decimal_macros::dec;
782 use wiremock::matchers::{method, path};
783 use wiremock::{Mock, MockServer, ResponseTemplate};
784
785 const DOCUMENTED_FILL: &str = r#"{
788 "closedPnl": "0.0",
789 "coin": "AVAX",
790 "crossed": false,
791 "dir": "Open Long",
792 "hash": "0xa166e3fa63c25663024b03f2e0da011a00307e4017465df020210d3d432e7cb8",
793 "oid": 90542681,
794 "px": "18.435",
795 "side": "B",
796 "startPosition": "26.86",
797 "sz": "93.53",
798 "time": 1681222254710,
799 "fee": "0.01",
800 "feeToken": "USDC",
801 "builderFee": "0.01",
802 "tid": 118906512037719
803 }"#;
804
805 async fn info_client_against(uri: String) -> hyperliquid_rust_sdk::InfoClient {
808 let mut client = hyperliquid_rust_sdk::InfoClient::new(
809 None,
810 Some(hyperliquid_rust_sdk::BaseUrl::Localhost),
811 )
812 .await
813 .unwrap();
814 client.http_client.base_url = uri;
815 client
816 }
817
818 async fn serve(body: String) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
819 let server = MockServer::start().await;
820 Mock::given(method("POST"))
821 .and(path("/info"))
822 .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
823 .mount(&server)
824 .await;
825 let client = info_client_against(server.uri()).await;
826 (server, client)
827 }
828
829 #[tokio::test]
830 async fn a_fill_carries_the_tid_and_fee_token_the_sdk_type_drops() {
831 let (_server, client) = serve(format!("[{DOCUMENTED_FILL}]")).await;
832
833 let fills = user_fills(&client, ethers::types::H160::zero())
834 .await
835 .unwrap();
836
837 assert_eq!(fills.len(), 1);
838 assert_eq!(fills[0].tid, 118_906_512_037_719);
839 assert_eq!(fills[0].fee_token.as_deref(), Some("USDC"));
840 assert_eq!(fills[0].oid, 90_542_681);
841 assert_eq!(fills[0].coin, "AVAX");
842 }
843
844 #[tokio::test]
845 async fn one_transaction_can_carry_several_fills_and_they_are_told_apart_by_tid() {
846 let sweep = r#"[
849 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
850 "hash":"0xsweep","oid":1,"px":"18.40","side":"B","startPosition":"0",
851 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":111},
852 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
853 "hash":"0xsweep","oid":1,"px":"18.45","side":"B","startPosition":"10",
854 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":222},
855 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
856 "hash":"0xsweep","oid":1,"px":"18.50","side":"B","startPosition":"20",
857 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":333}
858 ]"#;
859 let (_server, client) = serve(sweep.to_string()).await;
860
861 let fills = user_fills(&client, ethers::types::H160::zero())
862 .await
863 .unwrap();
864
865 let tids: Vec<u64> = fills.iter().map(|f| f.tid).collect();
866 assert_eq!(tids, vec![111, 222, 333]);
867 }
868
869 #[tokio::test]
870 async fn a_missing_fee_token_falls_back_rather_than_failing() {
871 let without = DOCUMENTED_FILL.replace(r#""feeToken": "USDC","#, "");
874 let (_server, client) = serve(format!("[{without}]")).await;
875
876 let fills = user_fills(&client, ethers::types::H160::zero())
877 .await
878 .unwrap();
879
880 assert_eq!(fills.len(), 1);
881 assert!(fills[0].fee_token.is_none());
882 }
883
884 #[tokio::test]
885 async fn a_missing_tid_fails_loudly_instead_of_falling_back_to_the_hash() {
886 let without = DOCUMENTED_FILL.replace(r#""tid": 118906512037719"#, r#""unused": 0"#);
889 let (_server, client) = serve(format!("[{without}]")).await;
890
891 let error = user_fills(&client, ethers::types::H160::zero())
892 .await
893 .unwrap_err();
894
895 assert!(
896 matches!(&error, UnindexedClientError::Internal(msg) if msg.contains("userFills")),
897 "expected a parse failure naming the endpoint, got {error:?}"
898 );
899 }
900
901 #[tokio::test]
902 async fn the_request_names_the_address_in_full() {
903 let server = MockServer::start().await;
906 Mock::given(method("POST"))
907 .and(path("/info"))
908 .and(wiremock::matchers::body_string_contains(
909 "0x000000000000000000000000000000000000dead",
910 ))
911 .and(wiremock::matchers::body_string_contains("userFills"))
912 .respond_with(ResponseTemplate::new(200).set_body_raw("[]", "application/json"))
913 .mount(&server)
914 .await;
915 let client = info_client_against(server.uri()).await;
916
917 let address = "0x000000000000000000000000000000000000dEaD"
918 .parse::<ethers::types::H160>()
919 .unwrap();
920
921 assert!(user_fills(&client, address).await.unwrap().is_empty());
924 }
925
926 const CID: &str = "85b60bff-64d5-49ec-95af-6268d7d1df63";
929 const CLOID: &str = "0x85b60bff64d549ec95af6268d7d1df63";
931
932 #[test]
933 fn only_the_canonical_uuid_form_can_be_a_cloid() {
934 let cloid = cid_to_cloid(&ClientOrderId::new(CID)).unwrap();
935 assert_eq!(cloid.to_string(), CID);
936
937 for other in [
939 "85B60BFF-64D5-49EC-95AF-6268D7D1DF63",
940 "85b60bff64d549ec95af6268d7d1df63",
941 "{85b60bff-64d5-49ec-95af-6268d7d1df63}",
942 "urn:uuid:85b60bff-64d5-49ec-95af-6268d7d1df63",
943 "example-1714100000000",
944 "",
945 ] {
946 assert_eq!(cid_to_cloid(&ClientOrderId::new(other)), None, "{other}");
947 }
948 }
949
950 #[test]
951 fn a_generated_uuid_client_id_is_always_a_cloid() {
952 for _ in 0..32 {
953 let cid = ClientOrderId::uuid();
954 assert!(cid_to_cloid(&cid).is_some(), "{cid}");
955 }
956 }
957
958 #[test]
959 fn the_reported_cloid_is_the_client_id_the_order_was_placed_under() {
960 assert_eq!(cloid_to_cid(CLOID), Some(ClientOrderId::new(CID)));
961
962 let cid = ClientOrderId::uuid();
964 let sent = format!("0x{}", cid_to_cloid(&cid).unwrap().simple());
965 assert_eq!(cloid_to_cid(&sent), Some(cid));
966 }
967
968 #[test]
969 fn a_cloid_not_in_the_venue_form_names_no_client_id() {
970 for malformed in [
971 CID,
972 "85b60bff64d549ec95af6268d7d1df63",
973 "0x85b60bff64d549ec95af6268d7d1df",
974 "0x85b60bff64d549ec95af6268d7d1df6300",
975 "0x85b60bff-64d5-49ec-95af-6268d7d1df63",
976 "0xzzb60bff64d549ec95af6268d7d1df63",
977 "0x",
978 ] {
979 assert_eq!(cloid_to_cid(malformed), None, "{malformed}");
980 }
981 }
982
983 #[test]
984 fn a_record_without_a_cloid_is_reported_under_its_oid() {
985 assert_eq!(record_cid(None, 42), Some(ClientOrderId::new("42")));
986 assert_eq!(record_cid(Some(CLOID), 42), Some(ClientOrderId::new(CID)));
987 assert_eq!(record_cid(Some("0x1234"), 42), None);
988 }
989
990 #[test]
993 fn every_documented_order_status_is_classified() {
994 use OrderStatus::*;
995 for (status, expected) in [
996 ("open", Open),
997 ("triggered", Open),
998 ("filled", Filled),
999 ("canceled", Cancelled),
1000 ("marginCanceled", Cancelled),
1001 ("vaultWithdrawalCanceled", Cancelled),
1002 ("openInterestCapCanceled", Cancelled),
1003 ("selfTradeCanceled", Cancelled),
1004 ("reduceOnlyCanceled", Cancelled),
1005 ("siblingFilledCanceled", Cancelled),
1006 ("delistedCanceled", Cancelled),
1007 ("liquidatedCanceled", Cancelled),
1008 ("scheduledCancel", Cancelled),
1009 ("rejected", Rejected),
1010 ("tickRejected", Rejected),
1011 ("minTradeNtlRejected", Rejected),
1012 ("perpMarginRejected", Rejected),
1013 ("reduceOnlyRejected", Rejected),
1014 ("badAloPxRejected", Rejected),
1015 ("iocCancelRejected", Rejected),
1016 ("badTriggerPxRejected", Rejected),
1017 ("marketOrderNoLiquidityRejected", Rejected),
1018 ("positionIncreaseAtOpenInterestCapRejected", Rejected),
1019 ("positionFlipAtOpenInterestCapRejected", Rejected),
1020 ("tooAggressiveAtOpenInterestCapRejected", Rejected),
1021 ("openInterestIncreaseRejected", Rejected),
1022 ("insufficientSpotBalanceRejected", Rejected),
1023 ("oracleRejected", Rejected),
1024 ("perpMaxPositionRejected", Rejected),
1025 ] {
1026 assert_eq!(OrderStatus::classify(status), Some(expected), "{status}");
1027 }
1028 assert_eq!(OrderStatus::classify("unknown_status"), None);
1029 }
1030
1031 fn order_update(status: &str, cloid: &str, sz: &str) -> hyperliquid_rust_sdk::OrderUpdate {
1032 serde_json::from_str(&format!(
1033 r#"{{"order": {{"coin": "ETH", "side": "B", "limitPx": "1605.0", "sz": "{sz}",
1034 "oid": 7001, "timestamp": 1714100000000, "origSz": "0.0075", "cloid": {cloid}}},
1035 "status": "{status}", "statusTimestamp": 1714100001000}}"#
1036 ))
1037 .unwrap()
1038 }
1039
1040 fn snapshot_of(event: UnindexedAccountEvent) -> UnindexedOrderSnapshot {
1041 match event.kind {
1042 crate::AccountEventKind::OrderSnapshot(snapshot) => snapshot.0,
1043 other => panic!("expected an order snapshot, got {other:?}"),
1044 }
1045 }
1046
1047 fn eth() -> InstrumentNameExchange {
1048 InstrumentNameExchange::from("ETH-USD-PERP")
1049 }
1050
1051 #[test]
1052 fn an_order_update_names_the_order_by_the_id_it_was_placed_under() {
1053 let update = order_update("open", &format!(r#""{CLOID}""#), "0.005");
1054 let order = snapshot_of(
1055 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1056 );
1057
1058 assert_eq!(order.key.cid, ClientOrderId::new(CID));
1059 assert_eq!(order.quantity, dec!(0.0075));
1060 match order.state {
1061 OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) => {
1062 assert_eq!(open.id, VenueOrderId::Assigned(OrderId::new("7001")));
1063 assert_eq!(open.filled_quantity, dec!(0.0025));
1064 }
1065 other => panic!("expected Open, got {other:?}"),
1066 }
1067 }
1068
1069 #[test]
1070 fn an_order_update_without_a_cloid_is_named_by_its_oid() {
1071 let update = order_update("open", "null", "0.0075");
1072 let order = snapshot_of(
1073 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1074 );
1075 assert_eq!(order.key.cid, ClientOrderId::new("7001"));
1076 }
1077
1078 #[test]
1079 fn an_order_update_with_a_malformed_cloid_is_dropped() {
1080 let update = order_update("open", r#""0x1234""#, "0.0075");
1081 assert!(
1082 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).is_none()
1083 );
1084 }
1085
1086 #[test]
1087 fn a_cancellation_for_any_reason_ends_the_order() {
1088 let update = order_update("selfTradeCanceled", &format!(r#""{CLOID}""#), "0.005");
1089 let order = snapshot_of(
1090 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1091 );
1092 match order.state {
1093 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1094 assert_eq!(cancelled.filled_quantity, dec!(0.0025));
1095 }
1096 other => panic!("expected Cancelled, got {other:?}"),
1097 }
1098 }
1099
1100 #[test]
1101 fn a_rejection_ends_the_order_and_names_the_reason() {
1102 let update = order_update("badAloPxRejected", &format!(r#""{CLOID}""#), "0.0075");
1103 let order = snapshot_of(
1104 order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1105 );
1106 match order.state {
1107 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
1108 OrderError::Rejected(ApiError::OrderRejected(reason)),
1109 )) => assert_eq!(reason, "badAloPxRejected"),
1110 other => panic!("expected a rejection, got {other:?}"),
1111 }
1112 }
1113
1114 const OPEN_ORDERS: &str = r#"[
1119 {"coin":"ETH","side":"B","limitPx":"1605.0","sz":"0.005","oid":7001,
1120 "timestamp":1714100000000,"origSz":"0.0075","cloid":"0x85b60bff64d549ec95af6268d7d1df63"},
1121 {"coin":"ETH","side":"A","limitPx":"1700.0","sz":"0.01","oid":7002,
1122 "timestamp":1714100000000,"origSz":"0.01"},
1123 {"coin":"BTC","side":"A","limitPx":"50000.0","sz":"0.001","oid":7003,
1124 "timestamp":1714100000000}
1125 ]"#;
1126
1127 #[tokio::test]
1128 async fn open_orders_carry_the_cloid_and_original_size_the_sdk_type_drops() {
1129 let (_server, client) = serve(OPEN_ORDERS.to_string()).await;
1130
1131 let rows = open_orders(&client, ethers::types::H160::zero())
1132 .await
1133 .unwrap();
1134
1135 assert_eq!(rows.len(), 3);
1136 assert_eq!(rows[0].cloid.as_deref(), Some(CLOID));
1137 assert_eq!(rows[0].orig_sz.as_deref(), Some("0.0075"));
1138 assert_eq!(rows[1].cloid, None);
1139 assert_eq!(rows[2].orig_sz, None);
1140 }
1141
1142 fn rows() -> Vec<OpenOrder> {
1143 serde_json::from_str(OPEN_ORDERS).unwrap()
1144 }
1145
1146 fn perp(coin: &str) -> Option<InstrumentNameExchange> {
1147 Some(perp_coin_to_instrument(coin))
1148 }
1149
1150 #[test]
1151 fn an_open_order_is_listed_under_its_client_id_with_its_fill_so_far() {
1152 let order = open_order_to_order(&rows()[0], ExchangeId::HyperliquidPerp, eth()).unwrap();
1153
1154 assert_eq!(order.key.cid, ClientOrderId::new(CID));
1155 assert_eq!(order.quantity, dec!(0.0075));
1156 assert_eq!(order.state.filled_quantity, dec!(0.0025));
1157 assert_eq!(order.state.id, VenueOrderId::Assigned(OrderId::new("7001")));
1158 }
1159
1160 #[test]
1161 fn an_open_order_without_its_original_size_is_its_remainder_with_nothing_filled() {
1162 let row = &rows()[2];
1163 let btc = InstrumentNameExchange::from("BTC-USD-PERP");
1164 let order = open_order_to_order(row, ExchangeId::HyperliquidPerp, btc).unwrap();
1165
1166 assert_eq!(order.key.cid, ClientOrderId::new("7003"));
1167 assert_eq!(order.quantity, dec!(0.001));
1168 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
1169 }
1170
1171 #[test]
1172 fn every_requested_instrument_is_listed_complete_even_with_nothing_open() {
1173 let sol = InstrumentNameExchange::from("SOL-USD-PERP");
1174 let mut listing = OpenOrderListing::new(
1175 &rows(),
1176 ExchangeId::HyperliquidPerp,
1177 &[eth(), sol.clone()],
1178 perp,
1179 );
1180
1181 let eth_entry = listing.remove(ð()).unwrap();
1182 assert!(eth_entry.orders_complete);
1183 assert_eq!(eth_entry.orders.len(), 2);
1184
1185 let sol_entry = listing.remove(&sol).unwrap();
1187 assert!(sol_entry.orders_complete);
1188 assert!(sol_entry.orders.is_empty());
1189
1190 assert_eq!(listing.into_snapshots().count(), 0);
1192 }
1193
1194 #[test]
1195 fn an_unfiltered_listing_covers_every_instrument_with_open_orders() {
1196 let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidPerp, &[], perp);
1197 let mut instruments = listing
1198 .into_snapshots()
1199 .map(|entry| entry.instrument.to_string())
1200 .collect::<Vec<_>>();
1201 instruments.sort();
1202 assert_eq!(instruments, ["BTC-USD-PERP", "ETH-USD-PERP"]);
1203 }
1204
1205 #[test]
1206 fn an_order_that_does_not_convert_leaves_only_its_instrument_incomplete() {
1207 let mut rows = rows();
1208 rows[1].side = "?".to_string();
1209 let btc = InstrumentNameExchange::from("BTC-USD-PERP");
1210 let mut listing = OpenOrderListing::new(
1211 &rows,
1212 ExchangeId::HyperliquidPerp,
1213 &[eth(), btc.clone()],
1214 perp,
1215 );
1216
1217 let eth_entry = listing.remove(ð()).unwrap();
1218 assert!(!eth_entry.orders_complete);
1219 assert_eq!(eth_entry.orders.len(), 1);
1220 assert!(listing.remove(&btc).unwrap().orders_complete);
1221 }
1222
1223 #[test]
1224 fn an_order_with_a_malformed_cloid_leaves_its_instrument_incomplete() {
1225 let mut rows = rows();
1226 rows[0].cloid = Some("0x1234".to_string());
1227 let mut listing = OpenOrderListing::new(&rows, ExchangeId::HyperliquidPerp, &[eth()], perp);
1228
1229 let eth_entry = listing.remove(ð()).unwrap();
1230 assert!(!eth_entry.orders_complete);
1231 assert_eq!(eth_entry.orders.len(), 1);
1232 }
1233
1234 #[test]
1235 fn coins_the_client_does_not_trade_are_left_out() {
1236 let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidSpot, &[], |coin| {
1237 is_spot_coin(coin).then(|| spot_coin_to_instrument(coin))
1238 });
1239 assert_eq!(listing.into_snapshots().count(), 0);
1240 }
1241}