Skip to main content

wickra_core/indicators/
kst.rs

1//! Know Sure Thing (KST).
2
3use crate::error::{Error, Result};
4use crate::indicators::roc::Roc;
5use crate::indicators::sma::Sma;
6use crate::traits::Indicator;
7
8/// `KST` output: the indicator line and its `SMA` signal line.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct KstOutput {
11    /// Weighted sum of four smoothed `ROC` series.
12    pub kst: f64,
13    /// `SMA` of `kst` over the signal period.
14    pub signal: f64,
15}
16
17/// Pring's Know Sure Thing — a long-horizon momentum oscillator that combines
18/// four `ROC` series at different lookbacks, each smoothed by its own `SMA`,
19/// summed with Pring's fixed weights `1, 2, 3, 4`:
20///
21/// ```text
22/// RCMA_i = SMA(ROC(close, roc_i), sma_i)        for i = 1..=4
23/// KST    = 1·RCMA_1 + 2·RCMA_2 + 3·RCMA_3 + 4·RCMA_4
24/// Signal = SMA(KST, signal_period)
25/// ```
26///
27/// Pring's recommended defaults are
28/// `(roc1, roc2, roc3, roc4) = (10, 15, 20, 30)`,
29/// `(sma1, sma2, sma3, sma4) = (10, 10, 10, 15)`,
30/// `signal_period = 9`. `Kst::classic()` constructs that configuration.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Indicator, Kst};
36///
37/// let mut kst = Kst::classic();
38/// let mut last = None;
39/// for i in 0..200 {
40///     last = kst.update(100.0 + f64::from(i));
41/// }
42/// assert!(last.is_some());
43/// ```
44#[derive(Debug, Clone)]
45pub struct Kst {
46    roc1_period: usize,
47    roc2_period: usize,
48    roc3_period: usize,
49    roc4_period: usize,
50    sma1_period: usize,
51    sma2_period: usize,
52    sma3_period: usize,
53    sma4_period: usize,
54    signal_period: usize,
55    roc1: Roc,
56    roc2: Roc,
57    roc3: Roc,
58    roc4: Roc,
59    sma1: Sma,
60    sma2: Sma,
61    sma3: Sma,
62    sma4: Sma,
63    signal_sma: Sma,
64    last_line: Option<f64>,
65    last_signal: Option<f64>,
66}
67
68impl Kst {
69    /// # Errors
70    /// Returns [`Error::PeriodZero`] if any of the nine periods is zero.
71    #[allow(clippy::too_many_arguments)]
72    pub fn new(
73        roc1: usize,
74        roc2: usize,
75        roc3: usize,
76        roc4: usize,
77        sma1: usize,
78        sma2: usize,
79        sma3: usize,
80        sma4: usize,
81        signal: usize,
82    ) -> Result<Self> {
83        if [roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal].contains(&0) {
84            return Err(Error::PeriodZero);
85        }
86        Ok(Self {
87            roc1_period: roc1,
88            roc2_period: roc2,
89            roc3_period: roc3,
90            roc4_period: roc4,
91            sma1_period: sma1,
92            sma2_period: sma2,
93            sma3_period: sma3,
94            sma4_period: sma4,
95            signal_period: signal,
96            roc1: Roc::new(roc1)?,
97            roc2: Roc::new(roc2)?,
98            roc3: Roc::new(roc3)?,
99            roc4: Roc::new(roc4)?,
100            sma1: Sma::new(sma1)?,
101            sma2: Sma::new(sma2)?,
102            sma3: Sma::new(sma3)?,
103            sma4: Sma::new(sma4)?,
104            signal_sma: Sma::new(signal)?,
105            last_line: None,
106            last_signal: None,
107        })
108    }
109
110    /// Pring's recommended defaults: `KST(10, 15, 20, 30, 10, 10, 10, 15, 9)`.
111    pub fn classic() -> Self {
112        Self::new(10, 15, 20, 30, 10, 10, 10, 15, 9).expect("classic KST parameters are valid")
113    }
114
115    /// Configured `(roc1, roc2, roc3, roc4, sma1, sma2, sma3, sma4, signal)`.
116    pub const fn periods(
117        &self,
118    ) -> (
119        usize,
120        usize,
121        usize,
122        usize,
123        usize,
124        usize,
125        usize,
126        usize,
127        usize,
128    ) {
129        (
130            self.roc1_period,
131            self.roc2_period,
132            self.roc3_period,
133            self.roc4_period,
134            self.sma1_period,
135            self.sma2_period,
136            self.sma3_period,
137            self.sma4_period,
138            self.signal_period,
139        )
140    }
141}
142
143impl Indicator for Kst {
144    type Input = f64;
145    type Output = KstOutput;
146
147    #[inline]
148    fn update(&mut self, input: f64) -> Option<KstOutput> {
149        // Feed every inner state machine on every input so they warm up in
150        // parallel. The KST line waits for all four RCMA branches; the signal
151        // line additionally waits for its own SMA to fill.
152        let r1 = self.roc1.update(input);
153        let r2 = self.roc2.update(input);
154        let r3 = self.roc3.update(input);
155        let r4 = self.roc4.update(input);
156        let rcma1 = r1.and_then(|x| self.sma1.update(x));
157        let rcma2 = r2.and_then(|x| self.sma2.update(x));
158        let rcma3 = r3.and_then(|x| self.sma3.update(x));
159        let rcma4 = r4.and_then(|x| self.sma4.update(x));
160        let (rcma1, rcma2, rcma3, rcma4) = (rcma1?, rcma2?, rcma3?, rcma4?);
161        let kst = rcma1 + 2.0 * rcma2 + 3.0 * rcma3 + 4.0 * rcma4;
162        self.last_line = Some(kst);
163        let signal = self.signal_sma.update(kst);
164        let signal = signal?;
165        self.last_signal = Some(signal);
166        Some(KstOutput { kst, signal })
167    }
168
169    fn reset(&mut self) {
170        self.roc1.reset();
171        self.roc2.reset();
172        self.roc3.reset();
173        self.roc4.reset();
174        self.sma1.reset();
175        self.sma2.reset();
176        self.sma3.reset();
177        self.sma4.reset();
178        self.signal_sma.reset();
179        self.last_line = None;
180        self.last_signal = None;
181    }
182
183    #[inline]
184    fn warmup_period(&self) -> usize {
185        // Each RCMA_i emits once the inner ROC has warmed up (roc_i + 1
186        // inputs) AND the SMA has filled (sma_i inputs through it). All four
187        // run in parallel so the slowest branch dominates, and the signal SMA
188        // adds signal_period − 1 inputs on top of the slowest branch.
189        let branch = |roc: usize, sma: usize| roc + sma;
190        let slowest = branch(self.roc1_period, self.sma1_period)
191            .max(branch(self.roc2_period, self.sma2_period))
192            .max(branch(self.roc3_period, self.sma3_period))
193            .max(branch(self.roc4_period, self.sma4_period));
194        slowest + self.signal_period - 1
195    }
196
197    #[inline]
198    fn is_ready(&self) -> bool {
199        self.last_signal.is_some()
200    }
201
202    #[inline]
203    fn name(&self) -> &'static str {
204        "KST"
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::traits::BatchExt;
212    use approx::assert_relative_eq;
213
214    #[test]
215    fn rejects_zero_period() {
216        assert!(matches!(
217            Kst::new(0, 15, 20, 30, 10, 10, 10, 15, 9),
218            Err(Error::PeriodZero)
219        ));
220        assert!(matches!(
221            Kst::new(10, 15, 20, 30, 10, 10, 10, 15, 0),
222            Err(Error::PeriodZero)
223        ));
224    }
225
226    #[test]
227    fn accessors_and_metadata() {
228        let kst = Kst::classic();
229        assert_eq!(kst.periods(), (10, 15, 20, 30, 10, 10, 10, 15, 9));
230        assert_eq!(kst.name(), "KST");
231        // The slowest branch is ROC(30) + SMA(15) = 45; signal_period - 1 = 8.
232        assert_eq!(kst.warmup_period(), 53);
233    }
234
235    #[test]
236    fn classic_factory_matches_pring_defaults() {
237        let kst = Kst::classic();
238        let (r1, r2, r3, r4, s1, s2, s3, s4, sig) = kst.periods();
239        assert_eq!((r1, r2, r3, r4), (10, 15, 20, 30));
240        assert_eq!((s1, s2, s3, s4), (10, 10, 10, 15));
241        assert_eq!(sig, 9);
242    }
243
244    #[test]
245    fn constant_series_yields_zero() {
246        // ROC is zero on a flat series, so every RCMA collapses to zero and
247        // KST itself is zero. The signal SMA inherits that.
248        let mut kst = Kst::classic();
249        let prices = vec![42.0_f64; 80];
250        let out = kst.batch(&prices);
251        for v in out.iter().skip(kst.warmup_period() - 1).flatten() {
252            assert_relative_eq!(v.kst, 0.0, epsilon = 1e-12);
253            assert_relative_eq!(v.signal, 0.0, epsilon = 1e-12);
254        }
255    }
256
257    #[test]
258    fn warmup_emits_first_value_at_warmup_period() {
259        let mut kst = Kst::new(2, 3, 4, 5, 2, 2, 2, 3, 2).unwrap();
260        // Slowest branch is ROC(5) + SMA(3) = 8; signal − 1 = 1; total 9.
261        assert_eq!(kst.warmup_period(), 9);
262        let prices: Vec<f64> = (1..=15).map(f64::from).collect();
263        let out = kst.batch(&prices);
264        for v in out.iter().take(8) {
265            assert!(v.is_none());
266        }
267        assert!(out[8].is_some());
268    }
269
270    #[test]
271    fn pure_uptrend_is_positive() {
272        // Monotonic uptrend -> every ROC > 0 -> every RCMA > 0 -> KST > 0.
273        let mut kst = Kst::classic();
274        let prices: Vec<f64> = (1..=120).map(|i| f64::from(i) * 2.0).collect();
275        let out = kst.batch(&prices);
276        let last = out.iter().rev().flatten().next().unwrap();
277        assert!(
278            last.kst > 0.0,
279            "KST on a clean uptrend should be positive: {}",
280            last.kst
281        );
282        assert!(last.signal > 0.0);
283    }
284
285    #[test]
286    fn batch_equals_streaming() {
287        let prices: Vec<f64> = (1..=120)
288            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1)
289            .collect();
290        let mut a = Kst::classic();
291        let mut b = Kst::classic();
292        assert_eq!(
293            a.batch(&prices),
294            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
295        );
296    }
297
298    #[test]
299    fn reset_clears_state() {
300        let mut kst = Kst::classic();
301        let prices: Vec<f64> = (1..=120).map(f64::from).collect();
302        kst.batch(&prices);
303        assert!(kst.is_ready());
304        kst.reset();
305        assert!(!kst.is_ready());
306    }
307}