Skip to main content

wickra_core/indicators/
percent_b.rs

1//! Bollinger %b.
2
3use crate::error::Result;
4use crate::traits::Indicator;
5
6use super::BollingerBands;
7
8/// Bollinger %b — where price sits within the Bollinger Bands.
9///
10/// ```text
11/// %b = (price − lower) / (upper − lower)
12/// ```
13///
14/// `%b = 1` means price is exactly on the upper band, `%b = 0` on the lower
15/// band, `%b = 0.5` on the middle band. The value is **not** clamped: price
16/// breaking above the upper band gives `%b > 1`, breaking below the lower band
17/// gives `%b < 0`. That makes %b a clean, scale-free way to compare a price's
18/// band position across instruments and to spot band overshoots.
19///
20/// # Example
21///
22/// ```
23/// use wickra_core::{Indicator, PercentB};
24///
25/// let mut indicator = PercentB::new(20, 2.0).unwrap();
26/// let mut last = None;
27/// for i in 0..80 {
28///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0);
29/// }
30/// assert!(last.is_some());
31/// ```
32#[derive(Debug, Clone)]
33pub struct PercentB {
34    bands: BollingerBands,
35    last: Option<f64>,
36}
37
38impl PercentB {
39    /// Construct a new %b indicator.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`crate::Error::PeriodZero`] for `period == 0` and
44    /// [`crate::Error::NonPositiveMultiplier`] for `multiplier <= 0`.
45    pub fn new(period: usize, multiplier: f64) -> Result<Self> {
46        Ok(Self {
47            bands: BollingerBands::new(period, multiplier)?,
48            last: None,
49        })
50    }
51
52    /// Configured period.
53    pub const fn period(&self) -> usize {
54        self.bands.period()
55    }
56
57    /// Configured multiplier.
58    pub const fn multiplier(&self) -> f64 {
59        self.bands.multiplier()
60    }
61
62    /// Current value if available.
63    pub const fn value(&self) -> Option<f64> {
64        self.last
65    }
66}
67
68impl Indicator for PercentB {
69    type Input = f64;
70    type Output = f64;
71
72    fn update(&mut self, input: f64) -> Option<f64> {
73        let o = self.bands.update(input)?;
74        let width = o.upper - o.lower;
75        let percent_b = if width == 0.0 {
76            // Bands collapsed onto the middle: price is exactly mid-band.
77            0.5
78        } else {
79            (input - o.lower) / width
80        };
81        self.last = Some(percent_b);
82        Some(percent_b)
83    }
84
85    fn reset(&mut self) {
86        self.bands.reset();
87        self.last = None;
88    }
89
90    fn warmup_period(&self) -> usize {
91        self.bands.warmup_period()
92    }
93
94    fn is_ready(&self) -> bool {
95        self.last.is_some()
96    }
97
98    fn name(&self) -> &'static str {
99        "PercentB"
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::traits::BatchExt;
107    use approx::assert_relative_eq;
108
109    #[test]
110    fn new_rejects_invalid_parameters() {
111        assert!(PercentB::new(0, 2.0).is_err());
112        assert!(PercentB::new(20, 0.0).is_err());
113        assert!(PercentB::new(20, -1.0).is_err());
114    }
115
116    /// Cover the public const accessors `period`, `multiplier`, `value`
117    /// and the Indicator-impl `warmup_period` + `name` methods. Existing
118    /// tests only exercise the numeric output of `update` / `batch` /
119    /// `reset` / `is_ready`, so the five getter bodies (lines 53-65,
120    /// 90-92, 98-100) were dead.
121    #[test]
122    fn accessors_and_metadata() {
123        let mut pb = PercentB::new(20, 2.0).unwrap();
124        assert_eq!(pb.period(), 20);
125        assert_relative_eq!(pb.multiplier(), 2.0, epsilon = 1e-12);
126        assert_eq!(pb.value(), None);
127        assert_eq!(pb.warmup_period(), 20);
128        assert_eq!(pb.name(), "PercentB");
129        for i in 1..=20 {
130            pb.update(f64::from(i));
131        }
132        assert!(pb.value().is_some());
133    }
134
135    #[test]
136    fn constant_series_yields_midpoint() {
137        // Flat prices: bands collapse, price is exactly mid-band -> 0.5.
138        let mut pb = PercentB::new(5, 2.0).unwrap();
139        let out = pb.batch(&[100.0; 20]);
140        for v in out.iter().skip(4).flatten() {
141            assert_relative_eq!(*v, 0.5, epsilon = 1e-12);
142        }
143    }
144
145    #[test]
146    fn matches_bands_definition() {
147        // %b must equal (price - lower) / (upper - lower) from BollingerBands.
148        let prices: Vec<f64> = (1..=60)
149            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
150            .collect();
151        let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
152        let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
153        for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
154            // Same warmup — emission shape must agree at every index.
155            assert_eq!(p.is_some(), b.is_some(), "warmup mismatch at index {i}");
156            if let (Some(pv), Some(bv)) = (p, b) {
157                let want = (prices[i] - bv.lower) / (bv.upper - bv.lower);
158                assert_relative_eq!(*pv, want, epsilon = 1e-12);
159            }
160        }
161    }
162
163    /// Deterministic price-at-middle assertion. With period=3, multiplier=2,
164    /// inputs `[1.0, 5.0, 3.0]` give SMA = (1+5+3)/3 = 3.0 at index 2, which
165    /// equals the third price exactly. The stddev is √(8/3) ≈ 1.633, so the
166    /// bands have non-zero width (the width==0 fallback at line 77 is NOT
167    /// taken) and the divide path at line 79 runs. Because price sits on
168    /// the centre line of symmetric bands, %b lands on exactly 0.5.
169    ///
170    /// The previous oscillation-based variant of this test never landed
171    /// `prices[i]` within 1e-9 of the rolling SMA, so its inner
172    /// `assert_relative_eq!` line was never executed.
173    #[test]
174    fn price_at_middle_is_half() {
175        let mut pb = PercentB::new(3, 2.0).unwrap();
176        let out = pb.batch(&[1.0, 5.0, 3.0]);
177        assert_eq!(out[0], None);
178        assert_eq!(out[1], None);
179        let v = out[2].expect("warmed up at index 2");
180        assert_relative_eq!(v, 0.5, epsilon = 1e-12);
181    }
182
183    #[test]
184    fn reset_clears_state() {
185        let mut pb = PercentB::new(5, 2.0).unwrap();
186        pb.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
187        assert!(pb.is_ready());
188        pb.reset();
189        assert!(!pb.is_ready());
190        assert_eq!(pb.update(1.0), None);
191    }
192
193    #[test]
194    fn batch_equals_streaming() {
195        let prices: Vec<f64> = (1..=80)
196            .map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
197            .collect();
198        let batch = PercentB::new(20, 2.0).unwrap().batch(&prices);
199        let mut b = PercentB::new(20, 2.0).unwrap();
200        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
201        assert_eq!(batch, streamed);
202    }
203}