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    fn update(&mut self, candle: Candle) -> Option<f64> {
92        // First bar establishes the baseline at `index`; the `if let` handles
93        // every later bar, which has both predecessors recorded by construction.
94        if let (Some(pc), Some(pv)) = (self.prev_close, self.prev_volume) {
95            if candle.volume < pv && pc != 0.0 {
96                let ret = (candle.close - pc) / pc;
97                self.index += self.index * ret;
98            }
99        }
100        self.prev_close = Some(candle.close);
101        self.prev_volume = Some(candle.volume);
102        self.has_emitted = true;
103        Some(self.index)
104    }
105
106    fn reset(&mut self) {
107        self.prev_close = None;
108        self.prev_volume = None;
109        self.index = STARTING_INDEX;
110        self.has_emitted = false;
111    }
112
113    fn warmup_period(&self) -> usize {
114        1
115    }
116
117    fn is_ready(&self) -> bool {
118        self.has_emitted
119    }
120
121    fn name(&self) -> &'static str {
122        "NVI"
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::traits::BatchExt;
130    use approx::assert_relative_eq;
131
132    fn c(close: f64, volume: f64, ts: i64) -> Candle {
133        Candle::new(close, close, close, close, volume, ts).unwrap()
134    }
135
136    #[test]
137    fn accessors_and_metadata() {
138        let mut n = Nvi::new();
139        assert_eq!(n.warmup_period(), 1);
140        assert_eq!(n.name(), "NVI");
141        assert_eq!(n.value(), None);
142        n.update(c(10.0, 100.0, 0));
143        assert_eq!(n.value(), Some(1000.0));
144    }
145
146    #[test]
147    fn default_matches_new() {
148        let a = Nvi::default();
149        let b = Nvi::new();
150        assert_eq!(a.warmup_period(), b.warmup_period());
151        assert_eq!(a.value(), b.value());
152        assert_eq!(a.is_ready(), b.is_ready());
153    }
154
155    #[test]
156    fn first_bar_seeds_baseline() {
157        let mut n = Nvi::new();
158        assert_relative_eq!(
159            n.update(c(10.0, 100.0, 0)).unwrap(),
160            1000.0,
161            epsilon = 1e-12
162        );
163    }
164
165    #[test]
166    fn volume_rise_leaves_index_unchanged() {
167        // Bar 2 has higher volume than bar 1, so NVI does not update even though
168        // the close changed.
169        let mut n = Nvi::new();
170        n.update(c(10.0, 100.0, 0));
171        let v = n.update(c(11.0, 200.0, 1)).unwrap();
172        assert_relative_eq!(v, 1000.0, epsilon = 1e-12);
173    }
174
175    #[test]
176    fn volume_fall_applies_percent_change() {
177        // Bar 2 has lower volume; NVI absorbs the percent close change.
178        //   1000 * (1 + (11 - 10)/10) = 1100.
179        let mut n = Nvi::new();
180        n.update(c(10.0, 200.0, 0));
181        let v = n.update(c(11.0, 100.0, 1)).unwrap();
182        assert_relative_eq!(v, 1100.0, epsilon = 1e-12);
183    }
184
185    #[test]
186    fn equal_volume_leaves_index_unchanged() {
187        // The textbook rule says "strictly less"; equal volume is skipped.
188        let mut n = Nvi::new();
189        n.update(c(10.0, 100.0, 0));
190        let v = n.update(c(11.0, 100.0, 1)).unwrap();
191        assert_relative_eq!(v, 1000.0, epsilon = 1e-12);
192    }
193
194    #[test]
195    fn zero_previous_close_contributes_no_return() {
196        // The previous close is exactly zero — guarded against div-by-zero.
197        let mut n = Nvi::new();
198        n.update(c(0.0, 200.0, 0));
199        let v = n.update(c(5.0, 100.0, 1)).unwrap();
200        assert_relative_eq!(v, 1000.0, epsilon = 1e-12);
201    }
202
203    #[test]
204    fn custom_baseline() {
205        let mut n = Nvi::with_baseline(100.0);
206        assert_relative_eq!(n.update(c(10.0, 100.0, 0)).unwrap(), 100.0, epsilon = 1e-12);
207    }
208
209    #[test]
210    fn batch_equals_streaming() {
211        let candles: Vec<Candle> = (0..80i64)
212            .map(|i| {
213                let f = i as f64;
214                c(
215                    100.0 + (f * 0.3).sin() * 5.0,
216                    50.0 + ((i % 7) as f64) * 10.0,
217                    i,
218                )
219            })
220            .collect();
221        let mut a = Nvi::new();
222        let mut b = Nvi::new();
223        assert_eq!(
224            a.batch(&candles),
225            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
226        );
227    }
228
229    #[test]
230    fn reset_clears_state() {
231        let mut n = Nvi::new();
232        n.batch(&[c(10.0, 200.0, 0), c(11.0, 100.0, 1)]);
233        assert!(n.is_ready());
234        n.reset();
235        assert!(!n.is_ready());
236        assert_eq!(n.value(), None);
237        // After reset, first bar re-seeds at the default baseline.
238        assert_relative_eq!(n.update(c(50.0, 1.0, 2)).unwrap(), 1000.0, epsilon = 1e-12);
239    }
240}