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