wickra_core/indicators/
breadth_thrust.rs1use crate::cross_section::CrossSection;
4use crate::error::Result;
5use crate::traits::Indicator;
6use crate::Ema;
7
8#[derive(Debug, Clone)]
36pub struct BreadthThrust {
37 ema: Ema,
38}
39
40impl BreadthThrust {
41 pub fn new(period: usize) -> Result<Self> {
47 Ok(Self {
48 ema: Ema::new(period)?,
49 })
50 }
51
52 #[must_use]
54 pub const fn period(&self) -> usize {
55 self.ema.period()
56 }
57}
58
59impl Indicator for BreadthThrust {
60 type Input = CrossSection;
61 type Output = f64;
62
63 #[inline]
64 fn update(&mut self, section: CrossSection) -> Option<f64> {
65 let advancers = section.advancers();
66 let decliners = section.decliners();
67 let participating = (advancers + decliners).max(1) as f64;
68 let share = advancers as f64 / participating;
69 self.ema.update(share)
70 }
71
72 fn reset(&mut self) {
73 self.ema.reset();
74 }
75
76 #[inline]
77 fn warmup_period(&self) -> usize {
78 self.ema.period()
79 }
80
81 #[inline]
82 fn is_ready(&self) -> bool {
83 self.ema.value().is_some()
84 }
85
86 #[inline]
87 fn name(&self) -> &'static str {
88 "BreadthThrust"
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use crate::cross_section::Member;
96 use crate::error::Error;
97 use crate::traits::BatchExt;
98 use approx::assert_relative_eq;
99
100 fn section(up: usize, down: usize) -> CrossSection {
101 let mut members = Vec::new();
102 for _ in 0..up {
103 members.push(Member::new(1.0, 10.0, false, false));
104 }
105 for _ in 0..down {
106 members.push(Member::new(-1.0, 10.0, false, false));
107 }
108 members.push(Member::new(0.0, 10.0, false, false));
109 CrossSection::new(members, 0).unwrap()
110 }
111
112 #[test]
113 fn accessors_and_metadata() {
114 let bt = BreadthThrust::new(10).unwrap();
115 assert_eq!(bt.name(), "BreadthThrust");
116 assert_eq!(bt.warmup_period(), 10);
117 assert_eq!(bt.period(), 10);
118 assert!(!bt.is_ready());
119 }
120
121 #[test]
122 fn rejects_zero_period() {
123 assert!(matches!(BreadthThrust::new(0), Err(Error::PeriodZero)));
124 }
125
126 #[test]
127 fn smooths_the_advancing_share() {
128 let mut bt = BreadthThrust::new(2).unwrap();
129 assert_eq!(bt.update(section(8, 2)), None);
131 let value = bt.update(section(6, 4)).unwrap();
133 assert!((value - 0.7).abs() < 1e-9);
134 assert!(bt.is_ready());
135 let value = bt.update(section(5, 5)).unwrap();
137 assert!((value - (0.7 + 2.0 / 3.0 * (0.5 - 0.7))).abs() < 1e-9);
138 }
139
140 #[test]
141 fn empty_participation_floors_to_zero_share() {
142 let mut bt = BreadthThrust::new(1).unwrap();
143 assert_eq!(bt.update(section(0, 0)), Some(0.0));
145 }
146
147 #[test]
148 fn reset_clears_state() {
149 let mut bt = BreadthThrust::new(2).unwrap();
150 bt.update(section(8, 2));
151 bt.update(section(6, 4));
152 assert!(bt.is_ready());
153 bt.reset();
154 assert!(!bt.is_ready());
155 assert_eq!(bt.update(section(8, 2)), None);
156 }
157
158 #[test]
159 fn batch_equals_streaming() {
160 let sections = vec![section(8, 2), section(6, 4), section(5, 5), section(0, 0)];
161 let mut a = BreadthThrust::new(2).unwrap();
162 let mut b = BreadthThrust::new(2).unwrap();
163 assert_eq!(
164 a.batch(§ions),
165 sections
166 .iter()
167 .map(|s| b.update(s.clone()))
168 .collect::<Vec<_>>()
169 );
170 }
171
172 fn mixed_sections() -> Vec<CrossSection> {
173 (0..30_usize)
174 .map(|i| section((i * 7) % 11, (i * 3) % 8))
175 .collect()
176 }
177
178 #[test]
179 fn rejects_oversized_period() {
180 let too_long = crate::error::MAX_PERIOD + 1;
181 assert!(matches!(
182 BreadthThrust::new(too_long),
183 Err(Error::InvalidPeriod { .. })
184 ));
185 }
186
187 #[test]
188 fn first_value_lands_exactly_at_warmup_index() {
189 let sections = mixed_sections();
190 let mut bt = BreadthThrust::new(10).unwrap();
191 let warmup = bt.warmup_period();
192 let out = bt.batch(§ions);
193 assert!(out.iter().take(warmup - 1).all(Option::is_none));
194 assert!(out.iter().skip(warmup - 1).all(Option::is_some));
195 }
196
197 #[test]
198 fn reset_replays_identically_to_fresh_instance() {
199 let sections = mixed_sections();
200 let mut used = BreadthThrust::new(10).unwrap();
201 used.batch(§ions);
202 used.reset();
203 let replay = used.batch(§ions);
204 assert_eq!(replay, BreadthThrust::new(10).unwrap().batch(§ions));
205 }
206
207 #[test]
208 fn batch_equals_streaming_bit_identical() {
209 let sections = mixed_sections();
210 let batch = BreadthThrust::new(10).unwrap().batch(§ions);
211 let mut streamer = BreadthThrust::new(10).unwrap();
212 let identical = sections
213 .iter()
214 .zip(&batch)
215 .all(|(s, b)| streamer.update(s.clone()).map(f64::to_bits) == b.map(f64::to_bits));
216 assert!(identical);
217 }
218
219 #[test]
226 fn reference_values_ema_ten() {
227 let mut bt = BreadthThrust::new(10).unwrap();
228 for _ in 0..5 {
229 assert_eq!(bt.update(section(3, 7)), None);
230 }
231 for _ in 0..4 {
232 assert_eq!(bt.update(section(4, 6)), None);
233 }
234 assert_relative_eq!(bt.update(section(4, 6)).unwrap(), 0.35, epsilon = 1e-12);
235 assert_relative_eq!(bt.update(section(9, 1)).unwrap(), 0.45, epsilon = 1e-12);
236 assert_relative_eq!(
237 bt.update(section(0, 0)).unwrap(),
238 0.45 * 9.0 / 11.0,
239 epsilon = 1e-12
240 );
241 }
242
243 #[test]
244 fn unanimous_breadth_pins_the_extremes() {
245 let mut up = BreadthThrust::new(3).unwrap();
246 let ups = up.batch(&[section(5, 0), section(7, 0), section(1, 0), section(2, 0)]);
247 assert_eq!(ups, vec![None, None, Some(1.0), Some(1.0)]);
248 let mut down = BreadthThrust::new(3).unwrap();
249 let downs = down.batch(&[section(0, 5), section(0, 7), section(0, 1)]);
250 assert_eq!(downs, vec![None, None, Some(0.0)]);
251 }
252}