Skip to main content

wickra_core/indicators/
trade_volume_index.rs

1//! Trade Volume Index (TVI) — cumulative volume signed by a minimum-tick rule.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Trade Volume Index — a cumulative line that adds volume while price ticks up
8/// and subtracts it while price ticks down, where "up" and "down" are decided by
9/// a **minimum tick value** rather than any change.
10///
11/// ```text
12/// change = close − prev_close
13/// if  change >  min_tick:  direction = +1
14/// if  change < −min_tick:  direction = −1
15/// else:                    direction unchanged   (price is "churning")
16/// TVI_t = TVI_{t−1} + direction * volume
17/// ```
18///
19/// The minimum tick value (MTV) is a dead-band: only moves larger than `min_tick`
20/// flip the accumulation direction, so a price drifting within the spread keeps
21/// adding volume in the last established direction instead of whipsawing. This is
22/// the cumulative-volume analogue of [`Obv`](crate::Obv), but with a noise filter
23/// and applied to close-to-close moves. Like all cumulative lines, only its slope
24/// and divergences against price carry meaning — the absolute level is arbitrary.
25///
26/// The first candle seeds the reference close and emits nothing; thereafter each
27/// bar emits the running total. Each `update` is O(1).
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Candle, Indicator, TradeVolumeIndex};
33///
34/// let mut indicator = TradeVolumeIndex::new(0.5).unwrap();
35/// let mut last = None;
36/// for i in 0..20 {
37///     let close = 100.0 + f64::from(i);
38///     let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap();
39///     last = indicator.update(c);
40/// }
41/// assert!(last.is_some());
42/// ```
43#[derive(Debug, Clone)]
44pub struct TradeVolumeIndex {
45    min_tick: f64,
46    prev_close: Option<f64>,
47    direction: f64,
48    tvi: f64,
49    last: Option<f64>,
50}
51
52impl TradeVolumeIndex {
53    /// Construct a new Trade Volume Index with the given minimum tick value.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`Error::InvalidParameter`] if `min_tick` is not finite or is
58    /// negative. A `min_tick` of `0` is allowed and makes every non-zero move
59    /// flip the direction.
60    pub fn new(min_tick: f64) -> Result<Self> {
61        if !min_tick.is_finite() || min_tick < 0.0 {
62            return Err(Error::InvalidParameter {
63                message: "trade volume index min_tick must be finite and non-negative",
64            });
65        }
66        Ok(Self {
67            min_tick,
68            prev_close: None,
69            direction: 0.0,
70            tvi: 0.0,
71            last: None,
72        })
73    }
74
75    /// Configured minimum tick value.
76    pub const fn min_tick(&self) -> f64 {
77        self.min_tick
78    }
79
80    /// Current value if available.
81    pub const fn value(&self) -> Option<f64> {
82        self.last
83    }
84}
85
86impl Indicator for TradeVolumeIndex {
87    type Input = Candle;
88    type Output = f64;
89
90    #[inline]
91    fn update(&mut self, candle: Candle) -> Option<f64> {
92        let Some(prev_close) = self.prev_close else {
93            self.prev_close = Some(candle.close);
94            return None;
95        };
96        let change = candle.close - prev_close;
97        if change > self.min_tick {
98            self.direction = 1.0;
99        } else if change < -self.min_tick {
100            self.direction = -1.0;
101        }
102        // Otherwise the direction is held from the previous bar (or 0 before the
103        // first decisive move), so a churning price keeps its last lean.
104        self.tvi += self.direction * candle.volume;
105        self.prev_close = Some(candle.close);
106        self.last = Some(self.tvi);
107        Some(self.tvi)
108    }
109
110    fn reset(&mut self) {
111        self.prev_close = None;
112        self.direction = 0.0;
113        self.tvi = 0.0;
114        self.last = None;
115    }
116
117    #[inline]
118    fn warmup_period(&self) -> usize {
119        2
120    }
121
122    #[inline]
123    fn is_ready(&self) -> bool {
124        self.last.is_some()
125    }
126
127    #[inline]
128    fn name(&self) -> &'static str {
129        "TradeVolumeIndex"
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::traits::BatchExt;
137    use approx::assert_relative_eq;
138
139    fn candle(close: f64, volume: f64) -> Candle {
140        Candle::new_unchecked(close, close, close, close, volume, 0)
141    }
142
143    #[test]
144    fn rejects_invalid_min_tick() {
145        assert!(matches!(
146            TradeVolumeIndex::new(-1.0),
147            Err(Error::InvalidParameter { .. })
148        ));
149        assert!(matches!(
150            TradeVolumeIndex::new(f64::NAN),
151            Err(Error::InvalidParameter { .. })
152        ));
153        assert!(TradeVolumeIndex::new(0.0).is_ok());
154    }
155
156    #[test]
157    fn accessors_and_metadata() {
158        let tvi = TradeVolumeIndex::new(0.25).unwrap();
159        assert_relative_eq!(tvi.min_tick(), 0.25, epsilon = 1e-12);
160        assert_eq!(tvi.warmup_period(), 2);
161        assert_eq!(tvi.name(), "TradeVolumeIndex");
162        assert!(!tvi.is_ready());
163        assert_eq!(tvi.value(), None);
164    }
165
166    #[test]
167    fn first_bar_seeds_without_output() {
168        let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
169        assert_eq!(tvi.update(candle(100.0, 1_000.0)), None);
170        assert!(tvi.update(candle(101.0, 1_000.0)).is_some());
171    }
172
173    #[test]
174    fn uptrend_accumulates_volume() {
175        // Each step of +1 exceeds the 0.5 tick -> direction +1 -> add volume.
176        let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
177        let candles = [
178            candle(100.0, 1_000.0), // seed
179            candle(101.0, 500.0),   // +1 -> +500
180            candle(102.0, 300.0),   // +1 -> +300
181        ];
182        let out = tvi.batch(&candles);
183        assert_relative_eq!(out[1].unwrap(), 500.0, epsilon = 1e-9);
184        assert_relative_eq!(out[2].unwrap(), 800.0, epsilon = 1e-9);
185    }
186
187    #[test]
188    fn small_move_holds_last_direction() {
189        // After an up-move, a sub-tick wobble keeps adding in the up direction.
190        let mut tvi = TradeVolumeIndex::new(1.0).unwrap();
191        let candles = [
192            candle(100.0, 1_000.0), // seed
193            candle(102.0, 400.0),   // +2 > tick -> dir +1, +400
194            candle(102.2, 100.0),   // +0.2 < tick -> hold dir +1, +100
195        ];
196        let out = tvi.batch(&candles);
197        assert_relative_eq!(out[1].unwrap(), 400.0, epsilon = 1e-9);
198        assert_relative_eq!(out[2].unwrap(), 500.0, epsilon = 1e-9);
199    }
200
201    #[test]
202    fn downtrend_distributes_volume() {
203        let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
204        let candles = [
205            candle(100.0, 1_000.0),
206            candle(99.0, 200.0), // -1 -> -200
207            candle(98.0, 300.0), // -1 -> -300
208        ];
209        let out = tvi.batch(&candles);
210        assert_relative_eq!(out[2].unwrap(), -500.0, epsilon = 1e-9);
211    }
212
213    #[test]
214    fn reset_clears_state() {
215        let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
216        tvi.batch(&[candle(100.0, 1.0), candle(101.0, 1.0), candle(102.0, 1.0)]);
217        assert!(tvi.is_ready());
218        tvi.reset();
219        assert!(!tvi.is_ready());
220        assert_eq!(tvi.value(), None);
221        assert_eq!(tvi.update(candle(100.0, 1.0)), None);
222    }
223
224    #[test]
225    fn batch_equals_streaming() {
226        let candles: Vec<Candle> = (0..80)
227            .map(|i| {
228                candle(
229                    100.0 + (f64::from(i) * 0.3).sin() * 5.0,
230                    1_000.0 + f64::from(i),
231                )
232            })
233            .collect();
234        let batch = TradeVolumeIndex::new(0.5).unwrap().batch(&candles);
235        let mut b = TradeVolumeIndex::new(0.5).unwrap();
236        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
237        assert_eq!(batch, streamed);
238    }
239}