Skip to main content

finance_solution/stocks/ta/
state.rs

1//! Incremental **state machines** for live / streaming bar updates.
2//!
3//! # What this is
4//!
5//! Pure math state: [`push`](SmaState::push) one bar at a time, [`push_bars`](SmaState::push_bars)
6//! for multi-bar payloads, or seed with [`from_history`](SmaState::from_history).
7//! **Not** a market-data engine — your quant system owns the feed, symbols, and calendars.
8//!
9//! # Constructor naming: `new` → `FinanceResult` (not `try_new`)
10//!
11//! Fallible construction uses **`new`**, matching this crate’s Result-only style
12//! (`Schedule::new_repeating`, `File::open` in the standard library — fallibility lives in the
13//! return type, not a `try_` prefix). There is no panicking twin.
14//!
15//! # What this is not
16//!
17//! - No sockets, no multi-symbol registry, no auto “session open”
18//! - Day reset of VWAP/RVOL is **your** call to [`VwapState::reset`] / rebuild
19//!
20//! # Quant engine sketch — **all TA on one symbol, every 5s bar**
21//!
22//! Desks often want every indicator updated on each bar. That is the intended use of
23//! `*State`: **one pipeline struct per symbol**, sequential `push` (microseconds), and
24//! `HashMap<Symbol, Pipeline>` + optional `rayon` **across symbols**. The crate does not
25//! parallelize indicators for a single bar (shared inputs; overhead dominates).
26//!
27//! ```
28//! use finance_solution::stocks::ta::*;
29//!
30//! const FAST: StochasticParams = StochasticParams::fast(9, 3);
31//! const MACD: MacdParams = MacdParams::standard();
32//! const BB: BollingerParams = BollingerParams::standard();
33//!
34//! /// Caller-owned: pack as many `*State` fields as the strategy needs.
35//! struct SymbolTa {
36//!     sma20: SmaState,
37//!     ema20: EmaState,
38//!     hma16: HmaState,
39//!     stoch: StochState,
40//!     macd: MacdState,
41//!     bb: BollingerState,
42//!     atr: AtrState,
43//!     rsi: RsiState,
44//!     vwap: VwapState,
45//!     donchian: DonchianState,
46//!     // …add LinReg, RVOL, Keltner, WMA, …
47//! }
48//!
49//! impl SymbolTa {
50//!     fn new() -> finance_solution::FinanceResult<Self> {
51//!         Ok(Self {
52//!             sma20: SmaState::new(20)?,
53//!             ema20: EmaState::new(20)?,
54//!             hma16: HmaState::new(16)?,
55//!             stoch: StochState::new(FAST)?,
56//!             macd: MacdState::new(MACD)?,
57//!             bb: BollingerState::new(BB)?,
58//!             atr: AtrState::new(AtrParams::period_14())?,
59//!             rsi: RsiState::new(RsiParams::period_14())?,
60//!             vwap: VwapState::new(VwapParams::cumulative_typical())?,
61//!             donchian: DonchianState::new(DonchianParams::period_20())?,
62//!         })
63//!     }
64//!
65//!     fn on_5s_bar(
66//!         &mut self,
67//!         high: f64,
68//!         low: f64,
69//!         close: f64,
70//!         volume: f64,
71//!     ) -> finance_solution::FinanceResult<()> {
72//!         let _ = self.sma20.push(close)?;
73//!         let _ = self.ema20.push(close)?;
74//!         let _ = self.hma16.push(close)?;
75//!         let _ = self.stoch.push(high, low, close)?;
76//!         let _ = self.macd.push(close)?;
77//!         let _ = self.bb.push(close)?;
78//!         let _ = self.atr.push(high, low, close)?;
79//!         let _ = self.rsi.push(close)?;
80//!         let _ = self.vwap.push(high, low, close, volume)?;
81//!         let _ = self.donchian.push(high, low)?;
82//!         Ok(())
83//!     }
84//! }
85//!
86//! # let mut p = SymbolTa::new().unwrap();
87//! # p.on_5s_bar(10.0, 9.0, 9.5, 1000.0).unwrap();
88//! ```
89//!
90//! # Parity with batch
91//!
92//! Batch free functions and `Validated*::compute` **call the same `*State` path** as live
93//! `push` (parity by construction). Prefer batch for research notebooks; prefer holding
94//! state for multi-symbol live updates.
95//!
96//! | Batch | Incremental |
97//! |-------|-------------|
98//! | [`sma`](super::sma) / [`ema`](super::ema) | [`SmaState`] / [`EmaState`] |
99//! | [`wma`](super::wma) / [`hma`](super::hma) | [`WmaState`](super::WmaState) / [`HmaState`](super::HmaState) |
100//! | [`stochastics`](super::stochastics) | [`StochState`] |
101//! | [`macd`](super::macd) | [`MacdState`] |
102//! | [`bollinger`](super::bollinger) | [`BollingerState`] |
103//! | [`keltner`](super::keltner) | [`KeltnerState`] |
104//! | [`donchian`](super::donchian) | [`DonchianState`](super::DonchianState) |
105//! | [`vwap`](super::vwap) | [`VwapState`] |
106//! | [`rvol`](super::rvol) | [`RvolState`] |
107//! | [`rsi`](super::rsi) | [`RsiState`](super::RsiState) |
108//! | [`atr`](super::atr) | [`AtrState`](super::AtrState) |
109//! | [`linear_regression`](super::linear_regression) | [`LinRegState`](super::LinRegState) |
110
111use crate::stocks::ta::bollinger::{BollingerParams, ValidatedBollinger};
112use crate::stocks::ta::common::{require_hlc, validate_positive_volume};
113use crate::stocks::ta::keltner::{KeltnerParams, ValidatedKeltner};
114use crate::stocks::ta::macd::{MacdParams, ValidatedMacd};
115use crate::stocks::ta::ring::{RingF64, RingPv, SlidingMax, SlidingMin};
116use crate::stocks::ta::rvol::{RvolParams, ValidatedRvol};
117use crate::stocks::ta::stochastic::{StochasticParams, ValidatedStochastic};
118use crate::stocks::ta::vwap::{ValidatedVwap, VwapMode, VwapParams, VwapPriceSource};
119use crate::util::error::{require_finite, FinanceError, FinanceResult};
120
121/// SMA/EMA state live in [`moving_average`](super::moving_average); re-exported here for the
122/// incremental-API index.
123pub use crate::stocks::ta::moving_average::{EmaState, SmaState};
124
125// ---------------------------------------------------------------------------
126// Stochastic
127// ---------------------------------------------------------------------------
128
129/// Incremental stochastic (fast/full via [`StochasticParams`]).
130///
131/// Highest-high / lowest-low lookback is **amortized O(1)** per bar (sliding max/min);
132/// %K / %D SMAs stay O(1) via ring sums.
133///
134/// # Examples
135/// ```
136/// use finance_solution::stocks::ta::{StochasticParams, StochState, stochastics};
137/// const P: StochasticParams = StochasticParams::fast(5, 3);
138/// let h: Vec<_> = (0..20).map(|i| 10.0 + i as f64).collect();
139/// let l: Vec<_> = (0..20).map(|i| 9.0 + i as f64).collect();
140/// let c: Vec<_> = (0..20).map(|i| 9.5 + i as f64).collect();
141/// let batch = stochastics(&h, &l, &c, P).unwrap();
142/// let mut st = StochState::new(P).unwrap();
143/// let mut last = None;
144/// for i in 0..20 {
145///     last = st.push(h[i], l[i], c[i]).unwrap();
146/// }
147/// let (bk, bd) = batch.last_kd().unwrap();
148/// let (sk, sd) = last.unwrap();
149/// assert!((bk - sk).abs() < 1e-9 && (bd - sd).abs() < 1e-9);
150/// ```
151#[derive(Clone, Debug)]
152pub struct StochState {
153    params: StochasticParams,
154    high_max: SlidingMax,
155    low_min: SlidingMin,
156    raw_k: RingF64,
157    smooth_k: RingF64,
158    last_k: Option<f64>,
159    last_d: Option<f64>,
160    /// Last raw %K before smoothing (for flat-window carry-forward).
161    prev_raw_k: Option<f64>,
162}
163
164impl StochState {
165    pub fn new(params: StochasticParams) -> FinanceResult<Self> {
166        let _ = ValidatedStochastic::new(params)?;
167        Ok(Self {
168            params,
169            high_max: SlidingMax::with_window(params.k_period),
170            low_min: SlidingMin::with_window(params.k_period),
171            raw_k: RingF64::with_capacity(params.k_smooth),
172            smooth_k: RingF64::with_capacity(params.d_period),
173            last_k: None,
174            last_d: None,
175            prev_raw_k: None,
176        })
177    }
178
179    pub fn from_history(
180        params: StochasticParams,
181        high: &[f64],
182        low: &[f64],
183        close: &[f64],
184    ) -> FinanceResult<Self> {
185        let mut s = Self::new(params)?;
186        require_hlc(high, low, close)?;
187        for i in 0..close.len() {
188            s.push(high[i], low[i], close[i])?;
189        }
190        Ok(s)
191    }
192
193    pub fn params(&self) -> StochasticParams {
194        self.params
195    }
196
197    pub fn reset(&mut self) {
198        self.high_max.clear();
199        self.low_min.clear();
200        self.raw_k.clear();
201        self.smooth_k.clear();
202        self.last_k = None;
203        self.last_d = None;
204        self.prev_raw_k = None;
205    }
206
207    /// Returns `Some((%K, %D))` when both are defined; `%K`-only warm-up yields `None`
208    /// until `%D` is ready (same as batch last_kd semantics for the pair).
209    ///
210    /// For finer control use [`StochState::push_detail`].
211    pub fn push(&mut self, high: f64, low: f64, close: f64) -> FinanceResult<Option<(f64, f64)>> {
212        let d = self.push_detail(high, low, close)?;
213        match (d.k, d.d) {
214            (Some(k), Some(dd)) => Ok(Some((k, dd))),
215            _ => Ok(None),
216        }
217    }
218
219    /// Full detail for this bar (either line may still be warming up).
220    pub fn push_detail(
221        &mut self,
222        high: f64,
223        low: f64,
224        close: f64,
225    ) -> FinanceResult<StochBarOutput> {
226        require_finite("high", high)?;
227        require_finite("low", low)?;
228        require_finite("close", close)?;
229        if high < low {
230            return Err(FinanceError::InvalidCashflow {
231                message: "high must be >= low for each bar",
232            });
233        }
234        let hh = self.high_max.push(high).unwrap();
235        let ll = self.low_min.push(low).unwrap();
236
237        let mut k_out = None;
238        let mut d_out = None;
239
240        if self.high_max.is_full() {
241            let range = hh - ll;
242            // Flat window: carry previous raw %K, else 50 (matches batch stochastics).
243            let raw = if range == 0.0 {
244                self.prev_raw_k.unwrap_or(50.0)
245            } else {
246                100.0 * (close - ll) / range
247            };
248            self.prev_raw_k = Some(raw);
249            let _ = self.raw_k.push(raw);
250            if self.raw_k.is_full() {
251                let sk = self.raw_k.sum() / self.params.k_smooth as f64;
252                self.last_k = Some(sk);
253                k_out = Some(sk);
254                let _ = self.smooth_k.push(sk);
255                if self.smooth_k.is_full() {
256                    let d = self.smooth_k.sum() / self.params.d_period as f64;
257                    self.last_d = Some(d);
258                    d_out = Some(d);
259                }
260            }
261        }
262        Ok(StochBarOutput { k: k_out, d: d_out })
263    }
264
265    pub fn last_kd(&self) -> Option<(f64, f64)> {
266        Some((self.last_k?, self.last_d?))
267    }
268
269    /// Push aligned H/L/C slices (same length) from one streaming payload.
270    pub fn push_bars(
271        &mut self,
272        high: &[f64],
273        low: &[f64],
274        close: &[f64],
275    ) -> FinanceResult<Vec<StochBarOutput>> {
276        require_hlc(high, low, close)?;
277        let mut out = Vec::with_capacity(close.len());
278        for i in 0..close.len() {
279            out.push(self.push_detail(high[i], low[i], close[i])?);
280        }
281        Ok(out)
282    }
283}
284
285/// One-bar stochastic output (warm-up allowed as `None`).
286#[derive(Clone, Copy, Debug, PartialEq)]
287pub struct StochBarOutput {
288    pub k: Option<f64>,
289    pub d: Option<f64>,
290}
291
292// ---------------------------------------------------------------------------
293// MACD
294// ---------------------------------------------------------------------------
295
296/// Incremental MACD (fast/slow/signal EMAs).
297#[derive(Clone, Debug)]
298pub struct MacdState {
299    params: MacdParams,
300    fast: EmaState,
301    slow: EmaState,
302    signal: EmaState,
303    last: Option<(f64, f64, f64)>,
304}
305
306impl MacdState {
307    pub fn new(params: MacdParams) -> FinanceResult<Self> {
308        let _ = ValidatedMacd::new(params)?;
309        Ok(Self {
310            params,
311            fast: EmaState::new(params.fast)?,
312            slow: EmaState::new(params.slow)?,
313            signal: EmaState::new(params.signal)?,
314            last: None,
315        })
316    }
317
318    pub fn from_history(params: MacdParams, closes: &[f64]) -> FinanceResult<Self> {
319        let mut s = Self::new(params)?;
320        for &c in closes {
321            s.push(c)?;
322        }
323        Ok(s)
324    }
325
326    pub fn params(&self) -> MacdParams {
327        self.params
328    }
329
330    pub fn reset(&mut self) {
331        self.fast.reset();
332        self.slow.reset();
333        self.signal.reset();
334        self.last = None;
335    }
336
337    /// `Some((macd, signal, hist))` when all three are defined.
338    pub fn push(&mut self, close: f64) -> FinanceResult<Option<(f64, f64, f64)>> {
339        let d = self.push_detail(close)?;
340        match (d.macd, d.signal, d.histogram) {
341            (Some(m), Some(s), Some(h)) => Ok(Some((m, s, h))),
342            _ => Ok(None),
343        }
344    }
345
346    /// Per-line warm-up: MACD may be `Some` before signal/histogram.
347    pub fn push_detail(&mut self, close: f64) -> FinanceResult<MacdBarOutput> {
348        let f = self.fast.push(close)?;
349        let s = self.slow.push(close)?;
350        let macd_line = match (f, s) {
351            (Some(a), Some(b)) => a - b,
352            _ => {
353                self.last = None;
354                return Ok(MacdBarOutput {
355                    macd: None,
356                    signal: None,
357                    histogram: None,
358                });
359            }
360        };
361        let sig = self.signal.push(macd_line)?;
362        match sig {
363            Some(signal) => {
364                let hist = macd_line - signal;
365                self.last = Some((macd_line, signal, hist));
366                Ok(MacdBarOutput {
367                    macd: Some(macd_line),
368                    signal: Some(signal),
369                    histogram: Some(hist),
370                })
371            }
372            None => {
373                self.last = None;
374                Ok(MacdBarOutput {
375                    macd: Some(macd_line),
376                    signal: None,
377                    histogram: None,
378                })
379            }
380        }
381    }
382
383    pub fn last(&self) -> Option<(f64, f64, f64)> {
384        self.last
385    }
386
387    /// Push many closes from one payload (full triple only when signal is ready).
388    pub fn push_bars(&mut self, closes: &[f64]) -> FinanceResult<Vec<Option<(f64, f64, f64)>>> {
389        let mut out = Vec::with_capacity(closes.len());
390        for &c in closes {
391            out.push(self.push(c)?);
392        }
393        Ok(out)
394    }
395
396    /// Push many closes; each bar may have MACD before signal (batch/parity path).
397    pub fn push_bars_detail(&mut self, closes: &[f64]) -> FinanceResult<Vec<MacdBarOutput>> {
398        let mut out = Vec::with_capacity(closes.len());
399        for &c in closes {
400            out.push(self.push_detail(c)?);
401        }
402        Ok(out)
403    }
404}
405
406/// One-bar MACD output (signal/hist may still be warming up).
407#[derive(Clone, Copy, Debug, PartialEq)]
408pub struct MacdBarOutput {
409    pub macd: Option<f64>,
410    pub signal: Option<f64>,
411    pub histogram: Option<f64>,
412}
413
414// ---------------------------------------------------------------------------
415// Bollinger
416// ---------------------------------------------------------------------------
417
418/// Incremental Bollinger Bands.
419///
420/// After warm-up each [`push`](Self::push) is **O(1)** via running `Σ` / `Σx²`
421/// (sample or population stdev from the ring sum).
422#[derive(Clone, Debug)]
423pub struct BollingerState {
424    params: BollingerParams,
425    ring: RingF64,
426    sum_sq: f64,
427    last: Option<BollingerBarOutput>,
428}
429
430/// One-bar Bollinger output.
431#[derive(Clone, Copy, Debug, PartialEq)]
432pub struct BollingerBarOutput {
433    pub middle: f64,
434    pub upper: f64,
435    pub lower: f64,
436    pub pct_b: Option<f64>,
437}
438
439impl BollingerState {
440    pub fn new(params: BollingerParams) -> FinanceResult<Self> {
441        let _ = ValidatedBollinger::new(params)?;
442        Ok(Self {
443            params,
444            ring: RingF64::with_capacity(params.period),
445            sum_sq: 0.0,
446            last: None,
447        })
448    }
449
450    pub fn from_history(params: BollingerParams, closes: &[f64]) -> FinanceResult<Self> {
451        let mut s = Self::new(params)?;
452        for &c in closes {
453            s.push(c)?;
454        }
455        Ok(s)
456    }
457
458    pub fn params(&self) -> BollingerParams {
459        self.params
460    }
461
462    pub fn reset(&mut self) {
463        self.ring.clear();
464        self.sum_sq = 0.0;
465        self.last = None;
466    }
467
468    pub fn push(&mut self, close: f64) -> FinanceResult<Option<BollingerBarOutput>> {
469        require_finite("close", close)?;
470        let n = self.params.period as f64;
471        if let Some(old) = self.ring.push(close) {
472            self.sum_sq += close * close - old * old;
473        } else {
474            self.sum_sq += close * close;
475        }
476        if !self.ring.is_full() {
477            self.last = None;
478            return Ok(None);
479        }
480        let sum = self.ring.sum();
481        let mid = sum / n;
482        // population var = E[x²] - mean²; sample uses (n-1) denominator on Σ(x-μ)²
483        let ss = (self.sum_sq - sum * sum / n).max(0.0);
484        let sd = match self.params.stdev {
485            crate::stocks::ta::common::StdevKind::Population => (ss / n).sqrt(),
486            crate::stocks::ta::common::StdevKind::Sample => {
487                if n > 1.0 {
488                    (ss / (n - 1.0)).sqrt()
489                } else {
490                    0.0
491                }
492            }
493        };
494        let band = self.params.num_std * sd;
495        let upper = mid + band;
496        let lower = mid - band;
497        let width = upper - lower;
498        let pct_b = if width > 0.0 {
499            Some((close - lower) / width)
500        } else {
501            None
502        };
503        let out = BollingerBarOutput {
504            middle: mid,
505            upper,
506            lower,
507            pct_b,
508        };
509        self.last = Some(out);
510        Ok(Some(out))
511    }
512
513    pub fn last(&self) -> Option<BollingerBarOutput> {
514        self.last
515    }
516
517    /// Push many closes from one payload.
518    pub fn push_bars(&mut self, closes: &[f64]) -> FinanceResult<Vec<Option<BollingerBarOutput>>> {
519        let mut out = Vec::with_capacity(closes.len());
520        for &c in closes {
521            out.push(self.push(c)?);
522        }
523        Ok(out)
524    }
525}
526
527// ---------------------------------------------------------------------------
528// Keltner
529// ---------------------------------------------------------------------------
530
531/// Incremental Keltner (EMA mid + Wilder ATR).
532#[derive(Clone, Debug)]
533pub struct KeltnerState {
534    params: KeltnerParams,
535    mid: EmaState,
536    atr: crate::stocks::ta::atr::AtrState,
537    last: Option<KeltnerBarOutput>,
538}
539
540#[derive(Clone, Copy, Debug, PartialEq)]
541pub struct KeltnerBarOutput {
542    pub middle: f64,
543    pub upper: f64,
544    pub lower: f64,
545    pub atr: f64,
546}
547
548impl KeltnerState {
549    pub fn new(params: KeltnerParams) -> FinanceResult<Self> {
550        let _ = ValidatedKeltner::new(params)?;
551        Ok(Self {
552            params,
553            mid: EmaState::new(params.ema_period)?,
554            atr: crate::stocks::ta::atr::AtrState::new(crate::stocks::ta::atr::AtrParams::new(
555                params.atr_period,
556            ))?,
557            last: None,
558        })
559    }
560
561    pub fn from_history(
562        params: KeltnerParams,
563        high: &[f64],
564        low: &[f64],
565        close: &[f64],
566    ) -> FinanceResult<Self> {
567        let mut s = Self::new(params)?;
568        require_hlc(high, low, close)?;
569        for i in 0..close.len() {
570            s.push(high[i], low[i], close[i])?;
571        }
572        Ok(s)
573    }
574
575    pub fn params(&self) -> KeltnerParams {
576        self.params
577    }
578
579    pub fn reset(&mut self) {
580        self.mid.reset();
581        self.atr.reset();
582        self.last = None;
583    }
584
585    pub fn push(
586        &mut self,
587        high: f64,
588        low: f64,
589        close: f64,
590    ) -> FinanceResult<Option<KeltnerBarOutput>> {
591        let atr_val = self.atr.push(high, low, close)?;
592        let mid = self.mid.push(close)?;
593        match (mid, atr_val) {
594            (Some(m), Some(a)) => {
595                let out = KeltnerBarOutput {
596                    middle: m,
597                    upper: m + self.params.atr_mult * a,
598                    lower: m - self.params.atr_mult * a,
599                    atr: a,
600                };
601                self.last = Some(out);
602                Ok(Some(out))
603            }
604            _ => {
605                self.last = None;
606                Ok(None)
607            }
608        }
609    }
610
611    pub fn last(&self) -> Option<KeltnerBarOutput> {
612        self.last
613    }
614
615    /// Push aligned H/L/C bars from one payload.
616    pub fn push_bars(
617        &mut self,
618        high: &[f64],
619        low: &[f64],
620        close: &[f64],
621    ) -> FinanceResult<Vec<Option<KeltnerBarOutput>>> {
622        require_hlc(high, low, close)?;
623        let mut out = Vec::with_capacity(close.len());
624        for i in 0..close.len() {
625            out.push(self.push(high[i], low[i], close[i])?);
626        }
627        Ok(out)
628    }
629}
630
631// ---------------------------------------------------------------------------
632// VWAP
633// ---------------------------------------------------------------------------
634
635/// Incremental VWAP (cumulative or rolling). Call [`VwapState::reset`] at session open if desired.
636///
637/// # Examples
638/// ```
639/// use finance_solution::stocks::ta::{VwapParams, VwapState};
640/// let mut v = VwapState::new(VwapParams::cumulative_typical()).unwrap();
641/// let a = v.push(10.0, 9.0, 9.5, 100.0).unwrap().unwrap();
642/// let b = v.push(11.0, 10.0, 10.5, 200.0).unwrap().unwrap();
643/// assert!(b > a || (b - a).abs() < 1.0); // volume-weighted drift
644/// v.reset(); // e.g. new regular trading day — caller decides
645/// assert!(v.push(10.0, 10.0, 10.0, 50.0).unwrap().unwrap() - 10.0 < 1e-12);
646/// ```
647#[derive(Clone, Debug)]
648pub struct VwapState {
649    params: VwapParams,
650    // cumulative
651    cum_pv: f64,
652    cum_v: f64,
653    // rolling
654    rolling: Option<RingPv>,
655    last: Option<f64>,
656}
657
658impl VwapState {
659    pub fn new(params: VwapParams) -> FinanceResult<Self> {
660        let _ = ValidatedVwap::new(params)?;
661        let rolling = match params.mode {
662            VwapMode::Cumulative => None,
663            VwapMode::Rolling { period } => Some(RingPv::with_capacity(period)),
664        };
665        Ok(Self {
666            params,
667            cum_pv: 0.0,
668            cum_v: 0.0,
669            rolling,
670            last: None,
671        })
672    }
673
674    pub fn from_history(
675        params: VwapParams,
676        high: &[f64],
677        low: &[f64],
678        close: &[f64],
679        volume: &[f64],
680    ) -> FinanceResult<Self> {
681        let mut s = Self::new(params)?;
682        require_hlc(high, low, close)?;
683        validate_positive_volume(volume)?;
684        if close.len() != volume.len() {
685            return Err(FinanceError::LengthMismatch {
686                left: close.len(),
687                right: volume.len(),
688                context: "close/volume",
689            });
690        }
691        for i in 0..close.len() {
692            s.push(high[i], low[i], close[i], volume[i])?;
693        }
694        Ok(s)
695    }
696
697    pub fn params(&self) -> VwapParams {
698        self.params
699    }
700
701    /// Clear cumulative / rolling window (typical: start of session).
702    pub fn reset(&mut self) {
703        self.cum_pv = 0.0;
704        self.cum_v = 0.0;
705        if let Some(r) = self.rolling.as_mut() {
706            r.clear();
707        }
708        self.last = None;
709    }
710
711    pub fn push(
712        &mut self,
713        high: f64,
714        low: f64,
715        close: f64,
716        volume: f64,
717    ) -> FinanceResult<Option<f64>> {
718        require_finite("high", high)?;
719        require_finite("low", low)?;
720        require_finite("close", close)?;
721        require_finite("volume", volume)?;
722        if high < low {
723            return Err(FinanceError::InvalidCashflow {
724                message: "high must be >= low for each bar",
725            });
726        }
727        if volume < 0.0 {
728            return Err(FinanceError::InvalidCashflow {
729                message: "volume must be non-negative",
730            });
731        }
732        let price = match self.params.price_source {
733            VwapPriceSource::Typical => (high + low + close) / 3.0,
734            VwapPriceSource::Close => close,
735        };
736        let out = match self.params.mode {
737            VwapMode::Cumulative => {
738                self.cum_pv += price * volume;
739                self.cum_v += volume;
740                if self.cum_v > 0.0 {
741                    Some(self.cum_pv / self.cum_v)
742                } else {
743                    None
744                }
745            }
746            VwapMode::Rolling { period } => {
747                let ring = self.rolling.as_mut().unwrap();
748                ring.push(price, volume);
749                if ring.len() >= period {
750                    ring.vwap()
751                } else {
752                    None
753                }
754            }
755        };
756        self.last = out;
757        Ok(out)
758    }
759
760    pub fn last(&self) -> Option<f64> {
761        self.last
762    }
763
764    /// Push aligned OHLC+V bars from one payload.
765    pub fn push_bars(
766        &mut self,
767        high: &[f64],
768        low: &[f64],
769        close: &[f64],
770        volume: &[f64],
771    ) -> FinanceResult<Vec<Option<f64>>> {
772        require_hlc(high, low, close)?;
773        validate_positive_volume(volume)?;
774        if close.len() != volume.len() {
775            return Err(FinanceError::LengthMismatch {
776                left: close.len(),
777                right: volume.len(),
778                context: "close/volume",
779            });
780        }
781        let mut out = Vec::with_capacity(close.len());
782        for i in 0..close.len() {
783            out.push(self.push(high[i], low[i], close[i], volume[i])?);
784        }
785        Ok(out)
786    }
787}
788
789// ---------------------------------------------------------------------------
790// RVOL
791// ---------------------------------------------------------------------------
792
793/// Incremental relative volume.
794#[derive(Clone, Debug)]
795pub struct RvolState {
796    params: RvolParams,
797    ring: RingF64,
798    last: Option<f64>,
799}
800
801impl RvolState {
802    pub fn new(params: RvolParams) -> FinanceResult<Self> {
803        let _ = ValidatedRvol::new(params)?;
804        Ok(Self {
805            params,
806            ring: RingF64::with_capacity(params.lookback),
807            last: None,
808        })
809    }
810
811    pub fn from_history(params: RvolParams, volume: &[f64]) -> FinanceResult<Self> {
812        let mut s = Self::new(params)?;
813        for &v in volume {
814            s.push(v)?;
815        }
816        Ok(s)
817    }
818
819    pub fn params(&self) -> RvolParams {
820        self.params
821    }
822
823    pub fn reset(&mut self) {
824        self.ring.clear();
825        self.last = None;
826    }
827
828    pub fn push(&mut self, volume: f64) -> FinanceResult<Option<f64>> {
829        require_finite("volume", volume)?;
830        if volume < 0.0 {
831            return Err(FinanceError::InvalidCashflow {
832                message: "volume must be non-negative",
833            });
834        }
835        let _ = self.ring.push(volume);
836        if !self.ring.is_full() {
837            self.last = None;
838            return Ok(None);
839        }
840        let mean = self.ring.sum() / self.params.lookback as f64;
841        let out = if mean > 0.0 {
842            Some(volume / mean)
843        } else {
844            None
845        };
846        self.last = out;
847        Ok(out)
848    }
849
850    pub fn last(&self) -> Option<f64> {
851        self.last
852    }
853
854    /// Push many volume samples from one payload.
855    pub fn push_bars(&mut self, volume: &[f64]) -> FinanceResult<Vec<Option<f64>>> {
856        let mut out = Vec::with_capacity(volume.len());
857        for &v in volume {
858            out.push(self.push(v)?);
859        }
860        Ok(out)
861    }
862}
863
864// ---------------------------------------------------------------------------
865// Parity tests
866// ---------------------------------------------------------------------------
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871    use crate::stocks::ta::bollinger::bollinger;
872    use crate::stocks::ta::keltner::keltner;
873    use crate::stocks::ta::macd::macd;
874    use crate::stocks::ta::moving_average::{ema, sma};
875    use crate::stocks::ta::rvol::rvol;
876    use crate::stocks::ta::stochastic::stochastics;
877    use crate::stocks::ta::vwap::vwap;
878
879    fn path(n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
880        let close: Vec<_> = (0..n)
881            .map(|i| 100.0 + i as f64 * 0.13 + ((i % 7) as f64) * 0.04)
882            .collect();
883        let high: Vec<_> = close.iter().map(|c| c + 0.35).collect();
884        let low: Vec<_> = close.iter().map(|c| c - 0.35).collect();
885        let vol: Vec<_> = (0..n).map(|i| 800.0 + i as f64 * 3.0).collect();
886        (high, low, close, vol)
887    }
888
889    fn approx_opt(a: Option<f64>, b: Option<f64>) {
890        match (a, b) {
891            (None, None) => {}
892            (Some(x), Some(y)) => assert!((x - y).abs() < 1e-9, "{x} vs {y}"),
893            _ => panic!("Option mismatch {a:?} vs {b:?}"),
894        }
895    }
896
897    #[test]
898    fn sma_parity() {
899        let (_, _, c, _) = path(40);
900        let batch = sma(&c, 10).unwrap();
901        let mut st = SmaState::new(10).unwrap();
902        for i in 0..c.len() {
903            approx_opt(st.push(c[i]).unwrap(), batch[i]);
904        }
905    }
906
907    #[test]
908    fn ema_parity() {
909        let (_, _, c, _) = path(40);
910        let batch = ema(&c, 10).unwrap();
911        let mut st = EmaState::new(10).unwrap();
912        for i in 0..c.len() {
913            approx_opt(st.push(c[i]).unwrap(), batch[i]);
914        }
915    }
916
917    #[test]
918    fn stoch_parity() {
919        let (h, l, c, _) = path(50);
920        let p = StochasticParams::full(14, 3, 3);
921        let batch = stochastics(&h, &l, &c, p).unwrap();
922        let mut st = StochState::new(p).unwrap();
923        for i in 0..c.len() {
924            let d = st.push_detail(h[i], l[i], c[i]).unwrap();
925            approx_opt(d.k, batch.k[i]);
926            approx_opt(d.d, batch.d[i]);
927        }
928    }
929
930    #[test]
931    fn macd_parity() {
932        let (_, _, c, _) = path(60);
933        let p = MacdParams::standard();
934        let batch = macd(&c, p).unwrap();
935        let mut st = MacdState::new(p).unwrap();
936        for i in 0..c.len() {
937            let o = st.push(c[i]).unwrap();
938            match (o, batch.signal[i], batch.histogram[i], batch.macd[i]) {
939                (Some((m, s, h)), Some(bs), Some(bh), Some(bm)) => {
940                    assert!((m - bm).abs() < 1e-8, "macd {i}");
941                    assert!((s - bs).abs() < 1e-8, "signal {i}");
942                    assert!((h - bh).abs() < 1e-8, "hist {i}");
943                }
944                (None, None, None, _) => {} // warm-up or macd-only in batch
945                other => panic!("macd parity at {i}: {other:?}"),
946            }
947        }
948        let bl = batch.last().unwrap();
949        let sl = st.last().unwrap();
950        assert!((bl.0 - sl.0).abs() < 1e-8);
951        assert!((bl.1 - sl.1).abs() < 1e-8);
952        assert!((bl.2 - sl.2).abs() < 1e-8);
953    }
954
955    #[test]
956    fn bollinger_parity() {
957        let (_, _, c, _) = path(40);
958        let p = BollingerParams::standard();
959        let batch = bollinger(&c, p).unwrap();
960        let mut st = BollingerState::new(p).unwrap();
961        for i in 0..c.len() {
962            let o = st.push(c[i]).unwrap();
963            match (o, batch.middle[i]) {
964                (None, None) => {}
965                (Some(bo), Some(m)) => {
966                    assert!((bo.middle - m).abs() < 1e-9);
967                    assert!((bo.upper - batch.upper[i].unwrap()).abs() < 1e-9);
968                    assert!((bo.lower - batch.lower[i].unwrap()).abs() < 1e-9);
969                }
970                other => panic!("{other:?}"),
971            }
972        }
973    }
974
975    #[test]
976    fn keltner_parity() {
977        let (h, l, c, _) = path(45);
978        let p = KeltnerParams::standard();
979        let batch = keltner(&h, &l, &c, p).unwrap();
980        let mut st = KeltnerState::new(p).unwrap();
981        for i in 0..c.len() {
982            let o = st.push(h[i], l[i], c[i]).unwrap();
983            match (o, batch.middle[i], batch.upper[i], batch.atr[i]) {
984                (Some(ko), Some(m), Some(u), Some(a)) => {
985                    assert!((ko.middle - m).abs() < 1e-8, "mid {i}");
986                    assert!((ko.upper - u).abs() < 1e-8, "upper {i}");
987                    assert!((ko.atr - a).abs() < 1e-8, "atr {i}");
988                }
989                (None, _, None, _) | (None, None, _, _) => {} // mid and/or atr still warming
990                other => panic!("keltner parity {i}: {other:?}"),
991            }
992        }
993        let sl = st.last().unwrap();
994        let bl_m = batch.middle.iter().rev().find_map(|x| *x).unwrap();
995        let bl_a = batch.atr.iter().rev().find_map(|x| *x).unwrap();
996        assert!((bl_m - sl.middle).abs() < 1e-8);
997        assert!((bl_a - sl.atr).abs() < 1e-8);
998    }
999
1000    #[test]
1001    fn vwap_cum_parity() {
1002        let (h, l, c, v) = path(30);
1003        let p = VwapParams::cumulative_typical();
1004        let batch = vwap(&h, &l, &c, &v, p).unwrap();
1005        let mut st = VwapState::new(p).unwrap();
1006        for i in 0..c.len() {
1007            approx_opt(st.push(h[i], l[i], c[i], v[i]).unwrap(), batch.vwap[i]);
1008        }
1009    }
1010
1011    #[test]
1012    fn vwap_reset() {
1013        let mut st = VwapState::new(VwapParams::cumulative_typical()).unwrap();
1014        st.push(10.0, 10.0, 10.0, 100.0).unwrap();
1015        st.reset();
1016        let x = st.push(20.0, 20.0, 20.0, 50.0).unwrap().unwrap();
1017        assert!((x - 20.0).abs() < 1e-12);
1018    }
1019
1020    #[test]
1021    fn rvol_parity() {
1022        let (_, _, _, v) = path(40);
1023        let p = RvolParams::days_20();
1024        let batch = rvol(&v, p).unwrap();
1025        let mut st = RvolState::new(p).unwrap();
1026        for i in 0..v.len() {
1027            approx_opt(st.push(v[i]).unwrap(), batch.rvol[i]);
1028        }
1029    }
1030
1031    #[test]
1032    fn rsi_atr_parity_via_from_history() {
1033        let (h, l, c, _) = path(60);
1034        let rsi_b = crate::stocks::ta::rsi::rsi(&c, crate::stocks::ta::rsi::RsiParams::period_14())
1035            .unwrap();
1036        let rsi_s = crate::stocks::ta::rsi::RsiState::from_history(
1037            crate::stocks::ta::rsi::RsiParams::period_14(),
1038            &c,
1039        )
1040        .unwrap();
1041        approx_opt(rsi_b.last(), rsi_s.last());
1042
1043        let atr_b =
1044            crate::stocks::ta::atr::atr(&h, &l, &c, crate::stocks::ta::atr::AtrParams::period_14())
1045                .unwrap();
1046        let atr_s = crate::stocks::ta::atr::AtrState::from_history(
1047            crate::stocks::ta::atr::AtrParams::period_14(),
1048            &h,
1049            &l,
1050            &c,
1051        )
1052        .unwrap();
1053        approx_opt(atr_b.last(), atr_s.last());
1054    }
1055
1056    #[test]
1057    fn from_history_matches_push() {
1058        let (h, l, c, _) = path(25);
1059        let p = StochasticParams::fast(9, 3);
1060        let a = StochState::from_history(p, &h, &l, &c).unwrap();
1061        let mut b = StochState::new(p).unwrap();
1062        for i in 0..c.len() {
1063            b.push(h[i], l[i], c[i]).unwrap();
1064        }
1065        assert_eq!(a.last_kd(), b.last_kd());
1066    }
1067}