Skip to main content

rustrade_execution/client/hyperliquid/
common.rs

1//! Shared utilities for Hyperliquid execution clients (perps and spot).
2//!
3//! Contains parsing helpers and stream wrappers used by both
4//! `HyperliquidClient` (perps) and `HyperliquidSpotClient`.
5//!
6//! Error mapping is in the `error` module.
7
8use 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/// Stream wrapper that cancels background tasks when dropped.
43///
44/// Ensures spawned WebSocket processing tasks are cleaned up when the consumer
45/// drops the account stream, preventing task leaks.
46#[derive(Debug)]
47pub struct CancelOnDropStream<S> {
48    inner: S,
49    cancel_token: CancellationToken,
50}
51
52impl<S> CancelOnDropStream<S> {
53    /// Create a new cancel-on-drop stream wrapper.
54    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/// Parse a decimal string from SDK response, logging warnings on failure.
77/// One fill from the `userFills` info endpoint, as the venue actually returns it.
78///
79/// # Why this exists rather than `hyperliquid_rust_sdk::UserFillsResponse`
80///
81/// The SDK's type does not model `tid`, the only per-fill identifier the endpoint offers, and it
82/// does not model `feeToken`. Neither omission is visible at runtime: the SDK does not set
83/// `deny_unknown_fields`, so both are parsed away silently. Without `tid` the only id left is
84/// `hash`, which identifies the *transaction* — one aggressive order sweeping several resting
85/// orders produces several fills under a single hash, and they are then indistinguishable.
86///
87/// Only the fields this client reads are declared. Unknown fields are ignored, so `hash`,
88/// `closedPnl`, `dir`, `startPosition`, `crossed` and `builderFee` cost nothing by being absent
89/// here.
90///
91/// Drop this in favour of the SDK type if it ever models `tid`.
92#[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    /// Identifies this fill. `hash` identifies the transaction that contained it, and one
104    /// transaction can contain many fills.
105    ///
106    /// Declared required rather than `Option<u64>` deliberately. If the venue ever stops sending
107    /// it, this client should fail loudly at the parse — falling back to `hash` would silently
108    /// restore a duplicate-id defect that is invisible until fills go missing during
109    /// reconciliation.
110    ///
111    /// Hyperliquid documents this as unique per fill, but qualified by coin rather than global,
112    /// which is why the dedup cache keys on the instrument alongside it.
113    pub tid: u64,
114
115    /// Asset the fee is denominated in.
116    ///
117    /// Optional because, unlike every other field here, nothing proves the endpoint sends it: the
118    /// SDK's own type omits it, so it has never been observed through this dependency. The caller
119    /// falls back to inferring the asset from the side when it is absent.
120    pub fee_token: Option<String>,
121    /// The cloid of the order filled, in the venue's form ([`cloid_to_cid`]). Absent for an order
122    /// placed without one.
123    pub cloid: Option<String>,
124}
125
126/// The span `start..=end` in epoch milliseconds, as `userFillsByTime` takes it, or `None` when it
127/// is empty or ends before the epoch. Fill times are whole milliseconds, read from the
128/// millisecond holding `start`; the caller applies the exact span to the fills it reads.
129pub(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
138/// The most fills one `userFillsByTime` response holds.
139const USER_FILLS_PER_RESPONSE: usize = 2_000;
140
141/// Read every fill `address` made from `start_ms` to `end_ms`, both inclusive, in epoch
142/// milliseconds, parsed into [`UserFill`] rather than the SDK's lossy type.
143///
144/// `userFillsByTime` returns at most 2,000 fills per response, oldest
145/// first, with both bounds inclusive (observed on mainnet, 2026-10-05). A full response is
146/// followed by a read from its last fill's time, which returns again the fills at that
147/// millisecond already read; those are dropped by coin and `tid`. Hyperliquid keeps only each
148/// wallet's 10,000 most recent fills, so the whole read is a handful of requests, and it cannot
149/// reach a fill older than those.
150///
151/// `userFills`, which takes no span, is not used: it returns only the 2,000 most recent fills, so
152/// a span holding more would come back short without saying so.
153///
154/// # Errors
155///
156/// Returns a transport error if a POST fails, or a parse error if a response does not match
157/// [`UserFill`] — which includes the case where `tid` has stopped being sent.
158/// [`Truncated`](UnindexedClientError::Truncated) when a full response holds only fills at the
159/// millisecond it was read from, so the read cannot advance.
160pub 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        // `{:?}` on H160 renders the full checksummed form; see `user_info`.
170        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        // The fills at `from` that the previous response already returned: a few at most, since
179        // they share one millisecond.
180        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/// One order from the `openOrders` info endpoint, as the venue actually returns it.
206///
207/// # Why this exists rather than `hyperliquid_rust_sdk::OpenOrdersResponse`
208///
209/// The SDK's type does not model `cloid`, the client order id the order was placed with, nor
210/// `origSz`. The venue sends both, although its documentation lists neither. Without `cloid` an
211/// order can only be reported under its venue `oid`, which is not the id the engine tracks it by.
212///
213/// Drop this in favour of the SDK type if it ever models `cloid`.
214#[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    /// Quantity still to fill.
221    pub sz: String,
222    pub oid: u64,
223    pub timestamp: u64,
224
225    /// Quantity the order was placed for.
226    ///
227    /// Optional because the endpoint's documentation does not list it. Without it the order's
228    /// filled quantity is unknown, and it is reported as its remaining quantity with nothing
229    /// filled.
230    #[serde(default)]
231    pub orig_sz: Option<String>,
232
233    /// The client order id the order was placed with, as `0x` and 32 hex digits (see
234    /// [`cloid_to_cid`]). Absent for an order placed without one.
235    #[serde(default)]
236    pub cloid: Option<String>,
237}
238
239/// Fetch every open order for `address`, parsed into [`OpenOrder`] rather than the SDK's lossy
240/// type, which drops the `cloid`.
241///
242/// # Errors
243///
244/// Returns a transport error if the POST fails, or a parse error if the response does not match
245/// [`OpenOrder`].
246pub 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
253/// POST the info request `{"type": kind, "user": address}`, with `"dex": dex` when `dex` names a
254/// builder-deployed (HIP-3) perpetual DEX, and parse the response as `T`.
255///
256/// Issued through the SDK's own [`HttpClient`](hyperliquid_rust_sdk::InfoClient::http_client), so
257/// base URL, TLS configuration and error type are exactly those of every other info request this
258/// client makes — only the response type differs.
259pub(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    // `{:?}` on H160 renders the checksummed 0x-prefixed form the endpoint expects. `Display`
266    // abbreviates the middle of the address ("0x1234…5678"), so it must not be used here.
267    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
274/// POST the info request `body`, of type `kind`, and parse the response as `T`.
275pub(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    // `Internal` rather than a connectivity error: a response that does not parse is a schema
287    // change or a venue-side regression, not a transient fault, and retrying will not fix it.
288    serde_json::from_str(&raw).map_err(|e| {
289        UnindexedClientError::Internal(format!("Hyperliquid {kind} response did not parse: {e}"))
290    })
291}
292
293/// Each spot token balance in `balances` (`spotClearinghouseState`), free of what is on hold.
294pub(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
310/// Convert one [`OpenOrder`] into an `Open` order on `instrument`, keyed as [`record_cid`] says.
311///
312/// `None`, with a `warn!`, when a field does not parse. The caller then cannot say the order is
313/// not open, so it must not declare its list complete.
314pub(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        // Neither the kind nor the time in force is in this response.
355        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
365/// Which instruments a read keeps: those in the slice it was given, or every one when the slice is
366/// empty, the "return all" sentinel of [`ExecutionClient`](crate::client::ExecutionClient)'s reads.
367pub(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    /// Whether the read keeps `instrument`.
375    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
383/// The open orders of an account snapshot, one entry per instrument.
384pub(super) struct OpenOrderListing(
385    HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
386);
387
388impl OpenOrderListing {
389    /// List `rows` by instrument, keeping only the rows `to_instrument` maps to an instrument in
390    /// `requested` (every instrument when `requested` is empty).
391    ///
392    /// Each entry declares its orders complete unless one of its rows failed to convert. That
393    /// holds because the endpoint returns every open order, unpaged, and because [`record_cid`]
394    /// reports each order placed through this client under the id it was placed with.
395    ///
396    /// Every requested instrument gets an entry, open orders or not. An entry is what lets the
397    /// engine judge an order that is no longer listed to be gone, and the instrument whose last
398    /// order was cancelled while the account stream was down is exactly the one with nothing
399    /// left to list. With `requested` empty there is nothing to enumerate, so only instruments
400    /// with open orders get one.
401    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    /// Take the entry for `instrument`, if it has one.
448    pub(super) fn remove(
449        &mut self,
450        instrument: &InstrumentNameExchange,
451    ) -> Option<UnindexedInstrumentAccountSnapshot> {
452        self.0.remove(instrument)
453    }
454
455    /// Every remaining entry.
456    pub(super) fn into_snapshots(self) -> impl Iterator<Item = UnindexedInstrumentAccountSnapshot> {
457        self.0.into_values()
458    }
459}
460
461/// What a Hyperliquid order status says about the order.
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463enum OrderStatus {
464    Open,
465    Filled,
466    Cancelled,
467    Rejected,
468}
469
470impl OrderStatus {
471    /// Classify an `orderUpdates` / `orderStatus` status string.
472    ///
473    /// Beyond `open`, `filled`, `canceled` and `rejected`, Hyperliquid names the reason for each
474    /// other way an order ends: `marginCanceled`, `selfTradeCanceled`, `reduceOnlyCanceled`,
475    /// `tickRejected`, `badAloPxRejected` and a dozen more. They are classified by their suffix
476    /// so that a reason added later still ends the order, rather than leaving it tracked as open
477    /// for good. `scheduledCancel` is the one ending that breaks the pattern.
478    ///
479    /// `triggered` is a trigger order whose condition was met. It is still working, and ends
480    /// with a later status of its own.
481    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
494/// Convert an `orderUpdates` message into an order snapshot event on `instrument`, keyed as
495/// [`record_cid`] says.
496///
497/// `None`, with a `warn!` naming the cause where the parse helpers do not, when a field does not
498/// parse or the status is not one [`OrderStatus::classify`] recognises.
499pub(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
513/// Convert an order record, as `orderUpdates` sends it and `orderStatus` answers with it, into the
514/// order it describes on `instrument`, keyed as [`record_cid`] says.
515///
516/// `None`, with a `warn!` naming the cause where the parse helpers do not, when a field does not
517/// parse or the status is not one [`OrderStatus::classify`] recognises.
518pub(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, // The update does not carry the average price.
553        )),
554        // An unparseable remaining size leaves the fill unknown, not the cancel.
555        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    // The update carries neither the order's kind nor its time in force.
564    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
586/// Parse SDK side string to rustrade Side.
587///
588/// Hyperliquid API returns "B" for buy/long and "A" for sell/short (ask-side).
589/// Extra variants included for defensive parsing of potential future API changes.
590pub 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
601/// Convert milliseconds timestamp to `DateTime<Utc>`.
602///
603/// Returns `None` for timestamps outside the representable range (year 292M+).
604pub fn millis_to_datetime(millis: u64) -> Option<DateTime<Utc>> {
605    Utc.timestamp_millis_opt(i64::try_from(millis).ok()?)
606        .single()
607}
608
609/// Map rustrade TimeInForce to Hyperliquid TIF string.
610pub 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", // Hyperliquid doesn't have FOK, use IOC
616        TimeInForce::GoodUntilEndOfDay => "Gtc", // No EOD on Hyperliquid
617        // Hyperliquid is a perpetuals DEX with no auction sessions and no native
618        // GTD. Coerce to GTC and surface the loss of semantics so downstream
619        // wrappers can decide whether to reject upstream.
620        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
627/// Why an order is refused when its client id cannot be a cloid ([`cid_to_cloid`]).
628pub(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
632/// The cloid to place an order under `cid` with, or `None` if `cid` cannot be one.
633///
634/// Hyperliquid stores a cloid as 16 bytes and reports it back as `0x` and 32 lowercase hex
635/// digits, which [`cloid_to_cid`] turns back into a client id. Only a `cid` that is the canonical
636/// text of a UUID — lowercase and hyphenated, as [`ClientOrderId::uuid`] makes — comes back
637/// unchanged. Any other spelling of a UUID would come back as a different id, under which the
638/// order's own updates could not find it, so it is refused along with every id that is not a UUID.
639pub 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
645/// The client id an order placed with `cloid` was placed under: the inverse of [`cid_to_cloid`].
646///
647/// `cloid` is the venue's form, `0x` and 32 hex digits. `None` for anything else.
648pub 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    // The 32-digit length check rules out the other spellings `try_parse` accepts.
654    let uuid = Uuid::try_parse(hex).ok()?;
655    Some(ClientOrderId::new(format_smolstr!("{}", uuid.hyphenated())))
656}
657
658/// Whether Hyperliquid applied a cancel of one order, read from its answer: `Err` with the reason
659/// when it did not.
660///
661/// Hyperliquid answers a cancel it could not apply with a top-level `ok`, and puts the error in
662/// the order's own status ("Order was never placed, already canceled, or filled.", for one), so
663/// the top level alone does not say. Only a `success` status is a cancel. An answer carrying no
664/// status, or another kind of status, is not taken as one either: the order may still be working.
665/// A cancel names one order, so only the first status is read.
666///
667/// The reason is not classified. Hyperliquid's message says the order was never placed, already
668/// cancelled, or filled, without saying which, so naming it already cancelled or already filled
669/// would be a guess. A caller learns how the order ended from the account stream or a lookup.
670pub(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
686/// The client id to report a venue order record under, given the record's `cloid` and `oid`.
687///
688/// Every order this client places carries a cloid ([`cid_to_cloid`]), so a record with one is
689/// reported under the id the order was placed with. A record without one was placed some other
690/// way, such as the web app, and has no client id at all. It is reported under its venue `oid`,
691/// the only identifier it has, which is stable across reads so the order is tracked once.
692///
693/// `None`, with a `warn!`, for a cloid that is not in the venue's form. The record's order is
694/// then unidentified, and must be left out rather than reported under a guess.
695pub(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
708/// Whether `coin` names a market of a kind [`CoinKind::of`] does not recognise.
709fn is_unknown_coin(coin: &str) -> bool {
710    !matches!(CoinKind::of(coin), CoinKind::Perp | CoinKind::Spot)
711}
712
713/// Log with one `warn!` the distinct coins among `coins` of a kind [`CoinKind::of`] does not
714/// recognise, which neither client reports. For a read that lists many rows at once.
715pub(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/// The coins of a kind [`CoinKind::of`] does not recognise that an account stream has logged, so
729/// it logs each once rather than on every event.
730#[derive(Debug, Default)]
731pub(super) struct UnknownCoins(HashSet<String>);
732
733impl UnknownCoins {
734    /// Log `coin` with `warn!` if it is of an unrecognised kind and not logged before.
735    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
747/// Build the spot instrument name for `pair`, from its tokens: `BASE-QUOTE-SPOT` (e.g.
748/// `HYPE-USDC-SPOT` for the pair Hyperliquid names `@107`).
749pub fn spot_pair_to_instrument(pair: &SpotPair) -> InstrumentNameExchange {
750    InstrumentNameExchange::from(format_smolstr!("{}-{}-SPOT", pair.base(), pair.quote()))
751}
752
753/// Extract coin pair from spot instrument (e.g., "PURR-USDC-SPOT" -> "PURR/USDC").
754///
755/// Returns `Option<String>` — `None` if the instrument doesn't match expected
756/// `BASE-QUOTE-SPOT` format. Callers should fail fast on `None` rather than
757/// send a malformed asset to the exchange.
758///
759/// The SDK's `ExchangeClient` addresses every spot pair by this `BASE/QUOTE` form as well as by
760/// the coin Hyperliquid names it (`@107`), but only spelled exactly as `spotMeta` spells the
761/// pair's tokens. The name is not checked against `spotMeta`, so `KPEPE-USDC-SPOT` gives
762/// `KPEPE/USDC`, which the SDK does not know for the pair `kPEPE/USDC`.
763pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
764    let (base, quote) = spot_base_quote(instrument)?;
765    Some(format!("{base}/{quote}"))
766}
767
768/// The base and quote tokens of a spot instrument named `BASE-QUOTE-SPOT`, `None` for a name of
769/// another form.
770pub(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// Test code: panics on bad input are acceptable
776#[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(&eth));
790
791        let given = [btc.clone()];
792        let one = InstrumentFilter::new(&given);
793        assert!(one.matches(&btc));
794        assert!(!one.matches(&eth));
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    /// [`info_tests::cancel_answer`] as the SDK reads it.
847    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    /// The exchange endpoint documentation's own example of a cancel that was not applied.
860    #[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        // Malformed instruments return None
911        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        // Zero timestamp (Unix epoch) is valid
942        assert!(millis_to_datetime(0).is_some());
943    }
944}
945
946#[cfg(test)]
947// Test code: panics on bad input are acceptable
948#[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    /// One fill exactly as the `userFills` documentation shows it, including the two fields the
957    /// SDK's own response type drops.
958    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    /// Build an `InfoClient` pointed at `uri`. `InfoClient::new` opens no connection -- it only
977    /// fills in the struct -- so this costs nothing and touches no network.
978    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    /// The wallet clients built in tests sign with. No real server sees what it signs.
992    pub(in crate::client::hyperliquid) fn test_wallet() -> ethers::signers::LocalWallet {
993        "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
994            .parse()
995            .unwrap()
996    }
997
998    /// Build an `ExchangeClient` pointed at `uri` that knows the assets in `coin_to_asset`.
999    /// `ExchangeClient::new` would read them over the network instead.
1000    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    /// Hyperliquid's answer to a cancel, as its exchange endpoint sends it, carrying `statuses`.
1016    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    /// The documentation's example of a cancel Hyperliquid did not apply, under a top-level `ok`.
1026    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        // A single aggressive order sweeping three resting orders: one `hash`, one `oid`, three
1058        // fills. This is the shape the old `TradeId(hash)` collapsed into one trade.
1059        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        // `feeToken` is the one field here that nothing proves the endpoint sends, so its absence
1083        // must not cost the caller the whole response.
1084        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        // Silently reverting to `hash` would reinstate the duplicate-id defect, and it would only
1098        // become visible as fills going missing during reconciliation.
1099        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        // `Display` for H160 abbreviates the middle of an address, which the venue would not
1115        // recognise. Only the `Debug` rendering is the full 0x-prefixed form.
1116        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        // The mock only answers a body carrying the full address, so reaching `Ok` is the
1133        // assertion.
1134        assert!(
1135            user_fills_by_time(&client, address, 0, u64::MAX)
1136                .await
1137                .unwrap()
1138                .is_empty()
1139        );
1140    }
1141
1142    /// `userFillsByTime` over `fills`, which are sorted by time, as observed on mainnet: both
1143    /// bounds inclusive, oldest first, at most [`USER_FILLS_PER_RESPONSE`] per response.
1144    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    /// `DOCUMENTED_FILL` with `tid` at `time`.
1166    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    /// A span holding more fills than one response is read completely, each fill once. Three
1187    /// fills share each millisecond, so a response ends inside one, and the next, read from that
1188    /// millisecond, returns again the fills of it already read.
1189    #[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        // Both bounds are inclusive.
1202        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    /// A full response of fills all at the millisecond it was read from cannot be read past.
1209    #[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    // ---- client order ids -------------------------------------------------------------------
1250
1251    const CID: &str = "85b60bff-64d5-49ec-95af-6268d7d1df63";
1252    /// `CID` as the venue reports it back: `0x` and the 32 lowercase hex digits.
1253    const CLOID: &str = "0x85b60bff64d549ec95af6268d7d1df63";
1254
1255    /// A REST fill decodes its order's cloid, and one placed without a cloid decodes as `None`.
1256    #[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        // Every other spelling of the same UUID would come back as `CID`, not as itself.
1279        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        // The round trip through the form the SDK sends, for a fresh id.
1304        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    // ---- order statuses -----------------------------------------------------------------------
1332
1333    #[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    /// A cancellation whose remaining size does not parse still ends the order, with its fill
1442    /// unknown rather than the update dropped.
1443    #[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    // ---- open orders --------------------------------------------------------------------------
1488
1489    /// The shape `openOrders` was observed to return: `origSz` and `cloid` included, although the
1490    /// endpoint's documentation lists neither. Values are synthetic.
1491    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(&eth()).unwrap();
1555        assert!(eth_entry.orders_complete);
1556        assert_eq!(eth_entry.orders.len(), 2);
1557
1558        // Nothing open: the entry is what lets an order that ended offline be seen to be gone.
1559        let sol_entry = listing.remove(&sol).unwrap();
1560        assert!(sol_entry.orders_complete);
1561        assert!(sol_entry.orders.is_empty());
1562
1563        // BTC was not requested.
1564        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(&eth()).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(&eth()).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}