Skip to main content

egui_charts/studies/builtin/
volume_price_trend.rs

1use crate::model::Bar;
2use crate::studies::palette;
3use crate::studies::stepper::Stepper;
4use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
5use egui::Color32;
6
7/// Volume Price Trend (VPT)
8/// Cumulative volume indicator that considers price changes
9/// Similar to OBV but uses percentage changes
10#[derive(Clone)]
11pub struct VolumePriceTrend {
12    values: IndicatorValues,
13    stepper: Stepper<(Option<f64>, f64)>,
14    color: Color32,
15    visible: bool,
16}
17
18impl VolumePriceTrend {
19    /// Create with the default plot colors.
20    pub fn new() -> Self {
21        Self {
22            values: IndicatorValues::new(),
23            stepper: Stepper::default(),
24            color: palette::BLUE, // Teal
25            visible: true,
26        }
27    }
28
29    /// Set the plot color.
30    pub fn with_color(mut self, color: Color32) -> Self {
31        self.color = color;
32        self
33    }
34}
35
36impl Default for VolumePriceTrend {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl Indicator for VolumePriceTrend {
43    fn source(&self) -> Option<crate::studies::StudySource> {
44        Some(crate::studies::StudySource::Price(
45            crate::model::PriceSource::Close,
46        ))
47    }
48
49    fn value_format(&self) -> crate::scales::StudyFormat {
50        crate::scales::StudyFormat::Volume
51    }
52
53    fn name(&self) -> &str {
54        "VPT"
55    }
56
57    fn desc(&self) -> &str {
58        "Volume Price Trend - Volume weighted by price change"
59    }
60
61    fn calculate(&mut self, data: &[Bar]) {
62        self.update(data, 0);
63    }
64
65    fn update(&mut self, data: &[Bar], from: usize) {
66        self.stepper
67            .run(data, from, &mut self.values, |state, bar| {
68                if !bar.close.is_finite() || !bar.volume.is_finite() {
69                    return IndicatorValue::None;
70                }
71                if let Some(previous) = state.0.filter(|p| *p != 0.0) {
72                    let change = (bar.close - previous) / previous;
73                    state.1 += bar.volume * change;
74                }
75                state.0 = Some(bar.close);
76                IndicatorValue::Single(state.1)
77            });
78    }
79
80    fn is_incremental(&self) -> bool {
81        true
82    }
83
84    fn values(&self) -> &IndicatorValues {
85        &self.values
86    }
87
88    fn colors(&self) -> Vec<Color32> {
89        vec![self.color]
90    }
91
92    fn set_colors(&mut self, colors: Vec<Color32>) {
93        if !colors.is_empty() {
94            self.color = colors[0];
95        }
96    }
97
98    fn is_overlay(&self) -> bool {
99        false
100    }
101
102    fn is_visible(&self) -> bool {
103        self.visible
104    }
105
106    fn set_visible(&mut self, visible: bool) {
107        self.visible = visible;
108    }
109
110    fn clone_box(&self) -> Box<dyn Indicator> {
111        Box::new(self.clone())
112    }
113
114    fn line_names(&self) -> Vec<String> {
115        vec!["VPT".to_string()]
116    }
117}
118
119/// Length of the NVI and PVI signal line, the published one-year EMA of the index.
120const SIGNAL_LEN: f64 = 255.0;
121
122/// Resume the volume-gated index and its first-value-seeded signal.
123fn volume_index(
124    stepper: &mut Stepper<(Option<[f64; 2]>, f64, f64)>,
125    values: &mut IndicatorValues,
126    data: &[Bar],
127    from: usize,
128    counts: fn(f64, f64) -> bool,
129) {
130    stepper.run(data, from, values, |state, bar| {
131        if !bar.close.is_finite() || !bar.volume.is_finite() {
132            return IndicatorValue::None;
133        }
134        if let Some([close, volume]) = state.0 {
135            if counts(bar.volume, volume) && close != 0.0 {
136                state.1 += state.1 * ((bar.close - close) / close);
137            }
138            state.2 += (state.1 - state.2) * (2.0 / (SIGNAL_LEN + 1.0));
139        } else {
140            state.1 = 1000.0;
141            state.2 = 1000.0;
142        }
143        state.0 = Some([bar.close, bar.volume]);
144        IndicatorValue::Multiple(vec![state.1, state.2])
145    });
146}
147
148/// Negative Volume Index (NVI)
149///
150/// Starts at 1000 and moves by the close's percentage change only on bars whose volume fell.
151/// Outputs `Multiple([index, signal])`: the signal line is the 255-bar EMA of the index, seeded
152/// with the first index value so that it starts at bar 0 like the index.
153#[derive(Clone)]
154pub struct NegativeVolumeIndex {
155    values: IndicatorValues,
156    stepper: Stepper<(Option<[f64; 2]>, f64, f64)>,
157    color: Color32,
158    signal_color: Color32,
159    visible: bool,
160}
161
162impl NegativeVolumeIndex {
163    /// Create with the default plot colors.
164    pub fn new() -> Self {
165        Self {
166            values: IndicatorValues::new(),
167            stepper: Stepper::default(),
168            color: palette::BLUE,
169            signal_color: palette::WARM_ORANGE,
170            visible: true,
171        }
172    }
173
174    /// Set the plot color.
175    pub fn with_color(mut self, color: Color32) -> Self {
176        self.color = color;
177        self
178    }
179}
180
181impl Default for NegativeVolumeIndex {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187impl Indicator for NegativeVolumeIndex {
188    fn source(&self) -> Option<crate::studies::StudySource> {
189        Some(crate::studies::StudySource::Price(
190            crate::model::PriceSource::Close,
191        ))
192    }
193
194    fn name(&self) -> &str {
195        "NVI"
196    }
197
198    fn desc(&self) -> &str {
199        "Negative Volume Index - Tracks price on low volume days"
200    }
201
202    fn calculate(&mut self, data: &[Bar]) {
203        self.update(data, 0);
204    }
205
206    fn update(&mut self, data: &[Bar], from: usize) {
207        volume_index(
208            &mut self.stepper,
209            &mut self.values,
210            data,
211            from,
212            |volume, prev| volume < prev,
213        );
214    }
215
216    fn is_incremental(&self) -> bool {
217        true
218    }
219
220    fn values(&self) -> &IndicatorValues {
221        &self.values
222    }
223
224    fn colors(&self) -> Vec<Color32> {
225        vec![self.color, self.signal_color]
226    }
227
228    fn set_colors(&mut self, colors: Vec<Color32>) {
229        if let Some(&color) = colors.first() {
230            self.color = color;
231        }
232        if let Some(&color) = colors.get(1) {
233            self.signal_color = color;
234        }
235    }
236
237    fn is_overlay(&self) -> bool {
238        false
239    }
240
241    fn is_visible(&self) -> bool {
242        self.visible
243    }
244
245    fn set_visible(&mut self, visible: bool) {
246        self.visible = visible;
247    }
248
249    fn clone_box(&self) -> Box<dyn Indicator> {
250        Box::new(self.clone())
251    }
252
253    fn line_count(&self) -> usize {
254        2
255    }
256
257    fn line_names(&self) -> Vec<String> {
258        vec!["NVI".to_string(), "Signal".to_string()]
259    }
260}
261
262/// Positive Volume Index (PVI)
263///
264/// Starts at 1000 and moves by the close's percentage change only on bars whose volume rose.
265/// Outputs `Multiple([index, signal])`: the signal line is the 255-bar EMA of the index, seeded
266/// with the first index value so that it starts at bar 0 like the index.
267#[derive(Clone)]
268pub struct PositiveVolumeIndex {
269    values: IndicatorValues,
270    stepper: Stepper<(Option<[f64; 2]>, f64, f64)>,
271    color: Color32,
272    signal_color: Color32,
273    visible: bool,
274}
275
276impl PositiveVolumeIndex {
277    /// Create with the default plot colors.
278    pub fn new() -> Self {
279        Self {
280            values: IndicatorValues::new(),
281            stepper: Stepper::default(),
282            color: palette::BLUE,
283            signal_color: palette::WARM_ORANGE,
284            visible: true,
285        }
286    }
287
288    /// Set the plot color.
289    pub fn with_color(mut self, color: Color32) -> Self {
290        self.color = color;
291        self
292    }
293}
294
295impl Default for PositiveVolumeIndex {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301impl Indicator for PositiveVolumeIndex {
302    fn source(&self) -> Option<crate::studies::StudySource> {
303        Some(crate::studies::StudySource::Price(
304            crate::model::PriceSource::Close,
305        ))
306    }
307
308    fn name(&self) -> &str {
309        "PVI"
310    }
311
312    fn desc(&self) -> &str {
313        "Positive Volume Index - Tracks price on high volume days"
314    }
315
316    fn calculate(&mut self, data: &[Bar]) {
317        self.update(data, 0);
318    }
319
320    fn update(&mut self, data: &[Bar], from: usize) {
321        volume_index(
322            &mut self.stepper,
323            &mut self.values,
324            data,
325            from,
326            |volume, prev| volume > prev,
327        );
328    }
329
330    fn is_incremental(&self) -> bool {
331        true
332    }
333
334    fn values(&self) -> &IndicatorValues {
335        &self.values
336    }
337
338    fn colors(&self) -> Vec<Color32> {
339        vec![self.color, self.signal_color]
340    }
341
342    fn set_colors(&mut self, colors: Vec<Color32>) {
343        if let Some(&color) = colors.first() {
344            self.color = color;
345        }
346        if let Some(&color) = colors.get(1) {
347            self.signal_color = color;
348        }
349    }
350
351    fn is_overlay(&self) -> bool {
352        false
353    }
354
355    fn is_visible(&self) -> bool {
356        self.visible
357    }
358
359    fn set_visible(&mut self, visible: bool) {
360        self.visible = visible;
361    }
362
363    fn clone_box(&self) -> Box<dyn Indicator> {
364        Box::new(self.clone())
365    }
366
367    fn line_count(&self) -> usize {
368        2
369    }
370
371    fn line_names(&self) -> Vec<String> {
372        vec!["PVI".to_string(), "Signal".to_string()]
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use chrono::Utc;
380
381    fn make_bar(close: f64, volume: f64) -> Bar {
382        Bar {
383            time: Utc::now(),
384            open: close,
385            high: close,
386            low: close,
387            close,
388            volume,
389            colors: None,
390            payload: None,
391        }
392    }
393
394    #[test]
395    fn test_vpt() {
396        let mut vpt = VolumePriceTrend::new();
397
398        let data = vec![
399            make_bar(100.0, 1000.0),
400            make_bar(105.0, 1200.0), // +5% change
401            make_bar(102.0, 800.0),  // -2.86% change
402        ];
403
404        vpt.calculate(&data);
405
406        assert_eq!(vpt.values.len(), 3);
407
408        // First VPT should be 0
409        let IndicatorValue::Single(v) = vpt.values.to_rows()[0] else {
410            panic!("no value: {:?}", vpt.values.to_rows()[0]);
411        };
412        assert_eq!(v, 0.0);
413    }
414
415    /// Index and signal line against the published definition. On a bar whose volume fell (NVI)
416    /// or rose (PVI), the index is multiplied by close / previous close (Fosback, "Stock Market
417    /// Logic", 1976). The index starts at 1000 and the signal is its 255-bar EMA, about one
418    /// trading year, seeded with its first value as the crate's EMA is. Expected values were
419    /// computed independently in exact rational arithmetic. Hand check, NVI bar 1: the index
420    /// rises 5% to 1050 and the signal is 1000 + (1050 - 1000) * 2 / 256 = 1000.390625.
421    #[test]
422    fn nvi_pvi_match_published_definition() {
423        // Volume down, up, unchanged with a zero close, down after a zero close, unchanged, up after
424        // a zero close (the index holds without a previous close), down, up.
425        let data: Vec<Bar> = [
426            (100.0, 1000.0),
427            (105.0, 800.0),
428            (108.0, 1200.0),
429            (0.0, 1200.0),
430            (50.0, 900.0),
431            (0.0, 900.0),
432            (40.0, 1100.0),
433            (44.0, 700.0),
434            (33.0, 1400.0),
435        ]
436        .iter()
437        .map(|&(close, volume)| make_bar(close, volume))
438        .collect();
439        let nvi = [
440            (1000.0, 1000.0),
441            (1050.0, 1000.390625),
442            (1050.0, 1000.7781982421875),
443            (1050.0, 1001.1627435684204),
444            (1050.0, 1001.5442846342921),
445            (1050.0, 1001.9228449105867),
446            (1050.0, 1002.2984476847228),
447            (1155.0, 1003.4914285621859),
448            (1155.0, 1004.6750892765438),
449        ];
450        let pvi = [
451            (1000.0, 1000.0),
452            (1000.0, 1000.0),
453            (1028.5714285714287, 1000.2232142857143),
454            (1028.5714285714287, 1000.4446847098214),
455            (1028.5714285714287, 1000.6644248962402),
456            (1028.5714285714287, 1000.8824483624527),
457            (1028.5714285714287, 1001.0987685203353),
458            (1028.5714285714287, 1001.3133986769844),
459            (771.4285714285714, 999.5174234641062),
460        ];
461        let cases = [
462            (
463                Box::new(NegativeVolumeIndex::new()) as Box<dyn Indicator>,
464                nvi,
465            ),
466            (Box::new(PositiveVolumeIndex::new()), pvi),
467        ];
468        for (mut study, expected) in cases {
469            study.calculate(&data);
470            assert_eq!(study.line_count(), 2, "{}", study.name());
471            assert_eq!(study.values().len(), expected.len(), "{}", study.name());
472            for (bar, (value, &(index, signal))) in
473                study.values().to_rows().iter().zip(&expected).enumerate()
474            {
475                let IndicatorValue::Multiple(lines) = value else {
476                    panic!(
477                        "{} bar {bar}: expected index and signal, got {value:?}",
478                        study.name()
479                    );
480                };
481                assert!(
482                    (lines[0] - index).abs() < 1e-12 && (lines[1] - signal).abs() < 1e-12,
483                    "{} bar {bar}: got {lines:?}, expected [{index}, {signal}]",
484                    study.name()
485                );
486            }
487        }
488    }
489}