1use super::bar::Bar;
8use super::bar_data::BarData;
9#[cfg(feature = "csv")]
10use chrono::DateTime;
11#[cfg(any(feature = "csv", test))]
12use chrono::Utc;
13
14#[cfg(feature = "csv")]
17const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S%.f";
18
19#[cfg(feature = "csv")]
21const HEADER: [&str; 6] = ["ts", "open", "high", "low", "close", "volume"];
22
23impl BarData {
24 #[cfg(feature = "csv")]
40 #[cfg_attr(docsrs, doc(cfg(feature = "csv")))]
41 pub fn from_csv(path: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
42 let mut rdr = csv::Reader::from_path(path)?;
43 let header = rdr.headers()?;
44 if !header
45 .iter()
46 .map(|name| name.trim().to_ascii_lowercase())
47 .eq(HEADER)
48 {
49 return Err(format!("Invalid CSV header: expected {}", HEADER.join(",")).into());
50 }
51 let mut bars = Vec::new();
52
53 for (line_num, result) in rdr.records().enumerate() {
54 let record =
55 result.map_err(|e| format!("Error reading CSV line {}: {}", line_num + 2, e))?;
56
57 if record.len() != 6 {
58 return Err(format!(
59 "Invalid CSV format at line {}: expected 6 fields, got {}",
60 line_num + 2,
61 record.len()
62 )
63 .into());
64 }
65
66 let ts = record
67 .get(0)
68 .ok_or_else(|| format!("Missing ts at line {}", line_num + 2))?;
69 let naive_dt = chrono::NaiveDateTime::parse_from_str(ts, TIME_FORMAT)
70 .or_else(|e| {
71 chrono::NaiveDate::parse_from_str(ts, "%Y-%m-%d")
72 .map(|date| date.and_time(chrono::NaiveTime::MIN))
73 .map_err(|_| e)
74 })
75 .map_err(|e| format!("Invalid ts '{}' at line {}: {}", ts, line_num + 2, e))?;
76 let ts = DateTime::<Utc>::from_naive_utc_and_offset(naive_dt, Utc);
77
78 let open: f64 = record[1]
79 .parse()
80 .map_err(|e| format!("Invalid open price at line {}: {}", line_num + 2, e))?;
81 let high: f64 = record[2]
82 .parse()
83 .map_err(|e| format!("Invalid high price at line {}: {}", line_num + 2, e))?;
84 let low: f64 = record[3]
85 .parse()
86 .map_err(|e| format!("Invalid low price at line {}: {}", line_num + 2, e))?;
87 let close: f64 = record[4]
88 .parse()
89 .map_err(|e| format!("Invalid close price at line {}: {}", line_num + 2, e))?;
90 let volume: f64 = record[5]
91 .parse()
92 .map_err(|e| format!("Invalid volume at line {}: {}", line_num + 2, e))?;
93
94 bars.push(Bar::new(ts, open, high, low, close, volume));
95 }
96
97 Ok(Self::from_bars(bars))
98 }
99
100 #[cfg(feature = "csv")]
115 #[cfg_attr(docsrs, doc(cfg(feature = "csv")))]
116 pub fn to_csv(&self, path: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
117 use std::fs::File;
118
119 let file = File::create(path)?;
120 let mut wtr = csv::Writer::from_writer(file);
121
122 wtr.write_record(HEADER)?;
124
125 for bar in &self.bars {
127 wtr.write_record([
128 bar.time.format(TIME_FORMAT).to_string(),
129 bar.open.to_string(),
130 bar.high.to_string(),
131 bar.low.to_string(),
132 bar.close.to_string(),
133 bar.volume.to_string(),
134 ])?;
135 }
136
137 wtr.flush()?;
138 Ok(())
139 }
140
141 #[cfg(feature = "csv")]
157 #[cfg_attr(docsrs, doc(cfg(feature = "csv")))]
158 pub fn to_csv_string(&self) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
159 let mut wtr = csv::Writer::from_writer(Vec::new());
160
161 wtr.write_record(HEADER)?;
163
164 for bar in &self.bars {
166 wtr.write_record([
167 bar.time.format(TIME_FORMAT).to_string(),
168 bar.open.to_string(),
169 bar.high.to_string(),
170 bar.low.to_string(),
171 bar.close.to_string(),
172 bar.volume.to_string(),
173 ])?;
174 }
175
176 let bytes = wtr.into_inner()?;
177 Ok(String::from_utf8(bytes)?)
178 }
179
180 #[cfg(feature = "csv")]
203 #[cfg_attr(docsrs, doc(cfg(feature = "csv")))]
204 pub fn to_csv_with_indicators(
205 &self,
206 indicators: &[(String, Vec<f64>)],
207 ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
208 let mut wtr = csv::Writer::from_writer(Vec::new());
209
210 let mut header: Vec<String> = HEADER.map(String::from).to_vec();
212 for (name, _) in indicators {
213 header.push(name.clone());
214 }
215 wtr.write_record(&header)?;
216
217 for (i, bar) in self.bars.iter().enumerate() {
219 let mut record: Vec<String> = vec![
220 bar.time.format(TIME_FORMAT).to_string(),
221 bar.open.to_string(),
222 bar.high.to_string(),
223 bar.low.to_string(),
224 bar.close.to_string(),
225 bar.volume.to_string(),
226 ];
227
228 for (_name, values) in indicators {
229 if i < values.len() {
230 record.push(values[i].to_string());
231 } else {
232 record.push(String::new());
233 }
234 }
235
236 wtr.write_record(&record)?;
237 }
238
239 let bytes = wtr.into_inner()?;
240 Ok(String::from_utf8(bytes)?)
241 }
242
243 pub fn to_json(&self, path: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
256 use std::fs::File;
257 use std::io::Write;
258
259 let json = serde_json::to_string_pretty(&self.bars)?;
260 let mut file = File::create(path)?;
261 file.write_all(json.as_bytes())?;
262 Ok(())
263 }
264
265 pub fn to_json_string(&self) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
275 Ok(serde_json::to_string_pretty(&self.bars)?)
276 }
277
278 pub fn from_json_string(json: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
289 let bars: Vec<Bar> = serde_json::from_str(json)?;
290 Ok(Self::from_bars(bars))
291 }
292
293 pub fn from_json(path: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
302 use std::fs;
303 let content = fs::read_to_string(path)?;
304 Self::from_json_string(&content)
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn test_json_round_trip() {
314 assert!(BarData::from_json_string("[]").unwrap().is_empty());
315 let now = Utc::now();
316 let mut bar = Bar::new(now, 100.0, 110.0, 95.0, 105.0, 1000.0);
317 bar.colors = Some(Box::new(crate::model::BarColors {
318 color: Some(egui::Color32::RED),
319 ..Default::default()
320 }));
321 bar.payload = Some(std::sync::Arc::new(serde_json::json!({ "id": 7 })));
322 let data = BarData::from_bars(vec![bar.clone(), Bar::whitespace(now)]);
323
324 let json = data.to_json_string().unwrap();
325 let parsed = BarData::from_json_string(&json).unwrap();
326
327 assert_eq!(parsed.len(), 2);
328 assert_eq!(parsed.bars[0], bar);
329 assert!(parsed.bars[1].is_whitespace());
331 }
332
333 #[test]
334 fn calendar_date_constructors_match_date_only_imports() {
335 #[cfg(feature = "csv")]
336 let path = temp_csv("calendar-dates");
337 for (year, month, day, midnight) in [
338 (1969, 12, 31, -86_400),
339 (2024, 2, 28, 1_709_078_400),
340 (2024, 2, 29, 1_709_164_800),
341 (2024, 3, 1, 1_709_251_200),
342 ] {
343 let date = chrono::NaiveDate::from_ymd_opt(year, month, day).unwrap();
344 let typed = [
345 Bar::from_date(date, 1.0, 2.0, 0.5, 1.5, 10.0),
346 Bar::from_value_on_date(date, 3.0),
347 Bar::whitespace_on_date(date),
348 ];
349 let json = format!(
350 r#"[{{"time":"{date}","open":1,"high":2,"low":0.5,"close":1.5,"volume":10}},
351 {{"time":"{date}","open":3,"high":3,"low":3,"close":3,"volume":0}},
352 {{"time":"{date}"}}]"#
353 );
354 let json = BarData::from_json_string(&json).unwrap();
355 #[cfg(feature = "csv")]
356 let csv = {
357 std::fs::write(
358 &path,
359 format!(
360 "ts,open,high,low,close,volume\n{date},1,2,0.5,1.5,10\n\
361 {date},3,3,3,3,0\n{date},NaN,NaN,NaN,NaN,NaN\n"
362 ),
363 )
364 .unwrap();
365 BarData::from_csv(&path).unwrap()
366 };
367 for bars in [
368 &typed[..],
369 &json.bars[..],
370 #[cfg(feature = "csv")]
371 &csv.bars[..],
372 ] {
373 assert_eq!(bars.len(), 3, "{date}");
374 for bar in bars {
375 assert_eq!(bar.time.timestamp(), midnight, "{date}");
376 assert_eq!(bar.time.timestamp_subsec_nanos(), 0, "{date}");
377 }
378 assert_eq!(bars[0], typed[0], "{date}");
379 assert_eq!(bars[1], typed[1], "{date}");
380 assert!(bars[2].is_whitespace(), "{date}");
381 }
382 }
383 #[cfg(feature = "csv")]
384 std::fs::remove_file(&path).unwrap();
385 }
386
387 #[test]
388 #[cfg(feature = "csv")]
389 fn test_csv_string_empty() {
390 let data = BarData::new();
391 let csv = data.to_csv_string().unwrap();
392 let lines: Vec<&str> = csv.trim().lines().collect();
393
394 assert_eq!(lines.len(), 1);
396 assert_eq!(lines[0], "ts,open,high,low,close,volume");
397 }
398
399 #[test]
400 #[cfg(feature = "csv")]
401 fn test_csv_with_indicators() {
402 let now = Utc::now();
403 let data = BarData::from_bars(vec![
404 Bar::new(now, 100.0, 110.0, 95.0, 105.0, 1000.0),
405 Bar::new(now, 105.0, 115.0, 100.0, 112.0, 1500.0),
406 Bar::new(now, 112.0, 120.0, 108.0, 118.0, 2000.0),
407 ]);
408
409 let indicators = vec![
410 ("SMA_20".to_string(), vec![105.0, 107.5]),
411 ("RSI_14".to_string(), vec![55.0, 60.0, 65.0]),
412 ];
413
414 let csv = data.to_csv_with_indicators(&indicators).unwrap();
415 let lines: Vec<&str> = csv.trim().lines().collect();
416
417 assert_eq!(lines.len(), 4);
419
420 assert!(lines[0].contains("SMA_20"));
422 assert!(lines[0].contains("RSI_14"));
423
424 let row3_fields: Vec<&str> = lines[3].split(',').collect();
427 assert_eq!(row3_fields.len(), 8); assert_eq!(row3_fields[6], ""); assert_eq!(row3_fields[7], "65"); }
431
432 #[test]
433 #[cfg(feature = "csv")]
434 fn test_csv_with_no_indicators() {
435 let now = Utc::now();
436 let data = BarData::from_bars(vec![Bar::new(now, 100.0, 110.0, 95.0, 105.0, 1000.0)]);
437
438 let indicators: Vec<(String, Vec<f64>)> = vec![];
439 let csv = data.to_csv_with_indicators(&indicators).unwrap();
440 let csv_plain = data.to_csv_string().unwrap();
441
442 assert_eq!(csv, csv_plain);
444 }
445
446 #[cfg(feature = "csv")]
448 fn temp_csv(name: &str) -> String {
449 let file = format!("egui-charts-{}-{name}.csv", std::process::id());
450 std::env::temp_dir().join(file).to_str().unwrap().to_owned()
451 }
452
453 #[test]
454 #[cfg(feature = "csv")]
455 fn bar_csv_round_trips_sub_second_times() {
456 let start = DateTime::parse_from_rfc3339("2024-03-15T10:00:00Z")
457 .unwrap()
458 .to_utc();
459 let times: Vec<DateTime<Utc>> = (0..3)
460 .map(|i| start + chrono::Duration::milliseconds(100 * i))
461 .collect();
462 let bars = times
463 .iter()
464 .map(|&t| Bar::new(t, 1.0, 2.0, 0.5, 1.5, 10.0))
465 .collect();
466 let path = temp_csv("sub-second");
467
468 BarData::from_bars(bars).to_csv(&path).unwrap();
469 let written = std::fs::read_to_string(&path).unwrap();
470 let loaded = BarData::from_csv(&path);
471 std::fs::remove_file(&path).unwrap();
472
473 let written_times: Vec<&str> = written
474 .lines()
475 .skip(1)
476 .map(|row| row.split(',').next().unwrap())
477 .collect();
478 assert_eq!(
479 written_times,
480 [
481 "2024-03-15 10:00:00",
482 "2024-03-15 10:00:00.100",
483 "2024-03-15 10:00:00.200"
484 ]
485 );
486 let loaded_times: Vec<DateTime<Utc>> =
487 loaded.unwrap().bars.iter().map(|b| b.time).collect();
488 assert_eq!(loaded_times, times);
489 }
490
491 #[test]
492 #[cfg(feature = "csv")]
493 fn from_csv_checks_the_header_and_reads_date_only_times() {
494 let refused = Err("Invalid CSV header: expected ts,open,high,low,close,volume".to_string());
495 let cases = [
496 (
497 "2024-03-15 10:00:00,1,2,0.5,1.5,10\n2024-03-15 10:01:00,1,2,0.5,1.5,10\n",
498 refused.clone(),
499 ),
500 (
501 "ts,close,high,low,open,volume\n2024-03-15 10:00:00,1.5,2,0.5,1,10\n",
502 refused,
503 ),
504 (
505 " TS , Open,HIGH,low,close,Volume\n2024-03-15 10:00:00,1,2,0.5,1.5,10\n",
506 Ok(vec![("2024-03-15 10:00:00 UTC".to_string(), 1.0, 1.5)]),
507 ),
508 (
510 "ts,open,high,low,close,volume\n2024-03-15,1,2,0.5,1.5,10\n",
511 Ok(vec![("2024-03-15 00:00:00 UTC".to_string(), 1.0, 1.5)]),
512 ),
513 ];
514 let path = temp_csv("header");
515 for (contents, expected) in cases {
516 std::fs::write(&path, contents).unwrap();
517 let got = BarData::from_csv(&path)
518 .map(|data| {
519 data.bars
520 .iter()
521 .map(|b| (b.time.to_string(), b.open, b.close))
522 .collect::<Vec<_>>()
523 })
524 .map_err(|e| e.to_string());
525 assert_eq!(got, expected, "{contents:?}");
526 }
527 std::fs::remove_file(&path).unwrap();
528 }
529}