Skip to main content

wickra_core/indicators/
volume_profile.rs

1//! Volume Profile — the full per-bin volume distribution over a rolling window.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Volume Profile output: the price domain plus the per-bin volume histogram.
10///
11/// `bins[i]` holds the volume attributed to the price bucket
12/// `[price_low + i * w, price_low + (i + 1) * w)` where
13/// `w = (price_high - price_low) / bins.len()`. The histogram sums to the total
14/// volume in the rolling window (within floating-point tolerance).
15#[derive(Debug, Clone, PartialEq)]
16pub struct VolumeProfileOutput {
17    /// Lowest price in the window — the lower edge of bin 0.
18    pub price_low: f64,
19    /// Highest price in the window — the upper edge of the last bin.
20    pub price_high: f64,
21    /// Per-bin volume, lowest price bucket first. Length equals `bin_count`.
22    pub bins: Vec<f64>,
23}
24
25/// Rolling Volume Profile over the last `period` candles.
26///
27/// Where [`crate::ValueArea`] reduces the same volume distribution to its
28/// summary levels (Point of Control, Value Area High / Low), Volume Profile
29/// exposes the **full histogram** so callers can inspect, render or post-process
30/// the raw distribution. Each candle's volume is spread uniformly across the
31/// bins its `[low, high]` range touches; a single-print bar (`low == high`)
32/// drops its whole volume into one bin. The histogram domain spans the window's
33/// lowest low to its highest high.
34///
35/// A window whose bars are all single-print at one price (`price_high == price_low`)
36/// is degenerate: the entire volume lands in bin 0 and both edges collapse to
37/// that price.
38///
39/// # Example
40///
41/// ```
42/// use wickra_core::{Candle, Indicator, VolumeProfile};
43///
44/// let mut vp = VolumeProfile::new(5, 10).unwrap();
45/// let mut last = None;
46/// for i in 0..10 {
47///     let base = 100.0 + f64::from(i);
48///     let candle =
49///         Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap();
50///     last = vp.update(candle);
51/// }
52/// let profile = last.unwrap();
53/// assert_eq!(profile.bins.len(), 10);
54/// ```
55#[allow(clippy::struct_field_names)]
56#[derive(Debug, Clone)]
57pub struct VolumeProfile {
58    period: usize,
59    bin_count: usize,
60    window: VecDeque<Candle>,
61    last: Option<VolumeProfileOutput>,
62}
63
64impl VolumeProfile {
65    /// Construct a Volume Profile indicator.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`Error::PeriodZero`] if `period` or `bin_count` is zero.
70    pub fn new(period: usize, bin_count: usize) -> Result<Self> {
71        if period == 0 || bin_count == 0 {
72            return Err(Error::PeriodZero);
73        }
74        Ok(Self {
75            period,
76            bin_count,
77            window: VecDeque::with_capacity(period),
78            last: None,
79        })
80    }
81
82    /// Classic Volume Profile: 20-bar rolling window, 50 bins.
83    pub fn classic() -> Self {
84        Self::new(20, 50).expect("classic VolumeProfile params are valid")
85    }
86
87    /// Configured `(period, bin_count)`.
88    pub const fn params(&self) -> (usize, usize) {
89        (self.period, self.bin_count)
90    }
91
92    /// How many values every emitted profile carries -- the bin count fixed at construction.
93    ///
94    /// Fixed for the lifetime of the indicator, so a caller can size a
95    /// buffer once instead of guessing.
96    pub const fn width(&self) -> usize {
97        self.bin_count
98    }
99
100    /// Most recent profile if available.
101    pub fn value(&self) -> Option<&VolumeProfileOutput> {
102        self.last.as_ref()
103    }
104
105    fn price_to_bin(&self, price: f64, win_low: f64, bin_width: f64) -> usize {
106        let raw = ((price - win_low) / bin_width).floor();
107        let max = (self.bin_count - 1) as f64;
108        raw.clamp(0.0, max) as usize
109    }
110
111    fn compute(&self) -> VolumeProfileOutput {
112        let mut win_low = f64::INFINITY;
113        let mut win_high = f64::NEG_INFINITY;
114        for candle in &self.window {
115            if candle.low < win_low {
116                win_low = candle.low;
117            }
118            if candle.high > win_high {
119                win_high = candle.high;
120            }
121        }
122        let span = win_high - win_low;
123        let mut bins = vec![0.0_f64; self.bin_count];
124
125        if span <= 0.0 {
126            // All bars are single-print at the same price.
127            let total: f64 = self.window.iter().map(|candle| candle.volume).sum();
128            bins[0] = total;
129            return VolumeProfileOutput {
130                price_low: win_low,
131                price_high: win_low,
132                bins,
133            };
134        }
135
136        let bin_width = span / self.bin_count as f64;
137        for candle in &self.window {
138            if candle.volume == 0.0 {
139                continue;
140            }
141            if candle.high <= candle.low {
142                let idx = self.price_to_bin(candle.low, win_low, bin_width);
143                bins[idx] += candle.volume;
144                continue;
145            }
146            let lo_idx = self.price_to_bin(candle.low, win_low, bin_width);
147            let hi_idx = self.price_to_bin(candle.high, win_low, bin_width);
148            let touched = hi_idx - lo_idx + 1;
149            let share = candle.volume / touched as f64;
150            for bin in bins.iter_mut().take(hi_idx + 1).skip(lo_idx) {
151                *bin += share;
152            }
153        }
154
155        VolumeProfileOutput {
156            price_low: win_low,
157            price_high: win_high,
158            bins,
159        }
160    }
161}
162
163impl Indicator for VolumeProfile {
164    type Input = Candle;
165    type Output = VolumeProfileOutput;
166
167    #[inline]
168    fn update(&mut self, candle: Candle) -> Option<VolumeProfileOutput> {
169        if self.window.len() == self.period {
170            self.window.pop_front();
171        }
172        self.window.push_back(candle);
173        if self.window.len() < self.period {
174            return None;
175        }
176        let out = self.compute();
177        self.last = Some(out.clone());
178        Some(out)
179    }
180
181    fn reset(&mut self) {
182        self.window.clear();
183        self.last = None;
184    }
185
186    #[inline]
187    fn warmup_period(&self) -> usize {
188        self.period
189    }
190
191    #[inline]
192    fn is_ready(&self) -> bool {
193        self.last.is_some()
194    }
195
196    #[inline]
197    fn name(&self) -> &'static str {
198        "VolumeProfile"
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn width_matches_the_emitted_payload() {
208        // The point of `width` is that a caller can size a buffer before
209        // the first value arrives, so it has to equal what update emits.
210        let mut ind = VolumeProfile::new(10, 20).unwrap();
211        let width = ind.width();
212        let mut emitted = 0;
213        for i in 0..40 {
214            #[allow(clippy::cast_precision_loss)]
215            let step = i as f64;
216            let price = 100.0 + step;
217            let candle =
218                Candle::new(price, price + 1.0, price - 1.0, price, 10.0, i * 3_600_000).unwrap();
219            if let Some(out) = ind.update(candle) {
220                assert_eq!(out.bins.len(), width);
221                emitted += 1;
222            }
223        }
224        assert!(emitted > 0, "the fixture must clear warmup");
225    }
226    use crate::traits::BatchExt;
227    use approx::assert_relative_eq;
228
229    fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
230        Candle::new(open, high, low, close, volume, ts).unwrap()
231    }
232
233    #[test]
234    fn rejects_zero_period() {
235        assert!(matches!(VolumeProfile::new(0, 50), Err(Error::PeriodZero)));
236    }
237
238    #[test]
239    fn rejects_zero_bin_count() {
240        assert!(matches!(VolumeProfile::new(20, 0), Err(Error::PeriodZero)));
241    }
242
243    #[test]
244    fn accessors_and_metadata() {
245        let vp = VolumeProfile::new(20, 50).unwrap();
246        assert_eq!(vp.name(), "VolumeProfile");
247        assert_eq!(vp.warmup_period(), 20);
248        assert_eq!(vp.params(), (20, 50));
249        assert!(vp.value().is_none());
250        assert!(!vp.is_ready());
251    }
252
253    #[test]
254    fn classic_params() {
255        let vp = VolumeProfile::classic();
256        assert_eq!(vp.params(), (20, 50));
257    }
258
259    #[test]
260    fn warms_up_over_period() {
261        let mut vp = VolumeProfile::new(3, 4).unwrap();
262        assert!(vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0)).is_none());
263        assert!(vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1)).is_none());
264        assert!(vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 2)).is_some());
265        assert!(vp.is_ready());
266    }
267
268    #[test]
269    fn reference_distribution() {
270        // Window of 2 candles, 4 bins.
271        // bar0: single print at 10, vol 100 -> bin 0 gets 100.
272        // bar1: 10..14, vol 80, spans 4 bins -> 20 each.
273        // domain: low=10, high=14, width=1 -> bins = [120, 20, 20, 20].
274        let mut vp = VolumeProfile::new(2, 4).unwrap();
275        assert!(vp.update(c(10.0, 10.0, 10.0, 10.0, 100.0, 0)).is_none());
276        let out = vp.update(c(10.0, 14.0, 10.0, 12.0, 80.0, 1)).unwrap();
277        assert_relative_eq!(out.price_low, 10.0, epsilon = 1e-12);
278        assert_relative_eq!(out.price_high, 14.0, epsilon = 1e-12);
279        assert_eq!(out.bins.len(), 4);
280        assert_relative_eq!(out.bins[0], 120.0, epsilon = 1e-9);
281        assert_relative_eq!(out.bins[1], 20.0, epsilon = 1e-9);
282        assert_relative_eq!(out.bins[2], 20.0, epsilon = 1e-9);
283        assert_relative_eq!(out.bins[3], 20.0, epsilon = 1e-9);
284    }
285
286    #[test]
287    fn conserves_total_volume() {
288        let mut vp = VolumeProfile::new(4, 8).unwrap();
289        let candles = [
290            c(10.0, 12.0, 9.0, 11.0, 30.0, 0),
291            c(11.0, 13.0, 10.0, 12.0, 40.0, 1),
292            c(12.0, 14.0, 11.0, 13.0, 50.0, 2),
293            c(13.0, 15.0, 12.0, 14.0, 60.0, 3),
294        ];
295        let out = vp.batch(&candles).pop().unwrap().unwrap();
296        let total: f64 = out.bins.iter().sum();
297        assert_relative_eq!(total, 180.0, epsilon = 1e-9);
298    }
299
300    #[test]
301    fn degenerate_single_price_window() {
302        // All bars single-print at 50 -> domain collapses, all volume in bin 0.
303        let mut vp = VolumeProfile::new(2, 4).unwrap();
304        vp.update(c(50.0, 50.0, 50.0, 50.0, 10.0, 0));
305        let out = vp.update(c(50.0, 50.0, 50.0, 50.0, 20.0, 1)).unwrap();
306        assert_relative_eq!(out.price_low, 50.0, epsilon = 1e-12);
307        assert_relative_eq!(out.price_high, 50.0, epsilon = 1e-12);
308        assert_relative_eq!(out.bins[0], 30.0, epsilon = 1e-9);
309        assert_relative_eq!(out.bins[1], 0.0, epsilon = 1e-12);
310    }
311
312    #[test]
313    fn zero_volume_bars_are_skipped() {
314        let mut vp = VolumeProfile::new(2, 4).unwrap();
315        vp.update(c(10.0, 14.0, 10.0, 12.0, 0.0, 0));
316        let out = vp.update(c(10.0, 14.0, 10.0, 12.0, 40.0, 1)).unwrap();
317        let total: f64 = out.bins.iter().sum();
318        assert_relative_eq!(total, 40.0, epsilon = 1e-9);
319    }
320
321    #[test]
322    fn rolling_window_drops_oldest() {
323        let mut vp = VolumeProfile::new(2, 4).unwrap();
324        vp.update(c(100.0, 100.0, 100.0, 100.0, 99.0, 0));
325        vp.update(c(10.0, 14.0, 10.0, 12.0, 40.0, 1));
326        // Third bar evicts the price-100 bar; domain is now 10..14 only.
327        let out = vp.update(c(10.0, 14.0, 10.0, 12.0, 40.0, 2)).unwrap();
328        assert_relative_eq!(out.price_high, 14.0, epsilon = 1e-12);
329        let total: f64 = out.bins.iter().sum();
330        assert_relative_eq!(total, 80.0, epsilon = 1e-9);
331    }
332
333    #[test]
334    fn reset_clears_state() {
335        let mut vp = VolumeProfile::new(2, 4).unwrap();
336        vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0));
337        vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1));
338        assert!(vp.is_ready());
339        vp.reset();
340        assert!(!vp.is_ready());
341        assert!(vp.value().is_none());
342    }
343
344    #[test]
345    fn batch_equals_streaming() {
346        let candles: Vec<Candle> = (0..30)
347            .map(|i| {
348                let base = 100.0 + f64::from(i % 7);
349                c(
350                    base,
351                    base + 2.0,
352                    base - 2.0,
353                    base,
354                    10.0 + f64::from(i),
355                    i64::from(i),
356                )
357            })
358            .collect();
359        let mut a = VolumeProfile::new(10, 16).unwrap();
360        let mut b = VolumeProfile::new(10, 16).unwrap();
361        assert_eq!(
362            a.batch(&candles),
363            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
364        );
365    }
366}