Skip to main content

wickra_core/indicators/
pvi.rs

1//! Positive Volume Index.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Default starting value; matches Norman Fosback's textbook convention.
7const STARTING_INDEX: f64 = 1000.0;
8
9/// Positive Volume Index (Paul Dysart, popularised by Norman Fosback).
10///
11/// The PVI only updates when **volume expands** — Fosback's interpretation is
12/// that the crowd ("uninformed money") trades on volume spikes, so the PVI
13/// tracks the crowd-driven leg of price action. When today's volume is at or
14/// below yesterday's, the PVI is left unchanged.
15///
16/// ```text
17/// PVI_t = PVI_{t−1} · (1 + (close_t − close_{t−1}) / close_{t−1})   if volume_t > volume_{t−1}
18/// PVI_t = PVI_{t−1}                                                  otherwise
19/// ```
20///
21/// The first bar establishes the baseline at `1000.0`. A bar whose previous
22/// close is zero contributes no return.
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{Candle, Indicator, Pvi};
28///
29/// let mut indicator = Pvi::new();
30/// let mut last = None;
31/// for i in 0..80 {
32///     let base = 100.0 + f64::from(i);
33///     let candle =
34///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
35///     last = indicator.update(candle);
36/// }
37/// assert!(last.is_some());
38/// ```
39#[derive(Debug, Clone)]
40pub struct Pvi {
41    prev_close: Option<f64>,
42    prev_volume: Option<f64>,
43    index: f64,
44    has_emitted: bool,
45}
46
47impl Pvi {
48    /// Construct a new PVI starting at `1000.0`.
49    pub const fn new() -> Self {
50        Self {
51            prev_close: None,
52            prev_volume: None,
53            index: STARTING_INDEX,
54            has_emitted: false,
55        }
56    }
57
58    /// Construct a new PVI with a custom starting baseline.
59    pub const fn with_baseline(baseline: f64) -> Self {
60        Self {
61            prev_close: None,
62            prev_volume: None,
63            index: baseline,
64            has_emitted: false,
65        }
66    }
67
68    /// Current cumulative value if at least one candle has been ingested.
69    pub const fn value(&self) -> Option<f64> {
70        if self.has_emitted {
71            Some(self.index)
72        } else {
73            None
74        }
75    }
76}
77
78impl Default for Pvi {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl Indicator for Pvi {
85    type Input = Candle;
86    type Output = f64;
87
88    fn update(&mut self, candle: Candle) -> Option<f64> {
89        if let (Some(pc), Some(pv)) = (self.prev_close, self.prev_volume) {
90            if candle.volume > pv && pc != 0.0 {
91                let ret = (candle.close - pc) / pc;
92                self.index += self.index * ret;
93            }
94        }
95        self.prev_close = Some(candle.close);
96        self.prev_volume = Some(candle.volume);
97        self.has_emitted = true;
98        Some(self.index)
99    }
100
101    fn reset(&mut self) {
102        self.prev_close = None;
103        self.prev_volume = None;
104        self.index = STARTING_INDEX;
105        self.has_emitted = false;
106    }
107
108    fn warmup_period(&self) -> usize {
109        1
110    }
111
112    fn is_ready(&self) -> bool {
113        self.has_emitted
114    }
115
116    fn name(&self) -> &'static str {
117        "PVI"
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::traits::BatchExt;
125    use approx::assert_relative_eq;
126
127    fn c(close: f64, volume: f64, ts: i64) -> Candle {
128        Candle::new(close, close, close, close, volume, ts).unwrap()
129    }
130
131    #[test]
132    fn accessors_and_metadata() {
133        let mut p = Pvi::new();
134        assert_eq!(p.warmup_period(), 1);
135        assert_eq!(p.name(), "PVI");
136        assert_eq!(p.value(), None);
137        p.update(c(10.0, 100.0, 0));
138        assert_eq!(p.value(), Some(1000.0));
139    }
140
141    #[test]
142    fn default_matches_new() {
143        let a = Pvi::default();
144        let b = Pvi::new();
145        assert_eq!(a.warmup_period(), b.warmup_period());
146        assert_eq!(a.value(), b.value());
147        assert_eq!(a.is_ready(), b.is_ready());
148    }
149
150    #[test]
151    fn first_bar_seeds_baseline() {
152        let mut p = Pvi::new();
153        assert_relative_eq!(
154            p.update(c(10.0, 100.0, 0)).unwrap(),
155            1000.0,
156            epsilon = 1e-12
157        );
158    }
159
160    #[test]
161    fn volume_rise_applies_percent_change() {
162        // 1000 * (1 + (11 - 10)/10) = 1100.
163        let mut p = Pvi::new();
164        p.update(c(10.0, 100.0, 0));
165        let v = p.update(c(11.0, 200.0, 1)).unwrap();
166        assert_relative_eq!(v, 1100.0, epsilon = 1e-12);
167    }
168
169    #[test]
170    fn volume_fall_leaves_index_unchanged() {
171        let mut p = Pvi::new();
172        p.update(c(10.0, 200.0, 0));
173        let v = p.update(c(11.0, 100.0, 1)).unwrap();
174        assert_relative_eq!(v, 1000.0, epsilon = 1e-12);
175    }
176
177    #[test]
178    fn equal_volume_leaves_index_unchanged() {
179        let mut p = Pvi::new();
180        p.update(c(10.0, 100.0, 0));
181        let v = p.update(c(11.0, 100.0, 1)).unwrap();
182        assert_relative_eq!(v, 1000.0, epsilon = 1e-12);
183    }
184
185    #[test]
186    fn zero_previous_close_contributes_no_return() {
187        let mut p = Pvi::new();
188        p.update(c(0.0, 100.0, 0));
189        let v = p.update(c(5.0, 200.0, 1)).unwrap();
190        assert_relative_eq!(v, 1000.0, epsilon = 1e-12);
191    }
192
193    #[test]
194    fn custom_baseline() {
195        let mut p = Pvi::with_baseline(100.0);
196        assert_relative_eq!(p.update(c(10.0, 100.0, 0)).unwrap(), 100.0, epsilon = 1e-12);
197    }
198
199    #[test]
200    fn batch_equals_streaming() {
201        let candles: Vec<Candle> = (0..80i64)
202            .map(|i| {
203                let f = i as f64;
204                c(
205                    100.0 + (f * 0.3).sin() * 5.0,
206                    50.0 + ((i % 7) as f64) * 10.0,
207                    i,
208                )
209            })
210            .collect();
211        let mut a = Pvi::new();
212        let mut b = Pvi::new();
213        assert_eq!(
214            a.batch(&candles),
215            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
216        );
217    }
218
219    #[test]
220    fn reset_clears_state() {
221        let mut p = Pvi::new();
222        p.batch(&[c(10.0, 100.0, 0), c(11.0, 200.0, 1)]);
223        assert!(p.is_ready());
224        p.reset();
225        assert!(!p.is_ready());
226        assert_eq!(p.value(), None);
227    }
228}