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        if period > crate::error::MAX_PERIOD {
68            return Err(Error::InvalidPeriod {
69                message: crate::error::PERIOD_ABOVE_MAX,
70            });
71        }
72        Ok(Self {
73            period,
74            prev_volume: None,
75            seed_gains: 0.0,
76            seed_losses: 0.0,
77            seed_count: 0,
78            avg_gain: None,
79            avg_loss: None,
80            last: None,
81        })
82    }
83
84    /// Configured smoothing period.
85    pub const fn period(&self) -> usize {
86        self.period
87    }
88
89    /// Current value if available.
90    pub const fn value(&self) -> Option<f64> {
91        self.last
92    }
93
94    fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
95        let denom = avg_gain + avg_loss;
96        if denom == 0.0 {
97            50.0
98        } else {
99            100.0 * (avg_gain / denom)
100        }
101    }
102}
103
104impl Indicator for VolumeRsi {
105    type Input = Candle;
106    type Output = f64;
107
108    #[inline]
109    fn update(&mut self, candle: Candle) -> Option<f64> {
110        let volume = candle.volume;
111        let Some(prev) = self.prev_volume else {
112            self.prev_volume = Some(volume);
113            return None;
114        };
115        let change = volume - prev;
116        self.prev_volume = Some(volume);
117        let gain = if change > 0.0 { change } else { 0.0 };
118        let loss = if change < 0.0 { -change } else { 0.0 };
119
120        if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
121            let n = self.period as f64;
122            let new_ag = (ag * (n - 1.0) + gain) / n;
123            let new_al = (al * (n - 1.0) + loss) / n;
124            self.avg_gain = Some(new_ag);
125            self.avg_loss = Some(new_al);
126            let v = Self::rsi_from_avgs(new_ag, new_al);
127            self.last = Some(v);
128            return Some(v);
129        }
130
131        self.seed_gains += gain;
132        self.seed_losses += loss;
133        self.seed_count += 1;
134        if self.seed_count == self.period {
135            let n = self.period as f64;
136            let ag = self.seed_gains / n;
137            let al = self.seed_losses / n;
138            self.avg_gain = Some(ag);
139            self.avg_loss = Some(al);
140            let v = Self::rsi_from_avgs(ag, al);
141            self.last = Some(v);
142            return Some(v);
143        }
144        None
145    }
146
147    fn reset(&mut self) {
148        self.prev_volume = None;
149        self.seed_gains = 0.0;
150        self.seed_losses = 0.0;
151        self.seed_count = 0;
152        self.avg_gain = None;
153        self.avg_loss = None;
154        self.last = None;
155    }
156
157    #[inline]
158    fn warmup_period(&self) -> usize {
159        self.period + 1
160    }
161
162    #[inline]
163    fn is_ready(&self) -> bool {
164        self.last.is_some()
165    }
166
167    #[inline]
168    fn name(&self) -> &'static str {
169        "VolumeRsi"
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::traits::BatchExt;
177    use approx::assert_relative_eq;
178
179    /// Candle whose only material field here is `volume`.
180    fn vol_candle(volume: f64) -> Candle {
181        Candle::new_unchecked(100.0, 101.0, 99.0, 100.5, volume, 0)
182    }
183
184    #[test]
185    fn rejects_zero_period() {
186        assert!(matches!(VolumeRsi::new(0), Err(Error::PeriodZero)));
187    }
188
189    #[test]
190    fn accessors_and_metadata() {
191        let v = VolumeRsi::new(14).unwrap();
192        assert_eq!(v.period(), 14);
193        assert_eq!(v.warmup_period(), 15);
194        assert_eq!(v.name(), "VolumeRsi");
195        assert!(!v.is_ready());
196        assert_eq!(v.value(), None);
197    }
198
199    #[test]
200    fn first_emission_at_warmup_period() {
201        let mut v = VolumeRsi::new(3).unwrap();
202        let candles: Vec<Candle> = (0..6).map(|i| vol_candle(1_000.0 + f64::from(i))).collect();
203        let out = v.batch(&candles);
204        // warmup_period == period + 1 == 4: first emission at index 3.
205        for o in out.iter().take(3) {
206            assert!(o.is_none());
207        }
208        assert!(out[3].is_some());
209    }
210
211    #[test]
212    fn rising_volume_is_one_hundred() {
213        // Every change positive -> avg_loss 0 -> RSI 100.
214        let mut v = VolumeRsi::new(5).unwrap();
215        let candles: Vec<Candle> = (1..=40).map(|i| vol_candle(f64::from(i) * 100.0)).collect();
216        let last = v.batch(&candles).into_iter().flatten().last().unwrap();
217        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
218    }
219
220    #[test]
221    fn falling_volume_is_zero() {
222        let mut v = VolumeRsi::new(5).unwrap();
223        let candles: Vec<Candle> = (1..=40)
224            .map(|i| vol_candle(5_000.0 - f64::from(i) * 100.0))
225            .collect();
226        let last = v.batch(&candles).into_iter().flatten().last().unwrap();
227        assert_relative_eq!(last, 0.0, epsilon = 1e-9);
228    }
229
230    #[test]
231    fn flat_volume_is_neutral() {
232        // Unchanged volume -> no gains and no losses -> neutral 50.
233        let mut v = VolumeRsi::new(3).unwrap();
234        let candles: Vec<Candle> = (0..20).map(|_| vol_candle(2_000.0)).collect();
235        let last = v.batch(&candles).into_iter().flatten().last().unwrap();
236        assert_relative_eq!(last, 50.0, epsilon = 1e-12);
237    }
238
239    #[test]
240    fn output_in_range() {
241        let mut v = VolumeRsi::new(14).unwrap();
242        let candles: Vec<Candle> = (0..200)
243            .map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.3).sin() * 600.0))
244            .collect();
245        for o in v.batch(&candles).into_iter().flatten() {
246            assert!((0.0..=100.0).contains(&o));
247        }
248    }
249
250    #[test]
251    fn reset_clears_state() {
252        let mut v = VolumeRsi::new(3).unwrap();
253        let candles: Vec<Candle> = (0..20)
254            .map(|i| vol_candle(1_000.0 + f64::from(i)))
255            .collect();
256        v.batch(&candles);
257        assert!(v.is_ready());
258        v.reset();
259        assert!(!v.is_ready());
260        assert_eq!(v.value(), None);
261        assert_eq!(v.update(vol_candle(1_000.0)), None);
262    }
263
264    #[test]
265    fn batch_equals_streaming() {
266        let candles: Vec<Candle> = (0..120)
267            .map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.25).sin() * 500.0))
268            .collect();
269        let batch = VolumeRsi::new(14).unwrap().batch(&candles);
270        let mut b = VolumeRsi::new(14).unwrap();
271        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
272        assert_eq!(batch, streamed);
273    }
274}