use crate::error::DataError;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::{Read, Write};
#[derive(Debug, Clone, PartialEq)]
pub struct OhlcvRow {
pub day: i32,
pub adj_open: f64,
pub adj_high: f64,
pub adj_low: f64,
pub adj_close: f64,
pub volume: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Field {
AdjOpen,
AdjHigh,
AdjLow,
AdjClose,
Volume,
}
impl Field {
fn col(self) -> usize {
match self {
Field::AdjOpen => 1,
Field::AdjHigh => 2,
Field::AdjLow => 3,
Field::AdjClose => 4,
Field::Volume => 5,
}
}
}
fn date_to_i32(s: &str) -> Result<i32, DataError> {
s.replace('-', "")
.parse()
.map_err(|_| DataError::Parse(format!("bad date '{s}'")))
}
fn i32_to_date(d: i32) -> String {
format!("{:04}-{:02}-{:02}", d / 10000, d / 100 % 100, d % 100)
}
pub fn parse_series(gz: &[u8], field: Field) -> Result<Vec<(i32, f64)>, DataError> {
let mut text = String::new();
GzDecoder::new(gz)
.read_to_string(&mut text)
.map_err(|e| DataError::Io(e.to_string()))?;
let col = field.col();
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("day") {
continue;
}
let cells: Vec<&str> = line.split(',').collect();
if cells.len() <= col {
return Err(DataError::Parse(format!(
"row has {} cells, need > {col}",
cells.len()
)));
}
let day = date_to_i32(cells[0].trim())?;
let val: f64 = cells[col]
.trim()
.parse()
.map_err(|_| DataError::Parse(format!("bad value '{}'", cells[col])))?;
out.push((day, val));
}
Ok(out)
}
pub fn write_series(rows: &[OhlcvRow]) -> Result<Vec<u8>, DataError> {
let mut buf = String::from("day,adj_open,adj_high,adj_low,adj_close,volume\n");
for r in rows {
buf.push_str(&format!(
"{},{},{},{},{},{}\n",
i32_to_date(r.day),
r.adj_open,
r.adj_high,
r.adj_low,
r.adj_close,
r.volume
));
}
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
enc.write_all(buf.as_bytes())
.map_err(|e| DataError::Io(e.to_string()))?;
enc.finish().map_err(|e| DataError::Io(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn row(day: i32, c: f64) -> OhlcvRow {
OhlcvRow {
day,
adj_open: c - 0.5,
adj_high: c + 1.0,
adj_low: c - 1.0,
adj_close: c,
volume: 1000.0,
}
}
#[test]
fn round_trips_and_selects_fields() {
let rows = vec![row(20240102, 10.5), row(20240103, 11.25)];
let gz = write_series(&rows).unwrap();
assert_eq!(
parse_series(&gz, Field::AdjClose).unwrap(),
vec![(20240102, 10.5), (20240103, 11.25)]
);
assert_eq!(
parse_series(&gz, Field::AdjHigh).unwrap(),
vec![(20240102, 11.5), (20240103, 12.25)]
);
assert_eq!(
parse_series(&gz, Field::AdjLow).unwrap(),
vec![(20240102, 9.5), (20240103, 10.25)]
);
assert_eq!(
parse_series(&gz, Field::AdjOpen).unwrap(),
vec![(20240102, 10.0), (20240103, 10.75)]
);
assert_eq!(
parse_series(&gz, Field::Volume).unwrap(),
vec![(20240102, 1000.0), (20240103, 1000.0)]
);
}
fn gz(text: &str) -> Vec<u8> {
let mut e = GzEncoder::new(Vec::new(), Compression::default());
e.write_all(text.as_bytes()).unwrap();
e.finish().unwrap()
}
#[test]
fn parse_errors_on_non_gzip_bad_value_bad_date_and_short_row() {
let hdr = "day,adj_open,adj_high,adj_low,adj_close,volume\n";
assert!(parse_series(b"not gzip", Field::AdjClose).is_err());
assert!(parse_series(
&gz(&format!("{hdr}2024-01-02,1,2,3,bad,5\n")),
Field::AdjClose
)
.is_err());
assert!(parse_series(&gz(&format!("{hdr}bad-date,1,2,3,4,5\n")), Field::AdjClose).is_err());
assert!(parse_series(&gz(&format!("{hdr}2024-01-02,1,2,3,4\n")), Field::Volume).is_err());
}
}