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 fn update(&mut self, candle: Candle) -> Option<ValueAreaOutput> {
207 if self.window.len() == self.period {
208 self.window.pop_front();
209 }
210 self.window.push_back(candle);
211 if self.window.len() < self.period {
212 return None;
213 }
214 let out = self.compute();
215 self.last = Some(out);
216 Some(out)
217 }
218
219 fn reset(&mut self) {
220 self.window.clear();
221 self.last = None;
222 }
223
224 fn warmup_period(&self) -> usize {
225 self.period
226 }
227
228 fn is_ready(&self) -> bool {
229 self.last.is_some()
230 }
231
232 fn name(&self) -> &'static str {
233 "ValueArea"
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::traits::BatchExt;
241 use approx::assert_relative_eq;
242
243 fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
244 Candle::new(open, high, low, close, volume, ts).unwrap()
245 }
246
247 #[test]
248 fn rejects_zero_period() {
249 assert!(matches!(ValueArea::new(0, 50, 0.7), Err(Error::PeriodZero)));
250 }
251
252 #[test]
253 fn rejects_zero_bin_count() {
254 assert!(matches!(ValueArea::new(20, 0, 0.7), Err(Error::PeriodZero)));
255 }
256
257 #[test]
258 fn rejects_invalid_value_area_pct() {
259 assert!(matches!(
260 ValueArea::new(20, 50, 0.0),
261 Err(Error::InvalidPeriod { .. })
262 ));
263 assert!(matches!(
264 ValueArea::new(20, 50, 1.5),
265 Err(Error::InvalidPeriod { .. })
266 ));
267 assert!(matches!(
268 ValueArea::new(20, 50, f64::NAN),
269 Err(Error::InvalidPeriod { .. })
270 ));
271 }
272
273 #[test]
274 fn accessors_and_metadata() {
275 let v = ValueArea::new(20, 50, 0.7).unwrap();
276 assert_eq!(v.params(), (20, 50, 0.7));
277 assert_eq!(v.name(), "ValueArea");
278 assert_eq!(v.warmup_period(), 20);
279 assert!(v.value().is_none());
280 }
281
282 #[test]
283 fn classic_is_constructible() {
284 let v = ValueArea::classic();
285 assert_eq!(v.params(), (20, 50, 0.70));
286 }
287
288 #[test]
289 fn warmup_emits_after_period() {
290 let mut v = ValueArea::new(5, 10, 0.7).unwrap();
291 for i in 0..4 {
292 let base = 100.0;
293 assert!(v
294 .update(c(base, base + 1.0, base - 1.0, base, 10.0, i))
295 .is_none());
296 }
297 let out = v
298 .update(c(100.0, 101.0, 99.0, 100.0, 10.0, 4))
299 .expect("ready after period");
300 assert!(out.vah >= out.poc);
303 assert!(out.poc >= out.val);
304 assert!(v.is_ready());
305 }
306
307 #[test]
308 fn batch_equals_streaming() {
309 let candles: Vec<Candle> = (0..40)
310 .map(|i| {
311 let base = 100.0 + (i as f64).sin();
312 c(base, base + 1.0, base - 1.0, base, 10.0 + i as f64, i)
313 })
314 .collect();
315 let mut a = ValueArea::new(10, 20, 0.7).unwrap();
316 let mut b = ValueArea::new(10, 20, 0.7).unwrap();
317 assert_eq!(
318 a.batch(&candles),
319 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
320 );
321 }
322
323 #[test]
324 fn reset_clears_state() {
325 let candles: Vec<Candle> = (0..20)
326 .map(|i| c(100.0, 101.0, 99.0, 100.0, 10.0, i))
327 .collect();
328 let mut v = ValueArea::new(5, 10, 0.7).unwrap();
329 v.batch(&candles);
330 assert!(v.is_ready());
331 v.reset();
332 assert!(!v.is_ready());
333 assert_eq!(v.update(candles[0]), None);
334 }
335
336 #[test]
337 fn constant_single_print_yields_collapsed_value_area() {
338 let candles: Vec<Candle> = (0..10)
341 .map(|i| c(100.0, 100.0, 100.0, 100.0, 5.0, i))
342 .collect();
343 let mut v = ValueArea::new(5, 20, 0.7).unwrap();
344 let out = v.batch(&candles).into_iter().flatten().last().unwrap();
345 assert_relative_eq!(out.poc, 100.0, epsilon = 1e-12);
346 assert_relative_eq!(out.vah, 100.0, epsilon = 1e-12);
347 assert_relative_eq!(out.val, 100.0, epsilon = 1e-12);
348 }
349
350 #[test]
351 fn single_print_bar_in_mixed_window_dumps_volume_into_one_bin() {
352 let candles = vec![
357 c(100.0, 100.5, 99.5, 100.0, 1.0, 0),
358 c(100.0, 100.5, 99.5, 100.0, 1.0, 1),
359 c(102.0, 102.0, 102.0, 102.0, 1000.0, 2),
360 c(100.0, 100.5, 99.5, 100.0, 1.0, 3),
361 c(100.0, 100.5, 99.5, 100.0, 1.0, 4),
362 ];
363 let mut v = ValueArea::new(5, 50, 0.70).unwrap();
364 let out = v.batch(&candles).into_iter().flatten().last().unwrap();
365 assert!(
367 (101.9..=102.1).contains(&out.poc),
368 "POC {} not near 102",
369 out.poc
370 );
371 }
372
373 #[test]
374 fn concentrated_volume_locates_poc_at_high_volume_bar() {
375 let mut candles = vec![
378 c(100.0, 100.5, 99.5, 100.0, 1.0, 0),
379 c(100.0, 100.5, 99.5, 100.0, 1.0, 1),
380 c(100.0, 100.5, 99.5, 100.0, 1.0, 2),
381 c(100.0, 100.5, 99.5, 100.0, 1.0, 3),
382 ];
383 candles.push(c(110.0, 110.5, 109.5, 110.0, 1000.0, 4));
384 let mut v = ValueArea::new(5, 50, 0.70).unwrap();
385 let out = v.batch(&candles).into_iter().flatten().last().unwrap();
386 assert!(
390 (109.5..=110.5).contains(&out.poc),
391 "POC {} not inside [109.5, 110.5]",
392 out.poc
393 );
394 assert!(out.vah >= out.poc);
396 assert!(out.val <= out.poc);
397 }
398
399 #[test]
400 fn value_area_brackets_point_of_control() {
401 let candles: Vec<Candle> = (0..30)
402 .map(|i| {
403 let base = 100.0 + (i as f64).cos() * 2.0;
404 c(base, base + 0.5, base - 0.5, base, 10.0, i)
405 })
406 .collect();
407 let mut v = ValueArea::new(15, 30, 0.70).unwrap();
408 for o in v.batch(&candles).into_iter().flatten() {
409 assert!(o.vah >= o.poc, "VAH {} < POC {}", o.vah, o.poc);
410 assert!(o.val <= o.poc, "VAL {} > POC {}", o.val, o.poc);
411 }
412 }
413
414 #[test]
415 fn zero_volume_bars_are_skipped_in_histogram() {
416 let candles = vec![
418 c(100.0, 100.5, 99.5, 100.0, 0.0, 0),
419 c(100.0, 100.5, 99.5, 100.0, 0.0, 1),
420 c(100.0, 100.5, 99.5, 100.0, 0.0, 2),
421 c(100.0, 100.5, 99.5, 100.0, 0.0, 3),
422 c(100.0, 100.5, 99.5, 100.0, 50.0, 4),
423 ];
424 let mut v = ValueArea::new(5, 20, 0.7).unwrap();
425 let out = v.batch(&candles).into_iter().flatten().last().unwrap();
426 assert!(out.poc.is_finite());
427 assert!(out.vah.is_finite());
428 assert!(out.val.is_finite());
429 }
430}