Skip to main content

wickra_core/indicators/
volume_rsi.rs

1//! Volume RSI — Wilder's RSI applied to the volume stream.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Volume RSI — the Relative Strength Index computed on **volume** changes
8/// instead of price changes.
9///
10/// Wilder's [`Rsi`](crate::Rsi) measures the balance of up- versus down-*price*
11/// moves; the Volume RSI applies the identical accumulator to the bar-over-bar
12/// change in volume:
13///
14/// ```text
15/// change_t = volume_t − volume_{t−1}
16/// gain     = max(change, 0),  loss = max(−change, 0)
17/// avg_gain, avg_loss = Wilder-smoothed over `period`
18/// VolumeRSI = 100 * avg_gain / (avg_gain + avg_loss)
19/// ```
20///
21/// Readings above `50` mean volume is expanding (more was added than removed over
22/// the smoothing window) and tend to confirm the prevailing move; readings below
23/// `50` mark contracting participation. Output is bounded in `[0, 100]`; a stretch
24/// of unchanged volume drives both averages to `0` and the indicator reports the
25/// neutral `50` rather than an undefined `0 / 0`.
26///
27/// Only the candle's **volume** is used. The first bar sets the previous volume,
28/// then `period` changes seed Wilder's averages, so the first value lands after
29/// `period + 1` inputs. Each `update` is O(1).
30///
31/// # Example
32///
33/// ```
34/// use wickra_core::{Candle, Indicator, VolumeRsi};
35///
36/// let mut indicator = VolumeRsi::new(14).unwrap();
37/// let mut last = None;
38/// for i in 0..40 {
39///     let v = 1_000.0 + (f64::from(i) * 0.3).sin() * 400.0;
40///     let c = Candle::new(100.0, 101.0, 99.0, 100.5, v, 0).unwrap();
41///     last = indicator.update(c);
42/// }
43/// assert!(last.is_some());
44/// ```
45#[derive(Debug, Clone)]
46pub struct VolumeRsi {
47    period: usize,
48    prev_volume: Option<f64>,
49    seed_gains: f64,
50    seed_losses: f64,
51    seed_count: usize,
52    avg_gain: Option<f64>,
53    avg_loss: Option<f64>,
54    last: Option<f64>,
55}
56
57impl VolumeRsi {
58    /// Construct a Volume RSI with the given Wilder smoothing `period`.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`Error::PeriodZero`] if `period == 0`.
63    pub fn new(period: usize) -> Result<Self> {
64        if period == 0 {
65            return Err(Error::PeriodZero);
66        }
67        Ok(Self {
68            period,
69            prev_volume: None,
70            seed_gains: 0.0,
71            seed_losses: 0.0,
72            seed_count: 0,
73            avg_gain: None,
74            avg_loss: None,
75            last: None,
76        })
77    }
78
79    /// Configured smoothing period.
80    pub const fn period(&self) -> usize {
81        self.period
82    }
83
84    /// Current value if available.
85    pub const fn value(&self) -> Option<f64> {
86        self.last
87    }
88
89    fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
90        let denom = avg_gain + avg_loss;
91        if denom == 0.0 {
92            50.0
93        } else {
94            100.0 * (avg_gain / denom)
95        }
96    }
97}
98
99impl Indicator for VolumeRsi {
100    type Input = Candle;
101    type Output = f64;
102
103    fn update(&mut self, candle: Candle) -> Option<f64> {
104        let volume = candle.volume;
105        let Some(prev) = self.prev_volume else {
106            self.prev_volume = Some(volume);
107            return None;
108        };
109        let change = volume - prev;
110        self.prev_volume = Some(volume);
111        let gain = if change > 0.0 { change } else { 0.0 };
112        let loss = if change < 0.0 { -change } else { 0.0 };
113
114        if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
115            let n = self.period as f64;
116            let new_ag = (ag * (n - 1.0) + gain) / n;
117            let new_al = (al * (n - 1.0) + loss) / n;
118            self.avg_gain = Some(new_ag);
119            self.avg_loss = Some(new_al);
120            let v = Self::rsi_from_avgs(new_ag, new_al);
121            self.last = Some(v);
122            return Some(v);
123        }
124
125        self.seed_gains += gain;
126        self.seed_losses += loss;
127        self.seed_count += 1;
128        if self.seed_count == self.period {
129            let n = self.period as f64;
130            let ag = self.seed_gains / n;
131            let al = self.seed_losses / n;
132            self.avg_gain = Some(ag);
133            self.avg_loss = Some(al);
134            let v = Self::rsi_from_avgs(ag, al);
135            self.last = Some(v);
136            return Some(v);
137        }
138        None
139    }
140
141    fn reset(&mut self) {
142        self.prev_volume = None;
143        self.seed_gains = 0.0;
144        self.seed_losses = 0.0;
145        self.seed_count = 0;
146        self.avg_gain = None;
147        self.avg_loss = None;
148        self.last = None;
149    }
150
151    fn warmup_period(&self) -> usize {
152        self.period + 1
153    }
154
155    fn is_ready(&self) -> bool {
156        self.last.is_some()
157    }
158
159    fn name(&self) -> &'static str {
160        "VolumeRsi"
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::traits::BatchExt;
168    use approx::assert_relative_eq;
169
170    /// Candle whose only material field here is `volume`.
171    fn vol_candle(volume: f64) -> Candle {
172        Candle::new_unchecked(100.0, 101.0, 99.0, 100.5, volume, 0)
173    }
174
175    #[test]
176    fn rejects_zero_period() {
177        assert!(matches!(VolumeRsi::new(0), Err(Error::PeriodZero)));
178    }
179
180    #[test]
181    fn accessors_and_metadata() {
182        let v = VolumeRsi::new(14).unwrap();
183        assert_eq!(v.period(), 14);
184        assert_eq!(v.warmup_period(), 15);
185        assert_eq!(v.name(), "VolumeRsi");
186        assert!(!v.is_ready());
187        assert_eq!(v.value(), None);
188    }
189
190    #[test]
191    fn first_emission_at_warmup_period() {
192        let mut v = VolumeRsi::new(3).unwrap();
193        let candles: Vec<Candle> = (0..6).map(|i| vol_candle(1_000.0 + f64::from(i))).collect();
194        let out = v.batch(&candles);
195        // warmup_period == period + 1 == 4: first emission at index 3.
196        for o in out.iter().take(3) {
197            assert!(o.is_none());
198        }
199        assert!(out[3].is_some());
200    }
201
202    #[test]
203    fn rising_volume_is_one_hundred() {
204        // Every change positive -> avg_loss 0 -> RSI 100.
205        let mut v = VolumeRsi::new(5).unwrap();
206        let candles: Vec<Candle> = (1..=40).map(|i| vol_candle(f64::from(i) * 100.0)).collect();
207        let last = v.batch(&candles).into_iter().flatten().last().unwrap();
208        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
209    }
210
211    #[test]
212    fn falling_volume_is_zero() {
213        let mut v = VolumeRsi::new(5).unwrap();
214        let candles: Vec<Candle> = (1..=40)
215            .map(|i| vol_candle(5_000.0 - f64::from(i) * 100.0))
216            .collect();
217        let last = v.batch(&candles).into_iter().flatten().last().unwrap();
218        assert_relative_eq!(last, 0.0, epsilon = 1e-9);
219    }
220
221    #[test]
222    fn flat_volume_is_neutral() {
223        // Unchanged volume -> no gains and no losses -> neutral 50.
224        let mut v = VolumeRsi::new(3).unwrap();
225        let candles: Vec<Candle> = (0..20).map(|_| vol_candle(2_000.0)).collect();
226        let last = v.batch(&candles).into_iter().flatten().last().unwrap();
227        assert_relative_eq!(last, 50.0, epsilon = 1e-12);
228    }
229
230    #[test]
231    fn output_in_range() {
232        let mut v = VolumeRsi::new(14).unwrap();
233        let candles: Vec<Candle> = (0..200)
234            .map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.3).sin() * 600.0))
235            .collect();
236        for o in v.batch(&candles).into_iter().flatten() {
237            assert!((0.0..=100.0).contains(&o));
238        }
239    }
240
241    #[test]
242    fn reset_clears_state() {
243        let mut v = VolumeRsi::new(3).unwrap();
244        let candles: Vec<Candle> = (0..20)
245            .map(|i| vol_candle(1_000.0 + f64::from(i)))
246            .collect();
247        v.batch(&candles);
248        assert!(v.is_ready());
249        v.reset();
250        assert!(!v.is_ready());
251        assert_eq!(v.value(), None);
252        assert_eq!(v.update(vol_candle(1_000.0)), None);
253    }
254
255    #[test]
256    fn batch_equals_streaming() {
257        let candles: Vec<Candle> = (0..120)
258            .map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.25).sin() * 500.0))
259            .collect();
260        let batch = VolumeRsi::new(14).unwrap().batch(&candles);
261        let mut b = VolumeRsi::new(14).unwrap();
262        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
263        assert_eq!(batch, streamed);
264    }
265}