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::order::{TimeInForce, id::ClientOrderId};
9use chrono::{DateTime, TimeZone, Utc};
10use futures::Stream;
11use rust_decimal::Decimal;
12use rustrade_instrument::{Side, instrument::name::InstrumentNameExchange};
13use smol_str::format_smolstr;
14use std::pin::Pin;
15use std::str::FromStr;
16use std::task::{Context, Poll};
17use tokio_util::sync::CancellationToken;
18use tracing::{debug, warn};
19use uuid::Uuid;
20
21/// Stream wrapper that cancels background tasks when dropped.
22///
23/// Ensures spawned WebSocket processing tasks are cleaned up when the consumer
24/// drops the account stream, preventing task leaks.
25#[derive(Debug)]
26pub struct CancelOnDropStream<S> {
27    inner: S,
28    cancel_token: CancellationToken,
29}
30
31impl<S> CancelOnDropStream<S> {
32    /// Create a new cancel-on-drop stream wrapper.
33    pub(crate) fn new(inner: S, cancel_token: CancellationToken) -> Self {
34        Self {
35            inner,
36            cancel_token,
37        }
38    }
39}
40
41impl<S: Stream + Unpin> Stream for CancelOnDropStream<S> {
42    type Item = S::Item;
43
44    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
45        Pin::new(&mut self.inner).poll_next(cx)
46    }
47}
48
49impl<S> Drop for CancelOnDropStream<S> {
50    fn drop(&mut self) {
51        self.cancel_token.cancel();
52    }
53}
54
55/// Parse a decimal string from SDK response, logging warnings on failure.
56pub fn parse_decimal(value: &str, field: &str) -> Option<Decimal> {
57    Decimal::from_str(value)
58        .map_err(|e| warn!(%field, %value, %e, "Failed to parse decimal"))
59        .ok()
60}
61
62/// Parse SDK side string to rustrade Side.
63///
64/// Hyperliquid API returns "B" for buy/long and "A" for sell/short (ask-side).
65/// Extra variants included for defensive parsing of potential future API changes.
66pub fn parse_side(side: &str) -> Option<Side> {
67    match side {
68        "B" | "b" | "BUY" | "Buy" | "buy" => Some(Side::Buy),
69        "A" | "a" | "S" | "s" | "SELL" | "Sell" | "sell" => Some(Side::Sell),
70        _ => {
71            warn!(%side, "Unknown side string");
72            None
73        }
74    }
75}
76
77/// Convert milliseconds timestamp to `DateTime<Utc>`.
78///
79/// Returns `None` for timestamps outside the representable range (year 292M+).
80pub fn millis_to_datetime(millis: u64) -> Option<DateTime<Utc>> {
81    Utc.timestamp_millis_opt(i64::try_from(millis).ok()?)
82        .single()
83}
84
85/// Round a price to 5 significant figures (Hyperliquid requirement).
86///
87/// Performs rounding using `Decimal` arithmetic to avoid floating-point precision
88/// errors. The final `f64` conversion happens only at the SDK interface boundary.
89pub fn round_to_5_sig_figs(value: Decimal) -> f64 {
90    use rust_decimal::prelude::ToPrimitive;
91
92    if value.is_zero() {
93        return 0.0;
94    }
95
96    // Use f64 only for computing magnitude (acceptable precision for this purpose;
97    // we only need to know which power of 10 the number is close to).
98    let abs_f = value.abs().to_f64().unwrap_or(0.0);
99    if abs_f == 0.0 {
100        return 0.0;
101    }
102
103    // Clamp magnitude to prevent overflow when computing scale factor
104    #[allow(clippy::cast_possible_truncation)]
105    let magnitude = abs_f.log10().floor().clamp(-30.0, 30.0) as i32;
106
107    // Round using Decimal arithmetic to preserve precision
108    let rounded = if magnitude >= 4 {
109        // Large numbers (e.g., 123456): scale down, round integer, scale back up
110        // Safety: magnitude >= 4 guarantees (magnitude - 4) is non-negative
111        #[allow(clippy::cast_sign_loss)]
112        let factor = Decimal::from(10i64.pow((magnitude - 4) as u32));
113        (value / factor).round() * factor
114    } else {
115        // Small/medium numbers: round to appropriate decimal places
116        #[allow(clippy::cast_sign_loss)]
117        let dp = (4 - magnitude) as u32;
118        value.round_dp(dp)
119    };
120
121    // SDK requires f64 — convert only at the interface boundary
122    rounded.to_f64().unwrap_or(0.0)
123}
124
125/// Map rustrade TimeInForce to Hyperliquid TIF string.
126pub fn map_tif(tif: &TimeInForce) -> &'static str {
127    match tif {
128        TimeInForce::GoodUntilCancelled { post_only: true } => "Alo",
129        TimeInForce::GoodUntilCancelled { post_only: false } => "Gtc",
130        TimeInForce::ImmediateOrCancel => "Ioc",
131        TimeInForce::FillOrKill => "Ioc", // Hyperliquid doesn't have FOK, use IOC
132        TimeInForce::GoodUntilEndOfDay => "Gtc", // No EOD on Hyperliquid
133        // Hyperliquid is a perpetuals DEX with no auction sessions and no native
134        // GTD. Coerce to GTC and surface the loss of semantics so downstream
135        // wrappers can decide whether to reject upstream.
136        TimeInForce::GoodTillDate { .. } | TimeInForce::AtOpen | TimeInForce::AtClose => {
137            warn!(time_in_force = ?tif, "Hyperliquid does not support this TimeInForce; coercing to Gtc");
138            "Gtc"
139        }
140    }
141}
142
143/// Convert a ClientOrderId to SDK cloid format (UUID) if valid.
144///
145/// Returns `Some(Uuid)` if the cid is a valid UUID, `None` otherwise.
146/// Non-UUID CIDs are logged at debug level since they're common in tests/examples.
147pub fn cid_to_cloid(cid: &ClientOrderId) -> Option<Uuid> {
148    match Uuid::parse_str(cid.0.as_str()) {
149        Ok(uuid) => Some(uuid),
150        Err(_) => {
151            debug!(cid = %cid.0, "CID is not a valid UUID, cloid will be None");
152            None
153        }
154    }
155}
156
157/// Build perp instrument name from Hyperliquid coin name (e.g., "BTC" -> "BTC-USD-PERP").
158pub fn perp_coin_to_instrument(coin: &str) -> InstrumentNameExchange {
159    InstrumentNameExchange::from(format_smolstr!("{}-USD-PERP", coin))
160}
161
162/// Extract coin name from perp instrument (e.g., "BTC-USD-PERP" -> "BTC").
163///
164/// Returns `String` because Hyperliquid SDK requires `String` for asset fields.
165pub fn instrument_to_perp_coin(instrument: &InstrumentNameExchange) -> String {
166    let s = instrument.as_ref();
167    // Expected format: "COIN-USD-PERP" or just "COIN"
168    match s.split_once('-') {
169        Some((coin, _)) => coin.to_string(),
170        None => s.to_string(),
171    }
172}
173
174/// Build spot instrument name from Hyperliquid coin pair (e.g., "PURR/USDC" -> "PURR-USDC-SPOT").
175///
176/// # Panics (debug builds only)
177///
178/// Debug-asserts if `coin` does not contain '/' — callers must verify `is_spot_coin()`
179/// before calling. The fallback path produces a malformed instrument name.
180pub fn spot_coin_to_instrument(coin: &str) -> InstrumentNameExchange {
181    match coin.split_once('/') {
182        Some((base, quote)) => {
183            InstrumentNameExchange::from(format_smolstr!("{}-{}-SPOT", base, quote))
184        }
185        None => {
186            debug_assert!(
187                false,
188                "spot_coin_to_instrument called with non-spot coin: {coin}"
189            );
190            InstrumentNameExchange::from(format_smolstr!("{}-SPOT", coin))
191        }
192    }
193}
194
195/// Extract coin pair from spot instrument (e.g., "PURR-USDC-SPOT" -> "PURR/USDC").
196///
197/// Returns `Option<String>` — `None` if the instrument doesn't match expected
198/// `BASE-QUOTE-SPOT` format. Callers should fail fast on `None` rather than
199/// send a malformed asset to the exchange.
200pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
201    let s = instrument.as_ref();
202    // Expected format: "BASE-QUOTE-SPOT" -> "BASE/QUOTE"
203    let without_suffix = s.strip_suffix("-SPOT")?;
204    let (base, quote) = without_suffix.split_once('-')?;
205    Some(format!("{}/{}", base, quote))
206}
207
208/// Check if a Hyperliquid coin name is a spot pair (contains '/').
209///
210/// Hyperliquid API uses pair format `"BASE/QUOTE"` (e.g., `"PURR/USDC"`) for spot coins
211/// and single symbols (e.g., `"BTC"`) for perpetuals. This naming convention is observed
212/// across all SDK examples and test fixtures. The invariant is validated by our spot
213/// fixture tests in `hyperliquid_spot_execution.rs`.
214pub fn is_spot_coin(coin: &str) -> bool {
215    coin.contains('/')
216}
217
218#[cfg(test)]
219// Test code: panics on bad input are acceptable
220#[allow(clippy::unwrap_used)]
221mod tests {
222    use super::*;
223    use rust_decimal_macros::dec;
224
225    #[test]
226    fn test_parse_decimal_valid() {
227        assert_eq!(parse_decimal("123.456", "test"), Some(dec!(123.456)));
228        assert_eq!(parse_decimal("0", "test"), Some(dec!(0)));
229        assert_eq!(parse_decimal("-50.5", "test"), Some(dec!(-50.5)));
230    }
231
232    #[test]
233    fn test_parse_decimal_invalid() {
234        assert_eq!(parse_decimal("", "test"), None);
235        assert_eq!(parse_decimal("abc", "test"), None);
236        assert_eq!(parse_decimal("12.34.56", "test"), None);
237    }
238
239    #[test]
240    fn test_parse_side() {
241        assert_eq!(parse_side("B"), Some(Side::Buy));
242        assert_eq!(parse_side("BUY"), Some(Side::Buy));
243        assert_eq!(parse_side("buy"), Some(Side::Buy));
244        assert_eq!(parse_side("A"), Some(Side::Sell));
245        assert_eq!(parse_side("S"), Some(Side::Sell));
246        assert_eq!(parse_side("SELL"), Some(Side::Sell));
247        assert_eq!(parse_side("sell"), Some(Side::Sell));
248        assert_eq!(parse_side("X"), None);
249        assert_eq!(parse_side(""), None);
250    }
251
252    #[test]
253    fn test_perp_coin_to_instrument() {
254        let inst = perp_coin_to_instrument("BTC");
255        assert_eq!(inst.as_ref(), "BTC-USD-PERP");
256
257        let inst = perp_coin_to_instrument("ETH");
258        assert_eq!(inst.as_ref(), "ETH-USD-PERP");
259    }
260
261    #[test]
262    fn test_instrument_to_perp_coin() {
263        let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("BTC-USD-PERP"));
264        assert_eq!(coin, "BTC");
265
266        let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("ETH-USD-PERP"));
267        assert_eq!(coin, "ETH");
268
269        // Just coin name without suffix
270        let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("SOL"));
271        assert_eq!(coin, "SOL");
272    }
273
274    #[test]
275    fn test_spot_coin_to_instrument() {
276        let inst = spot_coin_to_instrument("PURR/USDC");
277        assert_eq!(inst.as_ref(), "PURR-USDC-SPOT");
278
279        let inst = spot_coin_to_instrument("HYPE/USDC");
280        assert_eq!(inst.as_ref(), "HYPE-USDC-SPOT");
281    }
282
283    #[test]
284    fn test_instrument_to_spot_coin() {
285        let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("PURR-USDC-SPOT"));
286        assert_eq!(coin, Some("PURR/USDC".to_string()));
287
288        let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("HYPE-USDC-SPOT"));
289        assert_eq!(coin, Some("HYPE/USDC".to_string()));
290
291        // Malformed instruments return None
292        assert_eq!(
293            instrument_to_spot_coin(&InstrumentNameExchange::from("BTC-USD-PERP")),
294            None
295        );
296        assert_eq!(
297            instrument_to_spot_coin(&InstrumentNameExchange::from("INVALID")),
298            None
299        );
300    }
301
302    #[test]
303    fn test_is_spot_coin() {
304        assert!(is_spot_coin("PURR/USDC"));
305        assert!(is_spot_coin("HYPE/USDC"));
306        assert!(!is_spot_coin("BTC"));
307        assert!(!is_spot_coin("ETH"));
308    }
309
310    #[test]
311    fn test_round_to_5_sig_figs() {
312        assert_eq!(round_to_5_sig_figs(dec!(0)), 0.0);
313        assert_eq!(round_to_5_sig_figs(dec!(12345)), 12345.0);
314        assert_eq!(round_to_5_sig_figs(dec!(123456)), 123460.0);
315        assert_eq!(round_to_5_sig_figs(dec!(0.00012345)), 0.00012345);
316        assert_eq!(round_to_5_sig_figs(dec!(0.000123456)), 0.00012346);
317        assert_eq!(round_to_5_sig_figs(dec!(1.23456789)), 1.2346);
318    }
319
320    #[test]
321    fn test_map_tif() {
322        assert_eq!(
323            map_tif(&TimeInForce::GoodUntilCancelled { post_only: false }),
324            "Gtc"
325        );
326        assert_eq!(
327            map_tif(&TimeInForce::GoodUntilCancelled { post_only: true }),
328            "Alo"
329        );
330        assert_eq!(map_tif(&TimeInForce::ImmediateOrCancel), "Ioc");
331        assert_eq!(map_tif(&TimeInForce::FillOrKill), "Ioc");
332        assert_eq!(map_tif(&TimeInForce::GoodUntilEndOfDay), "Gtc");
333    }
334
335    #[test]
336    fn test_millis_to_datetime() {
337        let dt = millis_to_datetime(1714100000000).unwrap();
338        assert_eq!(dt.timestamp_millis(), 1714100000000);
339
340        // Zero timestamp (Unix epoch) is valid
341        assert!(millis_to_datetime(0).is_some());
342    }
343}