1use std::collections::VecDeque;
13
14use crate::error::{Error, Result};
15use crate::ohlcv::Candle;
16use crate::traits::Indicator;
17
18#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct ValueAreaOutput {
21 pub poc: f64,
23 pub vah: f64,
26 pub val: f64,
28}
29
30#[allow(clippy::struct_field_names)]
47#[derive(Debug, Clone)]
48pub struct ValueArea {
49 period: usize,
50 bin_count: usize,
51 value_area_pct: f64,
52 window: VecDeque<Candle>,
53 last: Option<ValueAreaOutput>,
54}
55
56impl ValueArea {
57 pub fn new(period: usize, bin_count: usize, value_area_pct: f64) -> Result<Self> {
64 if period == 0 || bin_count == 0 {
65 return Err(Error::PeriodZero);
66 }
67 if !value_area_pct.is_finite() || value_area_pct <= 0.0 || value_area_pct > 1.0 {
68 return Err(Error::InvalidPeriod {
69 message: "value_area_pct must be in (0, 1]",
70 });
71 }
72 Ok(Self {
73 period,
74 bin_count,
75 value_area_pct,
76 window: VecDeque::with_capacity(period),
77 last: None,
78 })
79 }
80
81 pub fn classic() -> Self {
83 Self::new(20, 50, 0.70).expect("classic ValueArea params are valid")
84 }
85
86 pub const fn params(&self) -> (usize, usize, f64) {
88 (self.period, self.bin_count, self.value_area_pct)
89 }
90
91 pub const fn value(&self) -> Option<ValueAreaOutput> {
93 self.last
94 }
95
96 fn compute(&self) -> ValueAreaOutput {
97 let mut win_low = f64::INFINITY;
99 let mut win_high = f64::NEG_INFINITY;
100 for c in &self.window {
101 if c.low < win_low {
102 win_low = c.low;
103 }
104 if c.high > win_high {
105 win_high = c.high;
106 }
107 }
108 let span = win_high - win_low;
109 let mut bins = vec![0.0_f64; self.bin_count];
110
111 if span <= 0.0 {
114 let total: f64 = self.window.iter().map(|c| c.volume).sum();
117 bins[0] = total;
118 return ValueAreaOutput {
119 poc: win_low,
120 vah: win_low,
121 val: win_low,
122 };
123 }
124 let bin_width = span / self.bin_count as f64;
125 for c in &self.window {
126 if c.volume == 0.0 {
127 continue;
128 }
129 if c.high <= c.low {
130 let idx = self.price_to_bin(c.low, win_low, bin_width);
131 bins[idx] += c.volume;
132 continue;
133 }
134 let lo_idx = self.price_to_bin(c.low, win_low, bin_width);
135 let hi_idx = self.price_to_bin(c.high, win_low, bin_width);
136 let touched = hi_idx - lo_idx + 1;
137 let share = c.volume / touched as f64;
138 for b in bins.iter_mut().take(hi_idx + 1).skip(lo_idx) {
139 *b += share;
140 }
141 }
142
143 let total: f64 = bins.iter().sum();
144 let mut poc_idx = 0_usize;
146 let mut poc_vol = bins[0];
147 for (i, v) in bins.iter().enumerate().skip(1) {
148 if *v > poc_vol {
149 poc_vol = *v;
150 poc_idx = i;
151 }
152 }
153
154 let target = total * self.value_area_pct;
160 let mut accumulated = poc_vol;
161 let mut lo = poc_idx;
162 let mut hi = poc_idx;
163 while accumulated < target && (lo > 0 || hi + 1 < self.bin_count) {
164 let can_go_up = hi + 1 < self.bin_count;
165 let can_go_down = lo > 0;
166 let up_v = if can_go_up {
167 bins[hi + 1]
168 } else {
169 f64::NEG_INFINITY
170 };
171 let down_v = if can_go_down {
172 bins[lo - 1]
173 } else {
174 f64::NEG_INFINITY
175 };
176 if can_go_up && (up_v >= down_v || !can_go_down) {
177 hi += 1;
178 accumulated += up_v;
179 } else {
180 lo -= 1;
181 accumulated += down_v;
182 }
183 }
184
185 let bin_mid = |i: usize| win_low + bin_width * (i as f64 + 0.5);
186 ValueAreaOutput {
187 poc: bin_mid(poc_idx),
188 vah: win_low + bin_width * (hi as f64 + 1.0),
189 val: win_low + bin_width * lo as f64,
190 }
191 }
192
193 fn price_to_bin(&self, price: f64, win_low: f64, bin_width: f64) -> usize {
194 let raw = ((price - win_low) / bin_width).floor();
197 let max = (self.bin_count - 1) as f64;
198 raw.clamp(0.0, max) as usize
199 }
200}
201
202impl Indicator for ValueArea {
203 type Input = Candle;
204 type Output = ValueAreaOutput;
205
206 #[inline]
207 fn update(&mut self, candle: Candle) -> Option<ValueAreaOutput> {
208 if self.window.len() == self.period {
209 self.window.pop_front();
210 }
211 self.window.push_back(candle);
212 if self.window.len() < self.period {
213 return None;
214 }
215 let out = self.compute();
216 self.last = Some(out);
217 Some(out)
218 }
219
220 fn reset(&mut self) {
221 self.window.clear();
222 self.last = None;
223 }
224
225 #[inline]
226 fn warmup_period(&self) -> usize {
227 self.period
228 }
229
230 #[inline]
231 fn is_ready(&self) -> bool {
232 self.last.is_some()
233 }
234
235 #[inline]
236 fn name(&self) -> &'static str {
237 "ValueArea"
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use crate::traits::BatchExt;
245 use approx::assert_relative_eq;
246
247 fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
248 Candle::new(open, high, low, close, volume, ts).unwrap()
249 }
250
251 #[test]
252 fn rejects_zero_period() {
253 assert!(matches!(ValueArea::new(0, 50, 0.7), Err(Error::PeriodZero)));
254 }
255
256 #[test]
257 fn rejects_zero_bin_count() {
258 assert!(matches!(ValueArea::new(20, 0, 0.7), Err(Error::PeriodZero)));
259 }
260
261 #[test]
262 fn rejects_invalid_value_area_pct() {
263 assert!(matches!(
264 ValueArea::new(20, 50, 0.0),
265 Err(Error::InvalidPeriod { .. })
266 ));
267 assert!(matches!(
268 ValueArea::new(20, 50, 1.5),
269 Err(Error::InvalidPeriod { .. })
270 ));
271 assert!(matches!(
272 ValueArea::new(20, 50, f64::NAN),
273 Err(Error::InvalidPeriod { .. })
274 ));
275 }
276
277 #[test]
278 fn accessors_and_metadata() {
279 let v = ValueArea::new(20, 50, 0.7).unwrap();
280 assert_eq!(v.params(), (20, 50, 0.7));
281 assert_eq!(v.name(), "ValueArea");
282 assert_eq!(v.warmup_period(), 20);
283 assert!(v.value().is_none());
284 }
285
286 #[test]
287 fn classic_is_constructible() {
288 let v = ValueArea::classic();
289 assert_eq!(v.params(), (20, 50, 0.70));
290 }
291
292 #[test]
293 fn warmup_emits_after_period() {
294 let mut v = ValueArea::new(5, 10, 0.7).unwrap();
295 for i in 0..4 {
296 let base = 100.0;
297 assert!(v
298 .update(c(base, base + 1.0, base - 1.0, base, 10.0, i))
299 .is_none());
300 }
301 let out = v
302 .update(c(100.0, 101.0, 99.0, 100.0, 10.0, 4))
303 .expect("ready after period");
304 assert!(out.vah >= out.poc);
307 assert!(out.poc >= out.val);
308 assert!(v.is_ready());
309 }
310
311 #[test]
312 fn batch_equals_streaming() {
313 let candles: Vec<Candle> = (0..40)
314 .map(|i| {
315 let base = 100.0 + (i as f64).sin();
316 c(base, base + 1.0, base - 1.0, base, 10.0 + i as f64, i)
317 })
318 .collect();
319 let mut a = ValueArea::new(10, 20, 0.7).unwrap();
320 let mut b = ValueArea::new(10, 20, 0.7).unwrap();
321 assert_eq!(
322 a.batch(&candles),
323 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
324 );
325 }
326
327 #[test]
328 fn reset_clears_state() {
329 let candles: Vec<Candle> = (0..20)
330 .map(|i| c(100.0, 101.0, 99.0, 100.0, 10.0, i))
331 .collect();
332 let mut v = ValueArea::new(5, 10, 0.7).unwrap();
333 v.batch(&candles);
334 assert!(v.is_ready());
335 v.reset();
336 assert!(!v.is_ready());
337 assert_eq!(v.update(candles[0]), None);
338 }
339
340 #[test]
341 fn constant_single_print_yields_collapsed_value_area() {
342 let candles: Vec<Candle> = (0..10)
345 .map(|i| c(100.0, 100.0, 100.0, 100.0, 5.0, i))
346 .collect();
347 let mut v = ValueArea::new(5, 20, 0.7).unwrap();
348 let out = v.batch(&candles).into_iter().flatten().last().unwrap();
349 assert_relative_eq!(out.poc, 100.0, epsilon = 1e-12);
350 assert_relative_eq!(out.vah, 100.0, epsilon = 1e-12);
351 assert_relative_eq!(out.val, 100.0, epsilon = 1e-12);
352 }
353
354 #[test]
355 fn single_print_bar_in_mixed_window_dumps_volume_into_one_bin() {
356 let candles = vec![
361 c(100.0, 100.5, 99.5, 100.0, 1.0, 0),
362 c(100.0, 100.5, 99.5, 100.0, 1.0, 1),
363 c(102.0, 102.0, 102.0, 102.0, 1000.0, 2),
364 c(100.0, 100.5, 99.5, 100.0, 1.0, 3),
365 c(100.0, 100.5, 99.5, 100.0, 1.0, 4),
366 ];
367 let mut v = ValueArea::new(5, 50, 0.70).unwrap();
368 let out = v.batch(&candles).into_iter().flatten().last().unwrap();
369 assert!(
371 (101.9..=102.1).contains(&out.poc),
372 "POC {} not near 102",
373 out.poc
374 );
375 }
376
377 #[test]
378 fn concentrated_volume_locates_poc_at_high_volume_bar() {
379 let mut candles = vec![
382 c(100.0, 100.5, 99.5, 100.0, 1.0, 0),
383 c(100.0, 100.5, 99.5, 100.0, 1.0, 1),
384 c(100.0, 100.5, 99.5, 100.0, 1.0, 2),
385 c(100.0, 100.5, 99.5, 100.0, 1.0, 3),
386 ];
387 candles.push(c(110.0, 110.5, 109.5, 110.0, 1000.0, 4));
388 let mut v = ValueArea::new(5, 50, 0.70).unwrap();
389 let out = v.batch(&candles).into_iter().flatten().last().unwrap();
390 assert!(
394 (109.5..=110.5).contains(&out.poc),
395 "POC {} not inside [109.5, 110.5]",
396 out.poc
397 );
398 assert!(out.vah >= out.poc);
400 assert!(out.val <= out.poc);
401 }
402
403 #[test]
404 fn value_area_brackets_point_of_control() {
405 let candles: Vec<Candle> = (0..30)
406 .map(|i| {
407 let base = 100.0 + (i as f64).cos() * 2.0;
408 c(base, base + 0.5, base - 0.5, base, 10.0, i)
409 })
410 .collect();
411 let mut v = ValueArea::new(15, 30, 0.70).unwrap();
412 for o in v.batch(&candles).into_iter().flatten() {
413 assert!(o.vah >= o.poc, "VAH {} < POC {}", o.vah, o.poc);
414 assert!(o.val <= o.poc, "VAL {} > POC {}", o.val, o.poc);
415 }
416 }
417
418 #[test]
419 fn zero_volume_bars_are_skipped_in_histogram() {
420 let candles = vec![
422 c(100.0, 100.5, 99.5, 100.0, 0.0, 0),
423 c(100.0, 100.5, 99.5, 100.0, 0.0, 1),
424 c(100.0, 100.5, 99.5, 100.0, 0.0, 2),
425 c(100.0, 100.5, 99.5, 100.0, 0.0, 3),
426 c(100.0, 100.5, 99.5, 100.0, 50.0, 4),
427 ];
428 let mut v = ValueArea::new(5, 20, 0.7).unwrap();
429 let out = v.batch(&candles).into_iter().flatten().last().unwrap();
430 assert!(out.poc.is_finite());
431 assert!(out.vah.is_finite());
432 assert!(out.val.is_finite());
433 }
434}