Skip to main content

egui_charts/model/bar/
io.rs

1//! Bar Data I/O Operations
2//!
3//! CSV and JSON import/export for bar data. Errors are `Send + Sync` so imports
4//! and exports can return their results across worker-thread boundaries. CSV methods
5//! require the `csv` feature; JSON methods are always available.
6
7use 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/// The time column of a bar CSV file, UTC. `%.f` writes the fraction of a second only when the
15/// time has one and reads a time with or without it.
16#[cfg(feature = "csv")]
17const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S%.f";
18
19/// The header row of a bar CSV file, written by every writer and required by `from_csv`.
20#[cfg(feature = "csv")]
21const HEADER: [&str; 6] = ["ts", "open", "high", "low", "close", "volume"];
22
23impl BarData {
24    /// Loads bars from a CSV file
25    ///
26    /// # Format
27    /// CSV format: ts,open,high,low,close,volume. The header row is required (any case, spaces
28    /// around names ignored) and the columns must be in this order.
29    /// Timestamps are in "YYYY-MM-DD HH:MM:SS" format (UTC), optionally with a fraction of a
30    /// second, such as "2024-03-15 10:00:00.250", or a date alone, "2024-03-15", for midnight UTC
31    /// of that day, the open of its day bar. A row of `NaN` values is a whitespace bar.
32    ///
33    /// # Example
34    /// ```no_run
35    /// use egui_charts::model::BarData;
36    ///
37    /// let data = BarData::from_csv("data.csv").expect("Failed to load CSV");
38    /// ```
39    #[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    /// Exports bars to a CSV file
101    ///
102    /// # Format
103    /// CSV format: ts,open,high,low,close,volume
104    /// Timestamps are in "YYYY-MM-DD HH:MM:SS" format (UTC), with the fraction of a second
105    /// when the bar's time has one, such as "2024-03-15 10:00:00.250"
106    ///
107    /// # Example
108    /// ```no_run
109    /// use egui_charts::model::BarData;
110    ///
111    /// let data = BarData::new();
112    /// data.to_csv("output.csv").expect("Failed to export CSV");
113    /// ```
114    #[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        // Write header
123        wtr.write_record(HEADER)?;
124
125        // Write data
126        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    /// Exports bars to a CSV string in memory
142    ///
143    /// # Format
144    /// CSV format: ts,open,high,low,close,volume
145    /// Timestamps are in "YYYY-MM-DD HH:MM:SS" format (UTC), with the fraction of a second
146    /// when the bar's time has one, such as "2024-03-15 10:00:00.250"
147    ///
148    /// # Example
149    /// ```
150    /// use egui_charts::model::BarData;
151    ///
152    /// let data = BarData::new();
153    /// let csv = data.to_csv_string().expect("Failed to export CSV");
154    /// assert!(csv.starts_with("ts,"));
155    /// ```
156    #[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        // Write header
162        wtr.write_record(HEADER)?;
163
164        // Write data
165        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    /// Exports bars to a CSV string with indicator values alongside bar data
181    ///
182    /// # Format
183    /// CSV format: ts,open,high,low,close,volume,indicator_1,indicator_2,...
184    /// Timestamps are in "YYYY-MM-DD HH:MM:SS" format (UTC), with the fraction of a second
185    /// when the bar's time has one, such as "2024-03-15 10:00:00.250"
186    ///
187    /// Each indicator is a `(name, values)` pair where `values` is aligned to the
188    /// bar data by index. Missing values (e.g. SMA warmup period) are empty strings.
189    ///
190    /// # Example
191    /// ```
192    /// use egui_charts::model::BarData;
193    ///
194    /// let data = BarData::new();
195    /// let indicators: Vec<(String, Vec<f64>)> = vec![
196    ///     ("SMA_20".to_string(), vec![]),
197    /// ];
198    /// let csv = data.to_csv_with_indicators(&indicators)
199    ///     .expect("Failed to export CSV with indicators");
200    /// assert!(csv.starts_with("ts,"));
201    /// ```
202    #[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        // Build header: ts,open,high,low,close,volume,indicator_1,...
211        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        // Write data rows
218        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    /// Exports bars to a JSON file
244    ///
245    /// # Format
246    /// JSON array of bar objects with full ISO 8601 timestamp
247    ///
248    /// # Example
249    /// ```no_run
250    /// use egui_charts::model::BarData;
251    ///
252    /// let data = BarData::new();
253    /// data.to_json("output.json").expect("Failed to export JSON");
254    /// ```
255    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    /// Returns JSON string representation of the bars
266    ///
267    /// # Example
268    /// ```
269    /// use egui_charts::model::BarData;
270    ///
271    /// let data = BarData::new();
272    /// let json = data.to_json_string().expect("Failed to serialize");
273    /// ```
274    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    /// Load bars from a JSON string
279    ///
280    /// # Example
281    /// ```
282    /// use egui_charts::model::BarData;
283    ///
284    /// let json = "[]";
285    /// let data = std::thread::spawn(move || BarData::from_json_string(json))
286    ///     .join().expect("Worker panicked").expect("Failed to parse");
287    /// ```
288    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    /// Load bars from a JSON file
294    ///
295    /// # Example
296    /// ```no_run
297    /// use egui_charts::model::BarData;
298    ///
299    /// let data = BarData::from_json("data.json").expect("Failed to load");
300    /// ```
301    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        // JSON writes NaN as null, which reads back as NaN.
330        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        // Header only
395        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        // Header + 3 data rows
418        assert_eq!(lines.len(), 4);
419
420        // Verify header includes indicator names
421        assert!(lines[0].contains("SMA_20"));
422        assert!(lines[0].contains("RSI_14"));
423
424        // Third row: SMA has no value (index 2, but SMA only has 2 values)
425        // so that cell should be empty
426        let row3_fields: Vec<&str> = lines[3].split(',').collect();
427        assert_eq!(row3_fields.len(), 8); // 6 bar fields + 2 indicators
428        assert_eq!(row3_fields[6], ""); // SMA missing for bar index 2
429        assert_eq!(row3_fields[7], "65"); // RSI present
430    }
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        // With empty indicators, should be identical to plain CSV
443        assert_eq!(csv, csv_plain);
444    }
445
446    /// A path in the temporary directory, unique to this process and `name`.
447    #[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            // A date alone is midnight UTC.
509            (
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}