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    #[inline]
73    fn update(&mut self, input: f64) -> Option<f64> {
74        let o = self.bands.update(input)?;
75        let width = o.upper - o.lower;
76        let percent_b = if width == 0.0 {
77            // Bands collapsed onto the middle: price is exactly mid-band.
78            0.5
79        } else {
80            (input - o.lower) / width
81        };
82        self.last = Some(percent_b);
83        Some(percent_b)
84    }
85
86    fn reset(&mut self) {
87        self.bands.reset();
88        self.last = None;
89    }
90
91    #[inline]
92    fn warmup_period(&self) -> usize {
93        self.bands.warmup_period()
94    }
95
96    #[inline]
97    fn is_ready(&self) -> bool {
98        self.last.is_some()
99    }
100
101    #[inline]
102    fn name(&self) -> &'static str {
103        "PercentB"
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::traits::BatchExt;
111    use approx::assert_relative_eq;
112
113    #[test]
114    fn new_rejects_invalid_parameters() {
115        assert!(PercentB::new(0, 2.0).is_err());
116        assert!(PercentB::new(20, 0.0).is_err());
117        assert!(PercentB::new(20, -1.0).is_err());
118    }
119
120    /// Cover the public const accessors `period`, `multiplier`, `value`
121    /// and the Indicator-impl `warmup_period` + `name` methods. Existing
122    /// tests only exercise the numeric output of `update` / `batch` /
123    /// `reset` / `is_ready`, so the five getter bodies (lines 53-65,
124    /// 90-92, 98-100) were dead.
125    #[test]
126    fn accessors_and_metadata() {
127        let mut pb = PercentB::new(20, 2.0).unwrap();
128        assert_eq!(pb.period(), 20);
129        assert_relative_eq!(pb.multiplier(), 2.0, epsilon = 1e-12);
130        assert_eq!(pb.value(), None);
131        assert_eq!(pb.warmup_period(), 20);
132        assert_eq!(pb.name(), "PercentB");
133        for i in 1..=20 {
134            pb.update(f64::from(i));
135        }
136        assert!(pb.value().is_some());
137    }
138
139    #[test]
140    fn constant_series_yields_midpoint() {
141        // Flat prices: bands collapse, price is exactly mid-band -> 0.5.
142        let mut pb = PercentB::new(5, 2.0).unwrap();
143        let out = pb.batch(&[100.0; 20]);
144        for v in out.iter().skip(4).flatten() {
145            assert_relative_eq!(*v, 0.5, epsilon = 1e-12);
146        }
147    }
148
149    #[test]
150    fn matches_bands_definition() {
151        // %b must equal (price - lower) / (upper - lower) from BollingerBands.
152        let prices: Vec<f64> = (1..=60)
153            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
154            .collect();
155        let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
156        let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
157        for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
158            // Same warmup — emission shape must agree at every index.
159            assert_eq!(p.is_some(), b.is_some(), "warmup mismatch at index {i}");
160            if let (Some(pv), Some(bv)) = (p, b) {
161                let want = (prices[i] - bv.lower) / (bv.upper - bv.lower);
162                assert_relative_eq!(*pv, want, epsilon = 1e-12);
163            }
164        }
165    }
166
167    /// Deterministic price-at-middle assertion. With period=3, multiplier=2,
168    /// inputs `[1.0, 5.0, 3.0]` give SMA = (1+5+3)/3 = 3.0 at index 2, which
169    /// equals the third price exactly. The stddev is √(8/3) ≈ 1.633, so the
170    /// bands have non-zero width (the width==0 fallback at line 77 is NOT
171    /// taken) and the divide path at line 79 runs. Because price sits on
172    /// the centre line of symmetric bands, %b lands on exactly 0.5.
173    ///
174    /// The previous oscillation-based variant of this test never landed
175    /// `prices[i]` within 1e-9 of the rolling SMA, so its inner
176    /// `assert_relative_eq!` line was never executed.
177    #[test]
178    fn price_at_middle_is_half() {
179        let mut pb = PercentB::new(3, 2.0).unwrap();
180        let out = pb.batch(&[1.0, 5.0, 3.0]);
181        assert_eq!(out[0], None);
182        assert_eq!(out[1], None);
183        let v = out[2].expect("warmed up at index 2");
184        assert_relative_eq!(v, 0.5, epsilon = 1e-12);
185    }
186
187    #[test]
188    fn reset_clears_state() {
189        let mut pb = PercentB::new(5, 2.0).unwrap();
190        pb.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
191        assert!(pb.is_ready());
192        pb.reset();
193        assert!(!pb.is_ready());
194        assert_eq!(pb.update(1.0), None);
195    }
196
197    #[test]
198    fn batch_equals_streaming() {
199        let prices: Vec<f64> = (1..=80)
200            .map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
201            .collect();
202        let batch = PercentB::new(20, 2.0).unwrap().batch(&prices);
203        let mut b = PercentB::new(20, 2.0).unwrap();
204        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
205        assert_eq!(batch, streamed);
206    }
207}