use crate::{error::DataError, subscription::candle::Candle};
use chrono::{DateTime, TimeZone, Utc};
use hyperliquid_rust_sdk::{BaseUrl, InfoClient};
use rust_decimal::Decimal;
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> {
debug!(
coin = %request.coin,
interval = %request.interval.as_str(),
start = %request.start_time,
end = %request.end_time,
"Fetching historical candles"
);
#[allow(clippy::cast_sign_loss)] let start_ms = request.start_time.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 {
candles.push(sdk_candle_to_candle(&sdk_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,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CandleInterval {
Min1,
Min3,
Min5,
Min15,
Min30,
Hour1,
Hour2,
Hour4,
Hour8,
Hour12,
Day1,
Day3,
Week1,
Month1,
}
impl CandleInterval {
pub fn as_str(&self) -> &'static str {
match self {
Self::Min1 => "1m",
Self::Min3 => "3m",
Self::Min5 => "5m",
Self::Min15 => "15m",
Self::Min30 => "30m",
Self::Hour1 => "1h",
Self::Hour2 => "2h",
Self::Hour4 => "4h",
Self::Hour8 => "8h",
Self::Hour12 => "12h",
Self::Day1 => "1d",
Self::Day3 => "3d",
Self::Week1 => "1w",
Self::Month1 => "1M",
}
}
}
fn sdk_candle_to_candle(
sdk: &hyperliquid_rust_sdk::CandlesSnapshotResponse,
) -> Result<Candle, DataError> {
let close_time = Utc
.timestamp_millis_opt(sdk.time_close as i64)
.single()
.ok_or_else(|| {
DataError::Socket(format!(
"Hyperliquid timestamp {} out of range",
sdk.time_close
))
})?;
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 test_candle_interval_as_str() {
assert_eq!(CandleInterval::Min1.as_str(), "1m");
assert_eq!(CandleInterval::Hour1.as_str(), "1h");
assert_eq!(CandleInterval::Day1.as_str(), "1d");
assert_eq!(CandleInterval::Month1.as_str(), "1M");
}
#[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() {
use rust_decimal_macros::dec;
let sdk_candle = hyperliquid_rust_sdk::CandlesSnapshotResponse {
time_open: 1704067200000,
time_close: 1704070800000,
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).unwrap();
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);
}
}