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 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 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 let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
173 let candles = [
174 candle(100.0, 1_000.0), candle(101.0, 500.0), candle(102.0, 300.0), ];
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 let mut tvi = TradeVolumeIndex::new(1.0).unwrap();
187 let candles = [
188 candle(100.0, 1_000.0), candle(102.0, 400.0), candle(102.2, 100.0), ];
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), candle(98.0, 300.0), ];
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}