Skip to main content

wickra_core/indicators/
value_area.rs

1//! Value Area (Point of Control + Value Area High / Low).
2//!
3//! Market-profile-style volume distribution over the last `period` candles,
4//! bucketed into `bin_count` price bins. Each candle's volume is spread
5//! uniformly across its `[low, high]` range (bin-approximation); single-print
6//! bars (`low == high`) dump their whole volume into a single bin. The
7//! Point of Control (POC) is the bin with the highest cumulative volume; the
8//! Value Area expands outward from the POC, always absorbing the
9//! higher-volume neighbour next, until the configured percentage of total
10//! volume (default 70%) is enclosed.
11
12use std::collections::VecDeque;
13
14use crate::error::{Error, Result};
15use crate::ohlcv::Candle;
16use crate::traits::Indicator;
17
18/// Value Area output: Point of Control, Value Area High and Value Area Low.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct ValueAreaOutput {
21    /// Point of Control — price of the bin with the highest cumulative volume.
22    pub poc: f64,
23    /// Value Area High — upper bound of the bins that together hold
24    /// `value_area_pct` of the rolling-window volume.
25    pub vah: f64,
26    /// Value Area Low — lower bound of those same bins.
27    pub val: f64,
28}
29
30/// Rolling Value Area indicator over the last `period` candles.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Candle, Indicator, ValueArea};
36///
37/// let mut va = ValueArea::new(5, 50, 0.70).unwrap();
38/// for i in 0..10 {
39///     let base = 100.0 + f64::from(i);
40///     let candle =
41///         Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap();
42///     va.update(candle);
43/// }
44/// assert!(va.is_ready());
45/// ```
46#[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    /// Construct a Value Area indicator.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`Error::PeriodZero`] if `period` or `bin_count` is zero,
62    /// and [`Error::InvalidPeriod`] if `value_area_pct` is not in `(0, 1]`.
63    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    /// Classic Value Area: 20-bar rolling window, 50 bins, 70% concentration.
82    pub fn classic() -> Self {
83        Self::new(20, 50, 0.70).expect("classic ValueArea params are valid")
84    }
85
86    /// Configured `(period, bin_count, value_area_pct)`.
87    pub const fn params(&self) -> (usize, usize, f64) {
88        (self.period, self.bin_count, self.value_area_pct)
89    }
90
91    /// Most recent output if available.
92    pub const fn value(&self) -> Option<ValueAreaOutput> {
93        self.last
94    }
95
96    fn compute(&self) -> ValueAreaOutput {
97        // Window-wide low / high spans the histogram domain.
98        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        // Distribute each candle's volume across its [low, high] range. A
112        // degenerate `low == high` bar drops its entire volume into one bin.
113        if span <= 0.0 {
114            // All bars are single-print at the same price — POC = that price,
115            // VAH = VAL = that price.
116            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        // POC = bin with highest volume.
145        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        // Expand Value Area outward from POC. At each step take the
155        // higher-volume neighbour (up or down). Equal volumes break upward,
156        // matching the CME convention. The loop condition guarantees at
157        // least one of `can_go_up` / `can_go_down` is true on every body
158        // entry, so the inner `else` branch is always reachable.
159        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        // Clamp the float into [0, bin_count - 1] before casting so the
195        // `as usize` step cannot overflow or wrap.
196        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        // All five bars are identical, so POC == bar mid; VAH/VAL bracket
305        // the window high/low.
306        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        // Every bar trades at exactly 100 (low == high == 100) — the
343        // histogram has zero span so POC == VAH == VAL == 100.
344        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        // Mix of wide-range bars (drive the window's span > 0) and one
357        // single-print bar at price 102 with massive volume. The single-print
358        // bar must dump its entire volume into one bin, making the POC land
359        // exactly on the bin that contains 102.
360        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        // POC must sit in the high-volume bin that holds price 102.
370        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        // Bars 0..3 sit at price 100 with volume 1; bar 4 dumps massive
380        // volume at price 110. POC must land near 110.
381        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        // POC must fall inside the high-volume bar's [low, high] range; ties
391        // among equal-volume bins resolve to the lowest index, so the POC
392        // sits on the left edge of bar 4's range rather than at its midpoint.
393        assert!(
394            (109.5..=110.5).contains(&out.poc),
395            "POC {} not inside [109.5, 110.5]",
396            out.poc
397        );
398        // VAH and VAL bracket POC.
399        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        // Only bar 4 carries any volume — POC must land at its mid.
421        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}