summaryrefslogtreecommitdiff
path: root/src/data.rs
blob: 24a80ef391d94e2072b29f11862f89cdfb135bf3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
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<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 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<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());
    }
}