rustrade_execution/client/hyperliquid/
common.rs1use 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#[derive(Debug)]
26pub struct CancelOnDropStream<S> {
27 inner: S,
28 cancel_token: CancellationToken,
29}
30
31impl<S> CancelOnDropStream<S> {
32 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
55pub 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
62pub 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
77pub fn millis_to_datetime(millis: u64) -> Option<DateTime<Utc>> {
81 Utc.timestamp_millis_opt(i64::try_from(millis).ok()?)
82 .single()
83}
84
85pub 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 let abs_f = value.abs().to_f64().unwrap_or(0.0);
99 if abs_f == 0.0 {
100 return 0.0;
101 }
102
103 #[allow(clippy::cast_possible_truncation)]
105 let magnitude = abs_f.log10().floor().clamp(-30.0, 30.0) as i32;
106
107 let rounded = if magnitude >= 4 {
109 #[allow(clippy::cast_sign_loss)]
112 let factor = Decimal::from(10i64.pow((magnitude - 4) as u32));
113 (value / factor).round() * factor
114 } else {
115 #[allow(clippy::cast_sign_loss)]
117 let dp = (4 - magnitude) as u32;
118 value.round_dp(dp)
119 };
120
121 rounded.to_f64().unwrap_or(0.0)
123}
124
125pub 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", TimeInForce::GoodUntilEndOfDay => "Gtc", 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
143pub 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
157pub fn perp_coin_to_instrument(coin: &str) -> InstrumentNameExchange {
159 InstrumentNameExchange::from(format_smolstr!("{}-USD-PERP", coin))
160}
161
162pub fn instrument_to_perp_coin(instrument: &InstrumentNameExchange) -> String {
166 let s = instrument.as_ref();
167 match s.split_once('-') {
169 Some((coin, _)) => coin.to_string(),
170 None => s.to_string(),
171 }
172}
173
174pub 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
195pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
201 let s = instrument.as_ref();
202 let without_suffix = s.strip_suffix("-SPOT")?;
204 let (base, quote) = without_suffix.split_once('-')?;
205 Some(format!("{}/{}", base, quote))
206}
207
208pub fn is_spot_coin(coin: &str) -> bool {
215 coin.contains('/')
216}
217
218#[cfg(test)]
219#[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 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 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 assert!(millis_to_datetime(0).is_some());
342 }
343}