1use 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
19pub 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
39pub const REPORT_EVENT_FIELD: &str = "report_event";
45
46pub 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
65fn field_col(field: &str) -> Option<usize> {
68 if field == REPORT_EVENT_FIELD {
69 Some(FUNDAMENTAL_FIELDS.len() + 1) } else {
71 FUNDAMENTAL_FIELDS
72 .iter()
73 .position(|f| *f == field)
74 .map(|i| i + 1)
75 }
76}
77
78pub fn is_fundamental_series(name: &str) -> bool {
82 field_col(name).is_some() || FACTOR_PANEL_FIELDS.contains(&name)
83}
84
85#[derive(Debug, Clone, PartialEq)]
89pub struct FundamentalRow {
90 pub day: i32,
91 pub values: Vec<f64>,
92 pub report_event: f64,
93}
94
95pub fn parse_fundamentals(bytes: &[u8], field: &str) -> Result<Vec<(i32, f64)>, DataError> {
100 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
111fn 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 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
140pub 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
166pub const FUNDAMENTALS_DIR: &str = "fundamentals";
169
170pub 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 if !is_fundamental_series(field) {
185 return Err(DataError::Parse(format!(
186 "unknown fundamental field '{field}'"
187 )));
188 }
189 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 #[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 assert!(!is_fundamental_series("close"));
261 assert!(!is_fundamental_series("unknown_field"));
262 assert!(is_fundamental_series("pe"));
264 assert!(is_fundamental_series("market_cap"));
265 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 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); assert!(pe.data[[0, 1]].is_nan()); assert_eq!(pe.data[[1, 1]], 20.0); assert!(pe.data[[1, 2]].is_nan()); assert!(
355 load_fundamental_panel(&src, &syms, "nope", 20240102, 20240103, FUNDAMENTALS_DIR)
356 .is_err()
357 );
358 }
359}