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