use crate::errors::{HyperliquidBacktestError, Result};
use chrono::{DateTime, FixedOffset, TimeZone};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HyperliquidData {
pub symbol: String,
pub datetime: Vec<DateTime<FixedOffset>>,
pub open: Vec<f64>,
pub high: Vec<f64>,
pub low: Vec<f64>,
pub close: Vec<f64>,
pub volume: Vec<f64>,
pub funding_rates: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FundingStatistics {
pub average_rate: f64,
pub volatility: f64,
pub min_rate: f64,
pub max_rate: f64,
pub positive_periods: usize,
pub negative_periods: usize,
pub total_periods: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheableFundingHistory {
pub coin: String,
pub funding_rate: String,
pub premium: String,
pub time: u64,
}
impl From<&hyperliquid_rust_sdk::FundingHistoryResponse> for CacheableFundingHistory {
fn from(response: &hyperliquid_rust_sdk::FundingHistoryResponse) -> Self {
Self {
coin: response.coin.clone(),
funding_rate: response.funding_rate.clone(),
premium: response.premium.clone(),
time: response.time,
}
}
}
impl From<CacheableFundingHistory> for hyperliquid_rust_sdk::FundingHistoryResponse {
fn from(cacheable: CacheableFundingHistory) -> Self {
Self {
coin: cacheable.coin,
funding_rate: cacheable.funding_rate,
premium: cacheable.premium,
time: cacheable.time,
}
}
}
pub struct HyperliquidDataFetcher {
info_client: hyperliquid_rust_sdk::InfoClient,
}
impl HyperliquidDataFetcher {
pub async fn new() -> std::result::Result<Self, hyperliquid_rust_sdk::Error> {
let info_client = hyperliquid_rust_sdk::InfoClient::new(None, Some(hyperliquid_rust_sdk::BaseUrl::Mainnet)).await?;
Ok(Self {
info_client,
})
}
pub fn supported_intervals() -> &'static [&'static str] {
&["1m", "5m", "15m", "1h", "4h", "1d"]
}
pub fn is_interval_supported(interval: &str) -> bool {
Self::supported_intervals().contains(&interval)
}
pub fn max_time_range_for_interval(interval: &str) -> u64 {
match interval {
"1m" => 7 * 24 * 3600, "5m" => 30 * 24 * 3600, "15m" => 90 * 24 * 3600, "1h" => 365 * 24 * 3600, "4h" => 2 * 365 * 24 * 3600, "1d" => 5 * 365 * 24 * 3600, _ => 365 * 24 * 3600, }
}
pub async fn fetch_ohlc_data(
&self,
coin: &str,
interval: &str,
start_time: u64,
end_time: u64,
) -> Result<Vec<hyperliquid_rust_sdk::CandlesSnapshotResponse>> {
HyperliquidData::validate_fetch_parameters(coin, interval, start_time, end_time)?;
let candles = self.info_client
.candles_snapshot(coin.to_string(), interval.to_string(), start_time, end_time)
.await
.map_err(|e| HyperliquidBacktestError::from(e))?;
self.validate_ohlc_response(&candles)?;
Ok(candles)
}
pub async fn fetch_funding_history(
&self,
coin: &str,
start_time: u64,
end_time: u64,
) -> Result<Vec<hyperliquid_rust_sdk::FundingHistoryResponse>> {
if coin.is_empty() {
return Err(HyperliquidBacktestError::validation("Coin cannot be empty"));
}
if start_time >= end_time {
return Err(HyperliquidBacktestError::invalid_time_range(start_time, end_time));
}
let funding_history = self.info_client
.funding_history(coin.to_string(), start_time, Some(end_time))
.await
.map_err(|e| HyperliquidBacktestError::from(e))?;
self.validate_funding_response(&funding_history)?;
Ok(funding_history)
}
fn validate_ohlc_response(&self, candles: &[hyperliquid_rust_sdk::CandlesSnapshotResponse]) -> Result<()> {
if candles.is_empty() {
return Err(HyperliquidBacktestError::validation("No OHLC data returned from API"));
}
for (i, candle) in candles.iter().enumerate() {
candle.open.parse::<f64>()
.map_err(|_| HyperliquidBacktestError::data_conversion(
format!("Invalid open price '{}' at index {}", candle.open, i)
))?;
candle.high.parse::<f64>()
.map_err(|_| HyperliquidBacktestError::data_conversion(
format!("Invalid high price '{}' at index {}", candle.high, i)
))?;
candle.low.parse::<f64>()
.map_err(|_| HyperliquidBacktestError::data_conversion(
format!("Invalid low price '{}' at index {}", candle.low, i)
))?;
candle.close.parse::<f64>()
.map_err(|_| HyperliquidBacktestError::data_conversion(
format!("Invalid close price '{}' at index {}", candle.close, i)
))?;
candle.vlm.parse::<f64>()
.map_err(|_| HyperliquidBacktestError::data_conversion(
format!("Invalid volume '{}' at index {}", candle.vlm, i)
))?;
if candle.time_open >= candle.time_close {
return Err(HyperliquidBacktestError::validation(
format!("Invalid candle timestamps: open {} >= close {} at index {}",
candle.time_open, candle.time_close, i)
));
}
}
for i in 1..candles.len() {
if candles[i].time_open <= candles[i - 1].time_open {
return Err(HyperliquidBacktestError::validation(
format!("Candles not in chronological order at indices {} and {}", i - 1, i)
));
}
}
Ok(())
}
fn validate_funding_response(&self, funding_history: &[hyperliquid_rust_sdk::FundingHistoryResponse]) -> Result<()> {
if funding_history.is_empty() {
return Ok(()); }
for (i, entry) in funding_history.iter().enumerate() {
entry.funding_rate.parse::<f64>()
.map_err(|_| HyperliquidBacktestError::data_conversion(
format!("Invalid funding rate '{}' at index {}", entry.funding_rate, i)
))?;
entry.premium.parse::<f64>()
.map_err(|_| HyperliquidBacktestError::data_conversion(
format!("Invalid premium '{}' at index {}", entry.premium, i)
))?;
}
for i in 1..funding_history.len() {
if funding_history[i].time <= funding_history[i - 1].time {
return Err(HyperliquidBacktestError::validation(
format!("Funding history not in chronological order at indices {} and {}", i - 1, i)
));
}
}
Ok(())
}
pub fn align_ohlc_and_funding_data(
&self,
ohlc_data: &[hyperliquid_rust_sdk::CandlesSnapshotResponse],
funding_data: &[hyperliquid_rust_sdk::FundingHistoryResponse],
) -> Result<(Vec<DateTime<FixedOffset>>, Vec<f64>)> {
if ohlc_data.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
let mut aligned_timestamps = Vec::new();
let mut aligned_funding_rates = Vec::new();
let funding_map: HashMap<u64, f64> = funding_data
.iter()
.map(|entry| {
let rate = entry.funding_rate.parse::<f64>()
.unwrap_or(0.0); (entry.time, rate)
})
.collect();
for candle in ohlc_data {
let ohlc_timestamp = candle.time_open;
let datetime = FixedOffset::east_opt(0)
.ok_or_else(|| HyperliquidBacktestError::data_conversion(
"Failed to create UTC timezone offset".to_string()
))?
.timestamp_opt(ohlc_timestamp as i64, 0)
.single()
.ok_or_else(|| HyperliquidBacktestError::data_conversion(
format!("Invalid timestamp {}", ohlc_timestamp)
))?;
let funding_rate = self.find_funding_rate_for_timestamp(ohlc_timestamp, &funding_map);
aligned_timestamps.push(datetime);
aligned_funding_rates.push(funding_rate);
}
Ok((aligned_timestamps, aligned_funding_rates))
}
fn find_funding_rate_for_timestamp(
&self,
timestamp: u64,
funding_map: &HashMap<u64, f64>,
) -> f64 {
if let Some(&rate) = funding_map.get(×tamp) {
return rate;
}
let mut best_timestamp = 0;
let mut best_rate = 0.0;
for (&funding_timestamp, &rate) in funding_map.iter() {
if funding_timestamp <= timestamp && funding_timestamp > best_timestamp {
best_timestamp = funding_timestamp;
best_rate = rate;
}
}
if best_timestamp == 0 {
let mut closest_timestamp = u64::MAX;
for (&funding_timestamp, &rate) in funding_map.iter() {
if funding_timestamp > timestamp && funding_timestamp < closest_timestamp {
closest_timestamp = funding_timestamp;
best_rate = rate;
}
}
}
best_rate
}
}
impl HyperliquidDataFetcher {
pub async fn new_with_custom_error() -> std::result::Result<Self, Box<dyn std::error::Error>> {
let info_client = hyperliquid_rust_sdk::InfoClient::new(None, Some(hyperliquid_rust_sdk::BaseUrl::Mainnet)).await?;
Ok(Self { info_client })
}
}
impl HyperliquidData {
pub async fn fetch(
coin: &str,
interval: &str,
start_time: u64,
end_time: u64,
) -> Result<Self> {
let fetcher = HyperliquidDataFetcher::new().await
.map_err(|e| HyperliquidBacktestError::HyperliquidApi(e.to_string()))?;
let ohlc_data = fetcher.fetch_ohlc_data(coin, interval, start_time, end_time).await?;
let funding_data = fetcher.fetch_funding_history(coin, start_time, end_time).await?;
let (aligned_timestamps, aligned_funding_rates) =
fetcher.align_ohlc_and_funding_data(&ohlc_data, &funding_data)?;
let mut open = Vec::new();
let mut high = Vec::new();
let mut low = Vec::new();
let mut close = Vec::new();
let mut volume = Vec::new();
for candle in &ohlc_data {
open.push(candle.open.parse::<f64>()?);
high.push(candle.high.parse::<f64>()?);
low.push(candle.low.parse::<f64>()?);
close.push(candle.close.parse::<f64>()?);
volume.push(candle.vlm.parse::<f64>()?);
}
let data = Self::with_ohlc_and_funding_data(
coin.to_string(),
aligned_timestamps,
open,
high,
low,
close,
volume,
aligned_funding_rates,
)?;
data.validate_all_data()?;
Ok(data)
}
pub fn with_ohlc_data(
symbol: String,
datetime: Vec<DateTime<FixedOffset>>,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> Result<Self> {
let len = datetime.len();
if open.len() != len || high.len() != len || low.len() != len || close.len() != len || volume.len() != len {
return Err(HyperliquidBacktestError::validation(
"All data arrays must have the same length"
));
}
let funding_rates = vec![f64::NAN; len];
Ok(Self {
symbol,
datetime,
open,
high,
low,
close,
volume,
funding_rates,
})
}
pub fn with_ohlc_and_funding_data(
symbol: String,
datetime: Vec<DateTime<FixedOffset>>,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
funding_rates: Vec<f64>,
) -> Result<Self> {
let len = datetime.len();
if open.len() != len || high.len() != len || low.len() != len || close.len() != len ||
volume.len() != len || funding_rates.len() != len {
return Err(HyperliquidBacktestError::validation(
"All data arrays must have the same length"
));
}
Ok(Self {
symbol,
datetime,
open,
high,
low,
close,
volume,
funding_rates,
})
}
pub fn len(&self) -> usize {
self.datetime.len()
}
pub fn is_empty(&self) -> bool {
self.datetime.is_empty()
}
pub fn validate_all_data(&self) -> Result<()> {
let len = self.datetime.len();
if self.open.len() != len || self.high.len() != len || self.low.len() != len ||
self.close.len() != len || self.volume.len() != len || self.funding_rates.len() != len {
return Err(HyperliquidBacktestError::validation(
"All data arrays must have the same length"
));
}
for i in 0..len {
if self.high[i] < self.low[i] {
return Err(HyperliquidBacktestError::validation(
format!("High price {} is less than low price {} at index {}",
self.high[i], self.low[i], i)
));
}
}
for i in 1..len {
if self.datetime[i] <= self.datetime[i - 1] {
return Err(HyperliquidBacktestError::validation(
format!("Timestamps not in chronological order at indices {} and {}",
i - 1, i)
));
}
}
Ok(())
}
pub fn to_rs_backtester_data(&self) -> rs_backtester::datas::Data {
use std::io::Write;
use tempfile::NamedTempFile;
let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
writeln!(temp_file, "DATE,OPEN,HIGH,LOW,CLOSE").expect("Failed to write header");
for i in 0..self.datetime.len() {
writeln!(
temp_file,
"{},{},{},{},{}",
self.datetime[i].to_rfc3339(),
self.open[i],
self.high[i],
self.low[i],
self.close[i]
).expect("Failed to write data");
}
temp_file.flush().expect("Failed to flush temp file");
rs_backtester::datas::Data::load(
temp_file.path().to_str().unwrap(),
&self.symbol
).expect("Failed to load data")
}
pub fn get_funding_rate_at(&self, timestamp: DateTime<FixedOffset>) -> Option<f64> {
if let Some(index) = self.datetime.iter().position(|&t| t == timestamp) {
let rate = self.funding_rates[index];
if !rate.is_nan() {
return Some(rate);
}
}
None
}
pub fn get_price_at_or_near(&self, timestamp: DateTime<FixedOffset>) -> Option<f64> {
if self.datetime.is_empty() {
return None;
}
if let Some(index) = self.datetime.iter().position(|&t| t == timestamp) {
return Some(self.close[index]);
}
let mut closest_index = 0;
let mut min_diff = i64::MAX;
for (i, &dt) in self.datetime.iter().enumerate() {
let diff = (dt.timestamp() - timestamp.timestamp()).abs();
if diff < min_diff {
min_diff = diff;
closest_index = i;
}
}
if min_diff <= 24 * 3600 {
Some(self.close[closest_index])
} else {
None
}
}
pub fn calculate_funding_statistics(&self) -> FundingStatistics {
let mut valid_rates = Vec::new();
let mut positive_periods = 0;
let mut negative_periods = 0;
for &rate in &self.funding_rates {
if !rate.is_nan() {
valid_rates.push(rate);
if rate > 0.0 {
positive_periods += 1;
} else if rate < 0.0 {
negative_periods += 1;
}
}
}
let total_periods = valid_rates.len();
let average_rate = if total_periods > 0 {
valid_rates.iter().sum::<f64>() / total_periods as f64
} else {
0.0
};
let min_rate = valid_rates.iter().fold(f64::INFINITY, |a, &b| a.min(b));
let max_rate = valid_rates.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
let volatility = if total_periods > 1 {
let variance = valid_rates.iter()
.map(|&r| (r - average_rate).powi(2))
.sum::<f64>() / (total_periods - 1) as f64;
variance.sqrt()
} else {
0.0
};
FundingStatistics {
average_rate,
volatility,
min_rate: if min_rate.is_finite() { min_rate } else { 0.0 },
max_rate: if max_rate.is_finite() { max_rate } else { 0.0 },
positive_periods,
negative_periods,
total_periods,
}
}
pub fn validate_fetch_parameters(
coin: &str,
interval: &str,
start_time: u64,
end_time: u64,
) -> Result<()> {
if coin.is_empty() {
return Err(HyperliquidBacktestError::validation("Coin cannot be empty"));
}
if !HyperliquidDataFetcher::is_interval_supported(interval) {
return Err(HyperliquidBacktestError::unsupported_interval(interval));
}
if start_time >= end_time {
return Err(HyperliquidBacktestError::invalid_time_range(start_time, end_time));
}
let current_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
if start_time > current_time + 86400 { return Err(HyperliquidBacktestError::validation("Start time cannot be in the future"));
}
if end_time > current_time + 86400 { return Err(HyperliquidBacktestError::validation("End time cannot be in the future"));
}
let max_range_seconds = HyperliquidDataFetcher::max_time_range_for_interval(interval);
if end_time - start_time > max_range_seconds {
return Err(HyperliquidBacktestError::validation(
format!("Time range too large for interval {}. Maximum range: {} days",
interval, max_range_seconds / 86400)
));
}
Ok(())
}
pub fn popular_trading_pairs() -> &'static [&'static str] {
&["BTC", "ETH", "ATOM", "MATIC", "DYDX", "SOL", "AVAX", "BNB", "APE", "OP"]
}
pub fn is_popular_pair(coin: &str) -> bool {
Self::popular_trading_pairs().contains(&coin)
}
}