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    #[inline]
89    fn update(&mut self, candle: Candle) -> Option<f64> {
90        if let (Some(pc), Some(pv)) = (self.prev_close, self.prev_volume) {
91            if candle.volume > pv && pc != 0.0 {
92                let ret = (candle.close - pc) / pc;
93                self.index += self.index * ret;
94            }
95        }
96        self.prev_close = Some(candle.close);
97        self.prev_volume = Some(candle.volume);
98        self.has_emitted = true;
99        Some(self.index)
100    }
101
102    fn reset(&mut self) {
103        self.prev_close = None;
104        self.prev_volume = None;
105        self.index = STARTING_INDEX;
106        self.has_emitted = false;
107    }
108
109    #[inline]
110    fn warmup_period(&self) -> usize {
111        1
112    }
113
114    #[inline]
115    fn is_ready(&self) -> bool {
116        self.has_emitted
117    }
118
119    #[inline]
120    fn name(&self) -> &'static str {
121        "PVI"
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::traits::BatchExt;
129    use approx::assert_relative_eq;
130
131    fn c(close: f64, volume: f64, ts: i64) -> Candle {
132        Candle::new(close, close, close, close, volume, ts).unwrap()
133    }
134
135    #[test]
136    fn accessors_and_metadata() {
137        let mut p = Pvi::new();
138        assert_eq!(p.warmup_period(), 1);
139        assert_eq!(p.name(), "PVI");
140        assert_eq!(p.value(), None);
141        p.update(c(10.0, 100.0, 0));
142        assert_eq!(p.value(), Some(1000.0));
143    }
144
145    #[test]
146    fn default_matches_new() {
147        let a = Pvi::default();
148        let b = Pvi::new();
149        assert_eq!(a.warmup_period(), b.warmup_period());
150        assert_eq!(a.value(), b.value());
151        assert_eq!(a.is_ready(), b.is_ready());
152    }
153
154    #[test]
155    fn first_bar_seeds_baseline() {
156        let mut p = Pvi::new();
157        assert_relative_eq!(
158            p.update(c(10.0, 100.0, 0)).unwrap(),
159            1000.0,
160            epsilon = 1e-12
161        );
162    }
163
164    #[test]
165    fn volume_rise_applies_percent_change() {
166        // 1000 * (1 + (11 - 10)/10) = 1100.
167        let mut p = Pvi::new();
168        p.update(c(10.0, 100.0, 0));
169        let v = p.update(c(11.0, 200.0, 1)).unwrap();
170        assert_relative_eq!(v, 1100.0, epsilon = 1e-12);
171    }
172
173    #[test]
174    fn volume_fall_leaves_index_unchanged() {
175        let mut p = Pvi::new();
176        p.update(c(10.0, 200.0, 0));
177        let v = p.update(c(11.0, 100.0, 1)).unwrap();
178        assert_relative_eq!(v, 1000.0, epsilon = 1e-12);
179    }
180
181    #[test]
182    fn equal_volume_leaves_index_unchanged() {
183        let mut p = Pvi::new();
184        p.update(c(10.0, 100.0, 0));
185        let v = p.update(c(11.0, 100.0, 1)).unwrap();
186        assert_relative_eq!(v, 1000.0, epsilon = 1e-12);
187    }
188
189    #[test]
190    fn zero_previous_close_contributes_no_return() {
191        let mut p = Pvi::new();
192        p.update(c(0.0, 100.0, 0));
193        let v = p.update(c(5.0, 200.0, 1)).unwrap();
194        assert_relative_eq!(v, 1000.0, epsilon = 1e-12);
195    }
196
197    #[test]
198    fn custom_baseline() {
199        let mut p = Pvi::with_baseline(100.0);
200        assert_relative_eq!(p.update(c(10.0, 100.0, 0)).unwrap(), 100.0, epsilon = 1e-12);
201    }
202
203    #[test]
204    fn batch_equals_streaming() {
205        let candles: Vec<Candle> = (0..80i64)
206            .map(|i| {
207                let f = i as f64;
208                c(
209                    100.0 + (f * 0.3).sin() * 5.0,
210                    50.0 + ((i % 7) as f64) * 10.0,
211                    i,
212                )
213            })
214            .collect();
215        let mut a = Pvi::new();
216        let mut b = Pvi::new();
217        assert_eq!(
218            a.batch(&candles),
219            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
220        );
221    }
222
223    #[test]
224    fn reset_clears_state() {
225        let mut p = Pvi::new();
226        p.batch(&[c(10.0, 100.0, 0), c(11.0, 200.0, 1)]);
227        assert!(p.is_ready());
228        p.reset();
229        assert!(!p.is_ready());
230        assert_eq!(p.value(), None);
231    }
232}