Skip to main content

wickra_core/indicators/
nvi.rs

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