Skip to main content

wickra_core/indicators/
stc.rs

1//! Schaff Trend Cycle (STC).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::ema::Ema;
7use crate::traits::Indicator;
8
9/// Doug Schaff's Trend Cycle — a doubly-`Stochastic`-smoothed MACD that
10/// produces a bounded `[0, 100]` reading reacting faster than `MACD` itself.
11///
12/// ```text
13/// macd_t  = EMA(close, fast)_t − EMA(close, slow)_t
14/// %K_t    = 100 · (macd − LL(macd, period)) / (HH(macd, period) − LL(macd, period))
15/// %D_t    = %D_{t-1} + factor · (%K_t − %D_{t-1})           // half-EMA when factor = 0.5
16/// %K2_t   = 100 · (%D − LL(%D, period)) / (HH(%D, period) − LL(%D, period))
17/// STC_t   = STC_{t-1} + factor · (%K2_t − STC_{t-1})
18/// ```
19///
20/// Wickra uses `factor = 0.5` and Schaff's recommended defaults
21/// `(fast = 23, slow = 50, period = 10)`. The stochastic stages clamp to `0`
22/// when the window range collapses (perfectly flat input), and the smoothing
23/// stages hold their previous value if the upstream stage is not yet ready —
24/// so a flat input series settles deterministically at `0` after warmup.
25///
26/// # Example
27///
28/// ```
29/// use wickra_core::{Indicator, Stc};
30///
31/// let mut stc = Stc::classic();
32/// let mut last = None;
33/// for i in 0..200 {
34///     last = stc.update(100.0 + f64::from(i));
35/// }
36/// assert!(last.is_some());
37/// ```
38#[derive(Debug, Clone)]
39pub struct Stc {
40    fast_period: usize,
41    slow_period: usize,
42    schaff_period: usize,
43    factor: f64,
44    fast_ema: Ema,
45    slow_ema: Ema,
46    macd_window: VecDeque<f64>,
47    d_window: VecDeque<f64>,
48    last_d: Option<f64>,
49    last_value: Option<f64>,
50}
51
52impl Stc {
53    /// # Errors
54    /// - [`Error::PeriodZero`] if any period is zero.
55    /// - [`Error::InvalidPeriod`] if `fast >= slow` or `factor` is not in `(0, 1]`.
56    pub fn new(fast: usize, slow: usize, schaff_period: usize, factor: f64) -> Result<Self> {
57        if fast == 0 || slow == 0 || schaff_period == 0 {
58            return Err(Error::PeriodZero);
59        }
60        if fast >= slow {
61            return Err(Error::InvalidPeriod {
62                message: "STC fast period must be strictly less than slow",
63            });
64        }
65        if !factor.is_finite() || factor <= 0.0 || factor > 1.0 {
66            return Err(Error::InvalidPeriod {
67                message: "STC factor must be a finite value in (0, 1]",
68            });
69        }
70        Ok(Self {
71            fast_period: fast,
72            slow_period: slow,
73            schaff_period,
74            factor,
75            fast_ema: Ema::new(fast)?,
76            slow_ema: Ema::new(slow)?,
77            macd_window: VecDeque::with_capacity(schaff_period),
78            d_window: VecDeque::with_capacity(schaff_period),
79            last_d: None,
80            last_value: None,
81        })
82    }
83
84    /// Schaff's recommended defaults `(fast = 23, slow = 50, period = 10, factor = 0.5)`.
85    pub fn classic() -> Self {
86        Self::new(23, 50, 10, 0.5).expect("classic STC parameters are valid")
87    }
88
89    /// Configured `(fast, slow, schaff_period, factor)`.
90    pub const fn params(&self) -> (usize, usize, usize, f64) {
91        (
92            self.fast_period,
93            self.slow_period,
94            self.schaff_period,
95            self.factor,
96        )
97    }
98}
99
100fn rolling_minmax(window: &VecDeque<f64>) -> (f64, f64) {
101    let mut lo = f64::INFINITY;
102    let mut hi = f64::NEG_INFINITY;
103    for &v in window {
104        if v < lo {
105            lo = v;
106        }
107        if v > hi {
108            hi = v;
109        }
110    }
111    (lo, hi)
112}
113
114impl Indicator for Stc {
115    type Input = f64;
116    type Output = f64;
117
118    fn update(&mut self, input: f64) -> Option<f64> {
119        let f = self.fast_ema.update(input);
120        let s = self.slow_ema.update(input);
121        let (f, s) = (f?, s?);
122        let macd = f - s;
123
124        if self.macd_window.len() == self.schaff_period {
125            self.macd_window.pop_front();
126        }
127        self.macd_window.push_back(macd);
128        if self.macd_window.len() < self.schaff_period {
129            return None;
130        }
131
132        let (lo, hi) = rolling_minmax(&self.macd_window);
133        let k = if hi > lo {
134            100.0 * (macd - lo) / (hi - lo)
135        } else {
136            0.0
137        };
138
139        let d = match self.last_d {
140            Some(prev) => prev + self.factor * (k - prev),
141            None => k,
142        };
143        self.last_d = Some(d);
144
145        if self.d_window.len() == self.schaff_period {
146            self.d_window.pop_front();
147        }
148        self.d_window.push_back(d);
149        if self.d_window.len() < self.schaff_period {
150            return None;
151        }
152
153        let (lo_d, hi_d) = rolling_minmax(&self.d_window);
154        let k2 = if hi_d > lo_d {
155            100.0 * (d - lo_d) / (hi_d - lo_d)
156        } else {
157            0.0
158        };
159
160        let stc = match self.last_value {
161            Some(prev) => prev + self.factor * (k2 - prev),
162            None => k2,
163        };
164        self.last_value = Some(stc);
165        Some(stc.clamp(0.0, 100.0))
166    }
167
168    fn reset(&mut self) {
169        self.fast_ema.reset();
170        self.slow_ema.reset();
171        self.macd_window.clear();
172        self.d_window.clear();
173        self.last_d = None;
174        self.last_value = None;
175    }
176
177    fn warmup_period(&self) -> usize {
178        // Slow EMA emits at `slow` inputs. Then the macd-window needs
179        // `schaff_period − 1` more inputs to fill, and the d-window another
180        // `schaff_period − 1` after that.
181        self.slow_period + 2 * (self.schaff_period - 1)
182    }
183
184    fn is_ready(&self) -> bool {
185        self.last_value.is_some() && self.d_window.len() == self.schaff_period
186    }
187
188    fn name(&self) -> &'static str {
189        "STC"
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::traits::BatchExt;
197
198    #[test]
199    fn rejects_zero_period() {
200        assert!(matches!(Stc::new(0, 50, 10, 0.5), Err(Error::PeriodZero)));
201        assert!(matches!(Stc::new(23, 0, 10, 0.5), Err(Error::PeriodZero)));
202        assert!(matches!(Stc::new(23, 50, 0, 0.5), Err(Error::PeriodZero)));
203    }
204
205    #[test]
206    fn rejects_invalid_params() {
207        assert!(matches!(
208            Stc::new(50, 23, 10, 0.5),
209            Err(Error::InvalidPeriod { .. })
210        ));
211        assert!(matches!(
212            Stc::new(23, 50, 10, 0.0),
213            Err(Error::InvalidPeriod { .. })
214        ));
215        assert!(matches!(
216            Stc::new(23, 50, 10, 1.5),
217            Err(Error::InvalidPeriod { .. })
218        ));
219        assert!(matches!(
220            Stc::new(23, 50, 10, f64::NAN),
221            Err(Error::InvalidPeriod { .. })
222        ));
223    }
224
225    #[test]
226    fn accessors_and_metadata() {
227        let stc = Stc::classic();
228        let (f, s, p, k) = stc.params();
229        assert_eq!((f, s, p), (23, 50, 10));
230        assert!((k - 0.5).abs() < 1e-12);
231        assert_eq!(stc.warmup_period(), 50 + 18);
232        assert_eq!(stc.name(), "STC");
233    }
234
235    #[test]
236    fn classic_factory() {
237        let (f, s, p, k) = Stc::classic().params();
238        assert_eq!((f, s, p), (23, 50, 10));
239        assert!((k - 0.5).abs() < 1e-12);
240    }
241
242    #[test]
243    fn constant_series_yields_zero() {
244        // Flat input -> macd is 0 every bar -> stochastic-on-flat-window
245        // returns 0 -> d stays at 0 -> %K2 returns 0 -> STC stays at 0.
246        let mut stc = Stc::new(3, 5, 4, 0.5).unwrap();
247        let out = stc.batch(&[42.0_f64; 80]);
248        for v in out.iter().rev().take(5).flatten() {
249            assert_eq!(*v, 0.0);
250        }
251    }
252
253    #[test]
254    fn warmup_emits_first_value_at_warmup_period() {
255        let mut stc = Stc::new(2, 4, 3, 0.5).unwrap();
256        // slow(4) + 2*(3-1) = 8.
257        assert_eq!(stc.warmup_period(), 8);
258        let prices: Vec<f64> = (1..=10).map(f64::from).collect();
259        let out = stc.batch(&prices);
260        for v in out.iter().take(7) {
261            assert!(v.is_none());
262        }
263        assert!(out[7].is_some());
264    }
265
266    #[test]
267    fn output_is_bounded() {
268        let mut stc = Stc::classic();
269        let prices: Vec<f64> = (0..400)
270            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 25.0)
271            .collect();
272        for v in stc.batch(&prices).iter().flatten() {
273            assert!((0.0..=100.0).contains(v), "STC out of [0, 100]: {v}");
274        }
275    }
276
277    #[test]
278    fn oscillating_series_visits_full_range() {
279        // STC needs a non-degenerate MACD range to exercise the two
280        // stochastic stages. A purely monotone series collapses the rolling
281        // window (constant MACD) and a purely flat one collapses both
282        // stages — in either case both inner ranges become zero and STC
283        // sticks at 0. A sinusoidal trend with enough amplitude makes the
284        // stages cycle through the full [0, 100] band.
285        let mut stc = Stc::classic();
286        let prices: Vec<f64> = (0..400)
287            .map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 30.0)
288            .collect();
289        let out = stc.batch(&prices);
290        let mut saw_high = false;
291        let mut saw_low = false;
292        for v in out.iter().flatten() {
293            if *v > 80.0 {
294                saw_high = true;
295            }
296            if *v < 20.0 {
297                saw_low = true;
298            }
299        }
300        assert!(
301            saw_high,
302            "STC should reach above 80 on a strong oscillation"
303        );
304        assert!(saw_low, "STC should reach below 20 on a strong oscillation");
305    }
306
307    #[test]
308    fn batch_equals_streaming() {
309        let prices: Vec<f64> = (1..=200)
310            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
311            .collect();
312        let mut a = Stc::classic();
313        let mut b = Stc::classic();
314        assert_eq!(
315            a.batch(&prices),
316            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
317        );
318    }
319
320    #[test]
321    fn reset_clears_state() {
322        let mut stc = Stc::classic();
323        stc.batch(&(1..=200).map(f64::from).collect::<Vec<_>>());
324        assert!(stc.is_ready());
325        stc.reset();
326        assert!(!stc.is_ready());
327        assert!(stc.last_value.is_none());
328    }
329}