use crate::{
error::DataError,
subscription::candle::{Candle, CandleInterval, close_time_from_open, open_time_from_close},
};
use chrono::{DateTime, TimeZone, Utc};
use hyperliquid_rust_sdk::{BaseUrl, InfoClient};
use rust_decimal::Decimal;
use rustrade_instrument::exchange::ExchangeId;
use tracing::debug;
#[derive(Debug)]
pub struct HyperliquidHistoricalData {
client: InfoClient,
}
impl HyperliquidHistoricalData {
pub async fn new(testnet: bool) -> Result<Self, DataError> {
let base_url = if testnet {
Some(BaseUrl::Testnet)
} else {
None
};
let client = InfoClient::new(None, base_url)
.await
.map_err(|e| DataError::Socket(format!("InfoClient creation: {e}")))?;
Ok(Self { client })
}
pub fn from_client(client: InfoClient) -> Self {
Self { client }
}
pub async fn fetch_candles(
&self,
request: HistoricalRequest,
) -> Result<Vec<Candle>, DataError> {
ensure_hyperliquid_supports(request.interval)?;
debug!(
coin = %request.coin,
interval = %request.interval.as_str(),
start = %request.start_time,
end = %request.end_time,
"Fetching historical candles"
);
let step = request.interval.to_step();
let request_start =
open_time_from_close(request.start_time, step).unwrap_or(request.start_time);
#[allow(clippy::cast_sign_loss)] let start_ms = request_start.timestamp_millis() as u64;
#[allow(clippy::cast_sign_loss)] let end_ms = request.end_time.timestamp_millis() as u64;
let response = self
.client
.candles_snapshot(
request.coin.clone(),
request.interval.as_str().to_string(),
start_ms,
end_ms,
)
.await
.map_err(|e| DataError::Socket(format!("Hyperliquid candles: {e}")))?;
let mut candles = Vec::with_capacity(response.len());
for sdk_candle in response {
let candle = sdk_candle_to_candle(&sdk_candle, request.interval)?;
if candle.close_time >= request.start_time && candle.close_time <= request.end_time {
candles.push(candle);
}
}
debug!(coin = %request.coin, count = candles.len(), "Received historical candles");
Ok(candles)
}
}
#[derive(Debug, Clone)]
pub struct HistoricalRequest {
pub coin: String,
pub interval: CandleInterval,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
}
impl HistoricalRequest {
pub fn hourly(coin: impl Into<String>, days: i64) -> Self {
let end_time = Utc::now();
let start_time = end_time - chrono::Duration::days(days);
Self {
coin: coin.into(),
interval: CandleInterval::Hour1,
start_time,
end_time,
}
}
pub fn daily(coin: impl Into<String>, days: i64) -> Self {
let end_time = Utc::now();
let start_time = end_time - chrono::Duration::days(days);
Self {
coin: coin.into(),
interval: CandleInterval::Day1,
start_time,
end_time,
}
}
}
fn ensure_hyperliquid_supports(interval: CandleInterval) -> Result<(), DataError> {
match interval {
CandleInterval::Sec1 | CandleInterval::Hour6 => Err(DataError::UnsupportedInterval {
exchange: ExchangeId::HyperliquidPerp,
interval,
}),
CandleInterval::Min1
| CandleInterval::Min3
| CandleInterval::Min5
| CandleInterval::Min15
| CandleInterval::Min30
| CandleInterval::Hour1
| CandleInterval::Hour2
| CandleInterval::Hour4
| CandleInterval::Hour8
| CandleInterval::Hour12
| CandleInterval::Day1
| CandleInterval::Day3
| CandleInterval::Week1
| CandleInterval::Month1 => Ok(()),
}
}
fn sdk_candle_to_candle(
sdk: &hyperliquid_rust_sdk::CandlesSnapshotResponse,
interval: CandleInterval,
) -> Result<Candle, DataError> {
let open_time = Utc
.timestamp_millis_opt(sdk.time_open as i64)
.single()
.ok_or_else(|| {
DataError::Socket(format!(
"Hyperliquid open timestamp {} out of range",
sdk.time_open
))
})?;
let close_time = close_time_from_open(open_time, interval.to_step()).ok_or_else(|| {
DataError::Socket(format!(
"Hyperliquid candle close_time overflow: open={open_time}, interval={}",
interval.as_str()
))
})?;
let open = sdk
.open
.parse::<Decimal>()
.map_err(|e| DataError::Socket(format!("parse open: {e}")))?;
let high = sdk
.high
.parse::<Decimal>()
.map_err(|e| DataError::Socket(format!("parse high: {e}")))?;
let low = sdk
.low
.parse::<Decimal>()
.map_err(|e| DataError::Socket(format!("parse low: {e}")))?;
let close = sdk
.close
.parse::<Decimal>()
.map_err(|e| DataError::Socket(format!("parse close: {e}")))?;
let volume = sdk
.vlm
.parse::<Decimal>()
.map_err(|e| DataError::Socket(format!("parse volume: {e}")))?;
Ok(Candle {
close_time,
open,
high,
low,
close,
volume,
trade_count: sdk.num_trades,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
#[test]
fn ensure_hyperliquid_supports_rejects_sec1_and_hour6() {
for interval in [CandleInterval::Sec1, CandleInterval::Hour6] {
let err = ensure_hyperliquid_supports(interval).unwrap_err();
assert!(
matches!(err, DataError::UnsupportedInterval { interval: i, .. } if i == interval),
"expected UnsupportedInterval for {interval}, got {err:?}"
);
}
}
#[test]
fn ensure_hyperliquid_supports_accepts_the_rest() {
for interval in CandleInterval::ALL {
let supported = !matches!(interval, CandleInterval::Sec1 | CandleInterval::Hour6);
assert_eq!(
ensure_hyperliquid_supports(interval).is_ok(),
supported,
"interval {interval}"
);
}
}
#[test]
fn test_historical_request_hourly_builder() {
let request = HistoricalRequest::hourly("BTC", 7);
assert_eq!(request.coin, "BTC");
assert_eq!(request.interval, CandleInterval::Hour1);
assert!(request.start_time < request.end_time);
}
#[test]
fn test_historical_request_daily_builder() {
let request = HistoricalRequest::daily("ETH", 30);
assert_eq!(request.coin, "ETH");
assert_eq!(request.interval, CandleInterval::Day1);
}
#[test]
fn test_sdk_candle_to_candle_normalises_close_time_from_open() {
use rust_decimal_macros::dec;
let sdk_candle = hyperliquid_rust_sdk::CandlesSnapshotResponse {
time_open: 1_704_067_200_000, time_close: 1_704_070_799_999, coin: "BTC".to_string(),
candle_interval: "1h".to_string(),
open: "45000.5".to_string(),
high: "45500.0".to_string(),
low: "44800.0".to_string(),
close: "45250.0".to_string(),
vlm: "1234.56".to_string(),
num_trades: 5000,
};
let candle = sdk_candle_to_candle(&sdk_candle, CandleInterval::Hour1).unwrap();
assert_eq!(candle.close_time.timestamp_millis(), 1_704_070_800_000);
assert_eq!(candle.open, dec!(45000.5));
assert_eq!(candle.high, dec!(45500.0));
assert_eq!(candle.low, dec!(44800.0));
assert_eq!(candle.close, dec!(45250.0));
assert_eq!(candle.volume, dec!(1234.56));
assert_eq!(candle.trade_count, 5000);
}
#[test]
fn test_sdk_candle_to_candle_monthly_calendar_boundary() {
let sdk_candle = hyperliquid_rust_sdk::CandlesSnapshotResponse {
time_open: 1_704_067_200_000, time_close: 1_706_745_599_999, coin: "BTC".to_string(),
candle_interval: "1M".to_string(),
open: "45000.0".to_string(),
high: "45000.0".to_string(),
low: "45000.0".to_string(),
close: "45000.0".to_string(),
vlm: "1.0".to_string(),
num_trades: 1,
};
let candle = sdk_candle_to_candle(&sdk_candle, CandleInterval::Month1).unwrap();
assert_eq!(candle.close_time.timestamp_millis(), 1_706_745_600_000);
}
}