Skip to main content

finance_solution/stocks/ta/
rvol.rs

1//! # Relative volume (RVOL)
2//!
3//! ```text
4//! rvol[i] = volume[i] / mean(volume[i − lookback + 1 ..= i])
5//! ```
6//!
7//! when the lookback window is full; warm-up bars are `None`.
8//!
9//! ## Word problem
10//!
11//! > Twenty bars printed 1,000 volume, then one bar prints 2,000. What is RVOL(20)?
12//!
13//! The lookback **includes the current bar**, so
14//! `mean = (19×1000 + 2000) / 20 = 1050` and `RVOL = 2000/1050 ≈ 1.905`.
15//!
16//! ```
17//! use finance_solution::stocks::ta::{rvol, RvolParams};
18//! let mut vol = vec![1_000.0; 20];
19//! vol.push(2_000.0);
20//! let s = rvol(&vol, RvolParams::days_20()).unwrap();
21//! assert!((s.rvol[20].unwrap() - 2000.0 / 1050.0).abs() < 1e-9);
22//! ```
23//!
24//! Constant volume → RVOL = 1 after warm-up (useful unit check).
25//!
26//! ## Quant pattern
27//!
28//! ```
29//! use finance_solution::stocks::ta::{RvolParams, ValidatedRvol, RvolState};
30//!
31//! const R20: RvolParams = RvolParams::days_20();
32//! let eng = ValidatedRvol::new(R20).unwrap();
33//! # let vol: Vec<f64> = (1..=30).map(|x| 1_000.0 + x as f64 * 10.0).collect();
34//! let s = eng.compute(&vol).unwrap();
35//! let mut live = RvolState::new(R20).unwrap();
36//! let _ = live.push_bars(&vol).unwrap();
37//! assert_eq!(s.rvol.len(), vol.len());
38//! ```
39//!
40//! ## Sample solution table
41//!
42//! ```text
43//! period   volume    rvol
44//! ------  -------  ------
45//!     18  1180.00     n/a
46//!     19  1190.00  1.0820
47//!     20  2000.00  1.7540
48//! ```
49
50use crate::stocks::ta::common::opt_cell;
51use crate::util::error::FinanceResult;
52use crate::util::primitives::PeriodLength;
53use crate::{columns_with_strings, print_table_locale_opt};
54
55/// RVOL lookback pack.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
57pub struct RvolParams {
58    pub lookback: usize,
59}
60
61impl RvolParams {
62    pub const fn new(lookback: usize) -> Self {
63        Self { lookback }
64    }
65
66    /// Common short lookback.
67    pub const fn days_20() -> Self {
68        Self { lookback: 20 }
69    }
70
71    /// Common longer lookback.
72    pub const fn days_50() -> Self {
73        Self { lookback: 50 }
74    }
75}
76
77/// Validated RVOL config.
78#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
79pub struct ValidatedRvol {
80    params: RvolParams,
81}
82
83impl ValidatedRvol {
84    pub fn new(params: RvolParams) -> FinanceResult<Self> {
85        PeriodLength::new(params.lookback)?;
86        Ok(Self { params })
87    }
88
89    pub fn params(self) -> RvolParams {
90        self.params
91    }
92
93    pub fn compute(self, volume: &[f64]) -> FinanceResult<RvolSeries> {
94        rvol_validated(volume, self)
95    }
96}
97
98#[derive(Clone, Debug, PartialEq)]
99pub struct RvolSeries {
100    pub rvol: Vec<Option<f64>>,
101    pub params: RvolParams,
102}
103
104impl RvolSeries {
105    pub fn last(&self) -> Option<f64> {
106        self.rvol.iter().rev().find_map(|x| *x)
107    }
108}
109
110#[derive(Clone, Debug)]
111pub struct RvolSolution {
112    series: RvolSeries,
113    volume: Vec<f64>,
114    formula: String,
115    symbolic_formula: String,
116}
117
118impl RvolSolution {
119    pub fn series(&self) -> &RvolSeries {
120        &self.series
121    }
122    pub fn formula(&self) -> &str {
123        &self.formula
124    }
125    pub fn symbolic_formula(&self) -> &str {
126        &self.symbolic_formula
127    }
128
129    /// # Sample output
130    /// ```text
131    /// period   volume    rvol
132    /// ------  -------  ------
133    ///     19  1190.00  1.0820
134    /// ```
135    pub fn print_table(&self) {
136        self.print_table_locale_opt(None, None);
137    }
138
139    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
140        self.print_table_locale_opt(Some(locale), Some(precision));
141    }
142
143    fn print_table_locale_opt(
144        &self,
145        locale: Option<&num_format::Locale>,
146        precision: Option<usize>,
147    ) {
148        let columns = columns_with_strings(&[
149            ("period", "i", true),
150            ("volume", "f", true),
151            ("rvol", "f", true),
152        ]);
153        let data = self
154            .volume
155            .iter()
156            .enumerate()
157            .map(|(i, v)| vec![i.to_string(), v.to_string(), opt_cell(self.series.rvol[i])])
158            .collect();
159        print_table_locale_opt(&columns, data, locale, precision);
160    }
161}
162
163pub fn rvol(volume: &[f64], params: RvolParams) -> FinanceResult<RvolSeries> {
164    ValidatedRvol::new(params)?.compute(volume)
165}
166
167/// # Examples
168/// ```
169/// use finance_solution::stocks::ta::{rvol_solution, RvolParams};
170/// let vol = vec![100.0; 25];
171/// let sol = rvol_solution(&vol, RvolParams::days_20()).unwrap();
172/// // Constant volume ⇒ RVOL ≈ 1 after warm-up
173/// assert!((sol.series().rvol[19].unwrap() - 1.0).abs() < 1e-12);
174/// ```
175pub fn rvol_solution(volume: &[f64], params: RvolParams) -> FinanceResult<RvolSolution> {
176    let series = rvol(volume, params)?;
177    let formula = format!(
178        "rvol[i] = volume[i] / mean(volume[i-{}+1 ..= i])",
179        params.lookback
180    );
181    let symbolic = "rvol = volume / sma(volume, lookback)".to_string();
182    Ok(RvolSolution {
183        series,
184        volume: volume.to_vec(),
185        formula,
186        symbolic_formula: symbolic,
187    })
188}
189
190fn rvol_validated(volume: &[f64], v: ValidatedRvol) -> FinanceResult<RvolSeries> {
191    let mut st = crate::stocks::ta::state::RvolState::new(v.params)?;
192    let rvol = st.push_bars(volume)?;
193    Ok(RvolSeries {
194        rvol,
195        params: v.params,
196    })
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn constant_is_one() {
205        let v = vec![500.0; 30];
206        let s = rvol(&v, RvolParams::days_20()).unwrap();
207        assert!((s.rvol[19].unwrap() - 1.0).abs() < 1e-12);
208    }
209
210    #[test]
211    fn lookback_one_is_unity_when_positive() {
212        let v = [10.0, 20.0, 5.0];
213        let s = rvol(&v, RvolParams::new(1)).unwrap();
214        assert!((s.rvol[0].unwrap() - 1.0).abs() < 1e-12);
215        assert!((s.rvol[1].unwrap() - 1.0).abs() < 1e-12);
216    }
217
218    #[test]
219    fn zero_mean_window_is_none() {
220        let v = vec![0.0; 25];
221        let s = rvol(&v, RvolParams::days_20()).unwrap();
222        assert!(s.rvol[19].is_none());
223    }
224
225    #[test]
226    fn spike_matches_doc_formula() {
227        let mut vol = vec![1_000.0; 20];
228        vol.push(2_000.0);
229        let s = rvol(&vol, RvolParams::days_20()).unwrap();
230        // mean includes current: (19*1000 + 2000)/20 = 1050
231        assert!((s.rvol[20].unwrap() - 2000.0 / 1050.0).abs() < 1e-12);
232    }
233
234    #[test]
235    fn negative_volume_err() {
236        assert!(rvol(&[1.0, -1.0], RvolParams::new(2)).is_err());
237    }
238}