use crate::FixedPoint;
use crate::trade::Tick;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LoaderError {
#[error("File I/O error for {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("CSV parse error at line {line} in {path}: {source}")]
CsvParse {
path: String,
line: usize,
#[source]
source: csv::Error,
},
#[error("Fixed-point conversion error at line {line} in {path}: {source}")]
FixedPoint {
path: String,
line: usize,
#[source]
source: crate::fixed_point::FixedPointError,
},
#[error("Record count mismatch in {path}: expected {expected}, got {actual}")]
CountMismatch {
path: String,
expected: usize,
actual: usize,
},
}
#[derive(Debug, serde::Deserialize)]
struct TickRecord {
a: i64,
p: String,
q: String,
f: i64,
l: i64,
#[serde(rename = "T")]
timestamp_ms: i64,
m: String,
}
impl TickRecord {
fn into_tick(self) -> Result<Tick, crate::fixed_point::FixedPointError> {
Ok(Tick {
ref_id: self.a,
price: FixedPoint::from_str(&self.p)?,
volume: FixedPoint::from_str(&self.q)?,
first_sub_id: self.f,
last_sub_id: self.l,
timestamp: self.timestamp_ms,
is_buyer_maker: self.m == "True",
is_best_match: None, best_bid: None,
best_ask: None,
})
}
}
pub fn load_btcusdt_test_data() -> Result<Vec<Tick>, LoaderError> {
let path = workspace_test_data_path("BTCUSDT/BTCUSDT_aggTrades_20250901.csv");
load_test_data(path, 5000)
}
pub fn load_ethusdt_test_data() -> Result<Vec<Tick>, LoaderError> {
let path = workspace_test_data_path("ETHUSDT/ETHUSDT_aggTrades_20250901.csv");
load_test_data(path, 10000)
}
fn workspace_test_data_path(relative_path: &str) -> std::path::PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace_root = std::path::Path::new(manifest_dir)
.parent() .unwrap()
.parent() .unwrap();
workspace_root.join("test_data").join(relative_path)
}
fn workspace_fixtures_path(relative_path: &str) -> std::path::PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace_root = std::path::Path::new(manifest_dir)
.parent() .unwrap()
.parent() .unwrap();
workspace_root
.join("tests")
.join("fixtures")
.join(relative_path)
}
#[derive(Debug, serde::Deserialize)]
struct TickRecordHeaderless {
ref_id: i64,
price: String,
quantity: String,
first_sub_id: i64,
last_sub_id: i64,
timestamp: i64,
is_buyer_maker: String,
is_best_match: String,
}
impl TickRecordHeaderless {
fn into_tick(self) -> Result<Tick, crate::fixed_point::FixedPointError> {
Ok(Tick {
ref_id: self.ref_id,
price: FixedPoint::from_str(&self.price)?,
volume: FixedPoint::from_str(&self.quantity)?,
first_sub_id: self.first_sub_id,
last_sub_id: self.last_sub_id,
timestamp: self.timestamp,
is_buyer_maker: self.is_buyer_maker == "True",
is_best_match: Some(self.is_best_match == "True"),
best_bid: None,
best_ask: None,
})
}
}
pub fn load_real_btcusdt_10k() -> Result<Vec<Tick>, LoaderError> {
let path = workspace_fixtures_path("BTCUSDT-aggTrades-sample-10k.csv");
load_headerless_data(path, 10001)
}
pub fn load_real_ethusdt_10k() -> Result<Vec<Tick>, LoaderError> {
let path = workspace_fixtures_path("ETHUSDT-aggTrades-sample-10k.csv");
load_headerless_data(path, 10001)
}
#[derive(Debug, serde::Deserialize)]
struct CloseRecord {
close: f64,
}
#[derive(Debug, serde::Deserialize)]
struct CloseDurRecord {
close: f64,
duration_us: f64,
}
pub fn load_real_btcusdt_bar_closes() -> Result<Vec<f64>, LoaderError> {
let path = workspace_fixtures_path("BTCUSDT-bars-close-sample-10k.csv");
load_close_series(path, 10000)
}
pub fn load_real_ethusdt_bar_closes() -> Result<Vec<f64>, LoaderError> {
let path = workspace_fixtures_path("ETHUSDT-bars-close-sample-10k.csv");
load_close_series(path, 10000)
}
pub fn load_real_btcusdt_bar_close_durs() -> Result<(Vec<f64>, Vec<f64>), LoaderError> {
let path = workspace_fixtures_path("BTCUSDT-bars-close-dur-sample-10k.csv");
load_close_dur_series(path, 10000)
}
pub fn load_real_ethusdt_bar_close_durs() -> Result<(Vec<f64>, Vec<f64>), LoaderError> {
let path = workspace_fixtures_path("ETHUSDT-bars-close-dur-sample-10k.csv");
load_close_dur_series(path, 10000)
}
#[must_use]
pub fn sha256_hex(data: &[u8]) -> String {
#[allow(clippy::unreadable_literal, clippy::many_single_char_names)]
fn digest(data: &[u8]) -> [u8; 32] {
const K: [u32; 64] = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
0xc67178f2,
];
let mut h: [u32; 8] = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
0x5be0cd19,
];
let bit_len = (data.len() as u64).wrapping_mul(8);
let mut msg = data.to_vec();
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&bit_len.to_be_bytes());
for block in msg.chunks_exact(64) {
let mut w = [0u32; 64];
for (i, word) in block.chunks_exact(4).enumerate() {
w[i] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]);
}
for i in 16..64 {
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16]
.wrapping_add(s0)
.wrapping_add(w[i - 7])
.wrapping_add(s1);
}
let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
for i in 0..64 {
let big_s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
let ch = (e & f) ^ ((!e) & g);
let t1 = hh
.wrapping_add(big_s1)
.wrapping_add(ch)
.wrapping_add(K[i])
.wrapping_add(w[i]);
let big_s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
let maj = (a & b) ^ (a & c) ^ (b & c);
let t2 = big_s0.wrapping_add(maj);
hh = g;
g = f;
f = e;
e = d.wrapping_add(t1);
d = c;
c = b;
b = a;
a = t1.wrapping_add(t2);
}
for (slot, v) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {
*slot = slot.wrapping_add(v);
}
}
let mut out = [0u8; 32];
for (i, word) in h.iter().enumerate() {
out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
}
out
}
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(64);
for byte in digest(data) {
s.push(HEX[(byte >> 4) as usize] as char);
s.push(HEX[(byte & 0x0f) as usize] as char);
}
s
}
fn load_close_series<P: AsRef<Path>>(
path: P,
expected_count: usize,
) -> Result<Vec<f64>, LoaderError> {
let path_str = path.as_ref().to_string_lossy().to_string();
let file = std::fs::File::open(&path).map_err(|e| LoaderError::Io {
path: path_str.clone(),
source: e,
})?;
let csv_buffer_size = (expected_count * 16).max(64 * 1024).min(2 * 1024 * 1024);
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.buffer_capacity(csv_buffer_size)
.from_reader(file);
let mut closes = Vec::with_capacity(expected_count);
for (line, result) in (1..).zip(reader.deserialize()) {
let record: CloseRecord = result.map_err(|e| LoaderError::CsvParse {
path: path_str.clone(),
line,
source: e,
})?;
closes.push(record.close);
}
let actual_count = closes.len();
if actual_count != expected_count {
return Err(LoaderError::CountMismatch {
path: path_str,
expected: expected_count,
actual: actual_count,
});
}
Ok(closes)
}
fn load_close_dur_series<P: AsRef<Path>>(
path: P,
expected_count: usize,
) -> Result<(Vec<f64>, Vec<f64>), LoaderError> {
let path_str = path.as_ref().to_string_lossy().to_string();
let file = std::fs::File::open(&path).map_err(|e| LoaderError::Io {
path: path_str.clone(),
source: e,
})?;
let csv_buffer_size = (expected_count * 32).max(64 * 1024).min(2 * 1024 * 1024);
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.buffer_capacity(csv_buffer_size)
.from_reader(file);
let mut closes = Vec::with_capacity(expected_count);
let mut durations = Vec::with_capacity(expected_count);
for (line, result) in (1..).zip(reader.deserialize()) {
let record: CloseDurRecord = result.map_err(|e| LoaderError::CsvParse {
path: path_str.clone(),
line,
source: e,
})?;
closes.push(record.close);
durations.push(record.duration_us);
}
let actual_count = closes.len();
if actual_count != expected_count {
return Err(LoaderError::CountMismatch {
path: path_str,
expected: expected_count,
actual: actual_count,
});
}
Ok((closes, durations))
}
fn load_headerless_data<P: AsRef<Path>>(
path: P,
expected_count: usize,
) -> Result<Vec<Tick>, LoaderError> {
let path_str = path.as_ref().to_string_lossy().to_string();
let file = std::fs::File::open(&path).map_err(|e| LoaderError::Io {
path: path_str.clone(),
source: e,
})?;
let csv_buffer_size = (expected_count * 100).max(64 * 1024).min(2 * 1024 * 1024);
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.buffer_capacity(csv_buffer_size)
.from_reader(file);
let mut trades = Vec::with_capacity(expected_count);
for (line, result) in (1..).zip(reader.deserialize()) {
let record: TickRecordHeaderless = result.map_err(|e| LoaderError::CsvParse {
path: path_str.clone(),
line,
source: e,
})?;
let trade = record.into_tick().map_err(|e| LoaderError::FixedPoint {
path: path_str.clone(),
line,
source: e,
})?;
trades.push(trade);
}
let actual_count = trades.len();
if actual_count != expected_count {
return Err(LoaderError::CountMismatch {
path: path_str,
expected: expected_count,
actual: actual_count,
});
}
Ok(trades)
}
fn load_test_data<P: AsRef<Path>>(
path: P,
expected_count: usize,
) -> Result<Vec<Tick>, LoaderError> {
let path_str = path.as_ref().to_string_lossy().to_string();
let file = std::fs::File::open(&path).map_err(|e| LoaderError::Io {
path: path_str.clone(),
source: e,
})?;
let csv_buffer_size = (expected_count * 100).max(64 * 1024).min(2 * 1024 * 1024);
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.buffer_capacity(csv_buffer_size)
.from_reader(file);
let mut trades = Vec::with_capacity(expected_count);
for (line, result) in (2..).zip(reader.deserialize()) {
let record: TickRecord = result.map_err(|e| LoaderError::CsvParse {
path: path_str.clone(),
line,
source: e,
})?;
let trade = record.into_tick().map_err(|e| LoaderError::FixedPoint {
path: path_str.clone(),
line,
source: e,
})?;
trades.push(trade);
}
let actual_count = trades.len();
if actual_count != expected_count {
return Err(LoaderError::CountMismatch {
path: path_str,
expected: expected_count,
actual: actual_count,
});
}
Ok(trades)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_btcusdt_data() {
let trades = load_btcusdt_test_data().expect("Failed to load BTCUSDT test data");
assert_eq!(
trades.len(),
5000,
"BTCUSDT should have exactly 5000 trades"
);
let first = &trades[0];
assert_eq!(first.ref_id, 1);
assert_eq!(first.price.to_string(), "50014.00859087");
assert_eq!(first.volume.to_string(), "0.12019569");
assert_eq!(first.first_sub_id, 1);
assert_eq!(first.last_sub_id, 1);
assert_eq!(first.timestamp, 1756710002083);
assert!(!first.is_buyer_maker);
}
#[test]
fn test_load_ethusdt_data() {
let trades = load_ethusdt_test_data().expect("Failed to load ETHUSDT test data");
assert_eq!(
trades.len(),
10000,
"ETHUSDT should have exactly 10000 trades"
);
for trade in &trades {
assert!(trade.price.0 > 0, "Price must be positive");
assert!(trade.volume.0 > 0, "Volume must be positive");
assert!(trade.timestamp > 0, "Timestamp must be positive");
}
}
#[test]
fn test_temporal_integrity() {
let trades = load_btcusdt_test_data().unwrap();
for i in 1..trades.len() {
assert!(
trades[i].timestamp >= trades[i - 1].timestamp,
"Temporal integrity violation at trade {}: {} < {}",
i,
trades[i].timestamp,
trades[i - 1].timestamp
);
}
}
}