Skip to main content

pine_data/
static_provider.rs

1//! A static [`Data`] served as a [`DataProvider`]: bars in memory or read from a
2//! CSV, answering `request` for their own symbol and resampling up to a coarser
3//! timeframe.
4
5use crate::DataError;
6use pine_core::{Bar, Data, DataProvider, Ohlcv, ProviderError, SymInfo, Timeframe};
7use std::io::Read;
8use std::path::Path;
9
10/// A fixed dataset served as a provider.
11pub struct StaticProvider {
12    data: Data,
13}
14
15impl StaticProvider {
16    pub fn new(data: Data) -> Self {
17        Self { data }
18    }
19
20    /// Serve the bars of a `time,open,high,low,close,volume` CSV. A header naming
21    /// the columns is required; `#` comment lines are ignored. The symbol
22    /// defaults to a placeholder — set it with [`with_syminfo`](Self::with_syminfo).
23    pub fn from_csv(path: impl AsRef<Path>) -> Result<Self, DataError> {
24        Ok(Self::new(Data::from_ohlcv(read_csv(path.as_ref())?)))
25    }
26
27    /// The symbol these bars belong to, exposed to scripts as `syminfo.*`.
28    pub fn with_syminfo(mut self, syminfo: SymInfo) -> Self {
29        self.data.syminfo = syminfo;
30        self
31    }
32
33    /// The bars and symbol this provider serves.
34    pub fn data(&self) -> &Data {
35        &self.data
36    }
37}
38
39impl DataProvider for StaticProvider {
40    fn request(&self, symbol: &str, timeframe: Timeframe) -> Result<Data, ProviderError> {
41        // One dataset only knows its own symbol; an empty symbol means "this".
42        let syminfo = &self.data.syminfo;
43        if !symbol.is_empty() && symbol != syminfo.tickerid && symbol != syminfo.ticker {
44            return Err(format!("no data for symbol {symbol:?}").into());
45        }
46        let tf_ms = timeframe
47            .to_millis()
48            .ok_or_else(|| format!("cannot resample to timeframe {:?}", timeframe.period()))?;
49        // Native spacing from the bars themselves; can't go below it.
50        let native = self
51            .data
52            .bars
53            .windows(2)
54            .next()
55            .map_or(tf_ms, |w| w[1].time - w[0].time);
56        if tf_ms <= native {
57            return Ok(self.data.clone());
58        }
59        Ok(Data {
60            syminfo: self.data.syminfo.clone(),
61            bars: resample(&self.data.bars, tf_ms),
62        })
63    }
64}
65
66/// Aggregate `bars` into `tf_ms` buckets (open of the first, high/low over all,
67/// close of the last, summed volume). A bucket's `time` is its **last**
68/// constituent bar's time — the bar it is confirmed on — so `request.security`
69/// can align it non-repainting.
70pub fn resample(bars: &[Bar], tf_ms: i64) -> Vec<Bar> {
71    let mut out: Vec<Bar> = Vec::new();
72    let mut current = None;
73    for bar in bars {
74        let key = bar.time.div_euclid(tf_ms);
75        if current == Some(key) {
76            let bucket = out.last_mut().expect("a bucket exists once current is set");
77            bucket.high = bucket.high.max(bar.high);
78            bucket.low = bucket.low.min(bar.low);
79            bucket.close = bar.close;
80            bucket.volume += bar.volume;
81            bucket.time = bar.time;
82        } else {
83            let mut bucket = bar.clone();
84            bucket.index = out.len() as u64;
85            out.push(bucket);
86            current = Some(key);
87        }
88    }
89    out
90}
91
92#[derive(serde::Deserialize)]
93struct Row {
94    /// Opening time as a UNIX timestamp in milliseconds.
95    time: i64,
96    open: f64,
97    high: f64,
98    low: f64,
99    close: f64,
100    volume: f64,
101}
102
103fn read_csv(path: &Path) -> Result<Vec<Ohlcv>, DataError> {
104    let file = std::fs::File::open(path).map_err(|source| DataError::Read {
105        path: path.display().to_string(),
106        source: source.into(),
107    })?;
108    read(file).map_err(|source| DataError::Read {
109        path: path.display().to_string(),
110        source,
111    })
112}
113
114fn read(source: impl Read) -> Result<Vec<Ohlcv>, csv::Error> {
115    csv::ReaderBuilder::new()
116        .comment(Some(b'#'))
117        .trim(csv::Trim::All)
118        .from_reader(source)
119        .deserialize()
120        .map(|row| {
121            let row: Row = row?;
122            Ok(Ohlcv {
123                time: row.time,
124                open: row.open,
125                high: row.high,
126                low: row.low,
127                close: row.close,
128                volume: row.volume,
129            })
130        })
131        .collect()
132}
133
134#[cfg(test)]
135mod tests {
136    use super::read;
137
138    #[test]
139    fn reads_rows_skipping_comments() {
140        let rows = read(
141            "time,open,high,low,close,volume\n\
142             # first bar\n\
143             0,100,105,95,102,1000\n\
144             60000,101,106,96,103,1010\n"
145                .as_bytes(),
146        )
147        .unwrap();
148
149        assert_eq!(rows.len(), 2);
150        assert_eq!(rows[0].time, 0);
151        assert_eq!(rows[0].close, 102.0);
152        assert_eq!(rows[1].time, 60000);
153        assert_eq!(rows[1].volume, 1010.0);
154    }
155
156    #[test]
157    fn columns_are_matched_by_header_not_position() {
158        let rows =
159            read("volume,close,low,high,open,time\n1000,102,95,105,100,0\n".as_bytes()).unwrap();
160
161        assert_eq!(rows[0].open, 100.0);
162        assert_eq!(rows[0].close, 102.0);
163        assert_eq!(rows[0].volume, 1000.0);
164    }
165
166    #[test]
167    fn reports_where_a_bad_row_is() {
168        let error = read(
169            "time,open,high,low,close,volume\n\
170             0,100,105,95,102,1000\n\
171             60000,101,106,96,oops,1010\n"
172                .as_bytes(),
173        )
174        .unwrap_err();
175
176        assert!(error.to_string().contains("line: 3"), "{error}");
177    }
178
179    #[test]
180    fn reports_a_missing_column() {
181        let error = read("time,open,high,low,close\n0,100,105,95,102\n".as_bytes()).unwrap_err();
182
183        assert!(error.to_string().contains("volume"), "{error}");
184    }
185}