1use std::path::Path;
29
30use rustrade_core::Candle;
31use serde::Deserialize;
32
33use crate::error::{Error, Result};
34
35#[derive(Debug, Deserialize)]
36struct CandleRow {
37 time: i64,
38 open: f64,
39 high: f64,
40 low: f64,
41 close: f64,
42 volume: f64,
43}
44
45impl From<CandleRow> for Candle {
46 fn from(r: CandleRow) -> Self {
47 Self {
48 time: r.time,
49 open: r.open,
50 high: r.high,
51 low: r.low,
52 close: r.close,
53 volume: r.volume,
54 }
55 }
56}
57
58pub fn load_csv<P: AsRef<Path>>(path: P) -> Result<Vec<Candle>> {
61 let mut rdr = csv::ReaderBuilder::new()
62 .comment(Some(b'#'))
63 .flexible(false)
64 .from_path(path.as_ref())
65 .map_err(|e| Error::Config(format!("failed to open CSV: {e}")))?;
66
67 let mut out = Vec::new();
68 for (idx, row) in rdr.deserialize::<CandleRow>().enumerate() {
69 let row = row.map_err(|e| {
70 Error::Config(format!(
71 "CSV row {} parse error: {e}",
72 idx + 2 ))
74 })?;
75 let candle: Candle = row.into();
76 crate::engine::validate_candle(&candle)
77 .map_err(|why| Error::Data(format!("CSV row {}: {why}", idx + 2)))?;
78 out.push(candle);
79 }
80 Ok(out)
81}
82
83pub fn load_csv_str(s: &str) -> Result<Vec<Candle>> {
85 let mut rdr = csv::ReaderBuilder::new()
86 .comment(Some(b'#'))
87 .flexible(false)
88 .from_reader(s.as_bytes());
89
90 let mut out = Vec::new();
91 for (idx, row) in rdr.deserialize::<CandleRow>().enumerate() {
92 let row =
93 row.map_err(|e| Error::Config(format!("CSV row {} parse error: {e}", idx + 2)))?;
94 let candle: Candle = row.into();
95 crate::engine::validate_candle(&candle)
96 .map_err(|why| Error::Data(format!("CSV row {}: {why}", idx + 2)))?;
97 out.push(candle);
98 }
99 Ok(out)
100}
101
102pub fn sort_chronological(mut candles: Vec<Candle>) -> Vec<Candle> {
106 candles.sort_by_key(|c| c.time);
107 candles
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn load_csv_str_basic() {
116 let csv = "\
117time,open,high,low,close,volume
1181000,1.0,2.0,0.5,1.5,10.0
1192000,1.5,2.5,1.0,2.0,12.0
1203000,2.0,3.0,1.8,2.8,8.0
121";
122 let candles = load_csv_str(csv).unwrap();
123 assert_eq!(candles.len(), 3);
124 assert_eq!(candles[0].time, 1000);
125 assert_eq!(candles[0].open, 1.0);
126 assert_eq!(candles[2].close, 2.8);
127 }
128
129 #[test]
130 fn load_csv_str_skips_comments_and_blanks() {
131 let csv = "\
132time,open,high,low,close,volume
133# top comment
1341000,1.0,2.0,0.5,1.5,10.0
135# mid comment
1362000,1.5,2.5,1.0,2.0,12.0
137";
138 let candles = load_csv_str(csv).unwrap();
139 assert_eq!(candles.len(), 2);
140 }
141
142 #[test]
143 fn load_csv_str_rejects_malformed_row() {
144 let csv = "\
145time,open,high,low,close,volume
1461000,not-a-number,2.0,0.5,1.5,10.0
147";
148 let err = load_csv_str(csv).unwrap_err();
149 assert!(matches!(err, Error::Config(_)));
150 }
151
152 #[test]
153 fn load_csv_str_rejects_non_positive_price() {
154 let csv = "\
156time,open,high,low,close,volume
1571000,1.0,2.0,0.5,0.0,10.0
158";
159 let err = load_csv_str(csv).unwrap_err();
160 assert!(matches!(err, Error::Data(_)), "got {err:?}");
161 }
162
163 #[test]
164 fn load_csv_str_rejects_non_finite_price() {
165 let csv = "\
167time,open,high,low,close,volume
1681000,1.0,2.0,0.5,inf,10.0
169";
170 let err = load_csv_str(csv).unwrap_err();
171 assert!(matches!(err, Error::Data(_)), "got {err:?}");
172 }
173
174 #[test]
175 fn sort_chronological_reorders_descending_input() {
176 let candles = vec![
177 Candle {
178 time: 3000,
179 open: 0.0,
180 high: 0.0,
181 low: 0.0,
182 close: 0.0,
183 volume: 0.0,
184 },
185 Candle {
186 time: 1000,
187 open: 0.0,
188 high: 0.0,
189 low: 0.0,
190 close: 0.0,
191 volume: 0.0,
192 },
193 Candle {
194 time: 2000,
195 open: 0.0,
196 high: 0.0,
197 low: 0.0,
198 close: 0.0,
199 volume: 0.0,
200 },
201 ];
202 let sorted = sort_chronological(candles);
203 assert_eq!(sorted[0].time, 1000);
204 assert_eq!(sorted[1].time, 2000);
205 assert_eq!(sorted[2].time, 3000);
206 }
207}