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}