use crate::infrastructure::ClickHouseConfig;
use crate::infrastructure::clickhouse::model::{ClickHouseRow, OHLCVData, PriceType};
use crate::utils::ChainError;
use chrono::{DateTime, Utc};
use clickhouse::Client;
use optionstratlib::Positive;
use optionstratlib::utils::TimeFrame;
use rust_decimal::Decimal;
use tracing::{debug, info, instrument};
pub struct ClickHouseClient {
pub(crate) client: Client,
config: ClickHouseConfig,
}
impl Default for ClickHouseClient {
fn default() -> ClickHouseClient {
match Self::new(ClickHouseConfig::default()) {
Ok(client) => client,
Err(e) => panic!("Failed to create default ClickHouse client: {}", e),
}
}
}
impl ClickHouseClient {
#[instrument(name = "clickhouse_client_new", skip(config), level = "debug")]
pub fn new(config: ClickHouseConfig) -> Result<Self, ChainError> {
let url = format!("http://{}:{}", config.host, config.port);
let client = Client::default()
.with_url(url)
.with_user(config.username.clone())
.with_password(config.password.clone())
.with_database(config.database.clone());
info!("Created new ClickHouse client for host: {}", config.host);
Ok(Self { client, config })
}
#[instrument(skip(self), level = "debug")]
pub async fn fetch_historical_prices(
&self,
symbol: &str,
timeframe: &TimeFrame,
start_date: &DateTime<Utc>,
limit: usize,
) -> Result<Vec<Positive>, ChainError> {
debug!(
"Fetching historical {} prices for {} from {} with timeframe {:?}",
limit, symbol, start_date, timeframe
);
let query = self.build_timeframe_query(symbol, timeframe, start_date, limit)?;
let results = self.execute_query(query).await?;
let prices: Vec<Positive> = results.into_iter().map(|data| data.close).collect();
info!("Fetched {} historical prices for {}", prices.len(), symbol);
Ok(prices)
}
#[instrument(skip(self), level = "debug")]
pub async fn fetch_ohlcv_data(
&self,
symbol: &str,
timeframe: &TimeFrame,
start_date: &DateTime<Utc>,
limit: usize,
) -> Result<Vec<OHLCVData>, ChainError> {
debug!(
"Fetching {} OHLCV data for {} from {} with timeframe {:?}",
limit, symbol, start_date, timeframe
);
let query = self.build_timeframe_query(symbol, timeframe, start_date, limit)?;
let results = self.execute_query(query).await?;
info!("Fetched {} OHLCV data points for {}", results.len(), symbol);
Ok(results)
}
fn build_timeframe_query(
&self,
symbol: &str,
timeframe: &TimeFrame,
start_date: &DateTime<Utc>,
limit: usize,
) -> Result<String, ChainError> {
let start_timestamp = start_date.timestamp();
if *timeframe == TimeFrame::Minute {
let query = format!(
"SELECT symbol, toInt64(toUnixTimestamp(timestamp)) as timestamp,
open, high, low, close, toUInt64(volume) as volume \
FROM ohlcv \
WHERE symbol = '{}' \
AND timestamp >= FROM_UNIXTIME({}) \
ORDER BY timestamp LIMIT {}",
symbol, start_timestamp, limit
);
return Ok(query);
}
let interval = match timeframe {
TimeFrame::Minute => "1 MINUTE", TimeFrame::Hour => "1 HOUR",
TimeFrame::Day => "1 DAY",
TimeFrame::Week => "1 WEEK",
TimeFrame::Month => "1 MONTH",
_ => {
return Err(ChainError::ClickHouseError(format!(
"Unsupported timeframe: {:?}",
timeframe
)));
}
};
let query = format!(
"WITH intervals AS (
SELECT
symbol,
toStartOfInterval(timestamp, INTERVAL {}) as interval_start,
any(open) as open,
max(high) as high,
min(low) as low,
any(close) as close,
sum(volume) as volume
FROM ohlcv
WHERE symbol = '{}' \
AND timestamp >= FROM_UNIXTIME({}) \
GROUP BY symbol, interval_start
ORDER BY interval_start
)
SELECT
symbol,
toInt64(toUnixTimestamp(interval_start)) as timestamp,
open, high, low, close, volume
FROM intervals LIMIT {}",
interval, symbol, start_timestamp, limit
);
Ok(query)
}
async fn execute_query(&self, query: String) -> Result<Vec<OHLCVData>, ChainError> {
debug!("Executing ClickHouse query: {}", query);
let rows: Vec<ClickHouseRow> = self.client.query(&query).fetch_all().await?;
let mut results = Vec::new();
for row in rows {
results.push(row.into());
}
Ok(results)
}
pub fn extract_prices(&self, data: &[OHLCVData], price_type: PriceType) -> Vec<Positive> {
data.iter()
.map(|ohlcv| match price_type {
PriceType::Open => ohlcv.open,
PriceType::High => ohlcv.high,
PriceType::Low => ohlcv.low,
PriceType::Close => ohlcv.close,
PriceType::Typical => {
let sum = ohlcv.high + ohlcv.low + ohlcv.close;
sum / Decimal::from(3)
}
})
.collect()
}
pub fn get_config(&mut self) -> &mut ClickHouseConfig {
&mut self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use optionstratlib::{Positive, pos};
use rust_decimal::Decimal;
#[test]
fn test_build_timeframe_query_minute() {
let _config = ClickHouseConfig {
host: "test-host".to_string(),
port: 8123,
username: "test-user".to_string(),
password: "test-pass".to_string(),
database: "test-db".to_string(),
};
fn build_timeframe_query(
symbol: &str,
timeframe: &TimeFrame,
start_date: &DateTime<Utc>,
end_date: &DateTime<Utc>,
) -> Result<String, ChainError> {
let start_date_str = start_date.format("%Y-%m-%d %H:%M:%S").to_string();
let end_date_str = end_date.format("%Y-%m-%d %H:%M:%S").to_string();
if *timeframe == TimeFrame::Minute {
return Ok(format!(
"SELECT symbol, timestamp, open, high, low, close, volume \
FROM ohlcv \
WHERE symbol = '{}' \
AND timestamp BETWEEN '{}' AND '{}' \
ORDER BY timestamp",
symbol, start_date_str, end_date_str
));
}
let interval = match timeframe {
TimeFrame::Minute => "1 MINUTE",
TimeFrame::Hour => "1 HOUR",
TimeFrame::Day => "1 DAY",
TimeFrame::Week => "1 WEEK",
TimeFrame::Month => "1 MONTH",
_ => {
return Err(ChainError::ClickHouseError(format!(
"Unsupported timeframe: {:?}",
timeframe
)));
}
};
Ok(format!(
"SELECT
symbol,
toStartOfInterval(timestamp, INTERVAL {}) as timestamp,
any(open) as open,
max(high) as high,
min(low) as low,
any(arrayElement(
groupArray(close),
length(groupArray(close))
)) as close,
sum(volume) as volume
FROM ohlcv
WHERE symbol = '{}'
AND timestamp BETWEEN '{}' AND '{}'
GROUP BY symbol, timestamp
ORDER BY timestamp",
interval, symbol, start_date_str, end_date_str
))
}
let symbol = "AAPL";
let timeframe = TimeFrame::Minute;
let start_date = Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
let end_date = Utc.with_ymd_and_hms(2023, 1, 2, 0, 0, 0).unwrap();
let query = build_timeframe_query(symbol, &timeframe, &start_date, &end_date).unwrap();
assert!(query.contains("SELECT symbol, timestamp, open, high, low, close, volume"));
assert!(query.contains("FROM ohlcv"));
assert!(query.contains("WHERE symbol = 'AAPL'"));
assert!(
query.contains("AND timestamp BETWEEN '2023-01-01 00:00:00' AND '2023-01-02 00:00:00'")
);
assert!(query.contains("ORDER BY timestamp"));
}
#[test]
fn test_build_timeframe_query_day() {
fn build_timeframe_query(
symbol: &str,
timeframe: &TimeFrame,
start_date: &DateTime<Utc>,
end_date: &DateTime<Utc>,
) -> Result<String, ChainError> {
let start_date_str = start_date.format("%Y-%m-%d %H:%M:%S").to_string();
let end_date_str = end_date.format("%Y-%m-%d %H:%M:%S").to_string();
if *timeframe == TimeFrame::Minute {
return Ok(format!(
"SELECT symbol, timestamp, open, high, low, close, volume \
FROM ohlcv \
WHERE symbol = '{}' \
AND timestamp BETWEEN '{}' AND '{}' \
ORDER BY timestamp",
symbol, start_date_str, end_date_str
));
}
let interval = match timeframe {
TimeFrame::Minute => "1 MINUTE",
TimeFrame::Hour => "1 HOUR",
TimeFrame::Day => "1 DAY",
TimeFrame::Week => "1 WEEK",
TimeFrame::Month => "1 MONTH",
_ => {
return Err(ChainError::ClickHouseError(format!(
"Unsupported timeframe: {:?}",
timeframe
)));
}
};
Ok(format!(
"SELECT
symbol,
toStartOfInterval(timestamp, INTERVAL {}) as timestamp,
any(open) as open,
max(high) as high,
min(low) as low,
any(arrayElement(
groupArray(close),
length(groupArray(close))
)) as close,
sum(volume) as volume
FROM ohlcv
WHERE symbol = '{}'
AND timestamp BETWEEN '{}' AND '{}'
GROUP BY symbol, timestamp
ORDER BY timestamp",
interval, symbol, start_date_str, end_date_str
))
}
let symbol = "AAPL";
let timeframe = TimeFrame::Day;
let start_date = Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
let end_date = Utc.with_ymd_and_hms(2023, 1, 31, 0, 0, 0).unwrap();
let query = build_timeframe_query(symbol, &timeframe, &start_date, &end_date).unwrap();
assert!(query.contains("toStartOfInterval(timestamp, INTERVAL 1 DAY)"));
assert!(query.contains("GROUP BY symbol, timestamp"));
assert!(query.contains("max(high) as high"));
assert!(query.contains("min(low) as low"));
assert!(query.contains("sum(volume) as volume"));
}
#[test]
fn test_extract_prices() {
fn extract_prices(data: &[OHLCVData], price_type: PriceType) -> Vec<Positive> {
data.iter()
.map(|ohlcv| match price_type {
PriceType::Open => ohlcv.open,
PriceType::High => ohlcv.high,
PriceType::Low => ohlcv.low,
PriceType::Close => ohlcv.close,
PriceType::Typical => {
let sum = ohlcv.high + ohlcv.low + ohlcv.close;
sum / Decimal::from(3)
}
})
.collect()
}
let data = vec![
OHLCVData {
symbol: "AAPL".to_string(),
timestamp: Utc.with_ymd_and_hms(2023, 1, 1, 10, 0, 0).unwrap(),
open: pos!(150.0),
high: pos!(155.0),
low: pos!(149.0),
close: pos!(153.0),
volume: 10000,
},
OHLCVData {
symbol: "AAPL".to_string(),
timestamp: Utc.with_ymd_and_hms(2023, 1, 1, 11, 0, 0).unwrap(),
open: pos!(153.0),
high: pos!(157.0),
low: pos!(152.0),
close: pos!(156.0),
volume: 15000,
},
];
let open_prices = extract_prices(&data, PriceType::Open);
let high_prices = extract_prices(&data, PriceType::High);
let low_prices = extract_prices(&data, PriceType::Low);
let close_prices = extract_prices(&data, PriceType::Close);
let typical_prices = extract_prices(&data, PriceType::Typical);
assert_eq!(open_prices, vec![pos!(150.0), pos!(153.0)]);
assert_eq!(high_prices, vec![pos!(155.0), pos!(157.0)]);
assert_eq!(low_prices, vec![pos!(149.0), pos!(152.0)]);
assert_eq!(close_prices, vec![pos!(153.0), pos!(156.0)]);
let expected_typical_1 = (pos!(155.0) + pos!(149.0) + pos!(153.0)) / Decimal::from(3);
let expected_typical_2 = (pos!(157.0) + pos!(152.0) + pos!(156.0)) / Decimal::from(3);
assert_eq!(typical_prices[0], expected_typical_1);
assert_eq!(typical_prices[1], expected_typical_2);
}
}