Skip to main content

rustrade_data/exchange/hyperliquid/
historical.rs

1//! Historical data fetcher for Hyperliquid.
2//!
3//! Provides one-shot historical candle data via `HyperliquidHistoricalData::fetch_candles()`.
4//! This is a separate utility API, not part of the WebSocket streaming connector.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use rustrade_data::exchange::hyperliquid::{
10//!     Network,
11//!     historical::{HyperliquidHistoricalData, HistoricalRequest},
12//! };
13//! use rustrade_data::subscription::candle::CandleInterval;
14//! use chrono::{Duration, Utc};
15//!
16//! let client = HyperliquidHistoricalData::new(Network::Mainnet).await?;
17//!
18//! let request = HistoricalRequest {
19//!     coin: "BTC".to_string(),
20//!     interval: CandleInterval::Hour1,
21//!     start_time: Utc::now() - Duration::days(7),
22//!     end_time: Utc::now(),
23//! };
24//!
25//! let candles = client.fetch_candles(request).await?;
26//! ```
27
28use super::Network;
29use crate::{
30    error::DataError,
31    subscription::candle::{Candle, CandleInterval, close_time_from_open, open_time_from_close},
32};
33use chrono::{DateTime, TimeZone, Utc};
34use hyperliquid_rust_sdk::{BaseUrl, InfoClient};
35use rust_decimal::Decimal;
36use rustrade_instrument::exchange::ExchangeId;
37use tracing::debug;
38
39/// Historical data fetcher for Hyperliquid.
40///
41/// Wraps the SDK's `InfoClient` for fetching historical OHLCV candles.
42///
43/// Not every [`CandleInterval`] variant is supported — Hyperliquid's
44/// `candleSnapshot` API rejects some resolutions; see [`fetch_candles`] for the
45/// per-interval availability contract.
46///
47/// [`fetch_candles`]: Self::fetch_candles
48#[derive(Debug)]
49pub struct HyperliquidHistoricalData {
50    client: InfoClient,
51}
52
53impl HyperliquidHistoricalData {
54    /// Create a new historical data client for `network`.
55    ///
56    /// # Errors
57    ///
58    /// Returns error if client creation fails.
59    pub async fn new(network: Network) -> Result<Self, DataError> {
60        let base_url = match network {
61            Network::Mainnet => BaseUrl::Mainnet,
62            Network::Testnet => BaseUrl::Testnet,
63        };
64        let client = InfoClient::new(None, Some(base_url))
65            .await
66            .map_err(|e| DataError::Socket(format!("InfoClient creation: {e}")))?;
67        Ok(Self { client })
68    }
69
70    /// Create from an existing `InfoClient`.
71    ///
72    /// Useful for sharing a client with other code or custom configuration.
73    pub fn from_client(client: InfoClient) -> Self {
74        Self { client }
75    }
76
77    /// Fetch historical candles for the given request.
78    ///
79    /// # Range contract
80    ///
81    /// Returns exactly the candles whose exclusive `close_time` falls within
82    /// `[request.start_time, request.end_time]` (both inclusive) — matched on
83    /// `close_time`, the field consumers receive (see
84    /// [`Candle::close_time`](crate::subscription::candle::Candle)). Hyperliquid's
85    /// API natively filters by the candle's open-time bucket, so this method
86    /// widens the request by one interval and trims the result by `close_time` to
87    /// honour the contract uniformly with the library's other historical fetches.
88    ///
89    /// # Arguments
90    ///
91    /// * `request` - Historical data request parameters
92    ///
93    /// # Returns
94    ///
95    /// Vector of candles in chronological order (oldest first).
96    ///
97    /// # Errors
98    ///
99    /// Returns [`DataError::UnsupportedInterval`] if the requested interval is not
100    /// supported by Hyperliquid (any sub-minute interval, and `Hour6` — the shared [`CandleInterval`] is
101    /// a venue-agnostic union, so this guard is required: the typed-path validator
102    /// checks instrument kind only, not interval). Returns `DataError::Socket` if
103    /// the API request fails or a candle's `close_time` cannot be computed
104    /// (overflow).
105    pub async fn fetch_candles(
106        &self,
107        request: HistoricalRequest,
108    ) -> Result<Vec<Candle>, DataError> {
109        // Hyperliquid does not offer 1-second or 6-hour candles. The shared
110        // `CandleInterval` is the union across all venues, so reject the variants
111        // Hyperliquid cannot serve before they reach the API (which would 400).
112        ensure_hyperliquid_supports(request.interval)?;
113
114        debug!(
115            coin = %request.coin,
116            interval = %request.interval.as_str(),
117            start = %request.start_time,
118            end = %request.end_time,
119            "Fetching historical candles"
120        );
121
122        let step = request.interval.to_step();
123
124        // Range contract: callers receive candles whose `close_time ∈ [start, end]`.
125        // Hyperliquid filters by the candle's native open-time bucket
126        // `[open, open + interval)`, NOT by our normalised `close_time`, so the
127        // candle whose `close_time == start` (open == start − interval) would be
128        // dropped. Widen the request lower bound by one interval to capture it,
129        // then trim the result by `close_time` below.
130        // `None` (underflow near DateTime::MIN_UTC) is not an error: the boundary
131        // candle would have an unrepresentable open and so cannot exist, making the
132        // un-widened bound already correct. See `open_time_from_close`.
133        let request_start =
134            open_time_from_close(request.start_time, step).unwrap_or(request.start_time);
135
136        #[allow(clippy::cast_sign_loss)] // Timestamps after 1970 are always positive
137        let start_ms = request_start.timestamp_millis() as u64;
138        #[allow(clippy::cast_sign_loss)] // Timestamps after 1970 are always positive
139        let end_ms = request.end_time.timestamp_millis() as u64;
140
141        let response = self
142            .client
143            .candles_snapshot(
144                request.coin.clone(),
145                request.interval.as_str().to_string(),
146                start_ms,
147                end_ms,
148            )
149            .await
150            .map_err(|e| DataError::Socket(format!("Hyperliquid candles: {e}")))?;
151
152        let mut candles = Vec::with_capacity(response.len());
153        for sdk_candle in response {
154            let candle = sdk_candle_to_candle(&sdk_candle, request.interval)?;
155            // Trim to the close_time range contract (the venue may return one
156            // extra candle past either boundary due to open-bucket filtering).
157            if candle.close_time >= request.start_time && candle.close_time <= request.end_time {
158                candles.push(candle);
159            }
160        }
161
162        debug!(coin = %request.coin, count = candles.len(), "Received historical candles");
163
164        Ok(candles)
165    }
166}
167
168/// Parameters for a historical candle request.
169#[derive(Debug, Clone)]
170pub struct HistoricalRequest {
171    /// Asset symbol (e.g., "BTC", "ETH").
172    pub coin: String,
173
174    /// Candle interval/resolution.
175    pub interval: CandleInterval,
176
177    /// Start time for the data range (inclusive).
178    pub start_time: DateTime<Utc>,
179
180    /// End time for the data range (inclusive).
181    pub end_time: DateTime<Utc>,
182}
183
184impl HistoricalRequest {
185    /// Create a request for hourly candles over the last N days.
186    pub fn hourly(coin: impl Into<String>, days: i64) -> Self {
187        let end_time = Utc::now();
188        let start_time = end_time - chrono::Duration::days(days);
189        Self {
190            coin: coin.into(),
191            interval: CandleInterval::Hour1,
192            start_time,
193            end_time,
194        }
195    }
196
197    /// Create a request for daily candles over the last N days.
198    pub fn daily(coin: impl Into<String>, days: i64) -> Self {
199        let end_time = Utc::now();
200        let start_time = end_time - chrono::Duration::days(days);
201        Self {
202            coin: coin.into(),
203            interval: CandleInterval::Day1,
204            start_time,
205            end_time,
206        }
207    }
208}
209
210/// Reject [`CandleInterval`] variants that Hyperliquid cannot serve.
211///
212/// The shared [`CandleInterval`] is the venue-agnostic union across all
213/// exchanges, so each venue must guard the intervals it does not support before
214/// they reach the API (Hyperliquid 400s on every sub-minute interval and on
215/// `Hour6`). The typed-path
216/// validator checks instrument kind only, never the interval, so this guard is
217/// the sole gate.
218///
219/// The match is intentionally exhaustive (no `_` arm): adding a variant to
220/// [`CandleInterval`] is a compile error here, forcing a conscious decision on
221/// whether Hyperliquid supports it rather than silently passing it to the API.
222fn ensure_hyperliquid_supports(interval: CandleInterval) -> Result<(), DataError> {
223    match interval {
224        CandleInterval::Sec1
225        | CandleInterval::Sec5
226        | CandleInterval::Sec15
227        | CandleInterval::Sec30
228        | CandleInterval::Hour6 => Err(DataError::UnsupportedInterval {
229            // Hyperliquid's candleSnapshot menu starts at 1m — every sub-minute
230            // resolution is unsupported, as is 6h — across the whole API (perp and
231            // spot alike); this client is coin-keyed, not venue-keyed, so
232            // `HyperliquidPerp` is a representative venue label.
233            exchange: ExchangeId::HyperliquidPerp,
234            interval,
235        }),
236        CandleInterval::Min1
237        | CandleInterval::Min3
238        | CandleInterval::Min5
239        | CandleInterval::Min15
240        | CandleInterval::Min30
241        | CandleInterval::Hour1
242        | CandleInterval::Hour2
243        | CandleInterval::Hour4
244        | CandleInterval::Hour8
245        | CandleInterval::Hour12
246        | CandleInterval::Day1
247        | CandleInterval::Day3
248        | CandleInterval::Week1
249        | CandleInterval::Month1 => Ok(()),
250    }
251}
252
253/// Convert SDK's `CandlesSnapshotResponse` to rustrade's `Candle`.
254///
255/// `close_time` is computed library-side as the exclusive end-of-period boundary
256/// (`time_open + interval`) via the shared [`close_time_from_open`] helper — the
257/// venue's raw `time_close` is **discarded** because Hyperliquid reports it as
258/// `period-end − 1ms` (the inclusive-last-ms convention, verified against the live
259/// `candleSnapshot` API), which does not satisfy the [`Candle`] contract.
260fn sdk_candle_to_candle(
261    sdk: &hyperliquid_rust_sdk::CandlesSnapshotResponse,
262    interval: CandleInterval,
263) -> Result<Candle, DataError> {
264    let open_time = Utc
265        .timestamp_millis_opt(sdk.time_open as i64)
266        .single()
267        .ok_or_else(|| {
268            DataError::Socket(format!(
269                "Hyperliquid open timestamp {} out of range",
270                sdk.time_open
271            ))
272        })?;
273
274    let close_time = close_time_from_open(open_time, interval.to_step()).ok_or_else(|| {
275        DataError::Socket(format!(
276            "Hyperliquid candle close_time overflow: open={open_time}, interval={}",
277            interval.as_str()
278        ))
279    })?;
280
281    let open = sdk
282        .open
283        .parse::<Decimal>()
284        .map_err(|e| DataError::Socket(format!("parse open: {e}")))?;
285    let high = sdk
286        .high
287        .parse::<Decimal>()
288        .map_err(|e| DataError::Socket(format!("parse high: {e}")))?;
289    let low = sdk
290        .low
291        .parse::<Decimal>()
292        .map_err(|e| DataError::Socket(format!("parse low: {e}")))?;
293    let close = sdk
294        .close
295        .parse::<Decimal>()
296        .map_err(|e| DataError::Socket(format!("parse close: {e}")))?;
297    let volume = sdk
298        .vlm
299        .parse::<Decimal>()
300        .map_err(|e| DataError::Socket(format!("parse volume: {e}")))?;
301
302    Ok(Candle {
303        close_time,
304        open,
305        high,
306        low,
307        close,
308        // Hyperliquid candles carry real consolidated volume and trade count.
309        volume: Some(volume),
310        trade_count: Some(sdk.num_trades),
311    })
312}
313
314#[cfg(test)]
315#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
316mod tests {
317    use super::*;
318
319    #[test]
320    fn ensure_hyperliquid_supports_rejects_sub_minute_and_hour6() {
321        // Sub-minute resolutions and `Hour6` exist on the shared union but
322        // Hyperliquid cannot serve them — the guard must reject them with a typed error.
323        for interval in [
324            CandleInterval::Sec1,
325            CandleInterval::Sec5,
326            CandleInterval::Sec15,
327            CandleInterval::Sec30,
328            CandleInterval::Hour6,
329        ] {
330            let err = ensure_hyperliquid_supports(interval).unwrap_err();
331            assert!(
332                matches!(err, DataError::UnsupportedInterval { interval: i, .. } if i == interval),
333                "expected UnsupportedInterval for {interval}, got {err:?}"
334            );
335        }
336    }
337
338    #[test]
339    fn ensure_hyperliquid_supports_accepts_the_rest() {
340        // Every variant except the ones Hyperliquid cannot serve must be accepted.
341        for interval in CandleInterval::ALL {
342            let supported = !matches!(
343                interval,
344                CandleInterval::Sec1
345                    | CandleInterval::Sec5
346                    | CandleInterval::Sec15
347                    | CandleInterval::Sec30
348                    | CandleInterval::Hour6
349            );
350            assert_eq!(
351                ensure_hyperliquid_supports(interval).is_ok(),
352                supported,
353                "interval {interval}"
354            );
355        }
356    }
357
358    #[test]
359    fn test_historical_request_hourly_builder() {
360        let request = HistoricalRequest::hourly("BTC", 7);
361        assert_eq!(request.coin, "BTC");
362        assert_eq!(request.interval, CandleInterval::Hour1);
363        assert!(request.start_time < request.end_time);
364    }
365
366    #[test]
367    fn test_historical_request_daily_builder() {
368        let request = HistoricalRequest::daily("ETH", 30);
369        assert_eq!(request.coin, "ETH");
370        assert_eq!(request.interval, CandleInterval::Day1);
371    }
372
373    #[test]
374    fn test_sdk_candle_to_candle_normalises_close_time_from_open() {
375        use rust_decimal_macros::dec;
376
377        // Hyperliquid reports `time_close` as `time_open + interval − 1ms` (the
378        // inclusive-last-ms convention, verified against the live API). The
379        // library must IGNORE that raw value and compute the exclusive boundary
380        // `time_open + interval`. Here time_open = 2024-01-01 00:00:00 UTC and the
381        // raw venue time_close is the `−1ms` value, which must NOT be used.
382        let sdk_candle = hyperliquid_rust_sdk::CandlesSnapshotResponse {
383            time_open: 1_704_067_200_000,  // 2024-01-01 00:00:00.000 UTC
384            time_close: 1_704_070_799_999, // 2024-01-01 00:59:59.999 UTC (1h − 1ms; ignored)
385            coin: "BTC".to_string(),
386            candle_interval: "1h".to_string(),
387            open: "45000.5".to_string(),
388            high: "45500.0".to_string(),
389            low: "44800.0".to_string(),
390            close: "45250.0".to_string(),
391            vlm: "1234.56".to_string(),
392            num_trades: 5000,
393        };
394
395        let candle = sdk_candle_to_candle(&sdk_candle, CandleInterval::Hour1).unwrap();
396
397        // Normalised boundary: exactly time_open + 1h, NOT the raw −1ms value.
398        assert_eq!(candle.close_time.timestamp_millis(), 1_704_070_800_000);
399        assert_eq!(candle.open, dec!(45000.5));
400        assert_eq!(candle.high, dec!(45500.0));
401        assert_eq!(candle.low, dec!(44800.0));
402        assert_eq!(candle.close, dec!(45250.0));
403        assert_eq!(candle.volume, Some(dec!(1234.56)));
404        assert_eq!(candle.trade_count, Some(5000));
405    }
406
407    #[test]
408    fn test_sdk_candle_to_candle_monthly_calendar_boundary() {
409        // A January monthly bar must close at Feb 1 00:00 UTC via calendar
410        // arithmetic (Months(1)), not open + 30 days.
411        let sdk_candle = hyperliquid_rust_sdk::CandlesSnapshotResponse {
412            time_open: 1_704_067_200_000,  // 2024-01-01 00:00:00 UTC
413            time_close: 1_706_745_599_999, // raw venue value (ignored)
414            coin: "BTC".to_string(),
415            candle_interval: "1M".to_string(),
416            open: "45000.0".to_string(),
417            high: "45000.0".to_string(),
418            low: "45000.0".to_string(),
419            close: "45000.0".to_string(),
420            vlm: "1.0".to_string(),
421            num_trades: 1,
422        };
423
424        let candle = sdk_candle_to_candle(&sdk_candle, CandleInterval::Month1).unwrap();
425
426        // 2024-02-01 00:00:00 UTC.
427        assert_eq!(candle.close_time.timestamp_millis(), 1_706_745_600_000);
428    }
429}