Skip to main content

pomelo_data/
factors.rs

1//! Pure snapshot-factor scoring helpers (no I/O), shared by every `pomelo-*`
2//! vendor sync crate so a `pe_industry_pctile` / `analyst_upside_pct` panel means
3//! the same thing regardless of which vendor produced it (conventions:
4//! citrusquant issue #211).
5//!
6//! These operate on values a vendor has **already extracted** — the
7//! vendor-specific field maps and the rating-count/string mappers stay in each
8//! vendor crate. Only the cross-vendor scoring math lives here.
9
10/// Midrank percentile of `v` within `cohort` in `[0, 1]`.
11pub fn percentile_rank(cohort: &[f64], v: f64) -> f64 {
12    if !v.is_finite() {
13        return 0.0;
14    }
15    let mut n = 0usize;
16    let mut below = 0usize;
17    let mut equal = 0usize;
18    for &c in cohort {
19        if !c.is_finite() {
20            continue;
21        }
22        n += 1;
23        if c < v {
24            below += 1;
25        } else if c == v {
26            equal += 1;
27        }
28    }
29    if n == 0 {
30        return 0.0;
31    }
32    (below as f64 + 0.5 * equal as f64) / n as f64
33}
34
35/// Minimum cohort size for a meaningful industry percentile.
36pub const MIN_COHORT: usize = 5;
37
38/// P/E industry percentile in `[0, 100]`; `None` for a non-positive P/E or a
39/// cohort thinner than [`MIN_COHORT`].
40pub fn pe_industry_pctile(pe: f64, cohort: &[f64]) -> Option<f64> {
41    if !pe.is_finite() || pe <= 0.0 {
42        return None;
43    }
44    let finite = cohort.iter().filter(|c| c.is_finite()).count();
45    if finite < MIN_COHORT {
46        return None;
47    }
48    Some(percentile_rank(cohort, pe) * 100.0)
49}
50
51/// `(target − close) / close × 100`; `None` when `close <= 0` or inputs aren't finite.
52pub fn analyst_upside_pct(target: f64, close: f64) -> Option<f64> {
53    if !target.is_finite() || !close.is_finite() || close <= 0.0 {
54        return None;
55    }
56    Some((target - close) / close * 100.0)
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    #[test]
64    fn percentile_rank_midrank_and_edges() {
65        let cohort = [10.0, 20.0, 30.0, 40.0, 50.0];
66        assert!((percentile_rank(&cohort, 30.0) - 0.5).abs() < 1e-9);
67        assert_eq!(percentile_rank(&[], 1.0), 0.0);
68        assert_eq!(percentile_rank(&cohort, f64::NAN), 0.0);
69    }
70
71    #[test]
72    fn pe_pctile_scale_and_thin_cohort() {
73        let cohort = [10.0, 20.0, 30.0, 40.0, 50.0];
74        assert_eq!(pe_industry_pctile(30.0, &cohort), Some(50.0));
75        assert_eq!(pe_industry_pctile(30.0, &[10.0, 20.0]), None);
76        assert_eq!(pe_industry_pctile(-1.0, &cohort), None);
77    }
78
79    #[test]
80    fn upside() {
81        assert_eq!(analyst_upside_pct(120.0, 100.0), Some(20.0));
82        assert!(analyst_upside_pct(1.0, 0.0).is_none());
83        assert!(analyst_upside_pct(f64::NAN, 100.0).is_none());
84    }
85}