wickra_core/indicators/
better_volume.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
48pub struct BetterVolume {
49 period: usize,
50 volumes: VecDeque<f64>,
51 ranges: VecDeque<f64>,
52 vol_sum: f64,
53 range_sum: f64,
54 last: Option<f64>,
55}
56
57impl BetterVolume {
58 pub fn new(period: usize) -> Result<Self> {
64 if period == 0 {
65 return Err(Error::PeriodZero);
66 }
67 if period > crate::error::MAX_PERIOD {
68 return Err(Error::InvalidPeriod {
69 message: crate::error::PERIOD_ABOVE_MAX,
70 });
71 }
72 Ok(Self {
73 period,
74 volumes: VecDeque::with_capacity(period),
75 ranges: VecDeque::with_capacity(period),
76 vol_sum: 0.0,
77 range_sum: 0.0,
78 last: None,
79 })
80 }
81
82 pub const fn period(&self) -> usize {
84 self.period
85 }
86
87 pub const fn value(&self) -> Option<f64> {
89 self.last
90 }
91}
92
93impl Indicator for BetterVolume {
94 type Input = Candle;
95 type Output = f64;
96
97 #[inline]
98 fn update(&mut self, candle: Candle) -> Option<f64> {
99 let range = candle.high - candle.low;
100 if self.volumes.len() == self.period {
101 self.vol_sum -= self.volumes.pop_front().expect("non-empty");
102 self.range_sum -= self.ranges.pop_front().expect("non-empty");
103 }
104 self.volumes.push_back(candle.volume);
105 self.ranges.push_back(range);
106 self.vol_sum += candle.volume;
107 self.range_sum += range;
108 if self.volumes.len() < self.period {
109 return None;
110 }
111 let n = self.period as f64;
112 let sma_vol = self.vol_sum / n;
113 let sma_range = self.range_sum / n;
114 let rel_vol = if sma_vol > 0.0 {
115 candle.volume / sma_vol
116 } else {
117 0.0
118 };
119 let rel_range = if sma_range > 0.0 {
120 range / sma_range
121 } else {
122 0.0
123 };
124 let out = rel_vol - rel_range;
125 self.last = Some(out);
126 Some(out)
127 }
128
129 fn reset(&mut self) {
130 self.volumes.clear();
131 self.ranges.clear();
132 self.vol_sum = 0.0;
133 self.range_sum = 0.0;
134 self.last = None;
135 }
136
137 #[inline]
138 fn warmup_period(&self) -> usize {
139 self.period
140 }
141
142 #[inline]
143 fn is_ready(&self) -> bool {
144 self.last.is_some()
145 }
146
147 #[inline]
148 fn name(&self) -> &'static str {
149 "BetterVolume"
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::traits::BatchExt;
157 use approx::assert_relative_eq;
158
159 fn candle(high: f64, low: f64, volume: f64) -> Candle {
160 Candle::new_unchecked(low, high, low, high, volume, 0)
161 }
162
163 #[test]
164 fn rejects_zero_period() {
165 assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero)));
166 }
167
168 #[test]
169 fn accessors_and_metadata() {
170 let bv = BetterVolume::new(20).unwrap();
171 assert_eq!(bv.period(), 20);
172 assert_eq!(bv.warmup_period(), 20);
173 assert_eq!(bv.name(), "BetterVolume");
174 assert!(!bv.is_ready());
175 assert_eq!(bv.value(), None);
176 }
177
178 #[test]
179 fn first_emission_at_warmup_period() {
180 let mut bv = BetterVolume::new(3).unwrap();
181 let candles: Vec<Candle> = (0..6).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
182 let out = bv.batch(&candles);
183 for v in out.iter().take(2) {
184 assert!(v.is_none());
185 }
186 assert!(out[2].is_some());
187 }
188
189 #[test]
190 fn steady_bars_are_neutral() {
191 let mut bv = BetterVolume::new(4).unwrap();
193 let candles: Vec<Candle> = (0..10).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
194 let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
195 assert_relative_eq!(last, 0.0, epsilon = 1e-9);
196 }
197
198 #[test]
199 fn churn_bar_is_positive() {
200 let mut bv = BetterVolume::new(4).unwrap();
202 let mut candles: Vec<Candle> = (0..3).map(|_| candle(105.0, 100.0, 1_000.0)).collect();
203 candles.push(candle(100.5, 100.0, 5_000.0)); let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
205 assert!(last > 0.0, "churn bar should be positive, got {last}");
206 }
207
208 #[test]
209 fn ease_of_movement_bar_is_negative() {
210 let mut bv = BetterVolume::new(4).unwrap();
212 let mut candles: Vec<Candle> = (0..3).map(|_| candle(101.0, 100.0, 5_000.0)).collect();
213 candles.push(candle(115.0, 100.0, 500.0)); let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
215 assert!(
216 last < 0.0,
217 "ease-of-movement bar should be negative, got {last}"
218 );
219 }
220
221 #[test]
222 fn zero_everything_is_zero() {
223 let mut bv = BetterVolume::new(3).unwrap();
225 let candles: Vec<Candle> = (0..6).map(|_| candle(100.0, 100.0, 0.0)).collect();
226 for v in bv.batch(&candles).into_iter().flatten() {
227 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
228 }
229 }
230
231 #[test]
232 fn reset_clears_state() {
233 let mut bv = BetterVolume::new(3).unwrap();
234 bv.batch(
235 &(0..6)
236 .map(|_| candle(102.0, 100.0, 1_000.0))
237 .collect::<Vec<_>>(),
238 );
239 assert!(bv.is_ready());
240 bv.reset();
241 assert!(!bv.is_ready());
242 assert_eq!(bv.value(), None);
243 assert_eq!(bv.update(candle(102.0, 100.0, 1_000.0)), None);
244 }
245
246 #[test]
247 fn batch_equals_streaming() {
248 let candles: Vec<Candle> = (0..120)
249 .map(|i| {
250 let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
251 candle(
252 base + 2.0,
253 base - 1.5,
254 1_000.0 + (f64::from(i) * 0.5).cos() * 400.0,
255 )
256 })
257 .collect();
258 let batch = BetterVolume::new(20).unwrap().batch(&candles);
259 let mut b = BetterVolume::new(20).unwrap();
260 let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
261 assert_eq!(batch, streamed);
262 }
263}