use crate::infrastructure::ClickHouseConfig;
use crate::infrastructure::clickhouse::model::{ClickHouseRow, OHLCVData, PriceType};
use crate::infrastructure::clickhouse::validate_symbol;
use crate::utils::ChainError;
use chrono::{DateTime, Utc};
use clickhouse::Client;
use clickhouse::query::Query;
use optionstratlib::utils::TimeFrame;
use positive::Positive;
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),
}
}
}
fn timeframe_query_template(timeframe: &TimeFrame) -> Result<String, ChainError> {
if *timeframe == TimeFrame::Minute {
return Ok(
"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 ?"
.to_string(),
);
}
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!(
"WITH intervals AS (
SELECT
symbol,
toStartOfInterval(timestamp, INTERVAL {}) as interval_start,
argMin(open, timestamp) as open,
max(high) as high,
min(low) as low,
argMax(close, timestamp) 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
))
}
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> {
validate_symbol(symbol)?;
debug!(
symbol = %symbol,
limit,
?timeframe,
"fetching historical prices"
);
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> {
validate_symbol(symbol)?;
debug!(
symbol = %symbol,
limit,
?timeframe,
"fetching OHLCV data"
);
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<Query, ChainError> {
validate_symbol(symbol)?;
let sql = timeframe_query_template(timeframe)?;
let start_timestamp = start_date.timestamp();
debug!(symbol = %symbol, sql = %sql, "running timeframe query");
let query = self
.client
.query(&sql)
.bind(symbol)
.bind(start_timestamp)
.bind(limit as u64);
Ok(query)
}
async fn execute_query(&self, query: Query) -> Result<Vec<OHLCVData>, ChainError> {
let rows: Vec<ClickHouseRow> = 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 positive::{Positive, pos_or_panic};
use rust_decimal::Decimal;
#[test]
fn test_timeframe_query_template_minute_uses_placeholders() {
let sql = timeframe_query_template(&TimeFrame::Minute).unwrap();
assert!(sql.contains("WHERE symbol = ?"));
assert!(sql.contains("FROM_UNIXTIME(?)"));
assert!(sql.contains("LIMIT ?"));
assert!(sql.contains("FROM ohlcv"));
assert!(!sql.contains("EVIL'--"));
assert!(!sql.contains('\''));
}
#[test]
fn test_timeframe_query_template_day_uses_placeholders() {
let sql = timeframe_query_template(&TimeFrame::Day).unwrap();
assert!(sql.contains("toStartOfInterval(timestamp, INTERVAL 1 DAY)"));
assert!(sql.contains("GROUP BY symbol, interval_start"));
assert!(sql.contains("max(high) as high"));
assert!(sql.contains("min(low) as low"));
assert!(sql.contains("sum(volume) as volume"));
assert!(sql.contains("WHERE symbol = ?"));
assert!(sql.contains("FROM_UNIXTIME(?)"));
assert!(sql.contains("LIMIT ?"));
assert!(!sql.contains("EVIL'--"));
assert!(!sql.contains('\''));
}
#[test]
fn test_timeframe_query_template_aggregates_are_deterministic() {
for tf in [
TimeFrame::Hour,
TimeFrame::Day,
TimeFrame::Week,
TimeFrame::Month,
] {
let sql = timeframe_query_template(&tf).unwrap();
assert!(
sql.contains("argMin(open, timestamp) as open"),
"{tf:?}: open must be the earliest row in the interval"
);
assert!(
sql.contains("argMax(close, timestamp) as close"),
"{tf:?}: close must be the latest row in the interval"
);
assert!(
!sql.contains("any("),
"{tf:?}: nondeterministic any() must not appear"
);
}
}
#[test]
fn test_timeframe_query_template_unsupported_timeframe() {
let result = timeframe_query_template(&TimeFrame::Year);
assert!(matches!(result, Err(ChainError::ClickHouseError(_))));
}
#[test]
fn test_build_timeframe_query_rejects_invalid_symbol() {
let client = ClickHouseClient::new(ClickHouseConfig {
host: "test-host".to_string(),
port: 8123,
username: "test-user".to_string(),
password: "test-pass".to_string(),
database: "test-db".to_string(),
})
.unwrap();
let start_date = Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
let result = client.build_timeframe_query("EVIL'--", &TimeFrame::Minute, &start_date, 10);
assert!(matches!(result, Err(ChainError::ClickHouseError(_))));
}
#[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_or_panic!(150.0),
high: pos_or_panic!(155.0),
low: pos_or_panic!(149.0),
close: pos_or_panic!(153.0),
volume: 10000,
},
OHLCVData {
symbol: "AAPL".to_string(),
timestamp: Utc.with_ymd_and_hms(2023, 1, 1, 11, 0, 0).unwrap(),
open: pos_or_panic!(153.0),
high: pos_or_panic!(157.0),
low: pos_or_panic!(152.0),
close: pos_or_panic!(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_or_panic!(150.0), pos_or_panic!(153.0)]
);
assert_eq!(
high_prices,
vec![pos_or_panic!(155.0), pos_or_panic!(157.0)]
);
assert_eq!(low_prices, vec![pos_or_panic!(149.0), pos_or_panic!(152.0)]);
assert_eq!(
close_prices,
vec![pos_or_panic!(153.0), pos_or_panic!(156.0)]
);
let expected_typical_1 =
(pos_or_panic!(155.0) + pos_or_panic!(149.0) + pos_or_panic!(153.0)) / Decimal::from(3);
let expected_typical_2 =
(pos_or_panic!(157.0) + pos_or_panic!(152.0) + pos_or_panic!(156.0)) / Decimal::from(3);
assert_eq!(typical_prices[0], expected_typical_1);
assert_eq!(typical_prices[1], expected_typical_2);
}
#[tokio::test]
#[ignore = "requires live ClickHouse on localhost:8123 (override via CLICKHOUSE_* env)"]
async fn test_hourly_aggregation_deterministic_against_live_clickhouse() {
let client = ClickHouseClient::new(ClickHouseConfig::default())
.expect("failed to build ClickHouse client");
client
.client
.query(
"CREATE TABLE IF NOT EXISTS ohlcv (\
symbol String, timestamp DateTime, \
open Float32, high Float32, low Float32, close Float32, \
volume UInt64) \
ENGINE = MergeTree ORDER BY (symbol, timestamp)",
)
.execute()
.await
.expect("failed to ensure ohlcv table");
let symbol = format!("OCSTEST{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
let base = 1_609_495_200_i64; let rows: [(i64, f32, f32, f32, f32, u64); 3] = [
(base + 30 * 60, 102.0, 110.0, 95.0, 103.0, 20),
(base + 59 * 60, 104.0, 108.0, 100.0, 107.0, 30),
(base, 100.0, 105.0, 99.0, 101.0, 10),
];
for (ts, open, high, low, close, volume) in rows {
client
.client
.query(
"INSERT INTO ohlcv \
(symbol, timestamp, open, high, low, close, volume) \
VALUES (?, FROM_UNIXTIME(?), ?, ?, ?, ?, ?)",
)
.bind(symbol.as_str())
.bind(ts)
.bind(open)
.bind(high)
.bind(low)
.bind(close)
.bind(volume)
.execute()
.await
.expect("failed to insert fixture row");
}
let start = Utc
.timestamp_opt(base, 0)
.single()
.expect("valid fixture timestamp");
let data = client
.fetch_ohlcv_data(&symbol, &TimeFrame::Hour, &start, 10)
.await
.expect("failed to fetch aggregated OHLCV data");
let _ = client
.client
.query("DELETE FROM ohlcv WHERE symbol = ?")
.bind(symbol.as_str())
.execute()
.await;
assert_eq!(data.len(), 1, "three minute rows form one hourly bucket");
let bucket = &data[0];
assert_eq!(
bucket.open,
pos_or_panic!(100.0),
"open is the row with the EARLIEST timestamp"
);
assert_eq!(
bucket.close,
pos_or_panic!(107.0),
"close is the row with the LATEST timestamp"
);
assert_eq!(bucket.high, pos_or_panic!(110.0));
assert_eq!(bucket.low, pos_or_panic!(95.0));
assert_eq!(bucket.volume, 60);
}
}