Skip to main content

wickra_core/indicators/
breadth_thrust.rs

1//! Breadth Thrust (Zweig) — an exponential moving average of the advancing-issues share.
2
3use crate::cross_section::CrossSection;
4use crate::error::Result;
5use crate::traits::Indicator;
6use crate::Ema;
7
8/// Breadth Thrust (Zweig) — an exponential moving average of the advancing-issues
9/// share, `advancers / (advancers + decliners)`.
10///
11/// Martin Zweig's breadth thrust smooths the fraction of participating issues
12/// that are advancing with an EMA (`α = 2 / (period + 1)`, seeded with the simple
13/// mean of the first `period` shares; Zweig's period is 10). A "thrust"
14/// fires when this average climbs from below ~0.40 (oversold, washed-out breadth)
15/// to above ~0.615 within about ten sessions — historically a rare, reliable
16/// signal that a powerful new advance has begun with broad participation.
17///
18/// Each tick's share floors the participating count to one, so a tick with no
19/// advancing or declining issues contributes a defined `0.0` instead of dividing
20/// by zero. The reading is `None` until `period` ticks have been seen.
21///
22/// `Input = CrossSection`, `Output = f64` (a share in `0..=1`),
23/// `warmup_period == period`.
24///
25/// # Example
26///
27/// ```
28/// use wickra_core::{BreadthThrust, CrossSection, Indicator, Member};
29///
30/// let mut bt = BreadthThrust::new(2).unwrap();
31/// let up = CrossSection::new(vec![Member::new(1.0, 1.0, false, false)], 0).unwrap();
32/// assert_eq!(bt.update(up.clone()), None); // warming up
33/// assert_eq!(bt.update(up), Some(1.0)); // both ticks 100% advancing
34/// ```
35#[derive(Debug, Clone)]
36pub struct BreadthThrust {
37    ema: Ema,
38}
39
40impl BreadthThrust {
41    /// Construct a new Breadth Thrust over the given window length.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
46    pub fn new(period: usize) -> Result<Self> {
47        Ok(Self {
48            ema: Ema::new(period)?,
49        })
50    }
51
52    /// Configured window length.
53    #[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        // share = 8 / 10 = 0.8 ; window not full yet.
130        assert_eq!(bt.update(section(8, 2)), None);
131        // share = 6 / 10 = 0.6 ; EMA(2) seeds with the mean (0.8 + 0.6) / 2 = 0.7.
132        let value = bt.update(section(6, 4)).unwrap();
133        assert!((value - 0.7).abs() < 1e-9);
134        assert!(bt.is_ready());
135        // share = 5 / 10 = 0.5 ; EMA(2), α = 2/3: 0.7 + 2/3 · (0.5 − 0.7).
136        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        // No advancers or decliners -> 0 / max(0, 1) = 0.0.
144        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(&sections),
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(&sections);
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(&sections);
202        used.reset();
203        let replay = used.batch(&sections);
204        assert_eq!(replay, BreadthThrust::new(10).unwrap().batch(&sections));
205    }
206
207    #[test]
208    fn batch_equals_streaming_bit_identical() {
209        let sections = mixed_sections();
210        let batch = BreadthThrust::new(10).unwrap().batch(&sections);
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    /// Hand-computed Zweig EMA(10). Ticks 1–5 are 3 up / 7 down (share 0.3),
220    /// ticks 6–10 are 4 up / 6 down (share 0.4): the seed is their simple mean
221    /// `(5 · 0.3 + 5 · 0.4) / 10 = 0.35`. Tick 11 is 9 up / 1 down (share 0.9):
222    /// `α = 2 / 11`, `EMA = 0.35 + (2/11) · (0.9 − 0.35) = 0.35 + 0.1 = 0.45`.
223    /// Tick 12 has no advancers or decliners (share 0):
224    /// `EMA = 0.45 + (2/11) · (0 − 0.45) = 0.45 · 9 / 11`.
225    #[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}