Skip to main content

kestrel_chartkit/indicator/
relative_volatility.rs

1use std::collections::VecDeque;
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6use crate::model::Bar;
7
8use super::smoothing::Rma;
9use super::{Indicator, IndicatorOutput};
10
11/// Which prices the Relative Volatility Index is measured on.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13#[cfg_attr(
14    feature = "serde",
15    derive(Serialize, Deserialize),
16    serde(rename_all = "snake_case")
17)]
18pub enum RelativeVolatilityVariant {
19    /// The closing price alone, as the index was first defined.
20    #[default]
21    Close,
22    /// The same measurement run separately on highs and on lows, then averaged. The later
23    /// revision, which reacts to the range rather than to one price per bar.
24    HighLow,
25}
26
27/// Relative Volatility Index: the Relative Strength Index construction applied to *volatility*
28/// instead of to price change.
29///
30/// For each bar the standard deviation of the last `stdev_len` prices is computed. That figure is
31/// then filed under "up" or "down" depending on which way the price moved, and the two are
32/// Wilder-smoothed over `smooth_len`:
33///
34/// ```text
35/// up_t    = stdev_t if price rose, else 0
36/// down_t  = stdev_t if price fell, else 0
37/// RVI     = 100 * rma(up) / (rma(up) + rma(down))
38/// ```
39///
40/// So it answers "is the recent movement concentrated on the up side or the down side", where
41/// *movement* means dispersion, not distance. A market that falls steadily with little scatter
42/// can therefore read differently from an RSI on the same bars.
43///
44/// **This is not the Relative Vigor Index**, which this crate registers as `rvi` and which
45/// compares the close-open span to the high-low span. Same three letters, unrelated measurement;
46/// hence the separate name.
47///
48/// No claim of numerical agreement with any other implementation is made: the published
49/// descriptions of this indicator leave the direction rule, the smoothing and the seed open, and
50/// what is implemented here is the contract stated above.
51///
52/// Unit: `0..=100`. A bar where the price did not move contributes to neither side. A window in
53/// which nothing moved at all leaves both averages at zero; the documented convention there is
54/// `50`, the same neutral reading this crate's RSI uses.
55///
56/// First output: once both the deviation window and the Wilder averages are ready, i.e. with bar
57/// `stdev_len + smooth_len - 1`. [`Indicator::reset`] clears the window and both averages.
58#[derive(Debug, Clone)]
59pub struct RelativeVolatilityIndex {
60    stdev_len: usize,
61    smooth_len: usize,
62    variant: RelativeVolatilityVariant,
63    close: DirectionalDeviation,
64    high: DirectionalDeviation,
65    low: DirectionalDeviation,
66}
67
68/// One price series' dispersion, split by the direction that series moved.
69#[derive(Debug, Clone)]
70struct DirectionalDeviation {
71    len: usize,
72    window: VecDeque<f64>,
73    previous: Option<f64>,
74    up: Rma,
75    down: Rma,
76}
77
78impl DirectionalDeviation {
79    fn new(len: usize, smooth_len: usize) -> Self {
80        Self {
81            len,
82            window: VecDeque::with_capacity(len),
83            previous: None,
84            up: Rma::new(smooth_len),
85            down: Rma::new(smooth_len),
86        }
87    }
88
89    fn update(&mut self, value: f64) -> Option<f64> {
90        self.window.push_back(value);
91        if self.window.len() > self.len {
92            self.window.pop_front();
93        }
94        let previous = self.previous.replace(value);
95        if self.window.len() < self.len {
96            return None;
97        }
98
99        // Population standard deviation over the centred window, the same form the band
100        // calculation in this crate uses.
101        let mean = self.window.iter().sum::<f64>() / self.len as f64;
102        let variance = self
103            .window
104            .iter()
105            .map(|entry| {
106                let diff = entry - mean;
107                diff * diff
108            })
109            .sum::<f64>()
110            / self.len as f64;
111        let deviation = variance.sqrt();
112
113        let previous = previous?;
114        let (up, down) = if value > previous {
115            (deviation, 0.0)
116        } else if value < previous {
117            (0.0, deviation)
118        } else {
119            (0.0, 0.0)
120        };
121
122        // Beide Glätter müssen jede Beobachtung sehen. Ein `?` auf dem ersten würde den zweiten
123        // während des Warmups überspringen, und die beiden Zustände liefen um die Warmup-Länge
124        // auseinander.
125        let up_avg = self.up.update(up);
126        let down_avg = self.down.update(down);
127        let (up_avg, down_avg) = (up_avg?, down_avg?);
128        let total = up_avg + down_avg;
129        Some(if total > 0.0 {
130            100.0 * up_avg / total
131        } else {
132            50.0
133        })
134    }
135
136    fn reset(&mut self) {
137        self.window.clear();
138        self.previous = None;
139        self.up.reset();
140        self.down.reset();
141    }
142}
143
144impl RelativeVolatilityIndex {
145    pub fn new(stdev_len: usize, smooth_len: usize, variant: RelativeVolatilityVariant) -> Self {
146        let stdev_len = stdev_len.max(2);
147        let smooth_len = smooth_len.max(1);
148        Self {
149            stdev_len,
150            smooth_len,
151            variant,
152            close: DirectionalDeviation::new(stdev_len, smooth_len),
153            high: DirectionalDeviation::new(stdev_len, smooth_len),
154            low: DirectionalDeviation::new(stdev_len, smooth_len),
155        }
156    }
157
158    pub fn with_defaults() -> Self {
159        Self::new(10, 14, RelativeVolatilityVariant::Close)
160    }
161
162    pub fn variant(&self) -> RelativeVolatilityVariant {
163        self.variant
164    }
165}
166
167impl Indicator for RelativeVolatilityIndex {
168    fn name(&self) -> &str {
169        "relative_volatility"
170    }
171
172    fn warmup_period(&self) -> usize {
173        self.stdev_len + self.smooth_len
174    }
175
176    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
177        match self.variant {
178            RelativeVolatilityVariant::Close => {
179                self.close.update(bar.close).map(IndicatorOutput::new)
180            }
181            RelativeVolatilityVariant::HighLow => {
182                // Both sides are advanced on every bar; the average is only published once both
183                // have a value, which they reach together.
184                let high = self.high.update(bar.high);
185                let low = self.low.update(bar.low);
186                match (high, low) {
187                    (Some(high), Some(low)) => Some(IndicatorOutput::new((high + low) / 2.0)),
188                    _ => None,
189                }
190            }
191        }
192    }
193
194    fn reset(&mut self) {
195        self.close.reset();
196        self.high.reset();
197        self.low.reset();
198    }
199}