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());
}
}
|