summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/data.rs49
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());
+ }
+}