use serde::{Deserialize, Serialize}; use std::path::Path; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Candle { #[serde(rename = "Date")] pub date: String, #[serde(rename = "Open")] pub open: f64, #[serde(rename = "High")] pub high: f64, #[serde(rename = "Low")] pub low: f64, #[serde(rename = "Close")] pub close: f64, #[serde(rename = "Volume")] pub volume: f64, } /// Read `Date,Open,High,Low,Close,Volume` rows; dates must be `YYYY-MM-DD`. /// Rows are sorted ascending by date; duplicate dates are an error. Rows whose Open/Close lie /// outside High/Low are repaired (High/Low widened) rather than rejected. pub fn load_csv(path: &Path) -> Result, String> { let mut reader = csv::Reader::from_path(path).map_err(|e| format!("{}: {e}", path.display()))?; let mut candles = Vec::new(); let mut repaired = 0usize; for (i, row) in reader.deserialize::().enumerate() { let line = i + 2; // header is line 1 let mut c = row.map_err(|e| format!("{}: line {line}: {e}", path.display()))?; if !valid_date(&c.date) { return Err(format!("{}: line {line}: bad date '{}', expected YYYY-MM-DD", path.display(), c.date)); } let prices = [c.open, c.high, c.low, c.close]; if prices.iter().any(|p| !p.is_finite() || *p <= 0.0) { return Err(format!("{}: line {line}: prices must be positive numbers", path.display())); } // Yahoo history has stray rows (e.g. the close outside the day's range). Keep the day but // widen High/Low so they include Open and Close; the count is reported below. let (hi, lo) = (prices.iter().cloned().fold(f64::MIN, f64::max), prices.iter().cloned().fold(f64::MAX, f64::min)); if hi != c.high || lo != c.low { repaired += 1; c.high = hi; c.low = lo; } candles.push(c); } if repaired > 0 { eprintln!("note: {}: {repaired} rows had Open/Close outside High/Low; High/Low widened to include them", path.display()); } candles.sort_by(|a, b| a.date.cmp(&b.date)); if let Some(w) = candles.windows(2).find(|w| w[0].date == w[1].date) { return Err(format!("{}: duplicate date {}", path.display(), w[0].date)); } if candles.is_empty() { return Err(format!("{}: no data rows", path.display())); } Ok(candles) } fn valid_date(s: &str) -> bool { let b = s.as_bytes(); b.len() == 10 && b[4] == b'-' && b[7] == b'-' && b.iter().enumerate().all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit()) } #[cfg(test)] mod tests { use super::*; use std::io::Write; fn load(body: &str) -> Result, String> { let path = std::env::temp_dir().join(format!("stock-test-{}-{}.csv", std::process::id(), body.len())); std::fs::File::create(&path).unwrap().write_all(body.as_bytes()).unwrap(); let r = load_csv(&path); let _ = std::fs::remove_file(&path); r } #[test] fn widens_high_low_instead_of_rejecting() { // close 67 below low 71.6, as in real Yahoo data let c = load("Date,Open,High,Low,Close,Volume\n2016-07-12,71.9,72.4,71.6,67,100\n").unwrap(); assert_eq!((c[0].high, c[0].low, c[0].close), (72.4, 67.0, 67.0)); } #[test] fn rejects_non_positive_prices_and_bad_dates() { assert!(load("Date,Open,High,Low,Close,Volume\n2016-07-12,0,1,1,1,100\n").is_err()); assert!(load("Date,Open,High,Low,Close,Volume\n12/07/2016,1,1,1,1,100\n").is_err()); } }