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    #[inline]
58    fn update(&mut self, section: CrossSection) -> Option<f64> {
59        let bullish = section.on_buy_signal_count() as f64;
60        let total = section.members.len() as f64;
61        self.has_emitted = true;
62        Some(100.0 * bullish / total)
63    }
64
65    fn reset(&mut self) {
66        self.has_emitted = false;
67    }
68
69    #[inline]
70    fn warmup_period(&self) -> usize {
71        1
72    }
73
74    #[inline]
75    fn is_ready(&self) -> bool {
76        self.has_emitted
77    }
78
79    #[inline]
80    fn name(&self) -> &'static str {
81        "BullishPercentIndex"
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::cross_section::Member;
89    use crate::traits::BatchExt;
90
91    fn tick(bullish: usize, bearish: usize) -> CrossSection {
92        let mut members = Vec::new();
93        for _ in 0..bullish {
94            members.push(Member::with_signals(1.0, 10.0, false, false, false, true));
95        }
96        for _ in 0..bearish {
97            members.push(Member::with_signals(-1.0, 10.0, false, false, false, false));
98        }
99        CrossSection::new(members, 0).unwrap()
100    }
101
102    #[test]
103    fn accessors_and_metadata() {
104        let bpi = BullishPercentIndex::new();
105        assert_eq!(bpi.name(), "BullishPercentIndex");
106        assert_eq!(bpi.warmup_period(), 1);
107        assert!(!bpi.is_ready());
108    }
109
110    #[test]
111    fn first_tick_emits_percentage() {
112        let mut bpi = BullishPercentIndex::new();
113        assert_eq!(bpi.update(tick(2, 2)), Some(50.0));
114        assert!(bpi.is_ready());
115    }
116
117    #[test]
118    fn all_bullish_is_one_hundred() {
119        let mut bpi = BullishPercentIndex::new();
120        assert_eq!(bpi.update(tick(5, 0)), Some(100.0));
121    }
122
123    #[test]
124    fn none_bullish_is_zero() {
125        let mut bpi = BullishPercentIndex::new();
126        assert_eq!(bpi.update(tick(0, 4)), Some(0.0));
127    }
128
129    #[test]
130    fn reset_clears_state() {
131        let mut bpi = BullishPercentIndex::new();
132        bpi.update(tick(2, 2));
133        assert!(bpi.is_ready());
134        bpi.reset();
135        assert!(!bpi.is_ready());
136    }
137
138    #[test]
139    fn batch_equals_streaming() {
140        let sections = vec![tick(2, 2), tick(5, 0), tick(0, 4)];
141        let mut a = BullishPercentIndex::new();
142        let mut b = BullishPercentIndex::new();
143        assert_eq!(
144            a.batch(&sections),
145            sections
146                .iter()
147                .map(|s| b.update(s.clone()))
148                .collect::<Vec<_>>()
149        );
150    }
151}