Skip to main content

egui_charts/studies/builtin/
historical_volatility.rs

1use super::{clamp_param, over_finite_bars};
2use crate::model::Bar;
3use crate::studies::palette;
4use crate::studies::stepper::{update_window, usable_behind};
5use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
6use egui::Color32;
7
8/// Historical Volatility (HV)
9/// Measures the annualized standard deviation of log returns
10/// Used for options pricing and risk assessment
11#[derive(Clone)]
12pub struct HistoricalVolatility {
13    /// Lookback period (typically 20 or 30)
14    period: usize,
15    /// Trading days per year (252 for stocks, 365 for crypto)
16    trading_days: f64,
17    values: IndicatorValues,
18    color: Color32,
19    visible: bool,
20}
21
22impl HistoricalVolatility {
23    /// Create with a lookback of `period` bars, clamped to at least 1.
24    pub fn new(period: usize) -> Self {
25        Self {
26            period: period.max(1),
27            trading_days: 252.0, // Default for stocks
28            values: IndicatorValues::new(),
29            color: palette::BLUE, // Deep Orange
30            visible: true,
31        }
32    }
33
34    /// Set the bars per year the volatility is annualized over, clamped into 0 to
35    /// `f64::MAX`, NaN to 0.
36    pub fn with_trading_days(mut self, days: f64) -> Self {
37        self.trading_days = clamp_param(days, 0.0, f64::MAX);
38        self
39    }
40
41    /// Annualize over 365 bars instead of the default 252.
42    pub fn for_crypto(mut self) -> Self {
43        self.trading_days = 365.0;
44        self
45    }
46
47    /// Set the plot color.
48    pub fn with_color(mut self, color: Color32) -> Self {
49        self.color = color;
50        self
51    }
52}
53
54/// Construct with the conventional default parameters.
55impl Default for HistoricalVolatility {
56    fn default() -> Self {
57        Self::new(10)
58    }
59}
60
61impl HistoricalVolatility {
62    /// The values over `data`, every bar of which has finite inputs.
63    fn compute(&self, data: &[Bar]) -> IndicatorValues {
64        let mut values = IndicatorValues::new();
65        if data.len() < self.period.saturating_add(1) {
66            for _ in 0..data.len() {
67                values.push(IndicatorValue::None);
68            }
69            return values;
70        }
71
72        // Calculate log returns
73        let mut log_returns = Vec::with_capacity(data.len());
74        log_returns.push(f64::NAN); // First bar has no return
75
76        for i in 1..data.len() {
77            if data[i - 1].close <= 0.0 || data[i].close <= 0.0 {
78                log_returns.push(f64::NAN);
79            } else {
80                let ret = (data[i].close / data[i - 1].close).ln();
81                log_returns.push(ret);
82            }
83        }
84
85        // Calculate rolling standard deviation
86        for i in 0..data.len() {
87            if i < self.period {
88                values.push(IndicatorValue::None);
89            } else {
90                let window = &log_returns[i + 1 - self.period..=i];
91
92                // Check for NaN values
93                let valid_returns: Vec<f64> =
94                    window.iter().filter(|&&x| !x.is_nan()).copied().collect();
95
96                if valid_returns.len() < 2 {
97                    values.push(IndicatorValue::None);
98                    continue;
99                }
100
101                // Calculate mean
102                let mean = valid_returns.iter().sum::<f64>() / valid_returns.len() as f64;
103
104                // Calculate variance
105                let variance = valid_returns
106                    .iter()
107                    .map(|&x| (x - mean).powi(2))
108                    .sum::<f64>()
109                    / (valid_returns.len() - 1) as f64;
110
111                // Standard deviation
112                let std_dev = variance.sqrt();
113
114                // Annualize: multiply by sqrt(trading_days) and convert to percentage
115                let annualized_hv = std_dev * self.trading_days.sqrt() * 100.0;
116
117                values.push(IndicatorValue::Single(annualized_hv));
118            }
119        }
120
121        values
122    }
123}
124
125impl Indicator for HistoricalVolatility {
126    fn source(&self) -> Option<crate::studies::StudySource> {
127        Some(crate::studies::StudySource::Price(
128            crate::model::PriceSource::Close,
129        ))
130    }
131
132    crate::studies::inputs::study_inputs!(self;
133        period: length("Period", 1, usize::MAX),
134        trading_days: number("Trading days", 0.0, f64::MAX)
135    ; {});
136
137    fn value_format(&self) -> crate::scales::StudyFormat {
138        crate::scales::StudyFormat::Percent
139    }
140
141    fn name(&self) -> &str {
142        "HV"
143    }
144
145    fn desc(&self) -> &str {
146        "Historical Volatility - Annualized standard deviation of returns"
147    }
148
149    fn calculate(&mut self, data: &[Bar]) {
150        self.update(data, 0);
151    }
152
153    fn update(&mut self, data: &[Bar], from: usize) {
154        let mut values = std::mem::take(&mut self.values);
155        update_window(
156            &mut values,
157            data,
158            from,
159            usable_behind(self.period, |b| b.close.is_finite()),
160            |data| over_finite_bars(data, |b| b.close.is_finite(), |data| self.compute(data)),
161        );
162        self.values = values;
163    }
164
165    fn is_incremental(&self) -> bool {
166        true
167    }
168
169    fn values(&self) -> &IndicatorValues {
170        &self.values
171    }
172
173    fn colors(&self) -> Vec<Color32> {
174        vec![self.color]
175    }
176
177    fn set_colors(&mut self, colors: Vec<Color32>) {
178        if !colors.is_empty() {
179            self.color = colors[0];
180        }
181    }
182
183    fn is_overlay(&self) -> bool {
184        false
185    }
186
187    fn is_visible(&self) -> bool {
188        self.visible
189    }
190
191    fn set_visible(&mut self, visible: bool) {
192        self.visible = visible;
193    }
194
195    fn clone_box(&self) -> Box<dyn Indicator> {
196        Box::new(self.clone())
197    }
198
199    fn line_names(&self) -> Vec<String> {
200        vec![format!("HV({})", self.period)]
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::studies::test_support::make_bar;
208
209    #[test]
210    fn test_hv_no_volatility() {
211        let mut hv = HistoricalVolatility::new(3);
212
213        // Constant prices = no volatility
214        let data = vec![
215            make_bar(100.0),
216            make_bar(100.0),
217            make_bar(100.0),
218            make_bar(100.0),
219            make_bar(100.0),
220        ];
221
222        hv.calculate(&data);
223
224        // With constant prices, HV should be 0
225        let rows = hv.values.to_rows();
226        let IndicatorValue::Single(v) = rows.last().unwrap() else {
227            panic!("no value: {:?}", hv.values.to_rows().last().unwrap());
228        };
229        assert!(v.abs() < 0.01, "HV should be ~0 for constant prices");
230    }
231
232    #[test]
233    fn test_hv_high_volatility() {
234        let mut hv = HistoricalVolatility::new(3);
235
236        // Large price swings
237        let data = vec![
238            make_bar(100.0),
239            make_bar(120.0),
240            make_bar(80.0),
241            make_bar(130.0),
242            make_bar(70.0),
243        ];
244
245        hv.calculate(&data);
246
247        for bar in 0..3 {
248            assert!(
249                matches!(hv.values.to_rows()[bar], IndicatorValue::None),
250                "warm-up bar {bar}"
251            );
252        }
253        // High volatility should produce large HV values
254        let rows = hv.values.to_rows();
255        let IndicatorValue::Single(v) = rows.last().unwrap() else {
256            panic!("no value: {:?}", hv.values.to_rows().last().unwrap());
257        };
258        assert!(*v > 100.0, "HV should be high for volatile prices, got {v}");
259    }
260}