Skip to main content

pomelo_data/
fundamentals.rs

1//! Per-symbol gzip CSV I/O for fundamentals — the native mirror of the Worker's
2//! `factor-panels.ts` export. Files hold dense, forward-filled fundamental fields
3//! (`day,pe,ps,pb,…`, oldest-first); `parse_fundamentals` extracts one field
4//! column into `(day, value)` rows. Same format/conventions as `csv_io.rs`.
5//!
6//! `FUNDAMENTAL_FIELDS` is the column contract: it MUST match the TS exporter's
7//! `FACTOR_FIELDS` order AND the `Data { name }` snake_case names in specs.
8
9use crate::date::{date_to_i32, i32_to_date};
10use crate::error::DataError;
11use crate::source::ObjectSource;
12use flate2::write::GzEncoder;
13use flate2::Compression;
14use ndarray::Array2;
15use std::collections::{BTreeSet, HashMap};
16use std::io::Write;
17use yuzu_core::panel::Panel;
18
19/// Fundamental field column names, in CSV order (column 0 is `day`).
20pub const FUNDAMENTAL_FIELDS: &[&str] = &[
21    "pe",
22    "ps",
23    "pb",
24    "roe",
25    "net_margin",
26    "debt_to_equity",
27    "market_cap",
28    "gross_margin",
29    "receivables_turnover",
30    "debt_to_assets",
31    "revenue",
32    "revenue_growth",
33    "eps_growth",
34    "operating_income_growth",
35    "net_income_growth",
36    "gross_profit_growth",
37];
38
39/// The extra trailing column marking real report-filing days (`1.0` on a day a
40/// new report was disclosed, else `0.0`). Not a [`FUNDAMENTAL_FIELDS`] factor —
41/// it's the event signal that the dense forward-filled factor columns can't
42/// express. Missing → `NaN`, which the engine's `is_true` (x == 1.0) reads as
43/// "no event", same as `0.0`.
44pub const REPORT_EVENT_FIELD: &str = "report_event";
45
46/// Snapshot-based factor fields whose combined panels (`panels/{name}.csv.gz`)
47/// are written directly by the Worker (not by `rebuild_combined_panels`, which
48/// only processes per-symbol fundamentals CSVs). Keeping them separate from
49/// `FUNDAMENTAL_FIELDS` prevents `rebuild_combined_panels` from overwriting them
50/// with all-NaN panels every nightly run.
51///
52/// ORDER PARITY: names must appear in the same order as `STABLE_FACTOR_NAMES`
53/// in `apps/web/src/lib/lemon/fields.ts`. A Vitest assertion in
54/// `factor-panels.test.ts` verifies the TS side; the Rust side is tested in
55/// `fundamentals.rs` (see the parity test below).
56pub const FACTOR_PANEL_FIELDS: &[&str] = &[
57    "piotroski_score",
58    "altman_z",
59    "fcf_yield",
60    "pe_industry_pctile",
61    "analyst_upside_pct",
62    "consensus_rating",
63];
64
65/// Column index (0 = `day`) of a series in the per-symbol fundamentals CSV, or
66/// `None` if the name is neither a factor nor [`REPORT_EVENT_FIELD`].
67fn field_col(field: &str) -> Option<usize> {
68    if field == REPORT_EVENT_FIELD {
69        Some(FUNDAMENTAL_FIELDS.len() + 1) // last column, after day + the 16 factors
70    } else {
71        FUNDAMENTAL_FIELDS
72            .iter()
73            .position(|f| *f == field)
74            .map(|i| i + 1)
75    }
76}
77
78/// Whether `name` is a series the fundamentals files carry (a factor, the
79/// report-event signal, or a Worker-written snapshot factor) — used by callers
80/// to route a spec series to the right combined-panel loader.
81pub fn is_fundamental_series(name: &str) -> bool {
82    field_col(name).is_some() || FACTOR_PANEL_FIELDS.contains(&name)
83}
84
85/// One dense row of fundamentals for a trading day. `values` is aligned to
86/// [`FUNDAMENTAL_FIELDS`]; unset fields are `NaN`. `report_event` is the trailing
87/// [`REPORT_EVENT_FIELD`] column.
88#[derive(Debug, Clone, PartialEq)]
89pub struct FundamentalRow {
90    pub day: i32,
91    pub values: Vec<f64>,
92    pub report_event: f64,
93}
94
95/// Parse fundamentals for `field` (one of [`FUNDAMENTAL_FIELDS`] or
96/// [`REPORT_EVENT_FIELD`]) into `(YYYYMMDD, value)` rows. The buffer's format is
97/// detected from its content: gzip CSV, plain CSV, or — with the `parquet`
98/// feature — Apache Parquet. Empty / `NaN` cells parse to `NaN`.
99pub fn parse_fundamentals(bytes: &[u8], field: &str) -> Result<Vec<(i32, f64)>, DataError> {
100    // Resolve the column first so an unknown field fails before any I/O.
101    let col = field_col(field)
102        .ok_or_else(|| DataError::Parse(format!("unknown fundamental field '{field}'")))?;
103    #[cfg(feature = "parquet")]
104    if crate::format::Format::detect(bytes) == crate::format::Format::Parquet {
105        return crate::parquet_io::read_series(bytes, field);
106    }
107    let text = crate::format::read_csv_text(bytes)?;
108    parse_fundamentals_csv(&text, col)
109}
110
111/// Extract column `col` (0 = `day`) from decoded fundamentals CSV text.
112fn parse_fundamentals_csv(text: &str, col: usize) -> Result<Vec<(i32, f64)>, DataError> {
113    let mut out = Vec::new();
114    for line in text.lines() {
115        let line = line.trim();
116        if line.is_empty() || line.starts_with("day") {
117            continue;
118        }
119        let cells: Vec<&str> = line.split(',').collect();
120        if cells.len() <= col {
121            return Err(DataError::Parse(format!(
122                "row has {} cells, need > {col}",
123                cells.len()
124            )));
125        }
126        let day = date_to_i32(cells[0].trim())?;
127        let cell = cells[col].trim();
128        // Empty cell → NaN (no fundamental that day); otherwise parse (incl. "NaN").
129        let val: f64 = if cell.is_empty() {
130            f64::NAN
131        } else {
132            cell.parse()
133                .map_err(|_| DataError::Parse(format!("bad value '{cell}'")))?
134        };
135        out.push((day, val));
136    }
137    Ok(out)
138}
139
140/// Serialize fundamentals rows (oldest-first) to gzip CSV with the standard header.
141pub fn write_fundamentals(rows: &[FundamentalRow]) -> Result<Vec<u8>, DataError> {
142    let mut buf = String::from("day");
143    for f in FUNDAMENTAL_FIELDS {
144        buf.push(',');
145        buf.push_str(f);
146    }
147    buf.push(',');
148    buf.push_str(REPORT_EVENT_FIELD);
149    buf.push('\n');
150    for r in rows {
151        buf.push_str(&i32_to_date(r.day));
152        for v in &r.values {
153            buf.push(',');
154            buf.push_str(&v.to_string());
155        }
156        buf.push(',');
157        buf.push_str(&r.report_event.to_string());
158        buf.push('\n');
159    }
160    let mut enc = GzEncoder::new(Vec::new(), Compression::default());
161    enc.write_all(buf.as_bytes())
162        .map_err(|e| DataError::Io(e.to_string()))?;
163    enc.finish().map_err(|e| DataError::Io(e.to_string()))
164}
165
166/// Default object-key directory for per-symbol fundamentals files. Override at the
167/// entry point (e.g. `YUZU_FUNDAMENTALS_DIR`) for a custom layout.
168pub const FUNDAMENTALS_DIR: &str = "fundamentals";
169
170/// Read `{dir}/{symbol}.csv.gz` for each symbol, extract `field`, filter to
171/// `[from, to]` (inclusive), and assemble a Panel: rows = sorted union of kept days,
172/// columns = `symbols` in order. Missing/corrupt files and absent cells are NaN.
173/// `dir` defaults to [`FUNDAMENTALS_DIR`] at call sites. The native mirror of the
174/// Worker building a factor panel — but read straight from object storage, no D1.
175pub fn load_fundamental_panel<S: ObjectSource + Sync>(
176    source: &S,
177    symbols: &[String],
178    field: &str,
179    from: i32,
180    to: i32,
181    dir: &str,
182) -> Result<Panel, DataError> {
183    // Validate the field once — an unknown field is a bug, not a missing file.
184    if !is_fundamental_series(field) {
185        return Err(DataError::Parse(format!(
186            "unknown fundamental field '{field}'"
187        )));
188    }
189    // Fetch + parse every symbol concurrently (network-bound); missing/corrupt
190    // files leave a NaN column rather than sinking the batch.
191    let per_symbol = crate::parallel::fetch_series(source, symbols, dir, from, to, |b| {
192        parse_fundamentals(b, field)
193    })?;
194
195    let mut date_set: BTreeSet<i32> = BTreeSet::new();
196    for map in &per_symbol {
197        date_set.extend(map.keys().copied());
198    }
199    let dates: Vec<i32> = date_set.into_iter().collect();
200    let row_of: HashMap<i32, usize> = dates.iter().enumerate().map(|(i, d)| (*d, i)).collect();
201    let mut data = Array2::from_elem((dates.len(), symbols.len()), f64::NAN);
202    for (c, map) in per_symbol.iter().enumerate() {
203        for (d, v) in map {
204            data[[row_of[d], c]] = *v;
205        }
206    }
207    Panel::new(dates, symbols.to_vec(), data).map_err(|e| DataError::Parse(e.to_string()))
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn frow(day: i32, set: &[(&str, f64)]) -> FundamentalRow {
215        let mut values = vec![f64::NAN; FUNDAMENTAL_FIELDS.len()];
216        let mut report_event = 0.0;
217        for (k, v) in set {
218            if *k == REPORT_EVENT_FIELD {
219                report_event = *v;
220                continue;
221            }
222            let i = FUNDAMENTAL_FIELDS.iter().position(|f| f == k).unwrap();
223            values[i] = *v;
224        }
225        FundamentalRow {
226            day,
227            values,
228            report_event,
229        }
230    }
231
232    /// Verify that FACTOR_PANEL_FIELDS contains exactly the expected stable
233    /// snapshot factor names in the expected order (mirrors the TS parity test
234    /// in factor-panels.test.ts).
235    #[test]
236    fn factor_panel_fields_order_and_count() {
237        let expected = [
238            "piotroski_score",
239            "altman_z",
240            "fcf_yield",
241            "pe_industry_pctile",
242            "analyst_upside_pct",
243            "consensus_rating",
244        ];
245        assert_eq!(
246            FACTOR_PANEL_FIELDS, &expected,
247            "FACTOR_PANEL_FIELDS must match TS STABLE_FACTOR_NAMES in order"
248        );
249    }
250
251    #[test]
252    fn is_fundamental_series_recognises_factor_panel_fields() {
253        for name in FACTOR_PANEL_FIELDS {
254            assert!(
255                is_fundamental_series(name),
256                "is_fundamental_series should return true for factor panel field '{name}'"
257            );
258        }
259        // Price fields are NOT fundamental series.
260        assert!(!is_fundamental_series("close"));
261        assert!(!is_fundamental_series("unknown_field"));
262        // Original fundamental fields still work.
263        assert!(is_fundamental_series("pe"));
264        assert!(is_fundamental_series("market_cap"));
265        // report_event still works.
266        assert!(is_fundamental_series("report_event"));
267    }
268
269    #[test]
270    fn report_event_is_a_trailing_column() {
271        let rows = vec![
272            frow(20240102, &[("pe", 10.0), ("report_event", 0.0)]),
273            frow(20240103, &[("pe", 11.0), ("report_event", 1.0)]),
274        ];
275        let bytes = write_fundamentals(&rows).unwrap();
276        // factors still resolve, and report_event round-trips from the last column
277        assert_eq!(
278            parse_fundamentals(&bytes, "pe").unwrap()[1],
279            (20240103, 11.0)
280        );
281        assert_eq!(
282            parse_fundamentals(&bytes, "report_event").unwrap(),
283            vec![(20240102, 0.0), (20240103, 1.0)]
284        );
285        assert!(is_fundamental_series("report_event"));
286        assert!(is_fundamental_series("pe"));
287        assert!(!is_fundamental_series("close"));
288    }
289
290    #[test]
291    fn roundtrip_and_field_extract() {
292        let rows = vec![
293            frow(20240102, &[("pe", 10.0), ("pb", 1.5), ("market_cap", 1e9)]),
294            frow(
295                20240103,
296                &[("pe", 11.0), ("pb", 1.6), ("market_cap", 1.1e9)],
297            ),
298        ];
299        let bytes = write_fundamentals(&rows).unwrap();
300        assert_eq!(
301            parse_fundamentals(&bytes, "pe").unwrap(),
302            vec![(20240102, 10.0), (20240103, 11.0)]
303        );
304        assert_eq!(
305            parse_fundamentals(&bytes, "market_cap").unwrap()[1],
306            (20240103, 1.1e9)
307        );
308        assert_eq!(
309            parse_fundamentals(&bytes, "pb").unwrap()[0],
310            (20240102, 1.5)
311        );
312    }
313
314    #[test]
315    fn unknown_field_errors_and_bad_gzip_errors() {
316        let bytes = write_fundamentals(&[frow(20240102, &[("pe", 10.0)])]).unwrap();
317        assert!(parse_fundamentals(&bytes, "not_a_field").is_err());
318        assert!(parse_fundamentals(b"not gzip", "pe").is_err());
319    }
320
321    #[test]
322    fn load_fundamental_panel_union_dates_and_nan() {
323        use crate::source::LocalSource;
324        use std::fs;
325        let dir = std::env::temp_dir().join("pomelo_data_fund_panel");
326        let _ = fs::remove_dir_all(&dir);
327        fs::create_dir_all(dir.join("fundamentals")).unwrap();
328        fs::write(
329            dir.join("fundamentals/AAPL.csv.gz"),
330            write_fundamentals(&[
331                frow(20240102, &[("pe", 10.0)]),
332                frow(20240103, &[("pe", 11.0)]),
333            ])
334            .unwrap(),
335        )
336        .unwrap();
337        fs::write(
338            dir.join("fundamentals/MSFT.csv.gz"),
339            write_fundamentals(&[frow(20240103, &[("pe", 20.0)])]).unwrap(),
340        )
341        .unwrap();
342        let src = LocalSource::new(&dir);
343        let syms = vec!["AAPL".to_string(), "MSFT".to_string(), "ZZZ".to_string()];
344
345        let pe = load_fundamental_panel(&src, &syms, "pe", 20240102, 20240103, FUNDAMENTALS_DIR)
346            .unwrap();
347        assert_eq!(pe.dates, vec![20240102, 20240103]);
348        assert_eq!(pe.data[[0, 0]], 10.0); // AAPL on 0102
349        assert!(pe.data[[0, 1]].is_nan()); // MSFT absent 0102
350        assert_eq!(pe.data[[1, 1]], 20.0); // MSFT on 0103
351        assert!(pe.data[[1, 2]].is_nan()); // ZZZ no file
352
353        // unknown field is an error, not an all-NaN panel
354        assert!(
355            load_fundamental_panel(&src, &syms, "nope", 20240102, 20240103, FUNDAMENTALS_DIR)
356                .is_err()
357        );
358    }
359}