1mod checks;
36mod report;
37mod scan;
38
39pub use report::{render_table, Check, DataAuditReport, Status};
40
41use pomelo_data::{load_panel, Field, ObjectLister, ObjectSource, PRICES_DIR};
42
43use checks::{
44 check_adjustment, check_calendar_gaps, check_coverage, check_index_membership,
45 check_nan_density, check_pit_lag, check_survivorship,
46};
47use scan::{list_price_symbols, scan_fundamentals};
48
49pub fn run_data_audit<S: ObjectSource + ObjectLister + Sync>(
54 src: &S,
55 data_dir: &str,
56 from: i32,
57 to: i32,
58) -> Result<DataAuditReport, String> {
59 let symbols = list_price_symbols(src);
60
61 let closes = if symbols.is_empty() {
64 None
65 } else {
66 Some(
67 load_panel(src, &symbols, Field::AdjClose, from, to, PRICES_DIR)
68 .map_err(|e| format!("loading price panel: {e}"))?,
69 )
70 };
71
72 let fund = scan_fundamentals(src, from, to);
74
75 let checks = vec![
76 check_coverage(src, &symbols, closes.as_ref()),
77 check_calendar_gaps(&symbols, closes.as_ref()),
78 check_adjustment(&symbols, closes.as_ref()),
79 check_survivorship(&symbols, closes.as_ref()),
80 check_nan_density(src, &symbols, &fund, from, to),
81 check_pit_lag(&fund),
82 check_index_membership(src, &symbols, from, to),
83 ];
84
85 let overall = checks.iter().map(|c| c.status).max().unwrap_or(Status::Ok);
86 Ok(DataAuditReport {
87 data_dir: data_dir.to_string(),
88 from,
89 to,
90 symbol_count: symbols.len(),
91 overall,
92 checks,
93 })
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use crate::report::status_str;
100 use pomelo_data::csv_io::{write_series, OhlcvRow};
101 use pomelo_data::fundamentals::{write_fundamentals, FundamentalRow};
102 use pomelo_data::LocalSource;
103 use pomelo_data::FUNDAMENTAL_FIELDS;
104 use std::fs;
105 use std::path::{Path, PathBuf};
106
107 fn run_local(dir: &Path, from: i32, to: i32) -> DataAuditReport {
109 let src = LocalSource::new(dir);
110 run_data_audit(&src, &dir.display().to_string(), from, to).unwrap()
111 }
112
113 fn tmp(tag: &str) -> PathBuf {
114 let d = std::env::temp_dir().join(format!("pomelo_audit_ut_{tag}"));
115 let _ = fs::remove_dir_all(&d);
116 fs::create_dir_all(&d).unwrap();
117 d
118 }
119
120 fn bar(day: i32, close: f64) -> OhlcvRow {
121 OhlcvRow {
122 day,
123 adj_open: close,
124 adj_high: close,
125 adj_low: close,
126 adj_close: close,
127 volume: 0.0,
128 }
129 }
130
131 fn write_prices(dir: &Path, sym: &str, bars: &[(i32, f64)]) {
132 let p = dir.join("prices");
133 fs::create_dir_all(&p).unwrap();
134 let rows: Vec<OhlcvRow> = bars.iter().map(|&(d, c)| bar(d, c)).collect();
135 fs::write(
136 p.join(format!("{sym}.csv.gz")),
137 write_series(&rows).unwrap(),
138 )
139 .unwrap();
140 }
141
142 fn find<'a>(r: &'a DataAuditReport, name: &str) -> &'a Check {
143 r.checks.iter().find(|c| c.name == name).unwrap()
144 }
145
146 fn build_rich_tree(tag: &str) -> PathBuf {
148 let dir = tmp(tag);
149 let d = [20240102, 20240103, 20240104, 20240105];
150 write_prices(
151 &dir,
152 "GOOD",
153 &[(d[0], 100.0), (d[1], 101.0), (d[2], 102.0), (d[3], 103.0)],
154 );
155 write_prices(&dir, "GAP", &[(d[0], 50.0), (d[1], 51.0), (d[3], 52.0)]); write_prices(
157 &dir,
158 "SPLIT",
159 &[(d[0], 100.0), (d[1], 100.0), (d[2], 200.0), (d[3], 201.0)],
160 );
161 write_prices(&dir, "DEAD", &[(d[0], 100.0), (d[1], 101.0)]); let fdir = dir.join("fundamentals");
165 fs::create_dir_all(&fdir).unwrap();
166 let mut vals = vec![f64::NAN; FUNDAMENTAL_FIELDS.len()];
167 vals[0] = 15.0; let rows = vec![
169 FundamentalRow {
170 day: 20231229,
171 values: vals.clone(),
172 report_event: 0.0,
173 },
174 FundamentalRow {
175 day: 20231231,
176 values: vals.clone(),
177 report_event: 1.0,
178 },
179 ];
180 fs::write(fdir.join("GOOD.csv.gz"), write_fundamentals(&rows).unwrap()).unwrap();
181
182 let pdir = dir.join("panels");
184 fs::create_dir_all(&pdir).unwrap();
185 fs::write(
186 pdir.join("piotroski_score.csv"),
187 "day,GOOD\n2024-01-02,\n2024-01-03,\n",
188 )
189 .unwrap();
190
191 let tdir = dir.join("tracked");
193 fs::create_dir_all(&tdir).unwrap();
194 fs::write(
195 tdir.join("universe.csv"),
196 "symbol,sector,market_cap\nGOOD,Tech,1e12\nGAP,Tech,1e11\nSPLIT,Tech,1e11\nDEAD,Tech,1e10\nMISSING,Tech,1e9\n",
197 )
198 .unwrap();
199 dir
200 }
201
202 #[test]
203 fn rich_tree_flags_each_check() {
204 let dir = build_rich_tree("rich");
205 let r = run_local(&dir, 20000101, 99991231);
206 assert_eq!(r.symbol_count, 4);
207 assert_eq!(r.overall, Status::Warn);
208
209 let cov = find(&r, "coverage");
210 assert_eq!(cov.status, Status::Warn);
211 assert!(cov.details["in_universe_missing_prices"]
212 .as_array()
213 .unwrap()
214 .iter()
215 .any(|v| v == "MISSING"));
216
217 assert_eq!(find(&r, "calendar_gaps").status, Status::Warn);
218 assert_eq!(find(&r, "calendar_gaps").details["total_holes"], 1);
219
220 assert_eq!(find(&r, "adjustment").status, Status::Warn);
221 assert_eq!(find(&r, "adjustment").details["flagged"], 1);
222
223 assert_eq!(find(&r, "survivorship").status, Status::Ok);
224 assert_eq!(find(&r, "survivorship").details["ended_early"], 1);
225
226 assert_eq!(find(&r, "nan_density").status, Status::Warn);
227 assert!(find(&r, "nan_density").details["all_nan_factor_panels"]
228 .as_array()
229 .unwrap()
230 .iter()
231 .any(|v| v == "piotroski_score"));
232
233 let pit = find(&r, "pit_lag");
234 assert_eq!(pit.status, Status::Warn);
235 assert_eq!(pit.details["report_events"], 1);
236 assert_eq!(pit.details["on_month_end"], 1);
237
238 assert_eq!(find(&r, "index_membership").status, Status::Ok);
239
240 let table = render_table(&r);
242 assert!(table.contains("WARN"));
243 assert!(table.contains("adjustment"));
244 }
245
246 #[test]
247 fn empty_tree_fails_and_takes_no_price_arms() {
248 let d = tmp("empty");
249 let r = run_local(&d, 20000101, 99991231);
250 assert_eq!(r.overall, Status::Fail);
251 assert_eq!(r.symbol_count, 0);
252 assert_eq!(find(&r, "coverage").status, Status::Fail);
253 for name in ["calendar_gaps", "adjustment", "survivorship"] {
254 assert_eq!(find(&r, name).status, Status::Ok);
255 }
256 let table = render_table(&r);
257 assert!(table.contains("FAIL"));
258 assert!(table.contains("coverage"));
259 assert_eq!(status_str(Status::Ok), "OK");
260 assert_eq!(status_str(Status::Warn), "WARN");
261 assert_eq!(status_str(Status::Fail), "FAIL");
262 }
263
264 #[test]
265 fn prices_without_universe_or_fundamentals_are_ok() {
266 let d = tmp("bare");
267 write_prices(
268 &d,
269 "AAA",
270 &[(20240102, 10.0), (20240103, 10.0), (20240104, 10.0)],
271 );
272 let r = run_local(&d, 20000101, 99991231);
273 assert_eq!(r.overall, Status::Ok);
274 let cov = find(&r, "coverage");
275 assert_eq!(cov.status, Status::Ok);
276 assert!(cov.summary.contains("no tracked/universe"));
277 assert!(cov.details["date_range"]["first_day"] == 20240102);
278 assert_eq!(find(&r, "survivorship").status, Status::Ok);
279 assert_eq!(find(&r, "nan_density").status, Status::Ok);
280 assert!(find(&r, "nan_density")
281 .summary
282 .contains("no fundamentals or factor panels"));
283 assert_eq!(find(&r, "pit_lag").status, Status::Ok);
284 }
285
286 #[test]
287 fn index_membership_panel_is_summarized() {
288 let d = tmp("index");
289 write_prices(&d, "AAA", &[(20240102, 10.0), (20240103, 10.0)]);
290 write_prices(&d, "BBB", &[(20240102, 10.0), (20240103, 10.0)]);
291 let pan = d.join("panels");
292 fs::create_dir_all(&pan).unwrap();
293 fs::write(
294 pan.join("in_sp500.csv"),
295 "day,AAA,BBB\n2024-01-02,1,\n2024-01-03,1,1\n",
296 )
297 .unwrap();
298 let r = run_local(&d, 20000101, 99991231);
299 let idx = find(&r, "index_membership");
300 assert_eq!(idx.status, Status::Ok);
301 let p0 = &idx.details["panels"][0];
302 assert_eq!(p0["panel"], "in_sp500");
303 assert_eq!(p0["min_members"], 1);
304 assert_eq!(p0["max_members"], 2);
305 assert_eq!(p0["last_members"], 2);
306 }
307
308 #[test]
309 fn edge_arms_single_point_zero_close_and_empty_membership() {
310 let d = tmp("edge");
311 write_prices(&d, "ONE", &[(20240102, 10.0)]);
312 write_prices(&d, "ZERO", &[(20240102, 0.0), (20240103, 10.0)]);
313 let pan = d.join("panels");
314 fs::create_dir_all(&pan).unwrap();
315 fs::write(pan.join("in_empty.csv"), "day,ONE,ZERO\n2024-01-02,,\n").unwrap();
316
317 let r = run_local(&d, 20000101, 99991231);
318 assert_eq!(find(&r, "adjustment").details["flagged"], 0);
320 assert_eq!(find(&r, "survivorship").status, Status::Ok);
321 let idx = find(&r, "index_membership");
322 assert_eq!(idx.status, Status::Warn);
323 assert_eq!(idx.details["panels"][0]["max_members"], 0);
324 }
325
326 #[test]
332 fn run_data_audit_over_a_stub_s3_source() {
333 use pomelo_s3::S3Source;
334 use std::io::{Read, Write};
335 use std::net::TcpListener;
336
337 fn list_xml(keys: &[&str]) -> String {
338 let contents: String = keys
339 .iter()
340 .map(|k| {
341 format!(
342 "<Contents><Key>{k}</Key><LastModified>2020-01-01T00:00:00.000Z</LastModified>\
343 <ETag>\"e\"</ETag><Size>1</Size><StorageClass>STANDARD</StorageClass></Contents>"
344 )
345 })
346 .collect();
347 format!(
348 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
349 <ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
350 <Name>bucket</Name><KeyCount>{}</KeyCount><MaxKeys>1000</MaxKeys>\
351 <IsTruncated>false</IsTruncated>{contents}<EncodingType>url</EncodingType>\
352 </ListBucketResult>",
353 keys.len()
354 )
355 }
356 fn http_ok_bytes(body: &[u8]) -> Vec<u8> {
357 let mut resp = format!(
358 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
359 body.len()
360 )
361 .into_bytes();
362 resp.extend_from_slice(body);
363 resp
364 }
365 const NOT_FOUND: &[u8] =
366 b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
367
368 let aapl = write_series(&[bar(20240102, 10.0), bar(20240103, 11.0)]).unwrap();
369 let msft = write_series(&[bar(20240102, 20.0), bar(20240103, 21.0)]).unwrap();
370
371 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
372 let addr = listener.local_addr().unwrap();
373 std::thread::spawn(move || {
374 for stream in listener.incoming() {
375 let Ok(mut sock) = stream else { break };
376 let mut buf = [0u8; 4096];
377 let read = sock.read(&mut buf).unwrap_or(0);
378 let req = String::from_utf8_lossy(&buf[..read]).to_string();
379 let first_line = req.lines().next().unwrap_or("");
380 let resp = if first_line.contains("prefix=prices") {
381 http_ok_bytes(
382 list_xml(&["prices/AAPL.csv.gz", "prices/MSFT.csv.gz"]).as_bytes(),
383 )
384 } else if first_line.contains("prices/AAPL.csv.gz") {
385 http_ok_bytes(&aapl)
386 } else if first_line.contains("prices/MSFT.csv.gz") {
387 http_ok_bytes(&msft)
388 } else if first_line.contains("prefix=fundamentals")
389 || first_line.contains("prefix=panels")
390 {
391 http_ok_bytes(list_xml(&[]).as_bytes())
392 } else {
393 NOT_FOUND.to_vec()
397 };
398 let _ = sock.write_all(&resp);
399 }
400 });
401
402 let src = S3Source::new(
403 &format!("http://{addr}"),
404 "bucket",
405 "ak",
406 "sk",
407 None,
408 "auto",
409 )
410 .unwrap();
411 let r = run_data_audit(&src, "s3://bucket", 20000101, 99991231).unwrap();
412 assert_eq!(r.data_dir, "s3://bucket");
413 assert_eq!(r.symbol_count, 2);
414 assert_eq!(find(&r, "coverage").status, Status::Ok);
415 assert_eq!(find(&r, "coverage").details["symbols_with_prices"], 2);
416 assert_eq!(find(&r, "nan_density").status, Status::Ok);
417 assert_eq!(find(&r, "index_membership").status, Status::Ok);
418 }
419}