wickra_core/indicators/
percent_b.rs1use crate::error::Result;
4use crate::traits::Indicator;
5
6use super::BollingerBands;
7
8#[derive(Debug, Clone)]
33pub struct PercentB {
34 bands: BollingerBands,
35 last: Option<f64>,
36}
37
38impl PercentB {
39 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 pub const fn period(&self) -> usize {
54 self.bands.period()
55 }
56
57 pub const fn multiplier(&self) -> f64 {
59 self.bands.multiplier()
60 }
61
62 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 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 #[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 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 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 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 #[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}