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}