Skip to main content

wickra_core/indicators/
bullish_percent_index.rs

1//! Bullish Percent Index — share of a universe on a point-and-figure buy signal.
2
3use crate::cross_section::CrossSection;
4use crate::traits::Indicator;
5
6/// Bullish Percent Index (BPI) — the percentage of symbols in a universe that are
7/// currently on a point-and-figure buy signal.
8///
9/// On each [`CrossSection`] tick the value is `100 * on_buy_signal_count /
10/// universe size`, read from the per-symbol `on_buy_signal` flag (the caller
11/// evaluates each symbol's point-and-figure chart when it builds the tick). It is
12/// a bounded `0..=100` gauge of how many issues are in a confirmed uptrend.
13/// Readings above 70 are considered overbought (broad strength, but a crowded
14/// market) and below 30 oversold; reversals from those zones are classic BPI
15/// buy/sell triggers.
16///
17/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`),
18/// `warmup_period == 1`. The universe is non-empty by construction, so the share
19/// is always defined.
20///
21/// # Example
22///
23/// ```
24/// use wickra_core::{BullishPercentIndex, CrossSection, Indicator, Member};
25///
26/// let mut bpi = BullishPercentIndex::new();
27/// // 2 of 4 symbols on a buy signal -> 50%.
28/// let tick = CrossSection::new(
29///     vec![
30///         Member::with_signals(1.0, 10.0, false, false, false, true),
31///         Member::with_signals(1.0, 10.0, false, false, false, true),
32///         Member::with_signals(-1.0, 10.0, false, false, false, false),
33///         Member::with_signals(-1.0, 10.0, false, false, false, false),
34///     ],
35///     0,
36/// )
37/// .unwrap();
38/// assert_eq!(bpi.update(tick), Some(50.0));
39/// ```
40#[derive(Debug, Clone, Default)]
41pub struct BullishPercentIndex {
42    has_emitted: bool,
43}
44
45impl BullishPercentIndex {
46    /// Construct a new Bullish Percent Index indicator.
47    #[must_use]
48    pub const fn new() -> Self {
49        Self { has_emitted: false }
50    }
51}
52
53impl Indicator for BullishPercentIndex {
54    type Input = CrossSection;
55    type Output = f64;
56
57    fn update(&mut self, section: CrossSection) -> Option<f64> {
58        let bullish = section.on_buy_signal_count() as f64;
59        let total = section.members.len() as f64;
60        self.has_emitted = true;
61        Some(100.0 * bullish / total)
62    }
63
64    fn reset(&mut self) {
65        self.has_emitted = false;
66    }
67
68    fn warmup_period(&self) -> usize {
69        1
70    }
71
72    fn is_ready(&self) -> bool {
73        self.has_emitted
74    }
75
76    fn name(&self) -> &'static str {
77        "BullishPercentIndex"
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::cross_section::Member;
85    use crate::traits::BatchExt;
86
87    fn tick(bullish: usize, bearish: usize) -> CrossSection {
88        let mut members = Vec::new();
89        for _ in 0..bullish {
90            members.push(Member::with_signals(1.0, 10.0, false, false, false, true));
91        }
92        for _ in 0..bearish {
93            members.push(Member::with_signals(-1.0, 10.0, false, false, false, false));
94        }
95        CrossSection::new(members, 0).unwrap()
96    }
97
98    #[test]
99    fn accessors_and_metadata() {
100        let bpi = BullishPercentIndex::new();
101        assert_eq!(bpi.name(), "BullishPercentIndex");
102        assert_eq!(bpi.warmup_period(), 1);
103        assert!(!bpi.is_ready());
104    }
105
106    #[test]
107    fn first_tick_emits_percentage() {
108        let mut bpi = BullishPercentIndex::new();
109        assert_eq!(bpi.update(tick(2, 2)), Some(50.0));
110        assert!(bpi.is_ready());
111    }
112
113    #[test]
114    fn all_bullish_is_one_hundred() {
115        let mut bpi = BullishPercentIndex::new();
116        assert_eq!(bpi.update(tick(5, 0)), Some(100.0));
117    }
118
119    #[test]
120    fn none_bullish_is_zero() {
121        let mut bpi = BullishPercentIndex::new();
122        assert_eq!(bpi.update(tick(0, 4)), Some(0.0));
123    }
124
125    #[test]
126    fn reset_clears_state() {
127        let mut bpi = BullishPercentIndex::new();
128        bpi.update(tick(2, 2));
129        assert!(bpi.is_ready());
130        bpi.reset();
131        assert!(!bpi.is_ready());
132    }
133
134    #[test]
135    fn batch_equals_streaming() {
136        let sections = vec![tick(2, 2), tick(5, 0), tick(0, 4)];
137        let mut a = BullishPercentIndex::new();
138        let mut b = BullishPercentIndex::new();
139        assert_eq!(
140            a.batch(&sections),
141            sections
142                .iter()
143                .map(|s| b.update(s.clone()))
144                .collect::<Vec<_>>()
145        );
146    }
147}