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    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        // All five bars are identical, so POC == bar mid; VAH/VAL bracket
301        // the window high/low.
302        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        // Every bar trades at exactly 100 (low == high == 100) — the
339        // histogram has zero span so POC == VAH == VAL == 100.
340        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        // Mix of wide-range bars (drive the window's span > 0) and one
353        // single-print bar at price 102 with massive volume. The single-print
354        // bar must dump its entire volume into one bin, making the POC land
355        // exactly on the bin that contains 102.
356        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        // POC must sit in the high-volume bin that holds price 102.
366        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        // Bars 0..3 sit at price 100 with volume 1; bar 4 dumps massive
376        // volume at price 110. POC must land near 110.
377        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        // POC must fall inside the high-volume bar's [low, high] range; ties
387        // among equal-volume bins resolve to the lowest index, so the POC
388        // sits on the left edge of bar 4's range rather than at its midpoint.
389        assert!(
390            (109.5..=110.5).contains(&out.poc),
391            "POC {} not inside [109.5, 110.5]",
392            out.poc
393        );
394        // VAH and VAL bracket POC.
395        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        // Only bar 4 carries any volume — POC must land at its mid.
417        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}