diff options
Diffstat (limited to 'src/data.rs')
| -rw-r--r-- | src/data.rs | 49 |
1 files changed, 45 insertions, 4 deletions
diff --git a/src/data.rs b/src/data.rs index c5fc18c..24a80ef 100644 --- a/src/data.rs +++ b/src/data.rs @@ -18,21 +18,35 @@ pub struct Candle { } /// 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 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<Vec<Candle>, 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::<Candle>().enumerate() { let line = i + 2; // header is line 1 - let c = row.map_err(|e| format!("{}: line {line}: {e}", path.display()))?; + 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)); } - if c.high < c.low || !(c.low..=c.high).contains(&c.close) { - return Err(format!("{}: line {line}: inconsistent High/Low/Close", path.display())); + 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)); @@ -50,3 +64,30 @@ fn valid_date(s: &str) -> bool { && 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<Vec<Candle>, 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()); + } +} |
