Skip to main content

wickra_core/indicators/
breadth_thrust.rs

1//! Breadth Thrust (Zweig) — a moving average of the advancing-issues share.
2
3use crate::cross_section::CrossSection;
4use crate::error::Result;
5use crate::traits::Indicator;
6use crate::Sma;
7
8/// Breadth Thrust (Zweig) — a simple 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 over a short window (the classic period is 10). A "thrust"
13/// fires when this average climbs from below ~0.40 (oversold, washed-out breadth)
14/// to above ~0.615 within about ten sessions — historically a rare, reliable
15/// signal that a powerful new advance has begun with broad participation.
16///
17/// Each tick's share floors the participating count to one, so a tick with no
18/// advancing or declining issues contributes a defined `0.0` instead of dividing
19/// by zero. The reading is `None` until `period` ticks have been seen.
20///
21/// `Input = CrossSection`, `Output = f64` (a share in `0..=1`),
22/// `warmup_period == period`.
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{BreadthThrust, CrossSection, Indicator, Member};
28///
29/// let mut bt = BreadthThrust::new(2).unwrap();
30/// let up = CrossSection::new(vec![Member::new(1.0, 1.0, false, false)], 0).unwrap();
31/// assert_eq!(bt.update(up.clone()), None); // warming up
32/// assert_eq!(bt.update(up), Some(1.0)); // both ticks 100% advancing
33/// ```
34#[derive(Debug, Clone)]
35pub struct BreadthThrust {
36    sma: Sma,
37}
38
39impl BreadthThrust {
40    /// Construct a new Breadth Thrust over the given window length.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
45    pub fn new(period: usize) -> Result<Self> {
46        Ok(Self {
47            sma: Sma::new(period)?,
48        })
49    }
50
51    /// Configured window length.
52    #[must_use]
53    pub const fn period(&self) -> usize {
54        self.sma.period()
55    }
56}
57
58impl Indicator for BreadthThrust {
59    type Input = CrossSection;
60    type Output = f64;
61
62    #[inline]
63    fn update(&mut self, section: CrossSection) -> Option<f64> {
64        let advancers = section.advancers();
65        let decliners = section.decliners();
66        let participating = (advancers + decliners).max(1) as f64;
67        let share = advancers as f64 / participating;
68        self.sma.update(share)
69    }
70
71    fn reset(&mut self) {
72        self.sma.reset();
73    }
74
75    #[inline]
76    fn warmup_period(&self) -> usize {
77        self.sma.period()
78    }
79
80    #[inline]
81    fn is_ready(&self) -> bool {
82        self.sma.value().is_some()
83    }
84
85    #[inline]
86    fn name(&self) -> &'static str {
87        "BreadthThrust"
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::cross_section::Member;
95    use crate::error::Error;
96    use crate::traits::BatchExt;
97
98    fn section(up: usize, down: usize) -> CrossSection {
99        let mut members = Vec::new();
100        for _ in 0..up {
101            members.push(Member::new(1.0, 10.0, false, false));
102        }
103        for _ in 0..down {
104            members.push(Member::new(-1.0, 10.0, false, false));
105        }
106        members.push(Member::new(0.0, 10.0, false, false));
107        CrossSection::new(members, 0).unwrap()
108    }
109
110    #[test]
111    fn accessors_and_metadata() {
112        let bt = BreadthThrust::new(10).unwrap();
113        assert_eq!(bt.name(), "BreadthThrust");
114        assert_eq!(bt.warmup_period(), 10);
115        assert_eq!(bt.period(), 10);
116        assert!(!bt.is_ready());
117    }
118
119    #[test]
120    fn rejects_zero_period() {
121        assert!(matches!(BreadthThrust::new(0), Err(Error::PeriodZero)));
122    }
123
124    #[test]
125    fn averages_the_advancing_share() {
126        let mut bt = BreadthThrust::new(2).unwrap();
127        // share = 8 / 10 = 0.8 ; window not full yet.
128        assert_eq!(bt.update(section(8, 2)), None);
129        // share = 6 / 10 = 0.6 ; SMA(2) = (0.8 + 0.6) / 2 = 0.7.
130        let value = bt.update(section(6, 4)).unwrap();
131        assert!((value - 0.7).abs() < 1e-9);
132        assert!(bt.is_ready());
133        // share = 5 / 10 = 0.5 ; SMA(2) = (0.6 + 0.5) / 2 = 0.55.
134        let value = bt.update(section(5, 5)).unwrap();
135        assert!((value - 0.55).abs() < 1e-9);
136    }
137
138    #[test]
139    fn empty_participation_floors_to_zero_share() {
140        let mut bt = BreadthThrust::new(1).unwrap();
141        // No advancers or decliners -> 0 / max(0, 1) = 0.0.
142        assert_eq!(bt.update(section(0, 0)), Some(0.0));
143    }
144
145    #[test]
146    fn reset_clears_state() {
147        let mut bt = BreadthThrust::new(2).unwrap();
148        bt.update(section(8, 2));
149        bt.update(section(6, 4));
150        assert!(bt.is_ready());
151        bt.reset();
152        assert!(!bt.is_ready());
153        assert_eq!(bt.update(section(8, 2)), None);
154    }
155
156    #[test]
157    fn batch_equals_streaming() {
158        let sections = vec![section(8, 2), section(6, 4), section(5, 5), section(0, 0)];
159        let mut a = BreadthThrust::new(2).unwrap();
160        let mut b = BreadthThrust::new(2).unwrap();
161        assert_eq!(
162            a.batch(&sections),
163            sections
164                .iter()
165                .map(|s| b.update(s.clone()))
166                .collect::<Vec<_>>()
167        );
168    }
169}