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//!
75//! Batch [`stochastics`] / [`ValidatedStochastic::compute`] is **the same path** as live state:
76//! `StochState::new` + [`StochState::push_bars`] (amortized O(1) HH/LL + ring SMAs).
77//!
78use crate::stocks::ta::common::opt_cell;
79use crate::stocks::ta::state::StochState;
80use crate::util::error::FinanceResult;
81use crate::util::primitives::PeriodLength;
82use crate::{columns_with_strings, print_table_locale_opt};
83
84/// Unvalidated (but `Copy`) stochastic parameter pack.
85///
86/// Build with [`StochasticParams::fast`], [`StochasticParams::full`], or struct update.
87/// Prefer validating once via [`ValidatedStochastic::new`] for hot paths.
88#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
89pub struct StochasticParams {
90    /// Lookback for highest high / lowest low.
91    pub k_period: usize,
92    /// SMA length on raw %K (`1` = Fast stochastic).
93    pub k_smooth: usize,
94    /// SMA length on smoothed %K → %D line.
95    pub d_period: usize,
96}
97
98impl StochasticParams {
99    /// Fast stochastic: raw %K over `k_period`, %D = SMA(`d_period`) of %K.
100    ///
101    /// Common packs: `fast(9, 3)`, `fast(14, 3)`.
102    pub const fn fast(k_period: usize, d_period: usize) -> Self {
103        Self {
104            k_period,
105            k_smooth: 1,
106            d_period,
107        }
108    }
109
110    /// Full stochastic: smooth raw %K by `k_smooth`, then %D by `d_period`.
111    ///
112    /// Common packs: `full(14, 3, 3)`, `full(60, 10, 1)`.
113    pub const fn full(k_period: usize, k_smooth: usize, d_period: usize) -> Self {
114        Self {
115            k_period,
116            k_smooth,
117            d_period,
118        }
119    }
120
121    /// Minimum bars before both %K and %D can be defined.
122    pub const fn warm_up_bars(self) -> usize {
123        // first raw %K at k_period-1; need k_smooth-1 more for smooth K; d_period-1 more for D
124        self.k_period
125            .saturating_add(self.k_smooth.saturating_sub(1))
126            .saturating_add(self.d_period.saturating_sub(1))
127    }
128}
129
130/// Params that passed period validation — safe to use in a tight loop.
131///
132/// Construction is O(1). [`ValidatedStochastic::compute`] is O(n) pure math.
133#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
134pub struct ValidatedStochastic {
135    params: StochasticParams,
136}
137
138impl ValidatedStochastic {
139    /// Validate all periods `≥ 1`.
140    pub fn new(params: StochasticParams) -> FinanceResult<Self> {
141        PeriodLength::new(params.k_period)?;
142        PeriodLength::new(params.k_smooth)?;
143        PeriodLength::new(params.d_period)?;
144        Ok(Self { params })
145    }
146
147    #[inline]
148    pub fn params(self) -> StochasticParams {
149        self.params
150    }
151
152    /// Compute %K / %D series (same length as inputs; warm-up = `None`).
153    pub fn compute(
154        self,
155        high: &[f64],
156        low: &[f64],
157        close: &[f64],
158    ) -> FinanceResult<StochasticSeries> {
159        stochastics_validated(high, low, close, self)
160    }
161}
162
163/// Aligned %K / %D output.
164#[derive(Clone, Debug, PartialEq)]
165pub struct StochasticSeries {
166    pub k: Vec<Option<f64>>,
167    pub d: Vec<Option<f64>>,
168    pub params: StochasticParams,
169}
170
171impl StochasticSeries {
172    /// Last defined %K / %D pair, if both present.
173    pub fn last_kd(&self) -> Option<(f64, f64)> {
174        let k = self.k.iter().rev().find_map(|x| *x)?;
175        let d = self.d.iter().rev().find_map(|x| *x)?;
176        Some((k, d))
177    }
178}
179
180/// Stochastic series with raw (possibly unvalidated) params — validates then computes.
181///
182/// For repeated calls with the same pack, prefer [`ValidatedStochastic`].
183pub fn stochastics(
184    high: &[f64],
185    low: &[f64],
186    close: &[f64],
187    params: StochasticParams,
188) -> FinanceResult<StochasticSeries> {
189    let v = ValidatedStochastic::new(params)?;
190    stochastics_validated(high, low, close, v)
191}
192
193/// Teaching solution: formulas + printable %K/%D table.
194///
195/// Prefer [`ValidatedStochastic::compute`] on the hot path; use this for notebooks,
196/// audit trails, and classroom demos.
197///
198/// # Examples
199/// ```
200/// use finance_solution::stocks::ta::{stochastics_solution, StochasticParams};
201/// # let h: Vec<_> = (0..20).map(|i| 20.0 + i as f64).collect();
202/// # let l: Vec<_> = (0..20).map(|i| 18.0 + i as f64).collect();
203/// # let c: Vec<_> = (0..20).map(|i| 19.0 + i as f64).collect();
204/// let sol = stochastics_solution(&h, &l, &c, StochasticParams::fast(9, 3)).unwrap();
205/// assert!(sol.formula().contains("9"));
206/// // sol.print_table();
207/// ```
208pub fn stochastics_solution(
209    high: &[f64],
210    low: &[f64],
211    close: &[f64],
212    params: StochasticParams,
213) -> FinanceResult<StochasticSolution> {
214    let series = stochastics(high, low, close, params)?;
215    let formula = format!(
216        "%K: stoch(k={}, smooth={}); %D: SMA(%K, {})",
217        params.k_period, params.k_smooth, params.d_period
218    );
219    let symbolic =
220        "raw_%K = 100 * (C - LL) / (HH - LL); %K = SMA(raw_%K, k_smooth); %D = SMA(%K, d)"
221            .to_string();
222    Ok(StochasticSolution {
223        series,
224        close: close.to_vec(),
225        formula,
226        symbolic_formula: symbolic,
227    })
228}
229
230/// Teaching wrapper around [`StochasticSeries`].
231#[derive(Clone, Debug)]
232pub struct StochasticSolution {
233    series: StochasticSeries,
234    close: Vec<f64>,
235    formula: String,
236    symbolic_formula: String,
237}
238
239impl StochasticSolution {
240    pub fn series(&self) -> &StochasticSeries {
241        &self.series
242    }
243    pub fn formula(&self) -> &str {
244        &self.formula
245    }
246    pub fn symbolic_formula(&self) -> &str {
247        &self.symbolic_formula
248    }
249    pub fn params(&self) -> StochasticParams {
250        self.series.params
251    }
252
253    /// # Sample output
254    /// ```text
255    /// period   close      k      d
256    /// ------  ------  -----  -----
257    ///      8   19.60  72.00    n/a
258    ///     10   19.80  68.00  70.00
259    /// ```
260    pub fn print_table(&self) {
261        self.print_table_locale_opt(None, None);
262    }
263
264    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
265        self.print_table_locale_opt(Some(locale), Some(precision));
266    }
267
268    fn print_table_locale_opt(
269        &self,
270        locale: Option<&num_format::Locale>,
271        precision: Option<usize>,
272    ) {
273        let columns = columns_with_strings(&[
274            ("period", "i", true),
275            ("close", "f", true),
276            ("k", "f", true),
277            ("d", "f", true),
278        ]);
279        let data = self
280            .close
281            .iter()
282            .enumerate()
283            .map(|(i, c)| {
284                vec![
285                    i.to_string(),
286                    c.to_string(),
287                    opt_cell(self.series.k[i]),
288                    opt_cell(self.series.d[i]),
289                ]
290            })
291            .collect();
292        print_table_locale_opt(&columns, data, locale, precision);
293    }
294}
295
296fn stochastics_validated(
297    high: &[f64],
298    low: &[f64],
299    close: &[f64],
300    v: ValidatedStochastic,
301) -> FinanceResult<StochasticSeries> {
302    let p = v.params;
303    let mut st = StochState::new(p)?;
304    let bars = st.push_bars(high, low, close)?;
305    let mut k = Vec::with_capacity(bars.len());
306    let mut d = Vec::with_capacity(bars.len());
307    for b in bars {
308        k.push(b.k);
309        d.push(b.d);
310    }
311    Ok(StochasticSeries { k, d, params: p })
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn fast_const_and_validate() {
320        let p = StochasticParams::fast(9, 3);
321        assert_eq!(p.k_smooth, 1);
322        let v = ValidatedStochastic::new(p).unwrap();
323        assert_eq!(v.params().k_period, 9);
324    }
325
326    #[test]
327    fn full_presets() {
328        let p = StochasticParams::full(14, 3, 3);
329        assert_eq!(p.warm_up_bars(), 14 + 2 + 2);
330    }
331
332    #[test]
333    fn series_length_and_warmup() {
334        let n = 30;
335        let high: Vec<_> = (0..n).map(|i| 100.0 + i as f64).collect();
336        let low: Vec<_> = (0..n).map(|i| 90.0 + i as f64).collect();
337        let close: Vec<_> = (0..n).map(|i| 95.0 + i as f64).collect();
338        let out = stochastics(&high, &low, &close, StochasticParams::fast(14, 3)).unwrap();
339        assert_eq!(out.k.len(), n);
340        assert!(out.k[12].is_none()); // before k_period
341        assert!(out.k[13].is_some());
342        // %D needs 3 %K values
343        assert!(out.d[13 + 2].is_some());
344    }
345
346    #[test]
347    fn zero_period_err() {
348        assert!(ValidatedStochastic::new(StochasticParams {
349            k_period: 0,
350            k_smooth: 1,
351            d_period: 3
352        })
353        .is_err());
354    }
355
356    #[test]
357    fn flat_window_carries_previous_raw() {
358        // i=2 first full window (range>0); i=4 window of three 12s is flat → carry i=3 raw.
359        let high = [10.0, 11.0, 12.0, 12.0, 12.0];
360        let low = [9.0, 10.0, 12.0, 12.0, 12.0];
361        let close = [9.5, 10.5, 12.0, 12.0, 12.0];
362        let p = StochasticParams::fast(3, 1);
363        let s = stochastics(&high, &low, &close, p).unwrap();
364        let k3 = s.k[3].unwrap();
365        // Fast k_smooth=1 → %K is raw; pure-flat bar carries previous raw.
366        assert!((s.k[4].unwrap() - k3).abs() < 1e-12);
367        // First flat-only bar would be 50 if no history; here we have history so not forced to 50
368        // unless prior raw happened to be 50.
369        assert!(s.k[4].is_some());
370    }
371
372    #[test]
373    fn k_in_unit_interval_when_range_positive() {
374        let n = 40;
375        let high: Vec<_> = (0..n).map(|i| 100.0 + (i % 5) as f64).collect();
376        let low: Vec<_> = (0..n).map(|i| 90.0 + (i % 5) as f64).collect();
377        let close: Vec<_> = (0..n).map(|i| 95.0 + (i % 5) as f64 * 0.5).collect();
378        let s = stochastics(&high, &low, &close, StochasticParams::full(14, 3, 3)).unwrap();
379        for k in s.k.iter().flatten() {
380            assert!(*k >= -1e-9 && *k <= 100.0 + 1e-9, "k={k}");
381        }
382    }
383
384    #[test]
385    fn high_lt_low_err() {
386        let h = [10.0, 9.0];
387        let l = [9.0, 10.0];
388        let c = [9.5, 9.5];
389        assert!(stochastics(&h, &l, &c, StochasticParams::fast(2, 1)).is_err());
390    }
391}