Skip to main content

finance_solution/stocks/ta/
stochastic.rs

1//! Stochastic oscillator — **one core**, many packs via [`StochasticParams`].
2//!
3//! # Fast vs Full
4//!
5//! Not two formulas: **Full** is Fast with extra `%K` smoothing.
6//!
7//! | Style | Params | Meaning |
8//! |-------|--------|---------|
9//! | Fast | `k_smooth = 1` | Raw %K; %D = SMA(%K, d) |
10//! | Full | `k_smooth > 1` | %K = SMA(raw %K, k_smooth); %D = SMA(%K, d) |
11//!
12//! # Quant pattern — `const` pack + validated engine + `.compute`
13//!
14//! This is the **recommended** way for production code that repeatedly runs the same
15//! stochastic variation. Build the pack once (often as a `const`), validate once into
16//! [`ValidatedStochastic`], then call [`.compute`](ValidatedStochastic::compute) on each
17//! new H/L/C batch. Construction is O(1); the O(n) work is only the series math.
18//!
19//! ```
20//! use finance_solution::stocks::ta::{StochasticParams, ValidatedStochastic};
21//!
22//! // 1) Strategy definition — fixed pack, zero heap, can live at module scope:
23//! const FAST_9_3: StochasticParams = StochasticParams::fast(9, 3);
24//! // Other common packs:
25//! // const FAST_14_3: StochasticParams = StochasticParams::fast(14, 3);
26//! // const FULL_14_3_3: StochasticParams = StochasticParams::full(14, 3, 3);
27//! // const FULL_60_10_1: StochasticParams = StochasticParams::full(60, 10, 1);
28//!
29//! // 2) Validate once at startup (period ≥ 1 checks):
30//! let stoch = ValidatedStochastic::new(FAST_9_3).unwrap();
31//!
32//! // 3) Hot path — many batches / symbols reuse `stoch`:
33//! # let h = vec![10.0; 20];
34//! # let l = vec![9.0; 20];
35//! # let c = vec![9.5; 20];
36//! let series = stoch.compute(&h, &l, &c).unwrap();
37//! assert_eq!(series.k.len(), h.len());
38//! // series.k / series.d are Option<f64> with warm-up = None
39//! ```
40//!
41//! Free function form (scripts / one-offs) is fine too — still uses the same `Copy` pack:
42//!
43//! ```
44//! use finance_solution::stocks::ta::{stochastics, StochasticParams};
45//! const FAST_9_3: StochasticParams = StochasticParams::fast(9, 3);
46//! # let h = [11.0_f64; 15];
47//! # let l = [10.0; 15];
48//! # let c = [10.5; 15];
49//! let _ = stochastics(&h, &l, &c, FAST_9_3).unwrap();
50//! ```
51//!
52//! Sample [`stochastics_solution`] table (illustrative):
53//!
54//! ```text
55//! period   close      k      d
56//! ------  ------  -----  -----
57//!      7   19.50    n/a    n/a
58//!      8   19.60  72.00    n/a
59//!     10   19.80  68.00  70.00
60//! ```
61//!
62//! ## Flat window (highest high == lowest low)
63//!
64//! When the lookback range is zero, `%K = 100 * (C − LL) / (HH − LL)` is undefined.
65//!
66//! | Policy | Pros | Cons |
67//! |--------|------|------|
68//! | Always **50** | Simple | Fake “neutral” every flat bar; can invent mean-reversion noise |
69//! | **`None` / skip** | Honest | Holes in the series after warm-up; breaks some smoothers |
70//! | **Carry previous raw %K**, else **50** on the first flat | Continuous series; no spurious 50 flip-flops | Still conventional when no history |
71//!
72//! **This crate uses carry-forward (else 50).** Batch and [`StochState`] share the rule so live
73//! and research match. Documented so you can wrap with a different policy if your desk requires it.
74//!
75use crate::stocks::ta::common::opt_cell;
76use crate::util::error::{require_finite, FinanceError, FinanceResult};
77use crate::util::primitives::PeriodLength;
78use crate::{columns_with_strings, print_table_locale_opt};
79
80/// Unvalidated (but `Copy`) stochastic parameter pack.
81///
82/// Build with [`StochasticParams::fast`], [`StochasticParams::full`], or struct update.
83/// Prefer validating once via [`ValidatedStochastic::new`] for hot paths.
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
85pub struct StochasticParams {
86    /// Lookback for highest high / lowest low.
87    pub k_period: usize,
88    /// SMA length on raw %K (`1` = Fast stochastic).
89    pub k_smooth: usize,
90    /// SMA length on smoothed %K → %D line.
91    pub d_period: usize,
92}
93
94impl StochasticParams {
95    /// Fast stochastic: raw %K over `k_period`, %D = SMA(`d_period`) of %K.
96    ///
97    /// Common packs: `fast(9, 3)`, `fast(14, 3)`.
98    pub const fn fast(k_period: usize, d_period: usize) -> Self {
99        Self {
100            k_period,
101            k_smooth: 1,
102            d_period,
103        }
104    }
105
106    /// Full stochastic: smooth raw %K by `k_smooth`, then %D by `d_period`.
107    ///
108    /// Common packs: `full(14, 3, 3)`, `full(60, 10, 1)`.
109    pub const fn full(k_period: usize, k_smooth: usize, d_period: usize) -> Self {
110        Self {
111            k_period,
112            k_smooth,
113            d_period,
114        }
115    }
116
117    /// Minimum bars before both %K and %D can be defined.
118    pub const fn warm_up_bars(self) -> usize {
119        // first raw %K at k_period-1; need k_smooth-1 more for smooth K; d_period-1 more for D
120        self.k_period
121            .saturating_add(self.k_smooth.saturating_sub(1))
122            .saturating_add(self.d_period.saturating_sub(1))
123    }
124}
125
126/// Params that passed period validation — safe to use in a tight loop.
127///
128/// Construction is O(1). [`ValidatedStochastic::compute`] is O(n) pure math.
129#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
130pub struct ValidatedStochastic {
131    params: StochasticParams,
132}
133
134impl ValidatedStochastic {
135    /// Validate all periods `≥ 1`.
136    pub fn new(params: StochasticParams) -> FinanceResult<Self> {
137        PeriodLength::new(params.k_period)?;
138        PeriodLength::new(params.k_smooth)?;
139        PeriodLength::new(params.d_period)?;
140        Ok(Self { params })
141    }
142
143    #[inline]
144    pub fn params(self) -> StochasticParams {
145        self.params
146    }
147
148    /// Compute %K / %D series (same length as inputs; warm-up = `None`).
149    pub fn compute(
150        self,
151        high: &[f64],
152        low: &[f64],
153        close: &[f64],
154    ) -> FinanceResult<StochasticSeries> {
155        stochastics_validated(high, low, close, self)
156    }
157}
158
159/// Aligned %K / %D output.
160#[derive(Clone, Debug, PartialEq)]
161pub struct StochasticSeries {
162    pub k: Vec<Option<f64>>,
163    pub d: Vec<Option<f64>>,
164    pub params: StochasticParams,
165}
166
167impl StochasticSeries {
168    /// Last defined %K / %D pair, if both present.
169    pub fn last_kd(&self) -> Option<(f64, f64)> {
170        let k = self.k.iter().rev().find_map(|x| *x)?;
171        let d = self.d.iter().rev().find_map(|x| *x)?;
172        Some((k, d))
173    }
174}
175
176/// Stochastic series with raw (possibly unvalidated) params — validates then computes.
177///
178/// For repeated calls with the same pack, prefer [`ValidatedStochastic`].
179pub fn stochastics(
180    high: &[f64],
181    low: &[f64],
182    close: &[f64],
183    params: StochasticParams,
184) -> FinanceResult<StochasticSeries> {
185    let v = ValidatedStochastic::new(params)?;
186    stochastics_validated(high, low, close, v)
187}
188
189/// Teaching solution: formulas + printable %K/%D table.
190///
191/// Prefer [`ValidatedStochastic::compute`] on the hot path; use this for notebooks,
192/// audit trails, and classroom demos.
193///
194/// # Examples
195/// ```
196/// use finance_solution::stocks::ta::{stochastics_solution, StochasticParams};
197/// # let h: Vec<_> = (0..20).map(|i| 20.0 + i as f64).collect();
198/// # let l: Vec<_> = (0..20).map(|i| 18.0 + i as f64).collect();
199/// # let c: Vec<_> = (0..20).map(|i| 19.0 + i as f64).collect();
200/// let sol = stochastics_solution(&h, &l, &c, StochasticParams::fast(9, 3)).unwrap();
201/// assert!(sol.formula().contains("9"));
202/// // sol.print_table();
203/// ```
204pub fn stochastics_solution(
205    high: &[f64],
206    low: &[f64],
207    close: &[f64],
208    params: StochasticParams,
209) -> FinanceResult<StochasticSolution> {
210    let series = stochastics(high, low, close, params)?;
211    let formula = format!(
212        "%K: stoch(k={}, smooth={}); %D: SMA(%K, {})",
213        params.k_period, params.k_smooth, params.d_period
214    );
215    let symbolic =
216        "raw_%K = 100 * (C - LL) / (HH - LL); %K = SMA(raw_%K, k_smooth); %D = SMA(%K, d)"
217            .to_string();
218    Ok(StochasticSolution {
219        series,
220        close: close.to_vec(),
221        formula,
222        symbolic_formula: symbolic,
223    })
224}
225
226/// Teaching wrapper around [`StochasticSeries`].
227#[derive(Clone, Debug)]
228pub struct StochasticSolution {
229    series: StochasticSeries,
230    close: Vec<f64>,
231    formula: String,
232    symbolic_formula: String,
233}
234
235impl StochasticSolution {
236    pub fn series(&self) -> &StochasticSeries {
237        &self.series
238    }
239    pub fn formula(&self) -> &str {
240        &self.formula
241    }
242    pub fn symbolic_formula(&self) -> &str {
243        &self.symbolic_formula
244    }
245    pub fn params(&self) -> StochasticParams {
246        self.series.params
247    }
248
249    /// # Sample output
250    /// ```text
251    /// period   close      k      d
252    /// ------  ------  -----  -----
253    ///      8   19.60  72.00    n/a
254    ///     10   19.80  68.00  70.00
255    /// ```
256    pub fn print_table(&self) {
257        self.print_table_locale_opt(None, None);
258    }
259
260    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
261        self.print_table_locale_opt(Some(locale), Some(precision));
262    }
263
264    fn print_table_locale_opt(
265        &self,
266        locale: Option<&num_format::Locale>,
267        precision: Option<usize>,
268    ) {
269        let columns = columns_with_strings(&[
270            ("period", "i", true),
271            ("close", "f", true),
272            ("k", "f", true),
273            ("d", "f", true),
274        ]);
275        let data = self
276            .close
277            .iter()
278            .enumerate()
279            .map(|(i, c)| {
280                vec![
281                    i.to_string(),
282                    c.to_string(),
283                    opt_cell(self.series.k[i]),
284                    opt_cell(self.series.d[i]),
285                ]
286            })
287            .collect();
288        print_table_locale_opt(&columns, data, locale, precision);
289    }
290}
291
292fn stochastics_validated(
293    high: &[f64],
294    low: &[f64],
295    close: &[f64],
296    v: ValidatedStochastic,
297) -> FinanceResult<StochasticSeries> {
298    let p = v.params;
299    check_hlc(high, low, close)?;
300    let n = close.len();
301    let mut raw_k = vec![None; n];
302    let kp = p.k_period;
303    let mut prev_raw: Option<f64> = None;
304
305    for i in 0..n {
306        if i + 1 < kp {
307            continue;
308        }
309        let start = i + 1 - kp;
310        let mut hh = f64::NEG_INFINITY;
311        let mut ll = f64::INFINITY;
312        for j in start..=i {
313            hh = hh.max(high[j]);
314            ll = ll.min(low[j]);
315        }
316        let range = hh - ll;
317        // Flat window: carry previous raw %K, else 50 (see module docs).
318        let raw = if range == 0.0 {
319            prev_raw.unwrap_or(50.0)
320        } else {
321            100.0 * (close[i] - ll) / range
322        };
323        prev_raw = Some(raw);
324        raw_k[i] = Some(raw);
325    }
326
327    let smooth_k = sma_option_series(&raw_k, p.k_smooth);
328    let d_line = sma_option_series(&smooth_k, p.d_period);
329
330    Ok(StochasticSeries {
331        k: smooth_k,
332        d: d_line,
333        params: p,
334    })
335}
336
337/// SMA over a series that already contains `None` warm-up: only full windows of `Some` values.
338fn sma_option_series(data: &[Option<f64>], period: usize) -> Vec<Option<f64>> {
339    let n = data.len();
340    let mut out = vec![None; n];
341    if period == 0 || n < period {
342        return out;
343    }
344    for i in (period - 1)..n {
345        let start = i + 1 - period;
346        let mut sum = 0.0;
347        let mut ok = true;
348        for j in start..=i {
349            match data[j] {
350                Some(v) => sum += v,
351                None => {
352                    ok = false;
353                    break;
354                }
355            }
356        }
357        if ok {
358            out[i] = Some(sum / period as f64);
359        }
360    }
361    out
362}
363
364fn check_hlc(high: &[f64], low: &[f64], close: &[f64]) -> FinanceResult<()> {
365    if high.is_empty() {
366        return Err(FinanceError::EmptyInput { what: "high" });
367    }
368    if high.len() != low.len() || high.len() != close.len() {
369        return Err(FinanceError::LengthMismatch {
370            left: high.len(),
371            right: close.len(),
372            context: "stochastic high/low/close",
373        });
374    }
375    for i in 0..high.len() {
376        require_finite("high", high[i])?;
377        require_finite("low", low[i])?;
378        require_finite("close", close[i])?;
379        if high[i] < low[i] {
380            return Err(FinanceError::InvalidCashflow {
381                message: "high must be >= low for each bar",
382            });
383        }
384    }
385    Ok(())
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn fast_const_and_validate() {
394        let p = StochasticParams::fast(9, 3);
395        assert_eq!(p.k_smooth, 1);
396        let v = ValidatedStochastic::new(p).unwrap();
397        assert_eq!(v.params().k_period, 9);
398    }
399
400    #[test]
401    fn full_presets() {
402        let p = StochasticParams::full(14, 3, 3);
403        assert_eq!(p.warm_up_bars(), 14 + 2 + 2);
404    }
405
406    #[test]
407    fn series_length_and_warmup() {
408        let n = 30;
409        let high: Vec<_> = (0..n).map(|i| 100.0 + i as f64).collect();
410        let low: Vec<_> = (0..n).map(|i| 90.0 + i as f64).collect();
411        let close: Vec<_> = (0..n).map(|i| 95.0 + i as f64).collect();
412        let out = stochastics(&high, &low, &close, StochasticParams::fast(14, 3)).unwrap();
413        assert_eq!(out.k.len(), n);
414        assert!(out.k[12].is_none()); // before k_period
415        assert!(out.k[13].is_some());
416        // %D needs 3 %K values
417        assert!(out.d[13 + 2].is_some());
418    }
419
420    #[test]
421    fn zero_period_err() {
422        assert!(ValidatedStochastic::new(StochasticParams {
423            k_period: 0,
424            k_smooth: 1,
425            d_period: 3
426        })
427        .is_err());
428    }
429
430    #[test]
431    fn flat_window_carries_previous_raw() {
432        // i=2 first full window (range>0); i=4 window of three 12s is flat → carry i=3 raw.
433        let high = [10.0, 11.0, 12.0, 12.0, 12.0];
434        let low = [9.0, 10.0, 12.0, 12.0, 12.0];
435        let close = [9.5, 10.5, 12.0, 12.0, 12.0];
436        let p = StochasticParams::fast(3, 1);
437        let s = stochastics(&high, &low, &close, p).unwrap();
438        let k3 = s.k[3].unwrap();
439        // Fast k_smooth=1 → %K is raw; pure-flat bar carries previous raw.
440        assert!((s.k[4].unwrap() - k3).abs() < 1e-12);
441        // First flat-only bar would be 50 if no history; here we have history so not forced to 50
442        // unless prior raw happened to be 50.
443        assert!(s.k[4].is_some());
444    }
445
446    #[test]
447    fn k_in_unit_interval_when_range_positive() {
448        let n = 40;
449        let high: Vec<_> = (0..n).map(|i| 100.0 + (i % 5) as f64).collect();
450        let low: Vec<_> = (0..n).map(|i| 90.0 + (i % 5) as f64).collect();
451        let close: Vec<_> = (0..n).map(|i| 95.0 + (i % 5) as f64 * 0.5).collect();
452        let s = stochastics(&high, &low, &close, StochasticParams::full(14, 3, 3)).unwrap();
453        for k in s.k.iter().flatten() {
454            assert!(*k >= -1e-9 && *k <= 100.0 + 1e-9, "k={k}");
455        }
456    }
457
458    #[test]
459    fn high_lt_low_err() {
460        let h = [10.0, 9.0];
461        let l = [9.0, 10.0];
462        let c = [9.5, 9.5];
463        assert!(stochastics(&h, &l, &c, StochasticParams::fast(2, 1)).is_err());
464    }
465}