wickra_core/indicators/
trade_volume_index.rs1use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7#[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 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 pub const fn min_tick(&self) -> f64 {
77 self.min_tick
78 }
79
80 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 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 let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
177 let candles = [
178 candle(100.0, 1_000.0), candle(101.0, 500.0), candle(102.0, 300.0), ];
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 let mut tvi = TradeVolumeIndex::new(1.0).unwrap();
191 let candles = [
192 candle(100.0, 1_000.0), candle(102.0, 400.0), candle(102.2, 100.0), ];
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), candle(98.0, 300.0), ];
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}