Skip to main content

rustrade_backtest/
loaders.rs

1//! Candle series loaders for backtests.
2//!
3//! Phase 4b ships a single loader: a CSV reader with a fixed column
4//! layout. Parquet, JSON-lines, and exchange-native dumps are
5//! out-of-scope for v0.1 — they're easy enough for users to write
6//! against [`crate::Backtest::with_candles`].
7//!
8//! # CSV format
9//!
10//! ```text
11//! time,open,high,low,close,volume
12//! 1700000000000,42000.0,42100.0,41900.0,42050.0,123.4
13//! 1700000060000,42050.0,42200.0,42030.0,42180.0,98.7
14//! ...
15//! ```
16//!
17//! - `time` is milliseconds since the UNIX epoch (`i64`).
18//! - The header row is required and column order is fixed.
19//! - Empty rows and rows beginning with `#` are skipped.
20//!
21//! # Sort order
22//!
23//! Candles are returned in the order they appear in the file. The
24//! [`Backtest`](crate::Backtest) engine assumes chronological order
25//! (oldest first); use [`sort_chronological`] if your source is
26//! newest-first.
27
28use 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
58/// Load candles from a CSV file. See the module docs for the expected
59/// format.
60pub 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 // +1 for 1-based, +1 for header
73            ))
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
83/// Load candles from a CSV string (in-memory). Mostly useful for tests.
84pub 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
102/// Sort candles by `time` ascending. Stable — preserves order for
103/// candles with identical timestamps (rare, but exchange ticks
104/// sometimes coincide).
105pub 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        // Structurally valid f64, but a zero/negative price is unusable.
155        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        // f64::from_str parses "inf"/"NaN"; validation must reject them.
166        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}