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 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 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 #[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 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 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 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 #[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}