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, validate_positive_volume};
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    validate_positive_volume(volume)?;
192    let lb = v.params.lookback;
193    let n = volume.len();
194    let mut out = vec![None; n];
195    if n < lb {
196        return Ok(RvolSeries {
197            rvol: out,
198            params: v.params,
199        });
200    }
201    let mut sum: f64 = volume[..lb].iter().sum();
202    let mean0 = sum / lb as f64;
203    if mean0 > 0.0 {
204        out[lb - 1] = Some(volume[lb - 1] / mean0);
205    }
206    for i in lb..n {
207        sum += volume[i] - volume[i - lb];
208        let mean = sum / lb as f64;
209        if mean > 0.0 {
210            out[i] = Some(volume[i] / mean);
211        }
212    }
213    Ok(RvolSeries {
214        rvol: out,
215        params: v.params,
216    })
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn constant_is_one() {
225        let v = vec![500.0; 30];
226        let s = rvol(&v, RvolParams::days_20()).unwrap();
227        assert!((s.rvol[19].unwrap() - 1.0).abs() < 1e-12);
228    }
229
230    #[test]
231    fn lookback_one_is_unity_when_positive() {
232        let v = [10.0, 20.0, 5.0];
233        let s = rvol(&v, RvolParams::new(1)).unwrap();
234        assert!((s.rvol[0].unwrap() - 1.0).abs() < 1e-12);
235        assert!((s.rvol[1].unwrap() - 1.0).abs() < 1e-12);
236    }
237
238    #[test]
239    fn zero_mean_window_is_none() {
240        let v = vec![0.0; 25];
241        let s = rvol(&v, RvolParams::days_20()).unwrap();
242        assert!(s.rvol[19].is_none());
243    }
244
245    #[test]
246    fn spike_matches_doc_formula() {
247        let mut vol = vec![1_000.0; 20];
248        vol.push(2_000.0);
249        let s = rvol(&vol, RvolParams::days_20()).unwrap();
250        // mean includes current: (19*1000 + 2000)/20 = 1050
251        assert!((s.rvol[20].unwrap() - 2000.0 / 1050.0).abs() < 1e-12);
252    }
253
254    #[test]
255    fn negative_volume_err() {
256        assert!(rvol(&[1.0, -1.0], RvolParams::new(2)).is_err());
257    }
258}