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::{InstrumentAccountSnapshot, UnindexedAccountEvent};
16use chrono::{DateTime, TimeZone, Utc};
17use futures::Stream;
18use rust_decimal::Decimal;
19use rustrade_instrument::{
20    Side, asset::name::AssetNameExchange, exchange::ExchangeId,
21    instrument::name::InstrumentNameExchange,
22};
23use serde::de::DeserializeOwned;
24use smol_str::format_smolstr;
25use std::collections::{HashMap, HashSet};
26use std::pin::Pin;
27use std::str::FromStr;
28use std::task::{Context, Poll};
29use tokio_util::sync::CancellationToken;
30use tracing::warn;
31use uuid::Uuid;
32
33/// Stream wrapper that cancels background tasks when dropped.
34///
35/// Ensures spawned WebSocket processing tasks are cleaned up when the consumer
36/// drops the account stream, preventing task leaks.
37#[derive(Debug)]
38pub struct CancelOnDropStream<S> {
39    inner: S,
40    cancel_token: CancellationToken,
41}
42
43impl<S> CancelOnDropStream<S> {
44    /// Create a new cancel-on-drop stream wrapper.
45    pub(crate) fn new(inner: S, cancel_token: CancellationToken) -> Self {
46        Self {
47            inner,
48            cancel_token,
49        }
50    }
51}
52
53impl<S: Stream + Unpin> Stream for CancelOnDropStream<S> {
54    type Item = S::Item;
55
56    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
57        Pin::new(&mut self.inner).poll_next(cx)
58    }
59}
60
61impl<S> Drop for CancelOnDropStream<S> {
62    fn drop(&mut self) {
63        self.cancel_token.cancel();
64    }
65}
66
67/// Parse a decimal string from SDK response, logging warnings on failure.
68/// One fill from the `userFills` info endpoint, as the venue actually returns it.
69///
70/// # Why this exists rather than `hyperliquid_rust_sdk::UserFillsResponse`
71///
72/// The SDK's type does not model `tid`, the only per-fill identifier the endpoint offers, and it
73/// does not model `feeToken`. Neither omission is visible at runtime: the SDK does not set
74/// `deny_unknown_fields`, so both are parsed away silently. Without `tid` the only id left is
75/// `hash`, which identifies the *transaction* — one aggressive order sweeping several resting
76/// orders produces several fills under a single hash, and they are then indistinguishable.
77///
78/// Only the fields this client reads are declared. Unknown fields are ignored, so `hash`,
79/// `closedPnl`, `dir`, `startPosition`, `crossed` and `builderFee` cost nothing by being absent
80/// here.
81///
82/// Drop this in favour of the SDK type if it ever models `tid`.
83#[derive(Debug, Clone, serde::Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct UserFill {
86    pub coin: String,
87    pub side: String,
88    pub px: String,
89    pub sz: String,
90    pub time: u64,
91    pub oid: u64,
92    pub fee: String,
93
94    /// Identifies this fill. `hash` identifies the transaction that contained it, and one
95    /// transaction can contain many fills.
96    ///
97    /// Declared required rather than `Option<u64>` deliberately. If the venue ever stops sending
98    /// it, this client should fail loudly at the parse — falling back to `hash` would silently
99    /// restore a duplicate-id defect that is invisible until fills go missing during
100    /// reconciliation.
101    ///
102    /// Hyperliquid documents this as unique per fill, but qualified by coin rather than global,
103    /// which is why the dedup cache keys on the instrument alongside it.
104    pub tid: u64,
105
106    /// Asset the fee is denominated in.
107    ///
108    /// Optional because, unlike every other field here, nothing proves the endpoint sends it: the
109    /// SDK's own type omits it, so it has never been observed through this dependency. The caller
110    /// falls back to inferring the asset from the side when it is absent.
111    pub fee_token: Option<String>,
112}
113
114/// Fetch `userFills` for `address`, parsing it into [`UserFill`] rather than the SDK's lossy type.
115///
116/// Issued through the SDK's own [`HttpClient`](hyperliquid_rust_sdk::InfoClient::http_client), so
117/// base URL, TLS configuration and error type are exactly those of every other info request this
118/// client makes — only the response type differs.
119///
120/// # Errors
121///
122/// Returns a transport error if the POST fails, or a parse error if the response does not match
123/// [`UserFill`] — which includes the case where `tid` has stopped being sent.
124pub async fn user_fills(
125    info_client: &hyperliquid_rust_sdk::InfoClient,
126    address: ethers::types::H160,
127) -> Result<Vec<UserFill>, UnindexedClientError> {
128    user_info(info_client, "userFills", address).await
129}
130
131/// One order from the `openOrders` info endpoint, as the venue actually returns it.
132///
133/// # Why this exists rather than `hyperliquid_rust_sdk::OpenOrdersResponse`
134///
135/// The SDK's type does not model `cloid`, the client order id the order was placed with, nor
136/// `origSz`. The venue sends both, although its documentation lists neither. Without `cloid` an
137/// order can only be reported under its venue `oid`, which is not the id the engine tracks it by.
138///
139/// Drop this in favour of the SDK type if it ever models `cloid`.
140#[derive(Debug, Clone, serde::Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct OpenOrder {
143    pub coin: String,
144    pub side: String,
145    pub limit_px: String,
146    /// Quantity still to fill.
147    pub sz: String,
148    pub oid: u64,
149    pub timestamp: u64,
150
151    /// Quantity the order was placed for.
152    ///
153    /// Optional because the endpoint's documentation does not list it. Without it the order's
154    /// filled quantity is unknown, and it is reported as its remaining quantity with nothing
155    /// filled.
156    #[serde(default)]
157    pub orig_sz: Option<String>,
158
159    /// The client order id the order was placed with, as `0x` and 32 hex digits (see
160    /// [`cloid_to_cid`]). Absent for an order placed without one.
161    #[serde(default)]
162    pub cloid: Option<String>,
163}
164
165/// Fetch every open order for `address`, parsed into [`OpenOrder`] rather than the SDK's lossy
166/// type, which drops the `cloid`.
167///
168/// # Errors
169///
170/// Returns a transport error if the POST fails, or a parse error if the response does not match
171/// [`OpenOrder`].
172pub async fn open_orders(
173    info_client: &hyperliquid_rust_sdk::InfoClient,
174    address: ethers::types::H160,
175) -> Result<Vec<OpenOrder>, UnindexedClientError> {
176    user_info(info_client, "openOrders", address).await
177}
178
179/// POST the info request `{"type": kind, "user": address}` and parse the response as `T`.
180///
181/// Issued through the SDK's own [`HttpClient`](hyperliquid_rust_sdk::InfoClient::http_client), so
182/// base URL, TLS configuration and error type are exactly those of every other info request this
183/// client makes — only the response type differs.
184async fn user_info<T: DeserializeOwned>(
185    info_client: &hyperliquid_rust_sdk::InfoClient,
186    kind: &str,
187    address: ethers::types::H160,
188) -> Result<T, UnindexedClientError> {
189    // `{:?}` on H160 renders the checksummed 0x-prefixed form the endpoint expects. `Display`
190    // abbreviates the middle of the address ("0x1234…5678"), so it must not be used here.
191    let body = format!(r#"{{"type":"{kind}","user":"{address:?}"}}"#);
192
193    let raw = info_client
194        .http_client
195        .post("/info", body)
196        .await
197        .map_err(map_sdk_error)?;
198
199    // `Internal` rather than a connectivity error: a response that does not parse is a schema
200    // change or a venue-side regression, not a transient fault, and retrying will not fix it.
201    serde_json::from_str(&raw).map_err(|e| {
202        UnindexedClientError::Internal(format!("Hyperliquid {kind} response did not parse: {e}"))
203    })
204}
205
206/// Convert one [`OpenOrder`] into an `Open` order on `instrument`, keyed as [`record_cid`] says.
207///
208/// `None`, with a `warn!`, when a field does not parse. The caller then cannot say the order is
209/// not open, so it must not declare its list complete.
210pub(super) fn open_order_to_order(
211    row: &OpenOrder,
212    exchange: ExchangeId,
213    instrument: InstrumentNameExchange,
214) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
215    let parsed = (|| {
216        let cid = record_cid(row.cloid.as_deref(), row.oid)?;
217        let side = parse_side(&row.side)?;
218        let price = parse_decimal(&row.limit_px, "limitPx")?;
219        let remaining = parse_decimal(&row.sz, "sz")?;
220        let quantity = match &row.orig_sz {
221            Some(orig_sz) => parse_decimal(orig_sz, "origSz")?,
222            None => remaining,
223        };
224        let time_exchange = millis_to_datetime(row.timestamp)?;
225        Some((cid, side, price, quantity, remaining, time_exchange))
226    })();
227
228    let Some((cid, side, price, quantity, remaining, time_exchange)) = parsed else {
229        warn!(
230            %exchange,
231            %instrument,
232            oid = row.oid,
233            order = ?row,
234            "Hyperliquid open order did not convert - leaving it out, so this instrument's order \
235             list is not complete"
236        );
237        return None;
238    };
239
240    Some(Order {
241        key: OrderKey {
242            exchange,
243            instrument,
244            strategy: StrategyId::unknown(),
245            cid,
246        },
247        side,
248        price: Some(price),
249        quantity,
250        // Neither the kind nor the time in force is in this response.
251        kind: OrderKind::Limit,
252        time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
253        state: Open {
254            id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", row.oid))),
255            time_exchange,
256            filled_quantity: (quantity - remaining).max(Decimal::ZERO),
257        },
258    })
259}
260
261type UnindexedInstrumentAccountSnapshot =
262    InstrumentAccountSnapshot<ExchangeId, AssetNameExchange, InstrumentNameExchange>;
263
264/// The open orders of an account snapshot, one entry per instrument.
265pub(super) struct OpenOrderListing(
266    HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
267);
268
269impl OpenOrderListing {
270    /// List `rows` by instrument, keeping only the rows `to_instrument` maps to an instrument in
271    /// `requested` (every instrument when `requested` is empty).
272    ///
273    /// Each entry declares its orders complete unless one of its rows failed to convert. That
274    /// holds because the endpoint returns every open order, unpaged, and because [`record_cid`]
275    /// reports each order placed through this client under the id it was placed with.
276    ///
277    /// Every requested instrument gets an entry, open orders or not. An entry is what lets the
278    /// engine judge an order that is no longer listed to be gone, and the instrument whose last
279    /// order was cancelled while the account stream was down is exactly the one with nothing
280    /// left to list. With `requested` empty there is nothing to enumerate, so only instruments
281    /// with open orders get one.
282    pub(super) fn new(
283        rows: &[OpenOrder],
284        exchange: ExchangeId,
285        requested: &[InstrumentNameExchange],
286        to_instrument: impl Fn(&str) -> Option<InstrumentNameExchange>,
287    ) -> Self {
288        fn entry<'a>(
289            listing: &'a mut HashMap<InstrumentNameExchange, UnindexedInstrumentAccountSnapshot>,
290            instrument: &InstrumentNameExchange,
291        ) -> &'a mut UnindexedInstrumentAccountSnapshot {
292            listing
293                .entry(instrument.clone())
294                .or_insert_with(|| InstrumentAccountSnapshot {
295                    instrument: instrument.clone(),
296                    orders: Vec::new(),
297                    orders_complete: true,
298                    position: None,
299                    isolated: None,
300                })
301        }
302
303        let requested_set = requested.iter().collect::<HashSet<_>>();
304        let mut listing = HashMap::with_capacity(requested.len());
305
306        for instrument in requested {
307            entry(&mut listing, instrument);
308        }
309
310        for row in rows {
311            let Some(instrument) = to_instrument(&row.coin) else {
312                continue;
313            };
314            if !requested_set.is_empty() && !requested_set.contains(&instrument) {
315                continue;
316            }
317            let order = open_order_to_order(row, exchange, instrument.clone());
318            let snapshot = entry(&mut listing, &instrument);
319            match order {
320                Some(order) => snapshot.orders.push(order.into()),
321                None => snapshot.orders_complete = false,
322            }
323        }
324
325        Self(listing)
326    }
327
328    /// Take the entry for `instrument`, if it has one.
329    pub(super) fn remove(
330        &mut self,
331        instrument: &InstrumentNameExchange,
332    ) -> Option<UnindexedInstrumentAccountSnapshot> {
333        self.0.remove(instrument)
334    }
335
336    /// Every remaining entry.
337    pub(super) fn into_snapshots(self) -> impl Iterator<Item = UnindexedInstrumentAccountSnapshot> {
338        self.0.into_values()
339    }
340}
341
342/// What a Hyperliquid order status says about the order.
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344enum OrderStatus {
345    Open,
346    Filled,
347    Cancelled,
348    Rejected,
349}
350
351impl OrderStatus {
352    /// Classify an `orderUpdates` / `orderStatus` status string.
353    ///
354    /// Beyond `open`, `filled`, `canceled` and `rejected`, Hyperliquid names the reason for each
355    /// other way an order ends: `marginCanceled`, `selfTradeCanceled`, `reduceOnlyCanceled`,
356    /// `tickRejected`, `badAloPxRejected` and a dozen more. They are classified by their suffix
357    /// so that a reason added later still ends the order, rather than leaving it tracked as open
358    /// for good. `scheduledCancel` is the one ending that breaks the pattern.
359    ///
360    /// `triggered` is a trigger order whose condition was met. It is still working, and ends
361    /// with a later status of its own.
362    fn classify(status: &str) -> Option<Self> {
363        match status {
364            "open" | "triggered" => Some(Self::Open),
365            "filled" => Some(Self::Filled),
366            "canceled" | "scheduledCancel" => Some(Self::Cancelled),
367            "rejected" => Some(Self::Rejected),
368            status if status.ends_with("Canceled") => Some(Self::Cancelled),
369            status if status.ends_with("Rejected") => Some(Self::Rejected),
370            _ => None,
371        }
372    }
373}
374
375/// Convert an `orderUpdates` message into an order snapshot event on `instrument`, keyed as
376/// [`record_cid`] says.
377///
378/// `None`, with a `warn!` naming the cause where the parse helpers do not, when a field does not
379/// parse or the status is not one [`OrderStatus::classify`] recognises.
380pub(super) fn order_update_to_account_event(
381    update: &hyperliquid_rust_sdk::OrderUpdate,
382    exchange: ExchangeId,
383    instrument: InstrumentNameExchange,
384) -> Option<UnindexedAccountEvent> {
385    let order = &update.order;
386    let Some(status) = OrderStatus::classify(&update.status) else {
387        warn!(%exchange, status = %update.status, oid = order.oid, "Unknown Hyperliquid order status - ignoring the update");
388        return None;
389    };
390    let cid = record_cid(order.cloid.as_deref(), order.oid)?;
391    let side = parse_side(&order.side)?;
392    let price = parse_decimal(&order.limit_px, "order.limitPx")?;
393    let orig_sz = parse_decimal(&order.orig_sz, "order.origSz")?;
394    let Some(time_exchange) = millis_to_datetime(update.status_timestamp) else {
395        warn!(%exchange, oid = order.oid, status_timestamp = update.status_timestamp, "Invalid Hyperliquid order update timestamp - ignoring the update");
396        return None;
397    };
398    let order_id = OrderId(format_smolstr!("{}", order.oid));
399    let filled_quantity = || {
400        parse_decimal(&order.sz, "order.sz")
401            .map(|remaining| (orig_sz - remaining).max(Decimal::ZERO))
402    };
403
404    let state = match status {
405        OrderStatus::Open => OrderState::active(Open {
406            id: VenueOrderId::Assigned(order_id),
407            time_exchange,
408            filled_quantity: filled_quantity()?,
409        }),
410        OrderStatus::Filled => OrderState::fully_filled(Filled::new(
411            order_id,
412            time_exchange,
413            orig_sz,
414            None, // The update does not carry the average price.
415        )),
416        OrderStatus::Cancelled => {
417            OrderState::inactive(Cancelled::new(order_id, time_exchange, filled_quantity()?))
418        }
419        OrderStatus::Rejected => OrderState::inactive(OrderError::Rejected(
420            ApiError::OrderRejected(update.status.clone()),
421        )),
422    };
423
424    // The update carries neither the order's kind nor its time in force.
425    let snapshot: UnindexedOrderSnapshot = Order {
426        key: OrderKey {
427            exchange,
428            instrument,
429            strategy: StrategyId::unknown(),
430            cid,
431        },
432        side,
433        price: Some(price),
434        quantity: orig_sz,
435        kind: OrderKind::Limit,
436        time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
437        state,
438    };
439
440    Some(crate::AccountEvent::new(
441        exchange,
442        crate::AccountEventKind::OrderSnapshot(
443            rustrade_integration::collection::snapshot::Snapshot(snapshot),
444        ),
445    ))
446}
447
448pub fn parse_decimal(value: &str, field: &str) -> Option<Decimal> {
449    Decimal::from_str(value)
450        .map_err(|e| warn!(%field, %value, %e, "Failed to parse decimal"))
451        .ok()
452}
453
454/// Parse SDK side string to rustrade Side.
455///
456/// Hyperliquid API returns "B" for buy/long and "A" for sell/short (ask-side).
457/// Extra variants included for defensive parsing of potential future API changes.
458pub fn parse_side(side: &str) -> Option<Side> {
459    match side {
460        "B" | "b" | "BUY" | "Buy" | "buy" => Some(Side::Buy),
461        "A" | "a" | "S" | "s" | "SELL" | "Sell" | "sell" => Some(Side::Sell),
462        _ => {
463            warn!(%side, "Unknown side string");
464            None
465        }
466    }
467}
468
469/// Convert milliseconds timestamp to `DateTime<Utc>`.
470///
471/// Returns `None` for timestamps outside the representable range (year 292M+).
472pub fn millis_to_datetime(millis: u64) -> Option<DateTime<Utc>> {
473    Utc.timestamp_millis_opt(i64::try_from(millis).ok()?)
474        .single()
475}
476
477/// Round a price to 5 significant figures (Hyperliquid requirement).
478///
479/// Performs rounding using `Decimal` arithmetic to avoid floating-point precision
480/// errors. The final `f64` conversion happens only at the SDK interface boundary.
481pub fn round_to_5_sig_figs(value: Decimal) -> f64 {
482    use rust_decimal::prelude::ToPrimitive;
483
484    if value.is_zero() {
485        return 0.0;
486    }
487
488    // Use f64 only for computing magnitude (acceptable precision for this purpose;
489    // we only need to know which power of 10 the number is close to).
490    let abs_f = value.abs().to_f64().unwrap_or(0.0);
491    if abs_f == 0.0 {
492        return 0.0;
493    }
494
495    // Clamp magnitude to prevent overflow when computing scale factor
496    #[allow(clippy::cast_possible_truncation)]
497    let magnitude = abs_f.log10().floor().clamp(-30.0, 30.0) as i32;
498
499    // Round using Decimal arithmetic to preserve precision
500    let rounded = if magnitude >= 4 {
501        // Large numbers (e.g., 123456): scale down, round integer, scale back up
502        // Safety: magnitude >= 4 guarantees (magnitude - 4) is non-negative
503        #[allow(clippy::cast_sign_loss)]
504        let factor = Decimal::from(10i64.pow((magnitude - 4) as u32));
505        (value / factor).round() * factor
506    } else {
507        // Small/medium numbers: round to appropriate decimal places
508        #[allow(clippy::cast_sign_loss)]
509        let dp = (4 - magnitude) as u32;
510        value.round_dp(dp)
511    };
512
513    // SDK requires f64 — convert only at the interface boundary
514    rounded.to_f64().unwrap_or(0.0)
515}
516
517/// Map rustrade TimeInForce to Hyperliquid TIF string.
518pub fn map_tif(tif: &TimeInForce) -> &'static str {
519    match tif {
520        TimeInForce::GoodUntilCancelled { post_only: true } => "Alo",
521        TimeInForce::GoodUntilCancelled { post_only: false } => "Gtc",
522        TimeInForce::ImmediateOrCancel => "Ioc",
523        TimeInForce::FillOrKill => "Ioc", // Hyperliquid doesn't have FOK, use IOC
524        TimeInForce::GoodUntilEndOfDay => "Gtc", // No EOD on Hyperliquid
525        // Hyperliquid is a perpetuals DEX with no auction sessions and no native
526        // GTD. Coerce to GTC and surface the loss of semantics so downstream
527        // wrappers can decide whether to reject upstream.
528        TimeInForce::GoodTillDate { .. } | TimeInForce::AtOpen | TimeInForce::AtClose => {
529            warn!(time_in_force = ?tif, "Hyperliquid does not support this TimeInForce; coercing to Gtc");
530            "Gtc"
531        }
532    }
533}
534
535/// Why an order is refused when its client id cannot be a cloid ([`cid_to_cloid`]).
536pub(super) const CLOID_REQUIRED: &str = "Hyperliquid requires the client order id to be a UUID in \
537     canonical form, as ClientOrderId::uuid() makes: the venue reports each order under that id, \
538     and an order under any other id could not be matched to its own updates";
539
540/// The cloid to place an order under `cid` with, or `None` if `cid` cannot be one.
541///
542/// Hyperliquid stores a cloid as 16 bytes and reports it back as `0x` and 32 lowercase hex
543/// digits, which [`cloid_to_cid`] turns back into a client id. Only a `cid` that is the canonical
544/// text of a UUID — lowercase and hyphenated, as [`ClientOrderId::uuid`] makes — comes back
545/// unchanged. Any other spelling of a UUID would come back as a different id, under which the
546/// order's own updates could not find it, so it is refused along with every id that is not a UUID.
547pub fn cid_to_cloid(cid: &ClientOrderId) -> Option<Uuid> {
548    let uuid = Uuid::try_parse(cid.0.as_str()).ok()?;
549    let mut canonical = Uuid::encode_buffer();
550    (uuid.hyphenated().encode_lower(&mut canonical) == cid.0.as_str()).then_some(uuid)
551}
552
553/// The client id an order placed with `cloid` was placed under: the inverse of [`cid_to_cloid`].
554///
555/// `cloid` is the venue's form, `0x` and 32 hex digits. `None` for anything else.
556pub fn cloid_to_cid(cloid: &str) -> Option<ClientOrderId> {
557    let hex = cloid.strip_prefix("0x")?;
558    if hex.len() != 32 {
559        return None;
560    }
561    // The 32-digit length check rules out the other spellings `try_parse` accepts.
562    let uuid = Uuid::try_parse(hex).ok()?;
563    Some(ClientOrderId::new(format_smolstr!("{}", uuid.hyphenated())))
564}
565
566/// The client id to report a venue order record under, given the record's `cloid` and `oid`.
567///
568/// Every order this client places carries a cloid ([`cid_to_cloid`]), so a record with one is
569/// reported under the id the order was placed with. A record without one was placed some other
570/// way, such as the web app, and has no client id at all. It is reported under its venue `oid`,
571/// the only identifier it has, which is stable across reads so the order is tracked once.
572///
573/// `None`, with a `warn!`, for a cloid that is not in the venue's form. The record's order is
574/// then unidentified, and must be left out rather than reported under a guess.
575pub(super) fn record_cid(cloid: Option<&str>, oid: u64) -> Option<ClientOrderId> {
576    match cloid {
577        None => Some(ClientOrderId::new(format_smolstr!("{oid}"))),
578        Some(cloid) => {
579            let cid = cloid_to_cid(cloid);
580            if cid.is_none() {
581                warn!(%cloid, oid, "Hyperliquid order carries a cloid that is not 0x and 32 hex digits");
582            }
583            cid
584        }
585    }
586}
587
588/// Build perp instrument name from Hyperliquid coin name (e.g., "BTC" -> "BTC-USD-PERP").
589pub fn perp_coin_to_instrument(coin: &str) -> InstrumentNameExchange {
590    InstrumentNameExchange::from(format_smolstr!("{}-USD-PERP", coin))
591}
592
593/// Extract coin name from perp instrument (e.g., "BTC-USD-PERP" -> "BTC").
594///
595/// Returns `String` because Hyperliquid SDK requires `String` for asset fields.
596pub fn instrument_to_perp_coin(instrument: &InstrumentNameExchange) -> String {
597    let s = instrument.as_ref();
598    // Expected format: "COIN-USD-PERP" or just "COIN"
599    match s.split_once('-') {
600        Some((coin, _)) => coin.to_string(),
601        None => s.to_string(),
602    }
603}
604
605/// Build spot instrument name from Hyperliquid coin pair (e.g., "PURR/USDC" -> "PURR-USDC-SPOT").
606///
607/// # Panics (debug builds only)
608///
609/// Debug-asserts if `coin` does not contain '/' — callers must verify `is_spot_coin()`
610/// before calling. The fallback path produces a malformed instrument name.
611pub fn spot_coin_to_instrument(coin: &str) -> InstrumentNameExchange {
612    match coin.split_once('/') {
613        Some((base, quote)) => {
614            InstrumentNameExchange::from(format_smolstr!("{}-{}-SPOT", base, quote))
615        }
616        None => {
617            debug_assert!(
618                false,
619                "spot_coin_to_instrument called with non-spot coin: {coin}"
620            );
621            InstrumentNameExchange::from(format_smolstr!("{}-SPOT", coin))
622        }
623    }
624}
625
626/// Extract coin pair from spot instrument (e.g., "PURR-USDC-SPOT" -> "PURR/USDC").
627///
628/// Returns `Option<String>` — `None` if the instrument doesn't match expected
629/// `BASE-QUOTE-SPOT` format. Callers should fail fast on `None` rather than
630/// send a malformed asset to the exchange.
631pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
632    let s = instrument.as_ref();
633    // Expected format: "BASE-QUOTE-SPOT" -> "BASE/QUOTE"
634    let without_suffix = s.strip_suffix("-SPOT")?;
635    let (base, quote) = without_suffix.split_once('-')?;
636    Some(format!("{}/{}", base, quote))
637}
638
639/// Check if a Hyperliquid coin name is a spot pair (contains '/').
640///
641/// Hyperliquid API uses pair format `"BASE/QUOTE"` (e.g., `"PURR/USDC"`) for spot coins
642/// and single symbols (e.g., `"BTC"`) for perpetuals. This naming convention is observed
643/// across all SDK examples and test fixtures. The invariant is validated by our spot
644/// fixture tests in `hyperliquid_spot_execution.rs`.
645pub fn is_spot_coin(coin: &str) -> bool {
646    coin.contains('/')
647}
648
649#[cfg(test)]
650// Test code: panics on bad input are acceptable
651#[allow(clippy::unwrap_used)]
652mod tests {
653    use super::*;
654    use rust_decimal_macros::dec;
655
656    #[test]
657    fn test_parse_decimal_valid() {
658        assert_eq!(parse_decimal("123.456", "test"), Some(dec!(123.456)));
659        assert_eq!(parse_decimal("0", "test"), Some(dec!(0)));
660        assert_eq!(parse_decimal("-50.5", "test"), Some(dec!(-50.5)));
661    }
662
663    #[test]
664    fn test_parse_decimal_invalid() {
665        assert_eq!(parse_decimal("", "test"), None);
666        assert_eq!(parse_decimal("abc", "test"), None);
667        assert_eq!(parse_decimal("12.34.56", "test"), None);
668    }
669
670    #[test]
671    fn test_parse_side() {
672        assert_eq!(parse_side("B"), Some(Side::Buy));
673        assert_eq!(parse_side("BUY"), Some(Side::Buy));
674        assert_eq!(parse_side("buy"), Some(Side::Buy));
675        assert_eq!(parse_side("A"), Some(Side::Sell));
676        assert_eq!(parse_side("S"), Some(Side::Sell));
677        assert_eq!(parse_side("SELL"), Some(Side::Sell));
678        assert_eq!(parse_side("sell"), Some(Side::Sell));
679        assert_eq!(parse_side("X"), None);
680        assert_eq!(parse_side(""), None);
681    }
682
683    #[test]
684    fn test_perp_coin_to_instrument() {
685        let inst = perp_coin_to_instrument("BTC");
686        assert_eq!(inst.as_ref(), "BTC-USD-PERP");
687
688        let inst = perp_coin_to_instrument("ETH");
689        assert_eq!(inst.as_ref(), "ETH-USD-PERP");
690    }
691
692    #[test]
693    fn test_instrument_to_perp_coin() {
694        let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("BTC-USD-PERP"));
695        assert_eq!(coin, "BTC");
696
697        let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("ETH-USD-PERP"));
698        assert_eq!(coin, "ETH");
699
700        // Just coin name without suffix
701        let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("SOL"));
702        assert_eq!(coin, "SOL");
703    }
704
705    #[test]
706    fn test_spot_coin_to_instrument() {
707        let inst = spot_coin_to_instrument("PURR/USDC");
708        assert_eq!(inst.as_ref(), "PURR-USDC-SPOT");
709
710        let inst = spot_coin_to_instrument("HYPE/USDC");
711        assert_eq!(inst.as_ref(), "HYPE-USDC-SPOT");
712    }
713
714    #[test]
715    fn test_instrument_to_spot_coin() {
716        let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("PURR-USDC-SPOT"));
717        assert_eq!(coin, Some("PURR/USDC".to_string()));
718
719        let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("HYPE-USDC-SPOT"));
720        assert_eq!(coin, Some("HYPE/USDC".to_string()));
721
722        // Malformed instruments return None
723        assert_eq!(
724            instrument_to_spot_coin(&InstrumentNameExchange::from("BTC-USD-PERP")),
725            None
726        );
727        assert_eq!(
728            instrument_to_spot_coin(&InstrumentNameExchange::from("INVALID")),
729            None
730        );
731    }
732
733    #[test]
734    fn test_is_spot_coin() {
735        assert!(is_spot_coin("PURR/USDC"));
736        assert!(is_spot_coin("HYPE/USDC"));
737        assert!(!is_spot_coin("BTC"));
738        assert!(!is_spot_coin("ETH"));
739    }
740
741    #[test]
742    fn test_round_to_5_sig_figs() {
743        assert_eq!(round_to_5_sig_figs(dec!(0)), 0.0);
744        assert_eq!(round_to_5_sig_figs(dec!(12345)), 12345.0);
745        assert_eq!(round_to_5_sig_figs(dec!(123456)), 123460.0);
746        assert_eq!(round_to_5_sig_figs(dec!(0.00012345)), 0.00012345);
747        assert_eq!(round_to_5_sig_figs(dec!(0.000123456)), 0.00012346);
748        assert_eq!(round_to_5_sig_figs(dec!(1.23456789)), 1.2346);
749    }
750
751    #[test]
752    fn test_map_tif() {
753        assert_eq!(
754            map_tif(&TimeInForce::GoodUntilCancelled { post_only: false }),
755            "Gtc"
756        );
757        assert_eq!(
758            map_tif(&TimeInForce::GoodUntilCancelled { post_only: true }),
759            "Alo"
760        );
761        assert_eq!(map_tif(&TimeInForce::ImmediateOrCancel), "Ioc");
762        assert_eq!(map_tif(&TimeInForce::FillOrKill), "Ioc");
763        assert_eq!(map_tif(&TimeInForce::GoodUntilEndOfDay), "Gtc");
764    }
765
766    #[test]
767    fn test_millis_to_datetime() {
768        let dt = millis_to_datetime(1714100000000).unwrap();
769        assert_eq!(dt.timestamp_millis(), 1714100000000);
770
771        // Zero timestamp (Unix epoch) is valid
772        assert!(millis_to_datetime(0).is_some());
773    }
774}
775
776#[cfg(test)]
777// Test code: panics on bad input are acceptable
778#[allow(clippy::unwrap_used)]
779mod info_tests {
780    use super::*;
781    use rust_decimal_macros::dec;
782    use wiremock::matchers::{method, path};
783    use wiremock::{Mock, MockServer, ResponseTemplate};
784
785    /// One fill exactly as the `userFills` documentation shows it, including the two fields the
786    /// SDK's own response type drops.
787    const DOCUMENTED_FILL: &str = r#"{
788        "closedPnl": "0.0",
789        "coin": "AVAX",
790        "crossed": false,
791        "dir": "Open Long",
792        "hash": "0xa166e3fa63c25663024b03f2e0da011a00307e4017465df020210d3d432e7cb8",
793        "oid": 90542681,
794        "px": "18.435",
795        "side": "B",
796        "startPosition": "26.86",
797        "sz": "93.53",
798        "time": 1681222254710,
799        "fee": "0.01",
800        "feeToken": "USDC",
801        "builderFee": "0.01",
802        "tid": 118906512037719
803    }"#;
804
805    /// Build an `InfoClient` pointed at `uri`. `InfoClient::new` opens no connection -- it only
806    /// fills in the struct -- so this costs nothing and touches no network.
807    async fn info_client_against(uri: String) -> hyperliquid_rust_sdk::InfoClient {
808        let mut client = hyperliquid_rust_sdk::InfoClient::new(
809            None,
810            Some(hyperliquid_rust_sdk::BaseUrl::Localhost),
811        )
812        .await
813        .unwrap();
814        client.http_client.base_url = uri;
815        client
816    }
817
818    async fn serve(body: String) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
819        let server = MockServer::start().await;
820        Mock::given(method("POST"))
821            .and(path("/info"))
822            .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
823            .mount(&server)
824            .await;
825        let client = info_client_against(server.uri()).await;
826        (server, client)
827    }
828
829    #[tokio::test]
830    async fn a_fill_carries_the_tid_and_fee_token_the_sdk_type_drops() {
831        let (_server, client) = serve(format!("[{DOCUMENTED_FILL}]")).await;
832
833        let fills = user_fills(&client, ethers::types::H160::zero())
834            .await
835            .unwrap();
836
837        assert_eq!(fills.len(), 1);
838        assert_eq!(fills[0].tid, 118_906_512_037_719);
839        assert_eq!(fills[0].fee_token.as_deref(), Some("USDC"));
840        assert_eq!(fills[0].oid, 90_542_681);
841        assert_eq!(fills[0].coin, "AVAX");
842    }
843
844    #[tokio::test]
845    async fn one_transaction_can_carry_several_fills_and_they_are_told_apart_by_tid() {
846        // A single aggressive order sweeping three resting orders: one `hash`, one `oid`, three
847        // fills. This is the shape the old `TradeId(hash)` collapsed into one trade.
848        let sweep = r#"[
849            {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
850             "hash":"0xsweep","oid":1,"px":"18.40","side":"B","startPosition":"0",
851             "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":111},
852            {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
853             "hash":"0xsweep","oid":1,"px":"18.45","side":"B","startPosition":"10",
854             "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":222},
855            {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
856             "hash":"0xsweep","oid":1,"px":"18.50","side":"B","startPosition":"20",
857             "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":333}
858        ]"#;
859        let (_server, client) = serve(sweep.to_string()).await;
860
861        let fills = user_fills(&client, ethers::types::H160::zero())
862            .await
863            .unwrap();
864
865        let tids: Vec<u64> = fills.iter().map(|f| f.tid).collect();
866        assert_eq!(tids, vec![111, 222, 333]);
867    }
868
869    #[tokio::test]
870    async fn a_missing_fee_token_falls_back_rather_than_failing() {
871        // `feeToken` is the one field here that nothing proves the endpoint sends, so its absence
872        // must not cost the caller the whole response.
873        let without = DOCUMENTED_FILL.replace(r#""feeToken": "USDC","#, "");
874        let (_server, client) = serve(format!("[{without}]")).await;
875
876        let fills = user_fills(&client, ethers::types::H160::zero())
877            .await
878            .unwrap();
879
880        assert_eq!(fills.len(), 1);
881        assert!(fills[0].fee_token.is_none());
882    }
883
884    #[tokio::test]
885    async fn a_missing_tid_fails_loudly_instead_of_falling_back_to_the_hash() {
886        // Silently reverting to `hash` would reinstate the duplicate-id defect, and it would only
887        // become visible as fills going missing during reconciliation.
888        let without = DOCUMENTED_FILL.replace(r#""tid": 118906512037719"#, r#""unused": 0"#);
889        let (_server, client) = serve(format!("[{without}]")).await;
890
891        let error = user_fills(&client, ethers::types::H160::zero())
892            .await
893            .unwrap_err();
894
895        assert!(
896            matches!(&error, UnindexedClientError::Internal(msg) if msg.contains("userFills")),
897            "expected a parse failure naming the endpoint, got {error:?}"
898        );
899    }
900
901    #[tokio::test]
902    async fn the_request_names_the_address_in_full() {
903        // `Display` for H160 abbreviates the middle of an address, which the venue would not
904        // recognise. Only the `Debug` rendering is the full 0x-prefixed form.
905        let server = MockServer::start().await;
906        Mock::given(method("POST"))
907            .and(path("/info"))
908            .and(wiremock::matchers::body_string_contains(
909                "0x000000000000000000000000000000000000dead",
910            ))
911            .and(wiremock::matchers::body_string_contains("userFills"))
912            .respond_with(ResponseTemplate::new(200).set_body_raw("[]", "application/json"))
913            .mount(&server)
914            .await;
915        let client = info_client_against(server.uri()).await;
916
917        let address = "0x000000000000000000000000000000000000dEaD"
918            .parse::<ethers::types::H160>()
919            .unwrap();
920
921        // The mock only answers a body carrying the full address, so reaching `Ok` is the
922        // assertion.
923        assert!(user_fills(&client, address).await.unwrap().is_empty());
924    }
925
926    // ---- client order ids -------------------------------------------------------------------
927
928    const CID: &str = "85b60bff-64d5-49ec-95af-6268d7d1df63";
929    /// `CID` as the venue reports it back: `0x` and the 32 lowercase hex digits.
930    const CLOID: &str = "0x85b60bff64d549ec95af6268d7d1df63";
931
932    #[test]
933    fn only_the_canonical_uuid_form_can_be_a_cloid() {
934        let cloid = cid_to_cloid(&ClientOrderId::new(CID)).unwrap();
935        assert_eq!(cloid.to_string(), CID);
936
937        // Every other spelling of the same UUID would come back as `CID`, not as itself.
938        for other in [
939            "85B60BFF-64D5-49EC-95AF-6268D7D1DF63",
940            "85b60bff64d549ec95af6268d7d1df63",
941            "{85b60bff-64d5-49ec-95af-6268d7d1df63}",
942            "urn:uuid:85b60bff-64d5-49ec-95af-6268d7d1df63",
943            "example-1714100000000",
944            "",
945        ] {
946            assert_eq!(cid_to_cloid(&ClientOrderId::new(other)), None, "{other}");
947        }
948    }
949
950    #[test]
951    fn a_generated_uuid_client_id_is_always_a_cloid() {
952        for _ in 0..32 {
953            let cid = ClientOrderId::uuid();
954            assert!(cid_to_cloid(&cid).is_some(), "{cid}");
955        }
956    }
957
958    #[test]
959    fn the_reported_cloid_is_the_client_id_the_order_was_placed_under() {
960        assert_eq!(cloid_to_cid(CLOID), Some(ClientOrderId::new(CID)));
961
962        // The round trip through the form the SDK sends, for a fresh id.
963        let cid = ClientOrderId::uuid();
964        let sent = format!("0x{}", cid_to_cloid(&cid).unwrap().simple());
965        assert_eq!(cloid_to_cid(&sent), Some(cid));
966    }
967
968    #[test]
969    fn a_cloid_not_in_the_venue_form_names_no_client_id() {
970        for malformed in [
971            CID,
972            "85b60bff64d549ec95af6268d7d1df63",
973            "0x85b60bff64d549ec95af6268d7d1df",
974            "0x85b60bff64d549ec95af6268d7d1df6300",
975            "0x85b60bff-64d5-49ec-95af-6268d7d1df63",
976            "0xzzb60bff64d549ec95af6268d7d1df63",
977            "0x",
978        ] {
979            assert_eq!(cloid_to_cid(malformed), None, "{malformed}");
980        }
981    }
982
983    #[test]
984    fn a_record_without_a_cloid_is_reported_under_its_oid() {
985        assert_eq!(record_cid(None, 42), Some(ClientOrderId::new("42")));
986        assert_eq!(record_cid(Some(CLOID), 42), Some(ClientOrderId::new(CID)));
987        assert_eq!(record_cid(Some("0x1234"), 42), None);
988    }
989
990    // ---- order statuses -----------------------------------------------------------------------
991
992    #[test]
993    fn every_documented_order_status_is_classified() {
994        use OrderStatus::*;
995        for (status, expected) in [
996            ("open", Open),
997            ("triggered", Open),
998            ("filled", Filled),
999            ("canceled", Cancelled),
1000            ("marginCanceled", Cancelled),
1001            ("vaultWithdrawalCanceled", Cancelled),
1002            ("openInterestCapCanceled", Cancelled),
1003            ("selfTradeCanceled", Cancelled),
1004            ("reduceOnlyCanceled", Cancelled),
1005            ("siblingFilledCanceled", Cancelled),
1006            ("delistedCanceled", Cancelled),
1007            ("liquidatedCanceled", Cancelled),
1008            ("scheduledCancel", Cancelled),
1009            ("rejected", Rejected),
1010            ("tickRejected", Rejected),
1011            ("minTradeNtlRejected", Rejected),
1012            ("perpMarginRejected", Rejected),
1013            ("reduceOnlyRejected", Rejected),
1014            ("badAloPxRejected", Rejected),
1015            ("iocCancelRejected", Rejected),
1016            ("badTriggerPxRejected", Rejected),
1017            ("marketOrderNoLiquidityRejected", Rejected),
1018            ("positionIncreaseAtOpenInterestCapRejected", Rejected),
1019            ("positionFlipAtOpenInterestCapRejected", Rejected),
1020            ("tooAggressiveAtOpenInterestCapRejected", Rejected),
1021            ("openInterestIncreaseRejected", Rejected),
1022            ("insufficientSpotBalanceRejected", Rejected),
1023            ("oracleRejected", Rejected),
1024            ("perpMaxPositionRejected", Rejected),
1025        ] {
1026            assert_eq!(OrderStatus::classify(status), Some(expected), "{status}");
1027        }
1028        assert_eq!(OrderStatus::classify("unknown_status"), None);
1029    }
1030
1031    fn order_update(status: &str, cloid: &str, sz: &str) -> hyperliquid_rust_sdk::OrderUpdate {
1032        serde_json::from_str(&format!(
1033            r#"{{"order": {{"coin": "ETH", "side": "B", "limitPx": "1605.0", "sz": "{sz}",
1034                 "oid": 7001, "timestamp": 1714100000000, "origSz": "0.0075", "cloid": {cloid}}},
1035               "status": "{status}", "statusTimestamp": 1714100001000}}"#
1036        ))
1037        .unwrap()
1038    }
1039
1040    fn snapshot_of(event: UnindexedAccountEvent) -> UnindexedOrderSnapshot {
1041        match event.kind {
1042            crate::AccountEventKind::OrderSnapshot(snapshot) => snapshot.0,
1043            other => panic!("expected an order snapshot, got {other:?}"),
1044        }
1045    }
1046
1047    fn eth() -> InstrumentNameExchange {
1048        InstrumentNameExchange::from("ETH-USD-PERP")
1049    }
1050
1051    #[test]
1052    fn an_order_update_names_the_order_by_the_id_it_was_placed_under() {
1053        let update = order_update("open", &format!(r#""{CLOID}""#), "0.005");
1054        let order = snapshot_of(
1055            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1056        );
1057
1058        assert_eq!(order.key.cid, ClientOrderId::new(CID));
1059        assert_eq!(order.quantity, dec!(0.0075));
1060        match order.state {
1061            OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) => {
1062                assert_eq!(open.id, VenueOrderId::Assigned(OrderId::new("7001")));
1063                assert_eq!(open.filled_quantity, dec!(0.0025));
1064            }
1065            other => panic!("expected Open, got {other:?}"),
1066        }
1067    }
1068
1069    #[test]
1070    fn an_order_update_without_a_cloid_is_named_by_its_oid() {
1071        let update = order_update("open", "null", "0.0075");
1072        let order = snapshot_of(
1073            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1074        );
1075        assert_eq!(order.key.cid, ClientOrderId::new("7001"));
1076    }
1077
1078    #[test]
1079    fn an_order_update_with_a_malformed_cloid_is_dropped() {
1080        let update = order_update("open", r#""0x1234""#, "0.0075");
1081        assert!(
1082            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).is_none()
1083        );
1084    }
1085
1086    #[test]
1087    fn a_cancellation_for_any_reason_ends_the_order() {
1088        let update = order_update("selfTradeCanceled", &format!(r#""{CLOID}""#), "0.005");
1089        let order = snapshot_of(
1090            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1091        );
1092        match order.state {
1093            OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(cancelled)) => {
1094                assert_eq!(cancelled.filled_quantity, dec!(0.0025));
1095            }
1096            other => panic!("expected Cancelled, got {other:?}"),
1097        }
1098    }
1099
1100    #[test]
1101    fn a_rejection_ends_the_order_and_names_the_reason() {
1102        let update = order_update("badAloPxRejected", &format!(r#""{CLOID}""#), "0.0075");
1103        let order = snapshot_of(
1104            order_update_to_account_event(&update, ExchangeId::HyperliquidPerp, eth()).unwrap(),
1105        );
1106        match order.state {
1107            OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
1108                OrderError::Rejected(ApiError::OrderRejected(reason)),
1109            )) => assert_eq!(reason, "badAloPxRejected"),
1110            other => panic!("expected a rejection, got {other:?}"),
1111        }
1112    }
1113
1114    // ---- open orders --------------------------------------------------------------------------
1115
1116    /// The shape `openOrders` was observed to return: `origSz` and `cloid` included, although the
1117    /// endpoint's documentation lists neither. Values are synthetic.
1118    const OPEN_ORDERS: &str = r#"[
1119        {"coin":"ETH","side":"B","limitPx":"1605.0","sz":"0.005","oid":7001,
1120         "timestamp":1714100000000,"origSz":"0.0075","cloid":"0x85b60bff64d549ec95af6268d7d1df63"},
1121        {"coin":"ETH","side":"A","limitPx":"1700.0","sz":"0.01","oid":7002,
1122         "timestamp":1714100000000,"origSz":"0.01"},
1123        {"coin":"BTC","side":"A","limitPx":"50000.0","sz":"0.001","oid":7003,
1124         "timestamp":1714100000000}
1125    ]"#;
1126
1127    #[tokio::test]
1128    async fn open_orders_carry_the_cloid_and_original_size_the_sdk_type_drops() {
1129        let (_server, client) = serve(OPEN_ORDERS.to_string()).await;
1130
1131        let rows = open_orders(&client, ethers::types::H160::zero())
1132            .await
1133            .unwrap();
1134
1135        assert_eq!(rows.len(), 3);
1136        assert_eq!(rows[0].cloid.as_deref(), Some(CLOID));
1137        assert_eq!(rows[0].orig_sz.as_deref(), Some("0.0075"));
1138        assert_eq!(rows[1].cloid, None);
1139        assert_eq!(rows[2].orig_sz, None);
1140    }
1141
1142    fn rows() -> Vec<OpenOrder> {
1143        serde_json::from_str(OPEN_ORDERS).unwrap()
1144    }
1145
1146    fn perp(coin: &str) -> Option<InstrumentNameExchange> {
1147        Some(perp_coin_to_instrument(coin))
1148    }
1149
1150    #[test]
1151    fn an_open_order_is_listed_under_its_client_id_with_its_fill_so_far() {
1152        let order = open_order_to_order(&rows()[0], ExchangeId::HyperliquidPerp, eth()).unwrap();
1153
1154        assert_eq!(order.key.cid, ClientOrderId::new(CID));
1155        assert_eq!(order.quantity, dec!(0.0075));
1156        assert_eq!(order.state.filled_quantity, dec!(0.0025));
1157        assert_eq!(order.state.id, VenueOrderId::Assigned(OrderId::new("7001")));
1158    }
1159
1160    #[test]
1161    fn an_open_order_without_its_original_size_is_its_remainder_with_nothing_filled() {
1162        let row = &rows()[2];
1163        let btc = InstrumentNameExchange::from("BTC-USD-PERP");
1164        let order = open_order_to_order(row, ExchangeId::HyperliquidPerp, btc).unwrap();
1165
1166        assert_eq!(order.key.cid, ClientOrderId::new("7003"));
1167        assert_eq!(order.quantity, dec!(0.001));
1168        assert_eq!(order.state.filled_quantity, Decimal::ZERO);
1169    }
1170
1171    #[test]
1172    fn every_requested_instrument_is_listed_complete_even_with_nothing_open() {
1173        let sol = InstrumentNameExchange::from("SOL-USD-PERP");
1174        let mut listing = OpenOrderListing::new(
1175            &rows(),
1176            ExchangeId::HyperliquidPerp,
1177            &[eth(), sol.clone()],
1178            perp,
1179        );
1180
1181        let eth_entry = listing.remove(&eth()).unwrap();
1182        assert!(eth_entry.orders_complete);
1183        assert_eq!(eth_entry.orders.len(), 2);
1184
1185        // Nothing open: the entry is what lets an order that ended offline be seen to be gone.
1186        let sol_entry = listing.remove(&sol).unwrap();
1187        assert!(sol_entry.orders_complete);
1188        assert!(sol_entry.orders.is_empty());
1189
1190        // BTC was not requested.
1191        assert_eq!(listing.into_snapshots().count(), 0);
1192    }
1193
1194    #[test]
1195    fn an_unfiltered_listing_covers_every_instrument_with_open_orders() {
1196        let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidPerp, &[], perp);
1197        let mut instruments = listing
1198            .into_snapshots()
1199            .map(|entry| entry.instrument.to_string())
1200            .collect::<Vec<_>>();
1201        instruments.sort();
1202        assert_eq!(instruments, ["BTC-USD-PERP", "ETH-USD-PERP"]);
1203    }
1204
1205    #[test]
1206    fn an_order_that_does_not_convert_leaves_only_its_instrument_incomplete() {
1207        let mut rows = rows();
1208        rows[1].side = "?".to_string();
1209        let btc = InstrumentNameExchange::from("BTC-USD-PERP");
1210        let mut listing = OpenOrderListing::new(
1211            &rows,
1212            ExchangeId::HyperliquidPerp,
1213            &[eth(), btc.clone()],
1214            perp,
1215        );
1216
1217        let eth_entry = listing.remove(&eth()).unwrap();
1218        assert!(!eth_entry.orders_complete);
1219        assert_eq!(eth_entry.orders.len(), 1);
1220        assert!(listing.remove(&btc).unwrap().orders_complete);
1221    }
1222
1223    #[test]
1224    fn an_order_with_a_malformed_cloid_leaves_its_instrument_incomplete() {
1225        let mut rows = rows();
1226        rows[0].cloid = Some("0x1234".to_string());
1227        let mut listing = OpenOrderListing::new(&rows, ExchangeId::HyperliquidPerp, &[eth()], perp);
1228
1229        let eth_entry = listing.remove(&eth()).unwrap();
1230        assert!(!eth_entry.orders_complete);
1231        assert_eq!(eth_entry.orders.len(), 1);
1232    }
1233
1234    #[test]
1235    fn coins_the_client_does_not_trade_are_left_out() {
1236        let listing = OpenOrderListing::new(&rows(), ExchangeId::HyperliquidSpot, &[], |coin| {
1237            is_spot_coin(coin).then(|| spot_coin_to_instrument(coin))
1238        });
1239        assert_eq!(listing.into_snapshots().count(), 0);
1240    }
1241}