1use crate::client::hyperliquid::error::map_sdk_error;
9use crate::error::UnindexedClientError;
10use crate::order::{TimeInForce, id::ClientOrderId};
11use chrono::{DateTime, TimeZone, Utc};
12use futures::Stream;
13use rust_decimal::Decimal;
14use rustrade_instrument::{Side, instrument::name::InstrumentNameExchange};
15use smol_str::format_smolstr;
16use std::pin::Pin;
17use std::str::FromStr;
18use std::task::{Context, Poll};
19use tokio_util::sync::CancellationToken;
20use tracing::{debug, warn};
21use uuid::Uuid;
22
23#[derive(Debug)]
28pub struct CancelOnDropStream<S> {
29 inner: S,
30 cancel_token: CancellationToken,
31}
32
33impl<S> CancelOnDropStream<S> {
34 pub(crate) fn new(inner: S, cancel_token: CancellationToken) -> Self {
36 Self {
37 inner,
38 cancel_token,
39 }
40 }
41}
42
43impl<S: Stream + Unpin> Stream for CancelOnDropStream<S> {
44 type Item = S::Item;
45
46 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
47 Pin::new(&mut self.inner).poll_next(cx)
48 }
49}
50
51impl<S> Drop for CancelOnDropStream<S> {
52 fn drop(&mut self) {
53 self.cancel_token.cancel();
54 }
55}
56
57#[derive(Debug, Clone, serde::Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct UserFill {
76 pub coin: String,
77 pub side: String,
78 pub px: String,
79 pub sz: String,
80 pub time: u64,
81 pub oid: u64,
82 pub fee: String,
83
84 pub tid: u64,
95
96 pub fee_token: Option<String>,
102}
103
104pub async fn user_fills(
115 info_client: &hyperliquid_rust_sdk::InfoClient,
116 address: ethers::types::H160,
117) -> Result<Vec<UserFill>, UnindexedClientError> {
118 let body = format!(r#"{{"type":"userFills","user":"{address:?}"}}"#);
121
122 let raw = info_client
123 .http_client
124 .post("/info", body)
125 .await
126 .map_err(map_sdk_error)?;
127
128 serde_json::from_str(&raw).map_err(|e| {
131 UnindexedClientError::Internal(format!("Hyperliquid userFills response did not parse: {e}"))
132 })
133}
134
135pub fn parse_decimal(value: &str, field: &str) -> Option<Decimal> {
136 Decimal::from_str(value)
137 .map_err(|e| warn!(%field, %value, %e, "Failed to parse decimal"))
138 .ok()
139}
140
141pub fn parse_side(side: &str) -> Option<Side> {
146 match side {
147 "B" | "b" | "BUY" | "Buy" | "buy" => Some(Side::Buy),
148 "A" | "a" | "S" | "s" | "SELL" | "Sell" | "sell" => Some(Side::Sell),
149 _ => {
150 warn!(%side, "Unknown side string");
151 None
152 }
153 }
154}
155
156pub fn millis_to_datetime(millis: u64) -> Option<DateTime<Utc>> {
160 Utc.timestamp_millis_opt(i64::try_from(millis).ok()?)
161 .single()
162}
163
164pub fn round_to_5_sig_figs(value: Decimal) -> f64 {
169 use rust_decimal::prelude::ToPrimitive;
170
171 if value.is_zero() {
172 return 0.0;
173 }
174
175 let abs_f = value.abs().to_f64().unwrap_or(0.0);
178 if abs_f == 0.0 {
179 return 0.0;
180 }
181
182 #[allow(clippy::cast_possible_truncation)]
184 let magnitude = abs_f.log10().floor().clamp(-30.0, 30.0) as i32;
185
186 let rounded = if magnitude >= 4 {
188 #[allow(clippy::cast_sign_loss)]
191 let factor = Decimal::from(10i64.pow((magnitude - 4) as u32));
192 (value / factor).round() * factor
193 } else {
194 #[allow(clippy::cast_sign_loss)]
196 let dp = (4 - magnitude) as u32;
197 value.round_dp(dp)
198 };
199
200 rounded.to_f64().unwrap_or(0.0)
202}
203
204pub fn map_tif(tif: &TimeInForce) -> &'static str {
206 match tif {
207 TimeInForce::GoodUntilCancelled { post_only: true } => "Alo",
208 TimeInForce::GoodUntilCancelled { post_only: false } => "Gtc",
209 TimeInForce::ImmediateOrCancel => "Ioc",
210 TimeInForce::FillOrKill => "Ioc", TimeInForce::GoodUntilEndOfDay => "Gtc", TimeInForce::GoodTillDate { .. } | TimeInForce::AtOpen | TimeInForce::AtClose => {
216 warn!(time_in_force = ?tif, "Hyperliquid does not support this TimeInForce; coercing to Gtc");
217 "Gtc"
218 }
219 }
220}
221
222pub fn cid_to_cloid(cid: &ClientOrderId) -> Option<Uuid> {
227 match Uuid::parse_str(cid.0.as_str()) {
228 Ok(uuid) => Some(uuid),
229 Err(_) => {
230 debug!(cid = %cid.0, "CID is not a valid UUID, cloid will be None");
231 None
232 }
233 }
234}
235
236pub fn perp_coin_to_instrument(coin: &str) -> InstrumentNameExchange {
238 InstrumentNameExchange::from(format_smolstr!("{}-USD-PERP", coin))
239}
240
241pub fn instrument_to_perp_coin(instrument: &InstrumentNameExchange) -> String {
245 let s = instrument.as_ref();
246 match s.split_once('-') {
248 Some((coin, _)) => coin.to_string(),
249 None => s.to_string(),
250 }
251}
252
253pub fn spot_coin_to_instrument(coin: &str) -> InstrumentNameExchange {
260 match coin.split_once('/') {
261 Some((base, quote)) => {
262 InstrumentNameExchange::from(format_smolstr!("{}-{}-SPOT", base, quote))
263 }
264 None => {
265 debug_assert!(
266 false,
267 "spot_coin_to_instrument called with non-spot coin: {coin}"
268 );
269 InstrumentNameExchange::from(format_smolstr!("{}-SPOT", coin))
270 }
271 }
272}
273
274pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
280 let s = instrument.as_ref();
281 let without_suffix = s.strip_suffix("-SPOT")?;
283 let (base, quote) = without_suffix.split_once('-')?;
284 Some(format!("{}/{}", base, quote))
285}
286
287pub fn is_spot_coin(coin: &str) -> bool {
294 coin.contains('/')
295}
296
297#[cfg(test)]
298#[allow(clippy::unwrap_used)]
300mod tests {
301 use super::*;
302 use rust_decimal_macros::dec;
303
304 #[test]
305 fn test_parse_decimal_valid() {
306 assert_eq!(parse_decimal("123.456", "test"), Some(dec!(123.456)));
307 assert_eq!(parse_decimal("0", "test"), Some(dec!(0)));
308 assert_eq!(parse_decimal("-50.5", "test"), Some(dec!(-50.5)));
309 }
310
311 #[test]
312 fn test_parse_decimal_invalid() {
313 assert_eq!(parse_decimal("", "test"), None);
314 assert_eq!(parse_decimal("abc", "test"), None);
315 assert_eq!(parse_decimal("12.34.56", "test"), None);
316 }
317
318 #[test]
319 fn test_parse_side() {
320 assert_eq!(parse_side("B"), Some(Side::Buy));
321 assert_eq!(parse_side("BUY"), Some(Side::Buy));
322 assert_eq!(parse_side("buy"), Some(Side::Buy));
323 assert_eq!(parse_side("A"), Some(Side::Sell));
324 assert_eq!(parse_side("S"), Some(Side::Sell));
325 assert_eq!(parse_side("SELL"), Some(Side::Sell));
326 assert_eq!(parse_side("sell"), Some(Side::Sell));
327 assert_eq!(parse_side("X"), None);
328 assert_eq!(parse_side(""), None);
329 }
330
331 #[test]
332 fn test_perp_coin_to_instrument() {
333 let inst = perp_coin_to_instrument("BTC");
334 assert_eq!(inst.as_ref(), "BTC-USD-PERP");
335
336 let inst = perp_coin_to_instrument("ETH");
337 assert_eq!(inst.as_ref(), "ETH-USD-PERP");
338 }
339
340 #[test]
341 fn test_instrument_to_perp_coin() {
342 let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("BTC-USD-PERP"));
343 assert_eq!(coin, "BTC");
344
345 let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("ETH-USD-PERP"));
346 assert_eq!(coin, "ETH");
347
348 let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("SOL"));
350 assert_eq!(coin, "SOL");
351 }
352
353 #[test]
354 fn test_spot_coin_to_instrument() {
355 let inst = spot_coin_to_instrument("PURR/USDC");
356 assert_eq!(inst.as_ref(), "PURR-USDC-SPOT");
357
358 let inst = spot_coin_to_instrument("HYPE/USDC");
359 assert_eq!(inst.as_ref(), "HYPE-USDC-SPOT");
360 }
361
362 #[test]
363 fn test_instrument_to_spot_coin() {
364 let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("PURR-USDC-SPOT"));
365 assert_eq!(coin, Some("PURR/USDC".to_string()));
366
367 let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("HYPE-USDC-SPOT"));
368 assert_eq!(coin, Some("HYPE/USDC".to_string()));
369
370 assert_eq!(
372 instrument_to_spot_coin(&InstrumentNameExchange::from("BTC-USD-PERP")),
373 None
374 );
375 assert_eq!(
376 instrument_to_spot_coin(&InstrumentNameExchange::from("INVALID")),
377 None
378 );
379 }
380
381 #[test]
382 fn test_is_spot_coin() {
383 assert!(is_spot_coin("PURR/USDC"));
384 assert!(is_spot_coin("HYPE/USDC"));
385 assert!(!is_spot_coin("BTC"));
386 assert!(!is_spot_coin("ETH"));
387 }
388
389 #[test]
390 fn test_round_to_5_sig_figs() {
391 assert_eq!(round_to_5_sig_figs(dec!(0)), 0.0);
392 assert_eq!(round_to_5_sig_figs(dec!(12345)), 12345.0);
393 assert_eq!(round_to_5_sig_figs(dec!(123456)), 123460.0);
394 assert_eq!(round_to_5_sig_figs(dec!(0.00012345)), 0.00012345);
395 assert_eq!(round_to_5_sig_figs(dec!(0.000123456)), 0.00012346);
396 assert_eq!(round_to_5_sig_figs(dec!(1.23456789)), 1.2346);
397 }
398
399 #[test]
400 fn test_map_tif() {
401 assert_eq!(
402 map_tif(&TimeInForce::GoodUntilCancelled { post_only: false }),
403 "Gtc"
404 );
405 assert_eq!(
406 map_tif(&TimeInForce::GoodUntilCancelled { post_only: true }),
407 "Alo"
408 );
409 assert_eq!(map_tif(&TimeInForce::ImmediateOrCancel), "Ioc");
410 assert_eq!(map_tif(&TimeInForce::FillOrKill), "Ioc");
411 assert_eq!(map_tif(&TimeInForce::GoodUntilEndOfDay), "Gtc");
412 }
413
414 #[test]
415 fn test_millis_to_datetime() {
416 let dt = millis_to_datetime(1714100000000).unwrap();
417 assert_eq!(dt.timestamp_millis(), 1714100000000);
418
419 assert!(millis_to_datetime(0).is_some());
421 }
422}
423
424#[cfg(test)]
425#[allow(clippy::unwrap_used)]
427mod user_fills_tests {
428 use super::*;
429 use wiremock::matchers::{method, path};
430 use wiremock::{Mock, MockServer, ResponseTemplate};
431
432 const DOCUMENTED_FILL: &str = r#"{
435 "closedPnl": "0.0",
436 "coin": "AVAX",
437 "crossed": false,
438 "dir": "Open Long",
439 "hash": "0xa166e3fa63c25663024b03f2e0da011a00307e4017465df020210d3d432e7cb8",
440 "oid": 90542681,
441 "px": "18.435",
442 "side": "B",
443 "startPosition": "26.86",
444 "sz": "93.53",
445 "time": 1681222254710,
446 "fee": "0.01",
447 "feeToken": "USDC",
448 "builderFee": "0.01",
449 "tid": 118906512037719
450 }"#;
451
452 async fn info_client_against(uri: String) -> hyperliquid_rust_sdk::InfoClient {
455 let mut client = hyperliquid_rust_sdk::InfoClient::new(
456 None,
457 Some(hyperliquid_rust_sdk::BaseUrl::Localhost),
458 )
459 .await
460 .unwrap();
461 client.http_client.base_url = uri;
462 client
463 }
464
465 async fn serve(body: String) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
466 let server = MockServer::start().await;
467 Mock::given(method("POST"))
468 .and(path("/info"))
469 .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
470 .mount(&server)
471 .await;
472 let client = info_client_against(server.uri()).await;
473 (server, client)
474 }
475
476 #[tokio::test]
477 async fn a_fill_carries_the_tid_and_fee_token_the_sdk_type_drops() {
478 let (_server, client) = serve(format!("[{DOCUMENTED_FILL}]")).await;
479
480 let fills = user_fills(&client, ethers::types::H160::zero())
481 .await
482 .unwrap();
483
484 assert_eq!(fills.len(), 1);
485 assert_eq!(fills[0].tid, 118_906_512_037_719);
486 assert_eq!(fills[0].fee_token.as_deref(), Some("USDC"));
487 assert_eq!(fills[0].oid, 90_542_681);
488 assert_eq!(fills[0].coin, "AVAX");
489 }
490
491 #[tokio::test]
492 async fn one_transaction_can_carry_several_fills_and_they_are_told_apart_by_tid() {
493 let sweep = r#"[
496 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
497 "hash":"0xsweep","oid":1,"px":"18.40","side":"B","startPosition":"0",
498 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":111},
499 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
500 "hash":"0xsweep","oid":1,"px":"18.45","side":"B","startPosition":"10",
501 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":222},
502 {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
503 "hash":"0xsweep","oid":1,"px":"18.50","side":"B","startPosition":"20",
504 "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":333}
505 ]"#;
506 let (_server, client) = serve(sweep.to_string()).await;
507
508 let fills = user_fills(&client, ethers::types::H160::zero())
509 .await
510 .unwrap();
511
512 let tids: Vec<u64> = fills.iter().map(|f| f.tid).collect();
513 assert_eq!(tids, vec![111, 222, 333]);
514 }
515
516 #[tokio::test]
517 async fn a_missing_fee_token_falls_back_rather_than_failing() {
518 let without = DOCUMENTED_FILL.replace(r#""feeToken": "USDC","#, "");
521 let (_server, client) = serve(format!("[{without}]")).await;
522
523 let fills = user_fills(&client, ethers::types::H160::zero())
524 .await
525 .unwrap();
526
527 assert_eq!(fills.len(), 1);
528 assert!(fills[0].fee_token.is_none());
529 }
530
531 #[tokio::test]
532 async fn a_missing_tid_fails_loudly_instead_of_falling_back_to_the_hash() {
533 let without = DOCUMENTED_FILL.replace(r#""tid": 118906512037719"#, r#""unused": 0"#);
536 let (_server, client) = serve(format!("[{without}]")).await;
537
538 let error = user_fills(&client, ethers::types::H160::zero())
539 .await
540 .unwrap_err();
541
542 assert!(
543 matches!(&error, UnindexedClientError::Internal(msg) if msg.contains("userFills")),
544 "expected a parse failure naming the endpoint, got {error:?}"
545 );
546 }
547
548 #[tokio::test]
549 async fn the_request_names_the_address_in_full() {
550 let server = MockServer::start().await;
553 Mock::given(method("POST"))
554 .and(path("/info"))
555 .and(wiremock::matchers::body_string_contains(
556 "0x000000000000000000000000000000000000dead",
557 ))
558 .and(wiremock::matchers::body_string_contains("userFills"))
559 .respond_with(ResponseTemplate::new(200).set_body_raw("[]", "application/json"))
560 .mount(&server)
561 .await;
562 let client = info_client_against(server.uri()).await;
563
564 let address = "0x000000000000000000000000000000000000dEaD"
565 .parse::<ethers::types::H160>()
566 .unwrap();
567
568 assert!(user_fills(&client, address).await.unwrap().is_empty());
571 }
572}