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    fn update(&mut self, candle: Candle) -> Option<f64> {
91        let Some(prev_close) = self.prev_close else {
92            self.prev_close = Some(candle.close);
93            return None;
94        };
95        let change = candle.close - prev_close;
96        if change > self.min_tick {
97            self.direction = 1.0;
98        } else if change < -self.min_tick {
99            self.direction = -1.0;
100        }
101        // Otherwise the direction is held from the previous bar (or 0 before the
102        // first decisive move), so a churning price keeps its last lean.
103        self.tvi += self.direction * candle.volume;
104        self.prev_close = Some(candle.close);
105        self.last = Some(self.tvi);
106        Some(self.tvi)
107    }
108
109    fn reset(&mut self) {
110        self.prev_close = None;
111        self.direction = 0.0;
112        self.tvi = 0.0;
113        self.last = None;
114    }
115
116    fn warmup_period(&self) -> usize {
117        2
118    }
119
120    fn is_ready(&self) -> bool {
121        self.last.is_some()
122    }
123
124    fn name(&self) -> &'static str {
125        "TradeVolumeIndex"
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::traits::BatchExt;
133    use approx::assert_relative_eq;
134
135    fn candle(close: f64, volume: f64) -> Candle {
136        Candle::new_unchecked(close, close, close, close, volume, 0)
137    }
138
139    #[test]
140    fn rejects_invalid_min_tick() {
141        assert!(matches!(
142            TradeVolumeIndex::new(-1.0),
143            Err(Error::InvalidParameter { .. })
144        ));
145        assert!(matches!(
146            TradeVolumeIndex::new(f64::NAN),
147            Err(Error::InvalidParameter { .. })
148        ));
149        assert!(TradeVolumeIndex::new(0.0).is_ok());
150    }
151
152    #[test]
153    fn accessors_and_metadata() {
154        let tvi = TradeVolumeIndex::new(0.25).unwrap();
155        assert_relative_eq!(tvi.min_tick(), 0.25, epsilon = 1e-12);
156        assert_eq!(tvi.warmup_period(), 2);
157        assert_eq!(tvi.name(), "TradeVolumeIndex");
158        assert!(!tvi.is_ready());
159        assert_eq!(tvi.value(), None);
160    }
161
162    #[test]
163    fn first_bar_seeds_without_output() {
164        let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
165        assert_eq!(tvi.update(candle(100.0, 1_000.0)), None);
166        assert!(tvi.update(candle(101.0, 1_000.0)).is_some());
167    }
168
169    #[test]
170    fn uptrend_accumulates_volume() {
171        // Each step of +1 exceeds the 0.5 tick -> direction +1 -> add volume.
172        let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
173        let candles = [
174            candle(100.0, 1_000.0), // seed
175            candle(101.0, 500.0),   // +1 -> +500
176            candle(102.0, 300.0),   // +1 -> +300
177        ];
178        let out = tvi.batch(&candles);
179        assert_relative_eq!(out[1].unwrap(), 500.0, epsilon = 1e-9);
180        assert_relative_eq!(out[2].unwrap(), 800.0, epsilon = 1e-9);
181    }
182
183    #[test]
184    fn small_move_holds_last_direction() {
185        // After an up-move, a sub-tick wobble keeps adding in the up direction.
186        let mut tvi = TradeVolumeIndex::new(1.0).unwrap();
187        let candles = [
188            candle(100.0, 1_000.0), // seed
189            candle(102.0, 400.0),   // +2 > tick -> dir +1, +400
190            candle(102.2, 100.0),   // +0.2 < tick -> hold dir +1, +100
191        ];
192        let out = tvi.batch(&candles);
193        assert_relative_eq!(out[1].unwrap(), 400.0, epsilon = 1e-9);
194        assert_relative_eq!(out[2].unwrap(), 500.0, epsilon = 1e-9);
195    }
196
197    #[test]
198    fn downtrend_distributes_volume() {
199        let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
200        let candles = [
201            candle(100.0, 1_000.0),
202            candle(99.0, 200.0), // -1 -> -200
203            candle(98.0, 300.0), // -1 -> -300
204        ];
205        let out = tvi.batch(&candles);
206        assert_relative_eq!(out[2].unwrap(), -500.0, epsilon = 1e-9);
207    }
208
209    #[test]
210    fn reset_clears_state() {
211        let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
212        tvi.batch(&[candle(100.0, 1.0), candle(101.0, 1.0), candle(102.0, 1.0)]);
213        assert!(tvi.is_ready());
214        tvi.reset();
215        assert!(!tvi.is_ready());
216        assert_eq!(tvi.value(), None);
217        assert_eq!(tvi.update(candle(100.0, 1.0)), None);
218    }
219
220    #[test]
221    fn batch_equals_streaming() {
222        let candles: Vec<Candle> = (0..80)
223            .map(|i| {
224                candle(
225                    100.0 + (f64::from(i) * 0.3).sin() * 5.0,
226                    1_000.0 + f64::from(i),
227                )
228            })
229            .collect();
230        let batch = TradeVolumeIndex::new(0.5).unwrap().batch(&candles);
231        let mut b = TradeVolumeIndex::new(0.5).unwrap();
232        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
233        assert_eq!(batch, streamed);
234    }
235}