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/// How many of a wallet's most recent fills Hyperliquid keeps.
142const FILLS_RETAINED: usize = 10_000;
143
144/// When the 10,000-fill limit cuts into a span that `fills` (everything `userFillsByTime` returned
145/// for it, before any filter by instrument) was read from `start_ms`: the time of the oldest fill
146/// held, when a full 10,000 came back and it is after `start_ms`. Fewer than 10,000 means nothing
147/// has been dropped, however old the span.
148pub(super) fn held_since(fills: &[UserFill], start_ms: u64) -> Option<DateTime<Utc>> {
149    if fills.len() < FILLS_RETAINED {
150        return None;
151    }
152    let oldest = fills.iter().map(|fill| fill.time).min()?;
153    (oldest > start_ms).then(|| {
154        Utc.timestamp_millis_opt(i64::try_from(oldest).ok()?)
155            .single()
156    })?
157}
158
159/// Read every fill `address` made from `start_ms` to `end_ms`, both inclusive, in epoch
160/// milliseconds, parsed into [`UserFill`] rather than the SDK's lossy type.
161///
162/// `userFillsByTime` returns at most 2,000 fills per response, oldest
163/// first, with both bounds inclusive (observed on mainnet, 2026-10-05). A full response is
164/// followed by a read from its last fill's time, which returns again the fills at that
165/// millisecond already read; those are dropped by coin and `tid`. Hyperliquid keeps only each
166/// wallet's 10,000 most recent fills, so the whole read is a handful of requests, and it cannot
167/// reach a fill older than those.
168///
169/// `userFills`, which takes no span, is not used: it returns only the 2,000 most recent fills, so
170/// a span holding more would come back short without saying so.
171///
172/// # Errors
173///
174/// Returns a transport error if a POST fails, or a parse error if a response does not match
175/// [`UserFill`] — which includes the case where `tid` has stopped being sent.
176/// [`Truncated`](UnindexedClientError::Truncated) when a full response holds only fills at the
177/// millisecond it was read from, so the read cannot advance.
178pub async fn user_fills_by_time(
179    info_client: &hyperliquid_rust_sdk::InfoClient,
180    address: ethers::types::H160,
181    start_ms: u64,
182    end_ms: u64,
183) -> Result<Vec<UserFill>, UnindexedClientError> {
184    let mut fills: Vec<UserFill> = Vec::new();
185    let mut from = start_ms;
186    loop {
187        // `{:?}` on H160 renders the full checksummed form; see `user_info`.
188        let body = format!(
189            r#"{{"type":"userFillsByTime","user":"{address:?}","startTime":{from},"endTime":{end_ms}}}"#
190        );
191        let page: Vec<UserFill> = info(info_client, "userFillsByTime", body).await?;
192        let full = page.len() >= USER_FILLS_PER_RESPONSE;
193        let Some(last_time) = page.last().map(|fill| fill.time) else {
194            break;
195        };
196        // The fills at `from` that the previous response already returned: a few at most, since
197        // they share one millisecond.
198        let seen: Vec<(String, u64)> = fills
199            .iter()
200            .rev()
201            .take_while(|fill| fill.time == from)
202            .map(|fill| (fill.coin.clone(), fill.tid))
203            .collect();
204        fills.extend(page.into_iter().filter(|fill| {
205            fill.time != from
206                || !seen
207                    .iter()
208                    .any(|(coin, tid)| *tid == fill.tid && *coin == fill.coin)
209        }));
210        if !full {
211            break;
212        }
213        if last_time <= from {
214            return Err(UnindexedClientError::Truncated {
215                fills_read: fills.len(),
216            });
217        }
218        from = last_time;
219    }
220    Ok(fills)
221}
222
223/// One order from the `openOrders` info endpoint, as the venue actually returns it.
224///
225/// # Why this exists rather than `hyperliquid_rust_sdk::OpenOrdersResponse`
226///
227/// The SDK's type does not model `cloid`, the client order id the order was placed with, nor
228/// `origSz`. The venue sends both, although its documentation lists neither. Without `cloid` an
229/// order can only be reported under its venue `oid`, which is not the id the engine tracks it by.
230///
231/// Drop this in favour of the SDK type if it ever models `cloid`.
232#[derive(Debug, Clone, serde::Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct OpenOrder {
235    pub coin: String,
236    pub side: String,
237    pub limit_px: String,
238    /// Quantity still to fill.
239    pub sz: String,
240    pub oid: u64,
241    pub timestamp: u64,
242
243    /// Quantity the order was placed for.
244    ///
245    /// Optional because the endpoint's documentation does not list it. Without it the order's
246    /// filled quantity is unknown, and it is reported as its remaining quantity with nothing
247    /// filled.
248    #[serde(default)]
249    pub orig_sz: Option<String>,
250
251    /// The client order id the order was placed with, as `0x` and 32 hex digits (see
252    /// [`cloid_to_cid`]). Absent for an order placed without one.
253    #[serde(default)]
254    pub cloid: Option<String>,
255}
256
257/// Fetch every open order for `address`, parsed into [`OpenOrder`] rather than the SDK's lossy
258/// type, which drops the `cloid`.
259///
260/// # Errors
261///
262/// Returns a transport error if the POST fails, or a parse error if the response does not match
263/// [`OpenOrder`].
264pub async fn open_orders(
265    info_client: &hyperliquid_rust_sdk::InfoClient,
266    address: ethers::types::H160,
267) -> Result<Vec<OpenOrder>, UnindexedClientError> {
268    user_info(info_client, "openOrders", address, None).await
269}
270
271/// POST the info request `{"type": kind, "user": address}`, with `"dex": dex` when `dex` names a
272/// builder-deployed (HIP-3) perpetual DEX, and parse the response as `T`.
273///
274/// Issued through the SDK's own [`HttpClient`](hyperliquid_rust_sdk::InfoClient::http_client), so
275/// base URL, TLS configuration and error type are exactly those of every other info request this
276/// client makes — only the response type differs.
277pub(super) async fn user_info<T: DeserializeOwned>(
278    info_client: &hyperliquid_rust_sdk::InfoClient,
279    kind: &str,
280    address: ethers::types::H160,
281    dex: Option<&str>,
282) -> Result<T, UnindexedClientError> {
283    // `{:?}` on H160 renders the checksummed 0x-prefixed form the endpoint expects. `Display`
284    // abbreviates the middle of the address ("0x1234…5678"), so it must not be used here.
285    let mut body = serde_json::json!({"type": kind, "user": format!("{address:?}")});
286    if let Some(dex) = dex {
287        body["dex"] = dex.into();
288    }
289    info(info_client, kind, body.to_string()).await
290}
291
292/// POST the info request `body`, of type `kind`, and parse the response as `T`.
293pub(super) async fn info<T: DeserializeOwned>(
294    info_client: &hyperliquid_rust_sdk::InfoClient,
295    kind: &str,
296    body: String,
297) -> Result<T, UnindexedClientError> {
298    let raw = info_client
299        .http_client
300        .post("/info", body)
301        .await
302        .map_err(map_sdk_error)?;
303
304    // `Internal` rather than a connectivity error: a response that does not parse is a schema
305    // change or a venue-side regression, not a transient fault, and retrying will not fix it.
306    serde_json::from_str(&raw).map_err(|e| {
307        UnindexedClientError::Internal(format!("Hyperliquid {kind} response did not parse: {e}"))
308    })
309}
310
311/// Each spot token balance in `balances` (`spotClearinghouseState`), free of what is on hold.
312pub(super) fn spot_balances<'a>(
313    balances: impl IntoIterator<Item = &'a hyperliquid_rust_sdk::UserTokenBalance>,
314    now: DateTime<Utc>,
315) -> impl Iterator<Item = AssetBalance<AssetNameExchange>> {
316    balances.into_iter().map(move |balance| {
317        let total = parse_decimal(&balance.total, "total").unwrap_or(Decimal::ZERO);
318        let hold = parse_decimal(&balance.hold, "hold").unwrap_or(Decimal::ZERO);
319        let free = (total - hold).max(Decimal::ZERO);
320        AssetBalance::new(
321            AssetNameExchange::from(balance.coin.as_str()),
322            Balance::new(total, free),
323            now,
324        )
325    })
326}
327
328/// Convert one [`OpenOrder`] into an `Open` order on `instrument`, keyed as [`record_cid`] says.
329///
330/// `None`, with a `warn!`, when a field does not parse. The caller then cannot say the order is
331/// not open, so it must not declare its list complete.
332pub(super) fn open_order_to_order(
333    row: &OpenOrder,
334    exchange: ExchangeId,
335    instrument: InstrumentNameExchange,
336) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
337    let parsed = (|| {
338        let cid = record_cid(row.cloid.as_deref(), row.oid)?;
339        let side = parse_side(&row.side)?;
340        let price = parse_decimal(&row.limit_px, "limitPx")?;
341        let remaining = parse_decimal(&row.sz, "sz")?;
342        let quantity = match &row.orig_sz {
343            Some(orig_sz) => parse_decimal(orig_sz, "origSz")?,
344            None => remaining,
345        };
346        let time_exchange = millis_to_datetime(row.timestamp)?;
347        Some((cid, side, price, quantity, remaining, time_exchange))
348    })();
349
350    let Some((cid, side, price, quantity, remaining, time_exchange)) = parsed else {
351        warn!(
352            %exchange,
353            %instrument,
354            oid = row.oid,
355            order = ?row,
356            "Hyperliquid open order did not convert - leaving it out, so this instrument's order \
357             list is not complete"
358        );
359        return None;
360    };
361
362    Some(Order {
363        key: OrderKey {
364            exchange,
365            instrument,
366            strategy: StrategyId::unknown(),
367            cid,
368        },
369        side,
370        price: Some(price),
371        quantity,
372        // Neither the kind nor the time in force is in this response.
373        kind: OrderKind::Limit,
374        time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
375        state: Open {
376            id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", row.oid))),
377            time_exchange,
378            filled_quantity: (quantity - remaining).max(Decimal::ZERO),
379        },
380    })
381}
382
383/// Which instruments a read keeps: those in the slice it was given, or every one when the slice is
384/// empty, the "return all" sentinel of [`ExecutionClient`](crate::client::ExecutionClient)'s reads.
385pub(super) struct InstrumentFilter<'a>(Option<FnvHashSet<&'a InstrumentNameExchange>>);
386
387impl<'a> InstrumentFilter<'a> {
388    pub(super) fn new(instruments: &'a [InstrumentNameExchange]) -> Self {
389        Self((!instruments.is_empty()).then(|| instruments.iter().collect()))
390    }
391
392    /// Whether the read keeps `instrument`.
393    pub(super) fn matches(&self, instrument: &InstrumentNameExchange) -> bool {
394        self.0.as_ref().is_none_or(|set| set.contains(instrument))
395    }
396}
397
398type UnindexedInstrumentAccountSnapshot =
399    InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
400
401/// The open orders of an account snapshot, one entry per instrument.
402pub(super) struct OpenOrderListing(
403    HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
404);
405
406impl OpenOrderListing {
407    /// List `rows` by instrument, keeping only the rows `to_instrument` maps to an instrument in
408    /// `requested` (every instrument when `requested` is empty).
409    ///
410    /// Each entry declares its orders complete unless one of its rows failed to convert. That
411    /// holds because the endpoint returns every open order, unpaged, and because [`record_cid`]
412    /// reports each order placed through this client under the id it was placed with.
413    ///
414    /// Every requested instrument gets an entry, open orders or not. An entry is what lets the
415    /// engine judge an order that is no longer listed to be gone, and the instrument whose last
416    /// order was cancelled while the account stream was down is exactly the one with nothing
417    /// left to list. With `requested` empty there is nothing to enumerate, so only instruments
418    /// with open orders get one.
419    pub(super) fn new(
420        rows: &[OpenOrder],
421        exchange: ExchangeId,
422        requested: &[InstrumentNameExchange],
423        to_instrument: impl Fn(&str) -> Option<InstrumentNameExchange>,
424    ) -> Self {
425        fn entry<'a>(
426            listing: &'a mut HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
427            instrument: &InstrumentNameExchange,
428        ) -> &'a mut UnindexedInstrumentAccountSnapshot {
429            listing
430                .entry(instrument.clone())
431                .or_insert_with(|| InstrumentAccountSnapshot {
432                    instrument: instrument.clone(),
433                    orders: Vec::new(),
434                    orders_complete: true,
435                    position: PositionReport::Unreported,
436                    isolated: None,
437                })
438        }
439
440        let filter = InstrumentFilter::new(requested);
441        let mut listing = HashMap::with_capacity(requested.len());
442
443        for instrument in requested {
444            entry(&mut listing, instrument);
445        }
446
447        for row in rows {
448            let Some(instrument) = to_instrument(&row.coin) else {
449                continue;
450            };
451            if !filter.matches(&instrument) {
452                continue;
453            }
454            let order = open_order_to_order(row, exchange, instrument.clone());
455            let snapshot = entry(&mut listing, &instrument);
456            match order {
457                Some(order) => snapshot.orders.push(order.into()),
458                None => snapshot.orders_complete = false,
459            }
460        }
461
462        Self(listing)
463    }
464
465    /// Take the entry for `instrument`, if it has one.
466    pub(super) fn remove(
467        &mut self,
468        instrument: &InstrumentNameExchange,
469    ) -> Option<UnindexedInstrumentAccountSnapshot> {
470        self.0.remove(instrument)
471    }
472
473    /// Every remaining entry.
474    pub(super) fn into_snapshots(self) -> impl Iterator<Item = UnindexedInstrumentAccountSnapshot> {
475        self.0.into_values()
476    }
477}
478
479/// What a Hyperliquid order status says about the order.
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481enum OrderStatus {
482    Open,
483    Filled,
484    Cancelled,
485    Rejected,
486}
487
488impl OrderStatus {
489    /// Classify an `orderUpdates` / `orderStatus` status string.
490    ///
491    /// Beyond `open`, `filled`, `canceled` and `rejected`, Hyperliquid names the reason for each
492    /// other way an order ends: `marginCanceled`, `selfTradeCanceled`, `reduceOnlyCanceled`,
493    /// `tickRejected`, `badAloPxRejected` and a dozen more. They are classified by their suffix
494    /// so that a reason added later still ends the order, rather than leaving it tracked as open
495    /// for good. `scheduledCancel` is the one ending that breaks the pattern.
496    ///
497    /// `triggered` is a trigger order whose condition was met. It is still working, and ends
498    /// with a later status of its own.
499    fn classify(status: &str) -> Option<Self> {
500        match status {
501            "open" | "triggered" => Some(Self::Open),
502            "filled" => Some(Self::Filled),
503            "canceled" | "scheduledCancel" => Some(Self::Cancelled),
504            "rejected" => Some(Self::Rejected),
505            status if status.ends_with("Canceled") => Some(Self::Cancelled),
506            status if status.ends_with("Rejected") => Some(Self::Rejected),
507            _ => None,
508        }
509    }
510}
511
512/// Convert an `orderUpdates` message into an order snapshot event on `instrument`, keyed as
513/// [`record_cid`] says.
514///
515/// `None`, with a `warn!` naming the cause where the parse helpers do not, when a field does not
516/// parse or the status is not one [`OrderStatus::classify`] recognises.
517pub(super) fn order_update_to_account_event(
518    update: &hyperliquid_rust_sdk::OrderUpdate,
519    exchange: ExchangeId,
520    instrument: InstrumentNameExchange,
521) -> Option<UnindexedAccountEvent> {
522    let snapshot = order_update_to_order(update, exchange, instrument)?;
523    Some(crate::AccountEvent::new(
524        exchange,
525        crate::AccountEventKind::OrderSnapshot(
526            rustrade_integration::collection::snapshot::Snapshot(snapshot),
527        ),
528    ))
529}
530
531/// Convert an order record, as `orderUpdates` sends it and `orderStatus` answers with it, into the
532/// order it describes on `instrument`, keyed as [`record_cid`] says.
533///
534/// `None`, with a `warn!` naming the cause where the parse helpers do not, when a field does not
535/// parse or the status is not one [`OrderStatus::classify`] recognises.
536pub(super) fn order_update_to_order(
537    update: &hyperliquid_rust_sdk::OrderUpdate,
538    exchange: ExchangeId,
539    instrument: InstrumentNameExchange,
540) -> Option<UnindexedOrderSnapshot> {
541    let order = &update.order;
542    let Some(status) = OrderStatus::classify(&update.status) else {
543        warn!(%exchange, status = %update.status, oid = order.oid, "Unknown Hyperliquid order status - ignoring the update");
544        return None;
545    };
546    let cid = record_cid(order.cloid.as_deref(), order.oid)?;
547    let side = parse_side(&order.side)?;
548    let price = parse_decimal(&order.limit_px, "order.limitPx")?;
549    let orig_sz = parse_decimal(&order.orig_sz, "order.origSz")?;
550    let Some(time_exchange) = millis_to_datetime(update.status_timestamp) else {
551        warn!(%exchange, oid = order.oid, status_timestamp = update.status_timestamp, "Invalid Hyperliquid order update timestamp - ignoring the update");
552        return None;
553    };
554    let order_id = OrderId(format_smolstr!("{}", order.oid));
555    let filled_quantity = || {
556        parse_decimal(&order.sz, "order.sz")
557            .map(|remaining| (orig_sz - remaining).max(Decimal::ZERO))
558    };
559
560    let state = match status {
561        OrderStatus::Open => OrderState::active(Open {
562            id: VenueOrderId::Assigned(order_id),
563            time_exchange,
564            filled_quantity: filled_quantity()?,
565        }),
566        OrderStatus::Filled => OrderState::fully_filled(Filled::new(
567            order_id,
568            time_exchange,
569            orig_sz,
570            None, // The update does not carry the average price.
571        )),
572        // An unparseable remaining size leaves the fill unknown, not the cancel.
573        OrderStatus::Cancelled => {
574            OrderState::inactive(Cancelled::new(order_id, time_exchange, filled_quantity()))
575        }
576        OrderStatus::Rejected => {
577            OrderState::inactive(OrderError::Rejected(order_rejection(update.status.clone())))
578        }
579    };
580
581    // The update carries neither the order's kind nor its time in force.
582    Some(Order {
583        key: OrderKey {
584            exchange,
585            instrument,
586            strategy: StrategyId::unknown(),
587            cid,
588        },
589        side,
590        price: Some(price),
591        quantity: orig_sz,
592        kind: OrderKind::Limit,
593        time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
594        state,
595    })
596}
597
598pub fn parse_decimal(value: &str, field: &str) -> Option<Decimal> {
599    Decimal::from_str(value)
600        .map_err(|e| warn!(%field, %value, %e, "Failed to parse decimal"))
601        .ok()
602}
603
604/// Parse SDK side string to rustrade Side.
605///
606/// Hyperliquid API returns "B" for buy/long and "A" for sell/short (ask-side).
607/// Extra variants included for defensive parsing of potential future API changes.
608pub fn parse_side(side: &str) -> Option<Side> {
609    match side {
610        "B" | "b" | "BUY" | "Buy" | "buy" => Some(Side::Buy),
611        "A" | "a" | "S" | "s" | "SELL" | "Sell" | "sell" => Some(Side::Sell),
612        _ => {
613            warn!(%side, "Unknown side string");
614            None
615        }
616    }
617}
618
619/// Convert milliseconds timestamp to `DateTime<Utc>`.
620///
621/// Returns `None` for timestamps outside the representable range (year 292M+).
622pub fn millis_to_datetime(millis: u64) -> Option<DateTime<Utc>> {
623    Utc.timestamp_millis_opt(i64::try_from(millis).ok()?)
624        .single()
625}
626
627/// Map rustrade TimeInForce to Hyperliquid TIF string.
628pub fn map_tif(tif: &TimeInForce) -> &'static str {
629    match tif {
630        TimeInForce::GoodUntilCancelled { post_only: true } => "Alo",
631        TimeInForce::GoodUntilCancelled { post_only: false } => "Gtc",
632        TimeInForce::ImmediateOrCancel => "Ioc",
633        TimeInForce::FillOrKill => "Ioc", // Hyperliquid doesn't have FOK, use IOC
634        TimeInForce::GoodUntilEndOfDay => "Gtc", // No EOD on Hyperliquid
635        // Hyperliquid is a perpetuals DEX with no auction sessions and no native
636        // GTD. Coerce to GTC and surface the loss of semantics so downstream
637        // wrappers can decide whether to reject upstream.
638        TimeInForce::GoodTillDate { .. } | TimeInForce::AtOpen | TimeInForce::AtClose => {
639            warn!(time_in_force = ?tif, "Hyperliquid does not support this TimeInForce; coercing to Gtc");
640            "Gtc"
641        }
642    }
643}
644
645/// Why an order is refused when its client id cannot be a cloid ([`cid_to_cloid`]).
646pub(super) const CLOID_REQUIRED: &str = "Hyperliquid requires the client order id to be a UUID in \
647     canonical form, as ClientOrderId::uuid() makes: the venue reports each order under that id, \
648     and an order under any other id could not be matched to its own updates";
649
650/// The cloid to place an order under `cid` with, or `None` if `cid` cannot be one.
651///
652/// Hyperliquid stores a cloid as 16 bytes and reports it back as `0x` and 32 lowercase hex
653/// digits, which [`cloid_to_cid`] turns back into a client id. Only a `cid` that is the canonical
654/// text of a UUID — lowercase and hyphenated, as [`ClientOrderId::uuid`] makes — comes back
655/// unchanged. Any other spelling of a UUID would come back as a different id, under which the
656/// order's own updates could not find it, so it is refused along with every id that is not a UUID.
657pub fn cid_to_cloid(cid: &ClientOrderId) -> Option<Uuid> {
658    let uuid = Uuid::try_parse(cid.0.as_str()).ok()?;
659    let mut canonical = Uuid::encode_buffer();
660    (uuid.hyphenated().encode_lower(&mut canonical) == cid.0.as_str()).then_some(uuid)
661}
662
663/// The client id an order placed with `cloid` was placed under: the inverse of [`cid_to_cloid`].
664///
665/// `cloid` is the venue's form, `0x` and 32 hex digits. `None` for anything else.
666pub fn cloid_to_cid(cloid: &str) -> Option<ClientOrderId> {
667    let hex = cloid.strip_prefix("0x")?;
668    if hex.len() != 32 {
669        return None;
670    }
671    // The 32-digit length check rules out the other spellings `try_parse` accepts.
672    let uuid = Uuid::try_parse(hex).ok()?;
673    Some(ClientOrderId::new(format_smolstr!("{}", uuid.hyphenated())))
674}
675
676/// Whether Hyperliquid applied a cancel of one order, read from its answer: `Err` with the reason
677/// when it did not.
678///
679/// Hyperliquid answers a cancel it could not apply with a top-level `ok`, and puts the error in
680/// the order's own status ("Order was never placed, already canceled, or filled.", for one), so
681/// the top level alone does not say. Only a `success` status is a cancel. An answer carrying no
682/// status, or another kind of status, is not taken as one either: the order may still be working.
683/// A cancel names one order, so only the first status is read.
684///
685/// The reason is not classified. Hyperliquid's message says the order was never placed, already
686/// cancelled, or filled, without saying which, so naming it already cancelled or already filled
687/// would be a guess. A caller learns how the order ended from the account stream or a lookup.
688pub(super) fn cancel_outcome(response: ExchangeResponseStatus) -> Result<(), String> {
689    let response = match response {
690        ExchangeResponseStatus::Ok(response) => response,
691        ExchangeResponseStatus::Err(reason) => return Err(reason),
692    };
693    match response
694        .data
695        .and_then(|data| data.statuses.into_iter().next())
696    {
697        Some(ExchangeDataStatus::Success) => Ok(()),
698        Some(ExchangeDataStatus::Error(reason)) => Err(reason),
699        Some(status) => Err(format!("cancel answered with status {status:?}")),
700        None => Err("cancel answered without a status for the order".to_owned()),
701    }
702}
703
704/// The client id to report a venue order record under, given the record's `cloid` and `oid`.
705///
706/// Every order this client places carries a cloid ([`cid_to_cloid`]), so a record with one is
707/// reported under the id the order was placed with. A record without one was placed some other
708/// way, such as the web app, and has no client id at all. It is reported under its venue `oid`,
709/// the only identifier it has, which is stable across reads so the order is tracked once.
710///
711/// `None`, with a `warn!`, for a cloid that is not in the venue's form. The record's order is
712/// then unidentified, and must be left out rather than reported under a guess.
713pub(super) fn record_cid(cloid: Option<&str>, oid: u64) -> Option<ClientOrderId> {
714    match cloid {
715        None => Some(ClientOrderId::new(format_smolstr!("{oid}"))),
716        Some(cloid) => {
717            let cid = cloid_to_cid(cloid);
718            if cid.is_none() {
719                warn!(%cloid, oid, "Hyperliquid order carries a cloid that is not 0x and 32 hex digits");
720            }
721            cid
722        }
723    }
724}
725
726/// Whether `coin` names a market of a kind [`CoinKind::of`] does not recognise.
727fn is_unknown_coin(coin: &str) -> bool {
728    !matches!(CoinKind::of(coin), CoinKind::Perp | CoinKind::Spot)
729}
730
731/// Log with one `warn!` the distinct coins among `coins` of a kind [`CoinKind::of`] does not
732/// recognise, which neither client reports. For a read that lists many rows at once.
733pub(super) fn warn_unknown_coins<'a>(coins: impl IntoIterator<Item = &'a str>) {
734    let unknown: BTreeSet<&str> = coins
735        .into_iter()
736        .filter(|coin| is_unknown_coin(coin))
737        .collect();
738    if !unknown.is_empty() {
739        warn!(
740            ?unknown,
741            "Hyperliquid coins name markets of an unrecognised kind; leaving them out"
742        );
743    }
744}
745
746/// The coins of a kind [`CoinKind::of`] does not recognise that an account stream has logged, so
747/// it logs each once rather than on every event.
748#[derive(Debug, Default)]
749pub(super) struct UnknownCoins(HashSet<String>);
750
751impl UnknownCoins {
752    /// Log `coin` with `warn!` if it is of an unrecognised kind and not logged before.
753    pub(super) fn warn_once(&mut self, coin: &str) {
754        if is_unknown_coin(coin) && !self.0.contains(coin) {
755            warn!(
756                %coin,
757                "Hyperliquid coin names a market of an unrecognised kind; leaving it out \
758                 (logged once per stream)"
759            );
760            self.0.insert(coin.to_owned());
761        }
762    }
763}
764
765/// Build the spot instrument name for `pair`, from its tokens: `BASE-QUOTE-SPOT` (e.g.
766/// `HYPE-USDC-SPOT` for the pair Hyperliquid names `@107`).
767pub fn spot_pair_to_instrument(pair: &SpotPair) -> InstrumentNameExchange {
768    InstrumentNameExchange::from(format_smolstr!("{}-{}-SPOT", pair.base(), pair.quote()))
769}
770
771/// Extract coin pair from spot instrument (e.g., "PURR-USDC-SPOT" -> "PURR/USDC").
772///
773/// Returns `Option<String>` — `None` if the instrument doesn't match expected
774/// `BASE-QUOTE-SPOT` format. Callers should fail fast on `None` rather than
775/// send a malformed asset to the exchange.
776///
777/// The SDK's `ExchangeClient` addresses every spot pair by this `BASE/QUOTE` form as well as by
778/// the coin Hyperliquid names it (`@107`), but only spelled exactly as `spotMeta` spells the
779/// pair's tokens. The name is not checked against `spotMeta`, so `KPEPE-USDC-SPOT` gives
780/// `KPEPE/USDC`, which the SDK does not know for the pair `kPEPE/USDC`.
781pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
782    let (base, quote) = spot_base_quote(instrument)?;
783    Some(format!("{base}/{quote}"))
784}
785
786/// The base and quote tokens of a spot instrument named `BASE-QUOTE-SPOT`, `None` for a name of
787/// another form.
788pub(super) fn spot_base_quote(instrument: &InstrumentNameExchange) -> Option<(&str, &str)> {
789    instrument.as_ref().strip_suffix("-SPOT")?.split_once('-')
790}
791
792#[cfg(test)]
793// Test code: panics on bad input are acceptable
794#[allow(clippy::unwrap_used)]
795mod tests {
796    use super::*;
797    use rust_decimal_macros::dec;
798
799    fn fills_at(times: impl IntoIterator<Item = u64>) -> Vec<UserFill> {
800        times
801            .into_iter()
802            .map(|time| {
803                serde_json::from_value(serde_json::json!({
804                    "coin": "BTC", "side": "B", "px": "1", "sz": "1", "time": time,
805                    "oid": 1, "fee": "0", "tid": time
806                }))
807                .unwrap()
808            })
809            .collect()
810    }
811
812    #[test]
813    fn held_since_is_the_oldest_fill_only_when_the_retention_limit_is_reached() {
814        let full = fills_at((0..FILLS_RETAINED as u64).map(|i| 5_000 + i));
815        assert_eq!(
816            held_since(&full, 1_000),
817            Some(Utc.timestamp_millis_opt(5_000).unwrap())
818        );
819
820        let short = fills_at((0..FILLS_RETAINED as u64 - 1).map(|i| 5_000 + i));
821        assert_eq!(held_since(&short, 1_000), None);
822    }
823
824    #[test]
825    fn held_since_is_none_when_the_oldest_fill_is_not_after_the_span_start() {
826        let full = fills_at((0..FILLS_RETAINED as u64).map(|i| 5_000 + i));
827
828        assert_eq!(held_since(&full, 5_000), None);
829        assert_eq!(held_since(&full, 9_000), None);
830    }
831
832    #[test]
833    fn an_instrument_filter_keeps_what_it_was_given_or_everything_when_given_nothing() {
834        let (btc, eth) = (
835            InstrumentNameExchange::new("BTC"),
836            InstrumentNameExchange::new("ETH"),
837        );
838
839        let all = InstrumentFilter::new(&[]);
840        assert!(all.matches(&btc) && all.matches(&eth));
841
842        let given = [btc.clone()];
843        let one = InstrumentFilter::new(&given);
844        assert!(one.matches(&btc));
845        assert!(!one.matches(&eth));
846    }
847
848    #[test]
849    fn test_parse_decimal_valid() {
850        assert_eq!(parse_decimal("123.456", "test"), Some(dec!(123.456)));
851        assert_eq!(parse_decimal("0", "test"), Some(dec!(0)));
852        assert_eq!(parse_decimal("-50.5", "test"), Some(dec!(-50.5)));
853    }
854
855    #[test]
856    fn test_parse_decimal_invalid() {
857        assert_eq!(parse_decimal("", "test"), None);
858        assert_eq!(parse_decimal("abc", "test"), None);
859        assert_eq!(parse_decimal("12.34.56", "test"), None);
860    }
861
862    #[test]
863    fn test_parse_side() {
864        assert_eq!(parse_side("B"), Some(Side::Buy));
865        assert_eq!(parse_side("BUY"), Some(Side::Buy));
866        assert_eq!(parse_side("buy"), Some(Side::Buy));
867        assert_eq!(parse_side("A"), Some(Side::Sell));
868        assert_eq!(parse_side("S"), Some(Side::Sell));
869        assert_eq!(parse_side("SELL"), Some(Side::Sell));
870        assert_eq!(parse_side("sell"), Some(Side::Sell));
871        assert_eq!(parse_side("X"), None);
872        assert_eq!(parse_side(""), None);
873    }
874
875    #[test]
876    fn test_spot_pair_to_instrument() {
877        let pairs = super::super::spot_coins::test_spot_pairs();
878        let instrument = |coin| spot_pair_to_instrument(pairs.get(coin).unwrap());
879
880        assert_eq!(instrument("PURR/USDC").as_ref(), "PURR-USDC-SPOT");
881        assert_eq!(instrument("@107").as_ref(), "HYPE-USDC-SPOT");
882        assert_eq!(instrument("@207").as_ref(), "HYPE-USDT0-SPOT");
883    }
884
885    #[test]
886    fn unknown_coins_are_recorded_once_and_known_ones_never() {
887        let mut unknown = UnknownCoins::default();
888        for coin in ["#12", "#12", "BTC", "@107", "PURR/USDC", "+3"] {
889            unknown.warn_once(coin);
890        }
891        assert_eq!(
892            unknown.0,
893            HashSet::from(["#12".to_owned(), "+3".to_owned()])
894        );
895    }
896
897    /// [`info_tests::cancel_answer`] as the SDK reads it.
898    fn cancel_answer(statuses: serde_json::Value) -> ExchangeResponseStatus {
899        serde_json::from_value(info_tests::cancel_answer(statuses)).unwrap()
900    }
901
902    #[test]
903    fn only_a_success_status_is_a_cancel() {
904        assert_eq!(
905            cancel_outcome(cancel_answer(serde_json::json!(["success"]))),
906            Ok(())
907        );
908    }
909
910    /// The exchange endpoint documentation's own example of a cancel that was not applied.
911    #[test]
912    fn an_error_status_under_a_top_level_ok_is_not_a_cancel() {
913        let answer = cancel_answer(serde_json::json!([
914            {"error": info_tests::CANCEL_NOT_APPLIED}
915        ]));
916        assert_eq!(
917            cancel_outcome(answer),
918            Err(info_tests::CANCEL_NOT_APPLIED.to_owned())
919        );
920    }
921
922    #[test]
923    fn an_answer_without_a_success_status_is_not_a_cancel() {
924        let no_data: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
925            "status": "ok",
926            "response": {"type": "cancel"},
927        }))
928        .unwrap();
929        let answers = [
930            no_data,
931            cancel_answer(serde_json::json!([])),
932            cancel_answer(serde_json::json!([{"resting": {"oid": 1}}])),
933        ];
934        for answer in answers {
935            let outcome = cancel_outcome(answer.clone());
936            assert!(outcome.is_err(), "{answer:?} read as {outcome:?}");
937        }
938    }
939
940    #[test]
941    fn a_top_level_error_is_not_a_cancel() {
942        let answer: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
943            "status": "err",
944            "response": "User or API Wallet does not exist.",
945        }))
946        .unwrap();
947        assert_eq!(
948            cancel_outcome(answer),
949            Err("User or API Wallet does not exist.".to_owned())
950        );
951    }
952
953    #[test]
954    fn test_instrument_to_spot_coin() {
955        let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("PURR-USDC-SPOT"));
956        assert_eq!(coin, Some("PURR/USDC".to_string()));
957
958        let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("HYPE-USDC-SPOT"));
959        assert_eq!(coin, Some("HYPE/USDC".to_string()));
960
961        // Malformed instruments return None
962        assert_eq!(
963            instrument_to_spot_coin(&InstrumentNameExchange::from("BTC-USDC-PERP")),
964            None
965        );
966        assert_eq!(
967            instrument_to_spot_coin(&InstrumentNameExchange::from("INVALID")),
968            None
969        );
970    }
971
972    #[test]
973    fn test_map_tif() {
974        assert_eq!(
975            map_tif(&TimeInForce::GoodUntilCancelled { post_only: false }),
976            "Gtc"
977        );
978        assert_eq!(
979            map_tif(&TimeInForce::GoodUntilCancelled { post_only: true }),
980            "Alo"
981        );
982        assert_eq!(map_tif(&TimeInForce::ImmediateOrCancel), "Ioc");
983        assert_eq!(map_tif(&TimeInForce::FillOrKill), "Ioc");
984        assert_eq!(map_tif(&TimeInForce::GoodUntilEndOfDay), "Gtc");
985    }
986
987    #[test]
988    fn test_millis_to_datetime() {
989        let dt = millis_to_datetime(1714100000000).unwrap();
990        assert_eq!(dt.timestamp_millis(), 1714100000000);
991
992        // Zero timestamp (Unix epoch) is valid
993        assert!(millis_to_datetime(0).is_some());
994    }
995}
996
997#[cfg(test)]
998// Test code: panics on bad input are acceptable
999#[allow(clippy::unwrap_used)]
1000pub(super) mod info_tests {
1001    use super::*;
1002    use crate::error::ApiError;
1003    use rust_decimal_macros::dec;
1004    use wiremock::matchers::{method, path};
1005    use wiremock::{Mock, MockServer, ResponseTemplate};
1006
1007    /// One fill exactly as the `userFills` documentation shows it, including the two fields the
1008    /// SDK's own response type drops.
1009    const DOCUMENTED_FILL: &str = r#"{
1010        "closedPnl": "0.0",
1011        "coin": "AVAX",
1012        "crossed": false,
1013        "dir": "Open Long",
1014        "hash": "0xa166e3fa63c25663024b03f2e0da011a00307e4017465df020210d3d432e7cb8",
1015        "oid": 90542681,
1016        "px": "18.435",
1017        "side": "B",
1018        "startPosition": "26.86",
1019        "sz": "93.53",
1020        "time": 1681222254710,
1021        "fee": "0.01",
1022        "feeToken": "USDC",
1023        "builderFee": "0.01",
1024        "tid": 118906512037719
1025    }"#;
1026
1027    /// Build an `InfoClient` pointed at `uri`. `InfoClient::new` opens no connection -- it only
1028    /// fills in the struct -- so this costs nothing and touches no network.
1029    pub(in crate::client::hyperliquid) async fn info_client_against(
1030        uri: String,
1031    ) -> hyperliquid_rust_sdk::InfoClient {
1032        let mut client = hyperliquid_rust_sdk::InfoClient::new(
1033            None,
1034            Some(hyperliquid_rust_sdk::BaseUrl::Localhost),
1035        )
1036        .await
1037        .unwrap();
1038        client.http_client.base_url = uri;
1039        client
1040    }
1041
1042    /// The wallet clients built in tests sign with. No real server sees what it signs.
1043    pub(in crate::client::hyperliquid) fn test_wallet() -> ethers::signers::LocalWallet {
1044        "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
1045            .parse()
1046            .unwrap()
1047    }
1048
1049    /// Build an `ExchangeClient` pointed at `uri` that knows the assets in `coin_to_asset`.
1050    /// `ExchangeClient::new` would read them over the network instead.
1051    pub(in crate::client::hyperliquid) async fn exchange_client_against(
1052        uri: String,
1053        coin_to_asset: HashMap<String, u32>,
1054    ) -> hyperliquid_rust_sdk::ExchangeClient {
1055        hyperliquid_rust_sdk::ExchangeClient {
1056            http_client: info_client_against(uri).await.http_client,
1057            wallet: test_wallet(),
1058            meta: hyperliquid_rust_sdk::Meta {
1059                universe: Vec::new(),
1060            },
1061            vault_address: None,
1062            coin_to_asset,
1063        }
1064    }
1065
1066    /// Hyperliquid's answer to a cancel, as its exchange endpoint sends it, carrying `statuses`.
1067    pub(in crate::client::hyperliquid) fn cancel_answer(
1068        statuses: serde_json::Value,
1069    ) -> serde_json::Value {
1070        serde_json::json!({
1071            "status": "ok",
1072            "response": {"type": "cancel", "data": {"statuses": statuses}},
1073        })
1074    }
1075
1076    /// The documentation's example of a cancel Hyperliquid did not apply, under a top-level `ok`.
1077    pub(in crate::client::hyperliquid) const CANCEL_NOT_APPLIED: &str =
1078        "Order was never placed, already canceled, or filled.";
1079
1080    async fn serve(body: String) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
1081        let server = MockServer::start().await;
1082        Mock::given(method("POST"))
1083            .and(path("/info"))
1084            .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
1085            .mount(&server)
1086            .await;
1087        let client = info_client_against(server.uri()).await;
1088        (server, client)
1089    }
1090
1091    #[tokio::test]
1092    async fn a_fill_carries_the_tid_and_fee_token_the_sdk_type_drops() {
1093        let (_server, client) = serve(format!("[{DOCUMENTED_FILL}]")).await;
1094
1095        let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1096            .await
1097            .unwrap();
1098
1099        assert_eq!(fills.len(), 1);
1100        assert_eq!(fills[0].tid, 118_906_512_037_719);
1101        assert_eq!(fills[0].fee_token.as_deref(), Some("USDC"));
1102        assert_eq!(fills[0].oid, 90_542_681);
1103        assert_eq!(fills[0].coin, "AVAX");
1104    }
1105
1106    #[tokio::test]
1107    async fn one_transaction_can_carry_several_fills_and_they_are_told_apart_by_tid() {
1108        // A single aggressive order sweeping three resting orders: one `hash`, one `oid`, three
1109        // fills. This is the shape the old `TradeId(hash)` collapsed into one trade.
1110        let sweep = r#"[
1111            {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1112             "hash":"0xsweep","oid":1,"px":"18.40","side":"B","startPosition":"0",
1113             "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":111},
1114            {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1115             "hash":"0xsweep","oid":1,"px":"18.45","side":"B","startPosition":"10",
1116             "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":222},
1117            {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1118             "hash":"0xsweep","oid":1,"px":"18.50","side":"B","startPosition":"20",
1119             "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":333}
1120        ]"#;
1121        let (_server, client) = serve(sweep.to_string()).await;
1122
1123        let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1124            .await
1125            .unwrap();
1126
1127        let tids: Vec<u64> = fills.iter().map(|f| f.tid).collect();
1128        assert_eq!(tids, vec![111, 222, 333]);
1129    }
1130
1131    #[tokio::test]
1132    async fn a_missing_fee_token_falls_back_rather_than_failing() {
1133        // `feeToken` is the one field here that nothing proves the endpoint sends, so its absence
1134        // must not cost the caller the whole response.
1135        let without = DOCUMENTED_FILL.replace(r#""feeToken": "USDC","#, "");
1136        let (_server, client) = serve(format!("[{without}]")).await;
1137
1138        let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1139            .await
1140            .unwrap();
1141
1142        assert_eq!(fills.len(), 1);
1143        assert!(fills[0].fee_token.is_none());
1144    }
1145
1146    #[tokio::test]
1147    async fn a_missing_tid_fails_loudly_instead_of_falling_back_to_the_hash() {
1148        // Silently reverting to `hash` would reinstate the duplicate-id defect, and it would only
1149        // become visible as fills going missing during reconciliation.
1150        let without = DOCUMENTED_FILL.replace(r#""tid": 118906512037719"#, r#""unused": 0"#);
1151        let (_server, client) = serve(format!("[{without}]")).await;
1152
1153        let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1154            .await
1155            .unwrap_err();
1156
1157        assert!(
1158            matches!(&error, UnindexedClientError::Internal(msg) if msg.contains("userFills")),
1159            "expected a parse failure naming the endpoint, got {error:?}"
1160        );
1161    }
1162
1163    #[tokio::test]
1164    async fn the_request_names_the_address_in_full() {
1165        // `Display` for H160 abbreviates the middle of an address, which the venue would not
1166        // recognise. Only the `Debug` rendering is the full 0x-prefixed form.
1167        let server = MockServer::start().await;
1168        Mock::given(method("POST"))
1169            .and(path("/info"))
1170            .and(wiremock::matchers::body_string_contains(
1171                "0x000000000000000000000000000000000000dead",
1172            ))
1173            .and(wiremock::matchers::body_string_contains("userFills"))
1174            .respond_with(ResponseTemplate::new(200).set_body_raw("[]", "application/json"))
1175            .mount(&server)
1176            .await;
1177        let client = info_client_against(server.uri()).await;
1178
1179        let address = "0x000000000000000000000000000000000000dEaD"
1180            .parse::<ethers::types::H160>()
1181            .unwrap();
1182
1183        // The mock only answers a body carrying the full address, so reaching `Ok` is the
1184        // assertion.
1185        assert!(
1186            user_fills_by_time(&client, address, 0, u64::MAX)
1187                .await
1188                .unwrap()
1189                .is_empty()
1190        );
1191    }
1192
1193    /// `userFillsByTime` over `fills`, which are sorted by time, as observed on mainnet: both
1194    /// bounds inclusive, oldest first, at most [`USER_FILLS_PER_RESPONSE`] per response.
1195    struct FillsByTime {
1196        fills: Vec<serde_json::Value>,
1197    }
1198
1199    impl wiremock::Respond for FillsByTime {
1200        fn respond(&self, request: &wiremock::Request) -> ResponseTemplate {
1201            let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
1202            assert_eq!(body["type"], "userFillsByTime");
1203            let bound = |name: &str| body[name].as_u64().unwrap();
1204            let (start, end) = (bound("startTime"), bound("endTime"));
1205            let page: Vec<_> = self
1206                .fills
1207                .iter()
1208                .filter(|fill| (start..=end).contains(&fill["time"].as_u64().unwrap()))
1209                .take(USER_FILLS_PER_RESPONSE)
1210                .cloned()
1211                .collect();
1212            ResponseTemplate::new(200).set_body_json(page)
1213        }
1214    }
1215
1216    /// `DOCUMENTED_FILL` with `tid` at `time`.
1217    fn fill(tid: u64, time: u64) -> serde_json::Value {
1218        let mut fill: serde_json::Value = serde_json::from_str(DOCUMENTED_FILL).unwrap();
1219        fill["tid"] = tid.into();
1220        fill["time"] = time.into();
1221        fill
1222    }
1223
1224    async fn serve_fills_by_time(
1225        fills: Vec<serde_json::Value>,
1226    ) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
1227        let server = MockServer::start().await;
1228        Mock::given(method("POST"))
1229            .and(path("/info"))
1230            .respond_with(FillsByTime { fills })
1231            .mount(&server)
1232            .await;
1233        let client = info_client_against(server.uri()).await;
1234        (server, client)
1235    }
1236
1237    /// A span holding more fills than one response is read completely, each fill once. Three
1238    /// fills share each millisecond, so a response ends inside one, and the next, read from that
1239    /// millisecond, returns again the fills of it already read.
1240    #[tokio::test]
1241    async fn a_span_with_more_fills_than_one_response_is_read_whole() {
1242        let fills = (0..4_500).map(|tid| fill(tid, 1_000 + tid / 3)).collect();
1243        let (server, client) = serve_fills_by_time(fills).await;
1244
1245        let read = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1246            .await
1247            .unwrap();
1248        let tids: Vec<u64> = read.iter().map(|fill| fill.tid).collect();
1249        assert_eq!(tids, (0..4_500).collect::<Vec<_>>());
1250        assert_eq!(server.received_requests().await.map(|r| r.len()), Some(3));
1251
1252        // Both bounds are inclusive.
1253        let read = user_fills_by_time(&client, ethers::types::H160::zero(), 1_010, 1_019)
1254            .await
1255            .unwrap();
1256        assert_eq!(read.len(), 30);
1257    }
1258
1259    /// A full response of fills all at the millisecond it was read from cannot be read past.
1260    #[tokio::test]
1261    async fn a_read_that_cannot_advance_is_truncated() {
1262        let fills = (0..USER_FILLS_PER_RESPONSE as u64)
1263            .map(|tid| fill(tid, 5))
1264            .collect();
1265        let (_server, client) = serve_fills_by_time(fills).await;
1266
1267        let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1268            .await
1269            .unwrap_err();
1270        assert!(
1271            matches!(error, UnindexedClientError::Truncated { fills_read: 2_000 }),
1272            "got {error:?}"
1273        );
1274    }
1275
1276    #[test]
1277    fn a_span_is_read_in_whole_milliseconds_and_an_empty_one_not_at_all() {
1278        let at = |s: &str| s.parse::<DateTime<Utc>>().unwrap();
1279        assert_eq!(
1280            span_millis(
1281                at("1970-01-01T00:00:01.000900Z"),
1282                at("1970-01-01T00:00:02.000900Z")
1283            ),
1284            Some((1_000, 2_000))
1285        );
1286        assert_eq!(
1287            span_millis(at("1969-12-31T23:59:59Z"), at("1970-01-01T00:00:01Z")),
1288            Some((0, 1_000))
1289        );
1290        assert_eq!(
1291            span_millis(at("1970-01-01T00:00:02Z"), at("1970-01-01T00:00:01Z")),
1292            None
1293        );
1294        assert_eq!(
1295            span_millis(at("1969-01-01T00:00:00Z"), at("1969-12-31T23:59:59Z")),
1296            None
1297        );
1298    }
1299
1300    // ---- client order ids -------------------------------------------------------------------
1301
1302    const CID: &str = "85b60bff-64d5-49ec-95af-6268d7d1df63";
1303    /// `CID` as the venue reports it back: `0x` and the 32 lowercase hex digits.
1304    const CLOID: &str = "0x85b60bff64d549ec95af6268d7d1df63";
1305
1306    /// A REST fill decodes its order's cloid, and one placed without a cloid decodes as `None`.
1307    #[test]
1308    fn a_user_fill_reads_its_cloid() {
1309        let fill = |cloid: &str| {
1310            serde_json::from_str::<UserFill>(&format!(
1311                r#"{{"coin": "BTC", "side": "B", "px": "1", "sz": "1", "time": 1, "oid": 7,
1312                    "fee": "0", "tid": 9{cloid}}}"#
1313            ))
1314            .unwrap()
1315        };
1316
1317        let with = fill(&format!(r#", "cloid": "{CLOID}""#));
1318        assert_eq!(with.cloid.as_deref(), Some(CLOID));
1319        assert!(cloid_to_cid(CLOID).is_some());
1320        assert_eq!(fill("").cloid, None);
1321        assert_eq!(fill(r#", "cloid": null"#).cloid, None);
1322    }
1323
1324    #[test]
1325    fn only_the_canonical_uuid_form_can_be_a_cloid() {
1326        let cloid = cid_to_cloid(&ClientOrderId::new(CID)).unwrap();
1327        assert_eq!(cloid.to_string(), CID);
1328
1329        // Every other spelling of the same UUID would come back as `CID`, not as itself.
1330        for other in [
1331            "85B60BFF-64D5-49EC-95AF-6268D7D1DF63",
1332            "85b60bff64d549ec95af6268d7d1df63",
1333            "{85b60bff-64d5-49ec-95af-6268d7d1df63}",
1334            "urn:uuid:85b60bff-64d5-49ec-95af-6268d7d1df63",
1335            "example-1714100000000",
1336            "",
1337        ] {
1338            assert_eq!(cid_to_cloid(&ClientOrderId::new(other)), None, "{other}");
1339        }
1340    }
1341
1342    #[test]
1343    fn a_generated_uuid_client_id_is_always_a_cloid() {
1344        for _ in 0..32 {
1345            let cid = ClientOrderId::uuid();
1346            assert!(cid_to_cloid(&cid).is_some(), "{cid}");
1347        }
1348    }
1349
1350    #[test]
1351    fn the_reported_cloid_is_the_client_id_the_order_was_placed_under() {
1352        assert_eq!(cloid_to_cid(CLOID), Some(ClientOrderId::new(CID)));
1353
1354        // The round trip through the form the SDK sends, for a fresh id.
1355        let cid = ClientOrderId::uuid();
1356        let sent = format!("0x{}", cid_to_cloid(&cid).unwrap().simple());
1357        assert_eq!(cloid_to_cid(&sent), Some(cid));
1358    }
1359
1360    #[test]
1361    fn a_cloid_not_in_the_venue_form_names_no_client_id() {
1362        for malformed in [
1363            CID,
1364            "85b60bff64d549ec95af6268d7d1df63",
1365            "0x85b60bff64d549ec95af6268d7d1df",
1366            "0x85b60bff64d549ec95af6268d7d1df6300",
1367            "0x85b60bff-64d5-49ec-95af-6268d7d1df63",
1368            "0xzzb60bff64d549ec95af6268d7d1df63",
1369            "0x",
1370        ] {
1371            assert_eq!(cloid_to_cid(malformed), None, "{malformed}");
1372        }
1373    }
1374
1375    #[test]
1376    fn a_record_without_a_cloid_is_reported_under_its_oid() {
1377        assert_eq!(record_cid(None, 42), Some(ClientOrderId::new("42")));
1378        assert_eq!(record_cid(Some(CLOID), 42), Some(ClientOrderId::new(CID)));
1379        assert_eq!(record_cid(Some("0x1234"), 42), None);
1380    }
1381
1382    // ---- order statuses -----------------------------------------------------------------------
1383
1384    #[test]
1385    fn every_documented_order_status_is_classified() {
1386        use OrderStatus::*;
1387        for (status, expected) in [
1388            ("open", Open),
1389            ("triggered", Open),
1390            ("filled", Filled),
1391            ("canceled", Cancelled),
1392            ("marginCanceled", Cancelled),
1393            ("vaultWithdrawalCanceled", Cancelled),
1394            ("openInterestCapCanceled", Cancelled),
1395            ("selfTradeCanceled", Cancelled),
1396            ("reduceOnlyCanceled", Cancelled),
1397            ("siblingFilledCanceled", Cancelled),
1398            ("delistedCanceled", Cancelled),
1399            ("liquidatedCanceled", Cancelled),
1400            ("scheduledCancel", Cancelled),
1401            ("rejected", Rejected),
1402            ("tickRejected", Rejected),
1403            ("minTradeNtlRejected", Rejected),
1404            ("perpMarginRejected", Rejected),
1405            ("reduceOnlyRejected", Rejected),
1406            ("badAloPxRejected", Rejected),
1407            ("iocCancelRejected", Rejected),
1408            ("badTriggerPxRejected", Rejected),
1409            ("marketOrderNoLiquidityRejected", Rejected),
1410            ("positionIncreaseAtOpenInterestCapRejected", Rejected),
1411            ("positionFlipAtOpenInterestCapRejected", Rejected),
1412            ("tooAggressiveAtOpenInterestCapRejected", Rejected),
1413            ("openInterestIncreaseRejected", Rejected),
1414            ("insufficientSpotBalanceRejected", Rejected),
1415            ("oracleRejected", Rejected),
1416            ("perpMaxPositionRejected", Rejected),
1417        ] {
1418            assert_eq!(OrderStatus::classify(status), Some(expected), "{status}");
1419        }
1420        assert_eq!(OrderStatus::classify("unknown_status"), None);
1421    }
1422
1423    fn order_update(status: &str, cloid: &str, sz: &str) -> hyperliquid_rust_sdk::OrderUpdate {
1424        serde_json::from_str(&format!(
1425            r#"{{"order": {{"coin": "ETH", "side": "B", "limitPx": "1605.0", "sz": "{sz}",
1426                 "oid": 7001, "timestamp": 1714100000000, "origSz": "0.0075", "cloid": {cloid}}},
1427               "status": "{status}", "statusTimestamp": 1714100001000}}"#
1428        ))
1429        .unwrap()
1430    }
1431
1432    fn snapshot_of(event: UnindexedAccountEvent) -> UnindexedOrderSnapshot {
1433        match event.kind {
1434            crate::AccountEventKind::OrderSnapshot(snapshot) => snapshot.0,
1435            other => panic!("expected an order snapshot, got {other:?}"),
1436        }
1437    }
1438
1439    fn eth() -> InstrumentNameExchange {
1440        InstrumentNameExchange::from("ETH-USDC-PERP")
1441    }
1442
1443    #[test]
1444    fn an_order_update_names_the_order_by_the_id_it_was_placed_under() {
1445        let update = order_update("open", &format!(r#""{CLOID}""#), "0.005");
1446        let order = snapshot_of(
1447            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1448        );
1449
1450        assert_eq!(order.key.cid, ClientOrderId::new(CID));
1451        assert_eq!(order.quantity, dec!(0.0075));
1452        match order.state {
1453            OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) => {
1454                assert_eq!(open.id, VenueOrderId::Assigned(OrderId::new("7001")));
1455                assert_eq!(open.filled_quantity, dec!(0.0025));
1456            }
1457            other => panic!("expected Open, got {other:?}"),
1458        }
1459    }
1460
1461    #[test]
1462    fn an_order_update_without_a_cloid_is_named_by_its_oid() {
1463        let update = order_update("open", "null", "0.0075");
1464        let order = snapshot_of(
1465            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1466        );
1467        assert_eq!(order.key.cid, ClientOrderId::new("7001"));
1468    }
1469
1470    #[test]
1471    fn an_order_update_with_a_malformed_cloid_is_dropped() {
1472        let update = order_update("open", r#""0x1234""#, "0.0075");
1473        assert!(
1474            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).is_none()
1475        );
1476    }
1477
1478    #[test]
1479    fn a_cancellation_for_any_reason_ends_the_order() {
1480        let update = order_update("selfTradeCanceled", &format!(r#""{CLOID}""#), "0.005");
1481        let order = snapshot_of(
1482            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1483        );
1484        match order.state {
1485            OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1486                assert_eq!(cancelled.filled_quantity, Some(dec!(0.0025)));
1487            }
1488            other => panic!("expected Cancelled, got {other:?}"),
1489        }
1490    }
1491
1492    /// A cancellation whose remaining size does not parse still ends the order, with its fill
1493    /// unknown rather than the update dropped.
1494    #[test]
1495    fn a_cancellation_with_an_unparseable_size_ends_the_order_with_its_fill_unknown() {
1496        let update = order_update("canceled", &format!(r#""{CLOID}""#), "not a number");
1497        let order = snapshot_of(
1498            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1499        );
1500        match order.state {
1501            OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1502                assert_eq!(cancelled.filled_quantity, None);
1503            }
1504            other => panic!("expected Cancelled, got {other:?}"),
1505        }
1506    }
1507
1508    #[test]
1509    fn a_status_saying_funds_ran_short_ends_the_order_as_balance_insufficient() {
1510        for status in ["perpMarginRejected", "insufficientSpotBalanceRejected"] {
1511            let update = order_update(status, &format!(r#""{CLOID}""#), "0.0075");
1512            let order = snapshot_of(
1513                order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1514            );
1515            match order.state {
1516                OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
1517                    OrderError::Rejected(ApiError::BalanceInsufficient(None, reason)),
1518                )) => assert_eq!(reason, status),
1519                other => panic!("{status}: expected BalanceInsufficient, got {other:?}"),
1520            }
1521        }
1522    }
1523
1524    #[test]
1525    fn a_rejection_ends_the_order_and_names_the_reason() {
1526        let update = order_update("badAloPxRejected", &format!(r#""{CLOID}""#), "0.0075");
1527        let order = snapshot_of(
1528            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1529        );
1530        match order.state {
1531            OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
1532                OrderError::Rejected(ApiError::OrderRejected(reason)),
1533            )) => assert_eq!(reason, "badAloPxRejected"),
1534            other => panic!("expected a rejection, got {other:?}"),
1535        }
1536    }
1537
1538    // ---- open orders --------------------------------------------------------------------------
1539
1540    /// The shape `openOrders` was observed to return: `origSz` and `cloid` included, although the
1541    /// endpoint's documentation lists neither. Values are synthetic.
1542    const OPEN_ORDERS: &str = r#"[
1543        {"coin":"ETH","side":"B","limitPx":"1605.0","sz":"0.005","oid":7001,
1544         "timestamp":1714100000000,"origSz":"0.0075","cloid":"0x85b60bff64d549ec95af6268d7d1df63"},
1545        {"coin":"ETH","side":"A","limitPx":"1700.0","sz":"0.01","oid":7002,
1546         "timestamp":1714100000000,"origSz":"0.01"},
1547        {"coin":"BTC","side":"A","limitPx":"50000.0","sz":"0.001","oid":7003,
1548         "timestamp":1714100000000}
1549    ]"#;
1550
1551    #[tokio::test]
1552    async fn open_orders_carry_the_cloid_and_original_size_the_sdk_type_drops() {
1553        let (_server, client) = serve(OPEN_ORDERS.to_string()).await;
1554
1555        let rows = open_orders(&client, ethers::types::H160::zero())
1556            .await
1557            .unwrap();
1558
1559        assert_eq!(rows.len(), 3);
1560        assert_eq!(rows[0].cloid.as_deref(), Some(CLOID));
1561        assert_eq!(rows[0].orig_sz.as_deref(), Some("0.0075"));
1562        assert_eq!(rows[1].cloid, None);
1563        assert_eq!(rows[2].orig_sz, None);
1564    }
1565
1566    fn rows() -> Vec<OpenOrder> {
1567        serde_json::from_str(OPEN_ORDERS).unwrap()
1568    }
1569
1570    fn perp(coin: &str) -> Option<InstrumentNameExchange> {
1571        Some(InstrumentNameExchange::from(format!("{coin}-USDC-PERP")))
1572    }
1573
1574    #[test]
1575    fn an_open_order_is_listed_under_its_client_id_with_its_fill_so_far() {
1576        let order = open_order_to_order(&rows()[0], ExchangeId::HyperliquidPerp, eth()).unwrap();
1577
1578        assert_eq!(order.key.cid, ClientOrderId::new(CID));
1579        assert_eq!(order.quantity, dec!(0.0075));
1580        assert_eq!(order.state.filled_quantity, dec!(0.0025));
1581        assert_eq!(order.state.id, VenueOrderId::Assigned(OrderId::new("7001")));
1582    }
1583
1584    #[test]
1585    fn an_open_order_without_its_original_size_is_its_remainder_with_nothing_filled() {
1586        let row = &rows()[2];
1587        let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1588        let order = open_order_to_order(row, ExchangeId::HyperliquidPerp, btc).unwrap();
1589
1590        assert_eq!(order.key.cid, ClientOrderId::new("7003"));
1591        assert_eq!(order.quantity, dec!(0.001));
1592        assert_eq!(order.state.filled_quantity, Decimal::ZERO);
1593    }
1594
1595    #[test]
1596    fn every_requested_instrument_is_listed_complete_even_with_nothing_open() {
1597        let sol = InstrumentNameExchange::from("SOL-USDC-PERP");
1598        let mut listing = OpenOrderListing::new(
1599            &rows(),
1600            ExchangeId::HyperliquidPerp,
1601            &[eth(), sol.clone()],
1602            perp,
1603        );
1604
1605        let eth_entry = listing.remove(&eth()).unwrap();
1606        assert!(eth_entry.orders_complete);
1607        assert_eq!(eth_entry.orders.len(), 2);
1608
1609        // Nothing open: the entry is what lets an order that ended offline be seen to be gone.
1610        let sol_entry = listing.remove(&sol).unwrap();
1611        assert!(sol_entry.orders_complete);
1612        assert!(sol_entry.orders.is_empty());
1613
1614        // BTC was not requested.
1615        assert_eq!(listing.into_snapshots().count(), 0);
1616    }
1617
1618    #[test]
1619    fn an_unfiltered_listing_covers_every_instrument_with_open_orders() {
1620        let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidPerp, &[], perp);
1621        let mut instruments = listing
1622            .into_snapshots()
1623            .map(|entry| entry.instrument.to_string())
1624            .collect::<Vec<_>>();
1625        instruments.sort();
1626        assert_eq!(instruments, ["BTC-USDC-PERP", "ETH-USDC-PERP"]);
1627    }
1628
1629    #[test]
1630    fn an_order_that_does_not_convert_leaves_only_its_instrument_incomplete() {
1631        let mut rows = rows();
1632        rows[1].side = "?".to_string();
1633        let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1634        let mut listing = OpenOrderListing::new(
1635            &rows,
1636            ExchangeId::HyperliquidPerp,
1637            &[eth(), btc.clone()],
1638            perp,
1639        );
1640
1641        let eth_entry = listing.remove(&eth()).unwrap();
1642        assert!(!eth_entry.orders_complete);
1643        assert_eq!(eth_entry.orders.len(), 1);
1644        assert!(listing.remove(&btc).unwrap().orders_complete);
1645    }
1646
1647    #[test]
1648    fn an_order_with_a_malformed_cloid_leaves_its_instrument_incomplete() {
1649        let mut rows = rows();
1650        rows[0].cloid = Some("0x1234".to_string());
1651        let mut listing = OpenOrderListing::new(&rows, ExchangeId::HyperliquidPerp, &[eth()], perp);
1652
1653        let eth_entry = listing.remove(&eth()).unwrap();
1654        assert!(!eth_entry.orders_complete);
1655        assert_eq!(eth_entry.orders.len(), 1);
1656    }
1657
1658    #[test]
1659    fn coins_the_client_does_not_trade_are_left_out() {
1660        let pairs = super::super::spot_coins::test_spot_pairs();
1661        let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidSpot, &[], |coin| {
1662            pairs.get(coin).map(spot_pair_to_instrument)
1663        });
1664        assert_eq!(listing.into_snapshots().count(), 0);
1665    }
1666}