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`), so orders can be placed with it.
742pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
743    let (base, quote) = spot_base_quote(instrument)?;
744    Some(format!("{base}/{quote}"))
745}
746
747/// The base and quote tokens of a spot instrument named `BASE-QUOTE-SPOT`, `None` for a name of
748/// another form.
749pub(super) fn spot_base_quote(instrument: &InstrumentNameExchange) -> Option<(&str, &str)> {
750    instrument.as_ref().strip_suffix("-SPOT")?.split_once('-')
751}
752
753#[cfg(test)]
754// Test code: panics on bad input are acceptable
755#[allow(clippy::unwrap_used)]
756mod tests {
757    use super::*;
758    use rust_decimal_macros::dec;
759
760    #[test]
761    fn test_parse_decimal_valid() {
762        assert_eq!(parse_decimal("123.456", "test"), Some(dec!(123.456)));
763        assert_eq!(parse_decimal("0", "test"), Some(dec!(0)));
764        assert_eq!(parse_decimal("-50.5", "test"), Some(dec!(-50.5)));
765    }
766
767    #[test]
768    fn test_parse_decimal_invalid() {
769        assert_eq!(parse_decimal("", "test"), None);
770        assert_eq!(parse_decimal("abc", "test"), None);
771        assert_eq!(parse_decimal("12.34.56", "test"), None);
772    }
773
774    #[test]
775    fn test_parse_side() {
776        assert_eq!(parse_side("B"), Some(Side::Buy));
777        assert_eq!(parse_side("BUY"), Some(Side::Buy));
778        assert_eq!(parse_side("buy"), Some(Side::Buy));
779        assert_eq!(parse_side("A"), Some(Side::Sell));
780        assert_eq!(parse_side("S"), Some(Side::Sell));
781        assert_eq!(parse_side("SELL"), Some(Side::Sell));
782        assert_eq!(parse_side("sell"), Some(Side::Sell));
783        assert_eq!(parse_side("X"), None);
784        assert_eq!(parse_side(""), None);
785    }
786
787    #[test]
788    fn test_spot_pair_to_instrument() {
789        let pairs = super::super::spot_coins::test_spot_pairs();
790        let instrument = |coin| spot_pair_to_instrument(pairs.get(coin).unwrap());
791
792        assert_eq!(instrument("PURR/USDC").as_ref(), "PURR-USDC-SPOT");
793        assert_eq!(instrument("@107").as_ref(), "HYPE-USDC-SPOT");
794        assert_eq!(instrument("@207").as_ref(), "HYPE-USDT0-SPOT");
795    }
796
797    #[test]
798    fn unknown_coins_are_recorded_once_and_known_ones_never() {
799        let mut unknown = UnknownCoins::default();
800        for coin in ["#12", "#12", "BTC", "@107", "PURR/USDC", "+3"] {
801            unknown.warn_once(coin);
802        }
803        assert_eq!(
804            unknown.0,
805            HashSet::from(["#12".to_owned(), "+3".to_owned()])
806        );
807    }
808
809    /// [`info_tests::cancel_answer`] as the SDK reads it.
810    fn cancel_answer(statuses: serde_json::Value) -> ExchangeResponseStatus {
811        serde_json::from_value(info_tests::cancel_answer(statuses)).unwrap()
812    }
813
814    #[test]
815    fn only_a_success_status_is_a_cancel() {
816        assert_eq!(
817            cancel_outcome(cancel_answer(serde_json::json!(["success"]))),
818            Ok(())
819        );
820    }
821
822    /// The exchange endpoint documentation's own example of a cancel that was not applied.
823    #[test]
824    fn an_error_status_under_a_top_level_ok_is_not_a_cancel() {
825        let answer = cancel_answer(serde_json::json!([
826            {"error": info_tests::CANCEL_NOT_APPLIED}
827        ]));
828        assert_eq!(
829            cancel_outcome(answer),
830            Err(info_tests::CANCEL_NOT_APPLIED.to_owned())
831        );
832    }
833
834    #[test]
835    fn an_answer_without_a_success_status_is_not_a_cancel() {
836        let no_data: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
837            "status": "ok",
838            "response": {"type": "cancel"},
839        }))
840        .unwrap();
841        let answers = [
842            no_data,
843            cancel_answer(serde_json::json!([])),
844            cancel_answer(serde_json::json!([{"resting": {"oid": 1}}])),
845        ];
846        for answer in answers {
847            let outcome = cancel_outcome(answer.clone());
848            assert!(outcome.is_err(), "{answer:?} read as {outcome:?}");
849        }
850    }
851
852    #[test]
853    fn a_top_level_error_is_not_a_cancel() {
854        let answer: ExchangeResponseStatus = serde_json::from_value(serde_json::json!({
855            "status": "err",
856            "response": "User or API Wallet does not exist.",
857        }))
858        .unwrap();
859        assert_eq!(
860            cancel_outcome(answer),
861            Err("User or API Wallet does not exist.".to_owned())
862        );
863    }
864
865    #[test]
866    fn test_instrument_to_spot_coin() {
867        let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("PURR-USDC-SPOT"));
868        assert_eq!(coin, Some("PURR/USDC".to_string()));
869
870        let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("HYPE-USDC-SPOT"));
871        assert_eq!(coin, Some("HYPE/USDC".to_string()));
872
873        // Malformed instruments return None
874        assert_eq!(
875            instrument_to_spot_coin(&InstrumentNameExchange::from("BTC-USDC-PERP")),
876            None
877        );
878        assert_eq!(
879            instrument_to_spot_coin(&InstrumentNameExchange::from("INVALID")),
880            None
881        );
882    }
883
884    #[test]
885    fn test_map_tif() {
886        assert_eq!(
887            map_tif(&TimeInForce::GoodUntilCancelled { post_only: false }),
888            "Gtc"
889        );
890        assert_eq!(
891            map_tif(&TimeInForce::GoodUntilCancelled { post_only: true }),
892            "Alo"
893        );
894        assert_eq!(map_tif(&TimeInForce::ImmediateOrCancel), "Ioc");
895        assert_eq!(map_tif(&TimeInForce::FillOrKill), "Ioc");
896        assert_eq!(map_tif(&TimeInForce::GoodUntilEndOfDay), "Gtc");
897    }
898
899    #[test]
900    fn test_millis_to_datetime() {
901        let dt = millis_to_datetime(1714100000000).unwrap();
902        assert_eq!(dt.timestamp_millis(), 1714100000000);
903
904        // Zero timestamp (Unix epoch) is valid
905        assert!(millis_to_datetime(0).is_some());
906    }
907}
908
909#[cfg(test)]
910// Test code: panics on bad input are acceptable
911#[allow(clippy::unwrap_used)]
912pub(super) mod info_tests {
913    use super::*;
914    use rust_decimal_macros::dec;
915    use wiremock::matchers::{method, path};
916    use wiremock::{Mock, MockServer, ResponseTemplate};
917
918    /// One fill exactly as the `userFills` documentation shows it, including the two fields the
919    /// SDK's own response type drops.
920    const DOCUMENTED_FILL: &str = r#"{
921        "closedPnl": "0.0",
922        "coin": "AVAX",
923        "crossed": false,
924        "dir": "Open Long",
925        "hash": "0xa166e3fa63c25663024b03f2e0da011a00307e4017465df020210d3d432e7cb8",
926        "oid": 90542681,
927        "px": "18.435",
928        "side": "B",
929        "startPosition": "26.86",
930        "sz": "93.53",
931        "time": 1681222254710,
932        "fee": "0.01",
933        "feeToken": "USDC",
934        "builderFee": "0.01",
935        "tid": 118906512037719
936    }"#;
937
938    /// Build an `InfoClient` pointed at `uri`. `InfoClient::new` opens no connection -- it only
939    /// fills in the struct -- so this costs nothing and touches no network.
940    pub(in crate::client::hyperliquid) async fn info_client_against(
941        uri: String,
942    ) -> hyperliquid_rust_sdk::InfoClient {
943        let mut client = hyperliquid_rust_sdk::InfoClient::new(
944            None,
945            Some(hyperliquid_rust_sdk::BaseUrl::Localhost),
946        )
947        .await
948        .unwrap();
949        client.http_client.base_url = uri;
950        client
951    }
952
953    /// The wallet clients built in tests sign with. No real server sees what it signs.
954    pub(in crate::client::hyperliquid) fn test_wallet() -> ethers::signers::LocalWallet {
955        "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
956            .parse()
957            .unwrap()
958    }
959
960    /// Build an `ExchangeClient` pointed at `uri` that knows the assets in `coin_to_asset`.
961    /// `ExchangeClient::new` would read them over the network instead.
962    pub(in crate::client::hyperliquid) async fn exchange_client_against(
963        uri: String,
964        coin_to_asset: HashMap<String, u32>,
965    ) -> hyperliquid_rust_sdk::ExchangeClient {
966        hyperliquid_rust_sdk::ExchangeClient {
967            http_client: info_client_against(uri).await.http_client,
968            wallet: test_wallet(),
969            meta: hyperliquid_rust_sdk::Meta {
970                universe: Vec::new(),
971            },
972            vault_address: None,
973            coin_to_asset,
974        }
975    }
976
977    /// Hyperliquid's answer to a cancel, as its exchange endpoint sends it, carrying `statuses`.
978    pub(in crate::client::hyperliquid) fn cancel_answer(
979        statuses: serde_json::Value,
980    ) -> serde_json::Value {
981        serde_json::json!({
982            "status": "ok",
983            "response": {"type": "cancel", "data": {"statuses": statuses}},
984        })
985    }
986
987    /// The documentation's example of a cancel Hyperliquid did not apply, under a top-level `ok`.
988    pub(in crate::client::hyperliquid) const CANCEL_NOT_APPLIED: &str =
989        "Order was never placed, already canceled, or filled.";
990
991    async fn serve(body: String) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
992        let server = MockServer::start().await;
993        Mock::given(method("POST"))
994            .and(path("/info"))
995            .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
996            .mount(&server)
997            .await;
998        let client = info_client_against(server.uri()).await;
999        (server, client)
1000    }
1001
1002    #[tokio::test]
1003    async fn a_fill_carries_the_tid_and_fee_token_the_sdk_type_drops() {
1004        let (_server, client) = serve(format!("[{DOCUMENTED_FILL}]")).await;
1005
1006        let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1007            .await
1008            .unwrap();
1009
1010        assert_eq!(fills.len(), 1);
1011        assert_eq!(fills[0].tid, 118_906_512_037_719);
1012        assert_eq!(fills[0].fee_token.as_deref(), Some("USDC"));
1013        assert_eq!(fills[0].oid, 90_542_681);
1014        assert_eq!(fills[0].coin, "AVAX");
1015    }
1016
1017    #[tokio::test]
1018    async fn one_transaction_can_carry_several_fills_and_they_are_told_apart_by_tid() {
1019        // A single aggressive order sweeping three resting orders: one `hash`, one `oid`, three
1020        // fills. This is the shape the old `TradeId(hash)` collapsed into one trade.
1021        let sweep = r#"[
1022            {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1023             "hash":"0xsweep","oid":1,"px":"18.40","side":"B","startPosition":"0",
1024             "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":111},
1025            {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1026             "hash":"0xsweep","oid":1,"px":"18.45","side":"B","startPosition":"10",
1027             "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":222},
1028            {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
1029             "hash":"0xsweep","oid":1,"px":"18.50","side":"B","startPosition":"20",
1030             "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":333}
1031        ]"#;
1032        let (_server, client) = serve(sweep.to_string()).await;
1033
1034        let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1035            .await
1036            .unwrap();
1037
1038        let tids: Vec<u64> = fills.iter().map(|f| f.tid).collect();
1039        assert_eq!(tids, vec![111, 222, 333]);
1040    }
1041
1042    #[tokio::test]
1043    async fn a_missing_fee_token_falls_back_rather_than_failing() {
1044        // `feeToken` is the one field here that nothing proves the endpoint sends, so its absence
1045        // must not cost the caller the whole response.
1046        let without = DOCUMENTED_FILL.replace(r#""feeToken": "USDC","#, "");
1047        let (_server, client) = serve(format!("[{without}]")).await;
1048
1049        let fills = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1050            .await
1051            .unwrap();
1052
1053        assert_eq!(fills.len(), 1);
1054        assert!(fills[0].fee_token.is_none());
1055    }
1056
1057    #[tokio::test]
1058    async fn a_missing_tid_fails_loudly_instead_of_falling_back_to_the_hash() {
1059        // Silently reverting to `hash` would reinstate the duplicate-id defect, and it would only
1060        // become visible as fills going missing during reconciliation.
1061        let without = DOCUMENTED_FILL.replace(r#""tid": 118906512037719"#, r#""unused": 0"#);
1062        let (_server, client) = serve(format!("[{without}]")).await;
1063
1064        let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1065            .await
1066            .unwrap_err();
1067
1068        assert!(
1069            matches!(&error, UnindexedClientError::Internal(msg) if msg.contains("userFills")),
1070            "expected a parse failure naming the endpoint, got {error:?}"
1071        );
1072    }
1073
1074    #[tokio::test]
1075    async fn the_request_names_the_address_in_full() {
1076        // `Display` for H160 abbreviates the middle of an address, which the venue would not
1077        // recognise. Only the `Debug` rendering is the full 0x-prefixed form.
1078        let server = MockServer::start().await;
1079        Mock::given(method("POST"))
1080            .and(path("/info"))
1081            .and(wiremock::matchers::body_string_contains(
1082                "0x000000000000000000000000000000000000dead",
1083            ))
1084            .and(wiremock::matchers::body_string_contains("userFills"))
1085            .respond_with(ResponseTemplate::new(200).set_body_raw("[]", "application/json"))
1086            .mount(&server)
1087            .await;
1088        let client = info_client_against(server.uri()).await;
1089
1090        let address = "0x000000000000000000000000000000000000dEaD"
1091            .parse::<ethers::types::H160>()
1092            .unwrap();
1093
1094        // The mock only answers a body carrying the full address, so reaching `Ok` is the
1095        // assertion.
1096        assert!(
1097            user_fills_by_time(&client, address, 0, u64::MAX)
1098                .await
1099                .unwrap()
1100                .is_empty()
1101        );
1102    }
1103
1104    /// `userFillsByTime` over `fills`, which are sorted by time, as observed on mainnet: both
1105    /// bounds inclusive, oldest first, at most [`USER_FILLS_PER_RESPONSE`] per response.
1106    struct FillsByTime {
1107        fills: Vec<serde_json::Value>,
1108    }
1109
1110    impl wiremock::Respond for FillsByTime {
1111        fn respond(&self, request: &wiremock::Request) -> ResponseTemplate {
1112            let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
1113            assert_eq!(body["type"], "userFillsByTime");
1114            let bound = |name: &str| body[name].as_u64().unwrap();
1115            let (start, end) = (bound("startTime"), bound("endTime"));
1116            let page: Vec<_> = self
1117                .fills
1118                .iter()
1119                .filter(|fill| (start..=end).contains(&fill["time"].as_u64().unwrap()))
1120                .take(USER_FILLS_PER_RESPONSE)
1121                .cloned()
1122                .collect();
1123            ResponseTemplate::new(200).set_body_json(page)
1124        }
1125    }
1126
1127    /// `DOCUMENTED_FILL` with `tid` at `time`.
1128    fn fill(tid: u64, time: u64) -> serde_json::Value {
1129        let mut fill: serde_json::Value = serde_json::from_str(DOCUMENTED_FILL).unwrap();
1130        fill["tid"] = tid.into();
1131        fill["time"] = time.into();
1132        fill
1133    }
1134
1135    async fn serve_fills_by_time(
1136        fills: Vec<serde_json::Value>,
1137    ) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
1138        let server = MockServer::start().await;
1139        Mock::given(method("POST"))
1140            .and(path("/info"))
1141            .respond_with(FillsByTime { fills })
1142            .mount(&server)
1143            .await;
1144        let client = info_client_against(server.uri()).await;
1145        (server, client)
1146    }
1147
1148    /// A span holding more fills than one response is read completely, each fill once. Three
1149    /// fills share each millisecond, so a response ends inside one, and the next, read from that
1150    /// millisecond, returns again the fills of it already read.
1151    #[tokio::test]
1152    async fn a_span_with_more_fills_than_one_response_is_read_whole() {
1153        let fills = (0..4_500).map(|tid| fill(tid, 1_000 + tid / 3)).collect();
1154        let (server, client) = serve_fills_by_time(fills).await;
1155
1156        let read = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1157            .await
1158            .unwrap();
1159        let tids: Vec<u64> = read.iter().map(|fill| fill.tid).collect();
1160        assert_eq!(tids, (0..4_500).collect::<Vec<_>>());
1161        assert_eq!(server.received_requests().await.map(|r| r.len()), Some(3));
1162
1163        // Both bounds are inclusive.
1164        let read = user_fills_by_time(&client, ethers::types::H160::zero(), 1_010, 1_019)
1165            .await
1166            .unwrap();
1167        assert_eq!(read.len(), 30);
1168    }
1169
1170    /// A full response of fills all at the millisecond it was read from cannot be read past.
1171    #[tokio::test]
1172    async fn a_read_that_cannot_advance_is_truncated() {
1173        let fills = (0..USER_FILLS_PER_RESPONSE as u64)
1174            .map(|tid| fill(tid, 5))
1175            .collect();
1176        let (_server, client) = serve_fills_by_time(fills).await;
1177
1178        let error = user_fills_by_time(&client, ethers::types::H160::zero(), 0, u64::MAX)
1179            .await
1180            .unwrap_err();
1181        assert!(
1182            matches!(error, UnindexedClientError::Truncated { fills_read: 2_000 }),
1183            "got {error:?}"
1184        );
1185    }
1186
1187    #[test]
1188    fn a_span_is_read_in_whole_milliseconds_and_an_empty_one_not_at_all() {
1189        let at = |s: &str| s.parse::<DateTime<Utc>>().unwrap();
1190        assert_eq!(
1191            span_millis(
1192                at("1970-01-01T00:00:01.000900Z"),
1193                at("1970-01-01T00:00:02.000900Z")
1194            ),
1195            Some((1_000, 2_000))
1196        );
1197        assert_eq!(
1198            span_millis(at("1969-12-31T23:59:59Z"), at("1970-01-01T00:00:01Z")),
1199            Some((0, 1_000))
1200        );
1201        assert_eq!(
1202            span_millis(at("1970-01-01T00:00:02Z"), at("1970-01-01T00:00:01Z")),
1203            None
1204        );
1205        assert_eq!(
1206            span_millis(at("1969-01-01T00:00:00Z"), at("1969-12-31T23:59:59Z")),
1207            None
1208        );
1209    }
1210
1211    // ---- client order ids -------------------------------------------------------------------
1212
1213    const CID: &str = "85b60bff-64d5-49ec-95af-6268d7d1df63";
1214    /// `CID` as the venue reports it back: `0x` and the 32 lowercase hex digits.
1215    const CLOID: &str = "0x85b60bff64d549ec95af6268d7d1df63";
1216
1217    #[test]
1218    fn only_the_canonical_uuid_form_can_be_a_cloid() {
1219        let cloid = cid_to_cloid(&ClientOrderId::new(CID)).unwrap();
1220        assert_eq!(cloid.to_string(), CID);
1221
1222        // Every other spelling of the same UUID would come back as `CID`, not as itself.
1223        for other in [
1224            "85B60BFF-64D5-49EC-95AF-6268D7D1DF63",
1225            "85b60bff64d549ec95af6268d7d1df63",
1226            "{85b60bff-64d5-49ec-95af-6268d7d1df63}",
1227            "urn:uuid:85b60bff-64d5-49ec-95af-6268d7d1df63",
1228            "example-1714100000000",
1229            "",
1230        ] {
1231            assert_eq!(cid_to_cloid(&ClientOrderId::new(other)), None, "{other}");
1232        }
1233    }
1234
1235    #[test]
1236    fn a_generated_uuid_client_id_is_always_a_cloid() {
1237        for _ in 0..32 {
1238            let cid = ClientOrderId::uuid();
1239            assert!(cid_to_cloid(&cid).is_some(), "{cid}");
1240        }
1241    }
1242
1243    #[test]
1244    fn the_reported_cloid_is_the_client_id_the_order_was_placed_under() {
1245        assert_eq!(cloid_to_cid(CLOID), Some(ClientOrderId::new(CID)));
1246
1247        // The round trip through the form the SDK sends, for a fresh id.
1248        let cid = ClientOrderId::uuid();
1249        let sent = format!("0x{}", cid_to_cloid(&cid).unwrap().simple());
1250        assert_eq!(cloid_to_cid(&sent), Some(cid));
1251    }
1252
1253    #[test]
1254    fn a_cloid_not_in_the_venue_form_names_no_client_id() {
1255        for malformed in [
1256            CID,
1257            "85b60bff64d549ec95af6268d7d1df63",
1258            "0x85b60bff64d549ec95af6268d7d1df",
1259            "0x85b60bff64d549ec95af6268d7d1df6300",
1260            "0x85b60bff-64d5-49ec-95af-6268d7d1df63",
1261            "0xzzb60bff64d549ec95af6268d7d1df63",
1262            "0x",
1263        ] {
1264            assert_eq!(cloid_to_cid(malformed), None, "{malformed}");
1265        }
1266    }
1267
1268    #[test]
1269    fn a_record_without_a_cloid_is_reported_under_its_oid() {
1270        assert_eq!(record_cid(None, 42), Some(ClientOrderId::new("42")));
1271        assert_eq!(record_cid(Some(CLOID), 42), Some(ClientOrderId::new(CID)));
1272        assert_eq!(record_cid(Some("0x1234"), 42), None);
1273    }
1274
1275    // ---- order statuses -----------------------------------------------------------------------
1276
1277    #[test]
1278    fn every_documented_order_status_is_classified() {
1279        use OrderStatus::*;
1280        for (status, expected) in [
1281            ("open", Open),
1282            ("triggered", Open),
1283            ("filled", Filled),
1284            ("canceled", Cancelled),
1285            ("marginCanceled", Cancelled),
1286            ("vaultWithdrawalCanceled", Cancelled),
1287            ("openInterestCapCanceled", Cancelled),
1288            ("selfTradeCanceled", Cancelled),
1289            ("reduceOnlyCanceled", Cancelled),
1290            ("siblingFilledCanceled", Cancelled),
1291            ("delistedCanceled", Cancelled),
1292            ("liquidatedCanceled", Cancelled),
1293            ("scheduledCancel", Cancelled),
1294            ("rejected", Rejected),
1295            ("tickRejected", Rejected),
1296            ("minTradeNtlRejected", Rejected),
1297            ("perpMarginRejected", Rejected),
1298            ("reduceOnlyRejected", Rejected),
1299            ("badAloPxRejected", Rejected),
1300            ("iocCancelRejected", Rejected),
1301            ("badTriggerPxRejected", Rejected),
1302            ("marketOrderNoLiquidityRejected", Rejected),
1303            ("positionIncreaseAtOpenInterestCapRejected", Rejected),
1304            ("positionFlipAtOpenInterestCapRejected", Rejected),
1305            ("tooAggressiveAtOpenInterestCapRejected", Rejected),
1306            ("openInterestIncreaseRejected", Rejected),
1307            ("insufficientSpotBalanceRejected", Rejected),
1308            ("oracleRejected", Rejected),
1309            ("perpMaxPositionRejected", Rejected),
1310        ] {
1311            assert_eq!(OrderStatus::classify(status), Some(expected), "{status}");
1312        }
1313        assert_eq!(OrderStatus::classify("unknown_status"), None);
1314    }
1315
1316    fn order_update(status: &str, cloid: &str, sz: &str) -> hyperliquid_rust_sdk::OrderUpdate {
1317        serde_json::from_str(&format!(
1318            r#"{{"order": {{"coin": "ETH", "side": "B", "limitPx": "1605.0", "sz": "{sz}",
1319                 "oid": 7001, "timestamp": 1714100000000, "origSz": "0.0075", "cloid": {cloid}}},
1320               "status": "{status}", "statusTimestamp": 1714100001000}}"#
1321        ))
1322        .unwrap()
1323    }
1324
1325    fn snapshot_of(event: UnindexedAccountEvent) -> UnindexedOrderSnapshot {
1326        match event.kind {
1327            crate::AccountEventKind::OrderSnapshot(snapshot) => snapshot.0,
1328            other => panic!("expected an order snapshot, got {other:?}"),
1329        }
1330    }
1331
1332    fn eth() -> InstrumentNameExchange {
1333        InstrumentNameExchange::from("ETH-USDC-PERP")
1334    }
1335
1336    #[test]
1337    fn an_order_update_names_the_order_by_the_id_it_was_placed_under() {
1338        let update = order_update("open", &format!(r#""{CLOID}""#), "0.005");
1339        let order = snapshot_of(
1340            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1341        );
1342
1343        assert_eq!(order.key.cid, ClientOrderId::new(CID));
1344        assert_eq!(order.quantity, dec!(0.0075));
1345        match order.state {
1346            OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) => {
1347                assert_eq!(open.id, VenueOrderId::Assigned(OrderId::new("7001")));
1348                assert_eq!(open.filled_quantity, dec!(0.0025));
1349            }
1350            other => panic!("expected Open, got {other:?}"),
1351        }
1352    }
1353
1354    #[test]
1355    fn an_order_update_without_a_cloid_is_named_by_its_oid() {
1356        let update = order_update("open", "null", "0.0075");
1357        let order = snapshot_of(
1358            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1359        );
1360        assert_eq!(order.key.cid, ClientOrderId::new("7001"));
1361    }
1362
1363    #[test]
1364    fn an_order_update_with_a_malformed_cloid_is_dropped() {
1365        let update = order_update("open", r#""0x1234""#, "0.0075");
1366        assert!(
1367            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).is_none()
1368        );
1369    }
1370
1371    #[test]
1372    fn a_cancellation_for_any_reason_ends_the_order() {
1373        let update = order_update("selfTradeCanceled", &format!(r#""{CLOID}""#), "0.005");
1374        let order = snapshot_of(
1375            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1376        );
1377        match order.state {
1378            OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1379                assert_eq!(cancelled.filled_quantity, Some(dec!(0.0025)));
1380            }
1381            other => panic!("expected Cancelled, got {other:?}"),
1382        }
1383    }
1384
1385    /// A cancellation whose remaining size does not parse still ends the order, with its fill
1386    /// unknown rather than the update dropped.
1387    #[test]
1388    fn a_cancellation_with_an_unparseable_size_ends_the_order_with_its_fill_unknown() {
1389        let update = order_update("canceled", &format!(r#""{CLOID}""#), "not a number");
1390        let order = snapshot_of(
1391            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1392        );
1393        match order.state {
1394            OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1395                assert_eq!(cancelled.filled_quantity, None);
1396            }
1397            other => panic!("expected Cancelled, got {other:?}"),
1398        }
1399    }
1400
1401    #[test]
1402    fn a_rejection_ends_the_order_and_names_the_reason() {
1403        let update = order_update("badAloPxRejected", &format!(r#""{CLOID}""#), "0.0075");
1404        let order = snapshot_of(
1405            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1406        );
1407        match order.state {
1408            OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
1409                OrderError::Rejected(ApiError::OrderRejected(reason)),
1410            )) => assert_eq!(reason, "badAloPxRejected"),
1411            other => panic!("expected a rejection, got {other:?}"),
1412        }
1413    }
1414
1415    // ---- open orders --------------------------------------------------------------------------
1416
1417    /// The shape `openOrders` was observed to return: `origSz` and `cloid` included, although the
1418    /// endpoint's documentation lists neither. Values are synthetic.
1419    const OPEN_ORDERS: &str = r#"[
1420        {"coin":"ETH","side":"B","limitPx":"1605.0","sz":"0.005","oid":7001,
1421         "timestamp":1714100000000,"origSz":"0.0075","cloid":"0x85b60bff64d549ec95af6268d7d1df63"},
1422        {"coin":"ETH","side":"A","limitPx":"1700.0","sz":"0.01","oid":7002,
1423         "timestamp":1714100000000,"origSz":"0.01"},
1424        {"coin":"BTC","side":"A","limitPx":"50000.0","sz":"0.001","oid":7003,
1425         "timestamp":1714100000000}
1426    ]"#;
1427
1428    #[tokio::test]
1429    async fn open_orders_carry_the_cloid_and_original_size_the_sdk_type_drops() {
1430        let (_server, client) = serve(OPEN_ORDERS.to_string()).await;
1431
1432        let rows = open_orders(&client, ethers::types::H160::zero())
1433            .await
1434            .unwrap();
1435
1436        assert_eq!(rows.len(), 3);
1437        assert_eq!(rows[0].cloid.as_deref(), Some(CLOID));
1438        assert_eq!(rows[0].orig_sz.as_deref(), Some("0.0075"));
1439        assert_eq!(rows[1].cloid, None);
1440        assert_eq!(rows[2].orig_sz, None);
1441    }
1442
1443    fn rows() -> Vec<OpenOrder> {
1444        serde_json::from_str(OPEN_ORDERS).unwrap()
1445    }
1446
1447    fn perp(coin: &str) -> Option<InstrumentNameExchange> {
1448        Some(InstrumentNameExchange::from(format!("{coin}-USDC-PERP")))
1449    }
1450
1451    #[test]
1452    fn an_open_order_is_listed_under_its_client_id_with_its_fill_so_far() {
1453        let order = open_order_to_order(&rows()[0], ExchangeId::HyperliquidPerp, eth()).unwrap();
1454
1455        assert_eq!(order.key.cid, ClientOrderId::new(CID));
1456        assert_eq!(order.quantity, dec!(0.0075));
1457        assert_eq!(order.state.filled_quantity, dec!(0.0025));
1458        assert_eq!(order.state.id, VenueOrderId::Assigned(OrderId::new("7001")));
1459    }
1460
1461    #[test]
1462    fn an_open_order_without_its_original_size_is_its_remainder_with_nothing_filled() {
1463        let row = &rows()[2];
1464        let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1465        let order = open_order_to_order(row, ExchangeId::HyperliquidPerp, btc).unwrap();
1466
1467        assert_eq!(order.key.cid, ClientOrderId::new("7003"));
1468        assert_eq!(order.quantity, dec!(0.001));
1469        assert_eq!(order.state.filled_quantity, Decimal::ZERO);
1470    }
1471
1472    #[test]
1473    fn every_requested_instrument_is_listed_complete_even_with_nothing_open() {
1474        let sol = InstrumentNameExchange::from("SOL-USDC-PERP");
1475        let mut listing = OpenOrderListing::new(
1476            &rows(),
1477            ExchangeId::HyperliquidPerp,
1478            &[eth(), sol.clone()],
1479            perp,
1480        );
1481
1482        let eth_entry = listing.remove(&eth()).unwrap();
1483        assert!(eth_entry.orders_complete);
1484        assert_eq!(eth_entry.orders.len(), 2);
1485
1486        // Nothing open: the entry is what lets an order that ended offline be seen to be gone.
1487        let sol_entry = listing.remove(&sol).unwrap();
1488        assert!(sol_entry.orders_complete);
1489        assert!(sol_entry.orders.is_empty());
1490
1491        // BTC was not requested.
1492        assert_eq!(listing.into_snapshots().count(), 0);
1493    }
1494
1495    #[test]
1496    fn an_unfiltered_listing_covers_every_instrument_with_open_orders() {
1497        let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidPerp, &[], perp);
1498        let mut instruments = listing
1499            .into_snapshots()
1500            .map(|entry| entry.instrument.to_string())
1501            .collect::<Vec<_>>();
1502        instruments.sort();
1503        assert_eq!(instruments, ["BTC-USDC-PERP", "ETH-USDC-PERP"]);
1504    }
1505
1506    #[test]
1507    fn an_order_that_does_not_convert_leaves_only_its_instrument_incomplete() {
1508        let mut rows = rows();
1509        rows[1].side = "?".to_string();
1510        let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1511        let mut listing = OpenOrderListing::new(
1512            &rows,
1513            ExchangeId::HyperliquidPerp,
1514            &[eth(), btc.clone()],
1515            perp,
1516        );
1517
1518        let eth_entry = listing.remove(&eth()).unwrap();
1519        assert!(!eth_entry.orders_complete);
1520        assert_eq!(eth_entry.orders.len(), 1);
1521        assert!(listing.remove(&btc).unwrap().orders_complete);
1522    }
1523
1524    #[test]
1525    fn an_order_with_a_malformed_cloid_leaves_its_instrument_incomplete() {
1526        let mut rows = rows();
1527        rows[0].cloid = Some("0x1234".to_string());
1528        let mut listing = OpenOrderListing::new(&rows, ExchangeId::HyperliquidPerp, &[eth()], perp);
1529
1530        let eth_entry = listing.remove(&eth()).unwrap();
1531        assert!(!eth_entry.orders_complete);
1532        assert_eq!(eth_entry.orders.len(), 1);
1533    }
1534
1535    #[test]
1536    fn coins_the_client_does_not_trade_are_left_out() {
1537        let pairs = super::super::spot_coins::test_spot_pairs();
1538        let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidSpot, &[], |coin| {
1539            pairs.get(coin).map(spot_pair_to_instrument)
1540        });
1541        assert_eq!(listing.into_snapshots().count(), 0);
1542    }
1543}