Skip to main content

kestrel_chartkit/indicator/
volume_profile.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
5use crate::model::Bar;
6
7/// Invalid [`VolumeProfileEngine`] configuration: `lookback`/`num_bins` of zero would produce an
8/// empty bin vector (and panic on the first non-flat window in `on_bar`) or an unbounded lookback
9/// window, so `try_new` rejects them explicitly (finding 06) rather than silently normalizing.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum VolumeProfileConfigError {
12    /// `lookback` was `0`; it must be at least `1`.
13    ZeroLookback,
14    /// `num_bins` was `0`; it must be at least `1`.
15    ZeroNumBins,
16}
17
18impl fmt::Display for VolumeProfileConfigError {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::ZeroLookback => write!(f, "lookback must be at least 1"),
22            Self::ZeroNumBins => write!(f, "num_bins must be at least 1"),
23        }
24    }
25}
26
27impl std::error::Error for VolumeProfileConfigError {}
28
29/// Volume Profile over the last `lookback` bars: volume by price, Point of Control and a 70 %
30/// value area.
31///
32/// The window's own range `[min low, max high]` is split into `num_bins` equal bins of width
33/// `step`. Each bar's volume — its range when it carries no volume — is spread evenly over the
34/// bins from `floor((low - min) / step)` to `floor((high - min) / step)`, both clamped to the last
35/// bin. The **POC** is the first bin with the largest volume, priced at its centre. The **value
36/// area** grows from the POC one bin at a time towards the larger neighbour (upwards on a tie)
37/// until it holds 70 % of the window's volume; **VAH** is the upper edge of its top bin, **VAL**
38/// the lower edge of its bottom bin.
39///
40/// `value` and `extra["vpoc"]`: the POC; further `extra["vah"]`, `extra["val"]`,
41/// `extra["total_volume"]`, `extra["vpoc_density"]` (POC volume over total),
42/// `extra["current_density"]` (the close's bin over total) and `extra["lvn_width"]` (`2 * step`).
43/// A window without range publishes the close.
44///
45/// First output: with the `lookback`-th bar. [`Indicator::reset`] clears the window.
46pub struct VolumeProfileEngine {
47    lookback: usize,
48    num_bins: usize,
49    bars: Vec<Bar>,
50    alerts: Vec<IndicatorAlert>,
51}
52
53impl VolumeProfileEngine {
54    /// Constructs a profile engine, clamping `lookback` and `num_bins` to a minimum of `1` each
55    /// (matching [`ExtendedVolumeProfileEngine`](super::volume_profile_extended::ExtendedVolumeProfileEngine)'s
56    /// and [`PersistentVolumeProfileEngine`](super::volume_profile_persistent::PersistentVolumeProfileEngine)'s
57    /// existing contract). `0` would otherwise leave `on_bar` with an empty bin vector (panicking
58    /// on the first non-flat window) or a warmup that can never complete. Prefer
59    /// [`VolumeProfileEngine::try_new`] for configuration-driven construction, where silently
60    /// substituting `1` would compute a different profile than requested.
61    pub fn new(lookback: usize, num_bins: usize) -> Self {
62        Self {
63            lookback: lookback.max(1),
64            num_bins: num_bins.max(1),
65            bars: Vec::new(),
66            alerts: Vec::new(),
67        }
68    }
69
70    /// Like [`VolumeProfileEngine::new`], but rejects a zero `lookback`/`num_bins` with
71    /// [`VolumeProfileConfigError`] instead of silently clamping it to `1`.
72    pub fn try_new(lookback: usize, num_bins: usize) -> Result<Self, VolumeProfileConfigError> {
73        if lookback == 0 {
74            return Err(VolumeProfileConfigError::ZeroLookback);
75        }
76        if num_bins == 0 {
77            return Err(VolumeProfileConfigError::ZeroNumBins);
78        }
79        Ok(Self::new(lookback, num_bins))
80    }
81
82    /// Feeds a bar into the volume profile engine, resetting internal state if `is_contract_boundary` is true.
83    ///
84    /// This prevents volume and distribution bins from previous contracts or expirations
85    /// from distorting the profile across commodity or futures roll boundaries.
86    pub fn on_bar_with_boundary(
87        &mut self,
88        bar: &Bar,
89        is_contract_boundary: bool,
90    ) -> Option<IndicatorOutput> {
91        if is_contract_boundary {
92            self.reset();
93        }
94        self.on_bar(bar)
95    }
96}
97
98impl Indicator for VolumeProfileEngine {
99    fn name(&self) -> &str {
100        "volume_profile"
101    }
102
103    fn warmup_period(&self) -> usize {
104        self.lookback
105    }
106
107    fn reset(&mut self) {
108        self.bars.clear();
109        self.alerts.clear();
110    }
111
112    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
113        self.bars.push(bar.clone());
114        if self.bars.len() > self.lookback {
115            self.bars.remove(0);
116        }
117
118        self.alerts.clear();
119
120        if self.bars.len() < self.lookback {
121            return None;
122        }
123
124        // Find min and max price across window
125        let mut min_p = f64::MAX;
126        let mut max_p = f64::MIN;
127        for b in &self.bars {
128            if b.low < min_p {
129                min_p = b.low;
130            }
131            if b.high > max_p {
132                max_p = b.high;
133            }
134        }
135
136        if (max_p - min_p).abs() < 1e-8 {
137            return Some(IndicatorOutput::new(bar.close));
138        }
139
140        let step = (max_p - min_p) / (self.num_bins as f64);
141        let mut bins = vec![0.0f64; self.num_bins];
142        let mut total_vol = 0.0f64;
143
144        for b in &self.bars {
145            let bar_vol = if b.volume > 0.0 {
146                b.volume
147            } else {
148                b.high - b.low
149            };
150            total_vol += bar_vol;
151
152            // Distribute volume proportionally across bins overlapping bar.low..bar.high
153            let raw_start = ((b.low - min_p) / step).floor();
154            let b_start = if raw_start.is_finite() && raw_start >= 0.0 {
155                (raw_start as usize).min(self.num_bins.saturating_sub(1))
156            } else {
157                0
158            };
159            let raw_end = ((b.high - min_p) / step).floor();
160            let b_end = if raw_end.is_finite() && raw_end >= 0.0 {
161                (raw_end as usize).min(self.num_bins.saturating_sub(1))
162            } else {
163                0
164            };
165            let b_end = b_end.max(b_start);
166            let bin_count = (b_end - b_start + 1) as f64;
167            let vol_per_bin = bar_vol / bin_count;
168
169            for bin in &mut bins[b_start..=b_end] {
170                *bin += vol_per_bin;
171            }
172        }
173
174        // Find POC (bin with max volume)
175        let mut max_bin_vol = 0.0f64;
176        let mut poc_idx = 0;
177        for (i, &v) in bins.iter().enumerate() {
178            if v > max_bin_vol {
179                max_bin_vol = v;
180                poc_idx = i;
181            }
182        }
183
184        let poc_price = min_p + (poc_idx as f64 + 0.5) * step;
185
186        // Calculate 70% Value Area (VAH & VAL)
187        let target_vol = total_vol * 0.70;
188        let mut accumulated_vol = bins[poc_idx];
189        let mut val_idx = poc_idx;
190        let mut vah_idx = poc_idx;
191
192        while accumulated_vol < target_vol && (val_idx > 0 || vah_idx < self.num_bins - 1) {
193            let next_down_vol = if val_idx > 0 { bins[val_idx - 1] } else { -1.0 };
194            let next_up_vol = if vah_idx < self.num_bins - 1 {
195                bins[vah_idx + 1]
196            } else {
197                -1.0
198            };
199
200            if next_up_vol >= next_down_vol && vah_idx < self.num_bins - 1 {
201                vah_idx += 1;
202                accumulated_vol += bins[vah_idx];
203            } else if val_idx > 0 {
204                val_idx -= 1;
205                accumulated_vol += bins[val_idx];
206            } else if vah_idx < self.num_bins - 1 {
207                vah_idx += 1;
208                accumulated_vol += bins[vah_idx];
209            }
210        }
211
212        let vah_price = min_p + (vah_idx as f64 + 1.0) * step;
213        let val_price = min_p + (val_idx as f64) * step;
214
215        // Evaluate current close relative to Volume Profile
216        let close = bar.close;
217        let dist_to_poc = (close - poc_price).abs();
218        let rel_dist_poc = dist_to_poc / close;
219
220        if rel_dist_poc <= 0.003 {
221            self.alerts.push(IndicatorAlert::new(
222                "price_at_poc",
223                format!("Price at Point of Control (POC: ${:.2})", poc_price),
224                0.85,
225            ));
226        } else if close > vah_price {
227            self.alerts.push(IndicatorAlert::new(
228                "price_above_vah",
229                format!(
230                    "Price Above Value Area High (${:.2} > VAH ${:.2})",
231                    close, vah_price
232                ),
233                0.80,
234            ));
235        } else if close < val_price {
236            self.alerts.push(IndicatorAlert::new(
237                "price_below_val",
238                format!(
239                    "Price Below Value Area Low (${:.2} < VAL ${:.2})",
240                    close, val_price
241                ),
242                0.80,
243            ));
244        }
245
246        let raw_curr = ((close - min_p) / step).floor();
247        let curr_bin_idx = if raw_curr.is_finite() && raw_curr >= 0.0 {
248            (raw_curr as usize).min(self.num_bins.saturating_sub(1))
249        } else {
250            0
251        };
252        let curr_bin_vol = bins.get(curr_bin_idx).copied().unwrap_or(0.0);
253        let curr_density = if total_vol > 0.0 {
254            curr_bin_vol / total_vol
255        } else {
256            0.0
257        };
258        let vpoc_density = if total_vol > 0.0 {
259            max_bin_vol / total_vol
260        } else {
261            0.0
262        };
263
264        let mut extra = HashMap::new();
265        extra.insert("vpoc".to_string(), poc_price);
266        extra.insert("vah".to_string(), vah_price);
267        extra.insert("val".to_string(), val_price);
268        extra.insert("total_volume".to_string(), total_vol);
269        extra.insert("vpoc_density".to_string(), vpoc_density);
270        extra.insert("current_density".to_string(), curr_density);
271        extra.insert("lvn_width".to_string(), step * 2.0); // Approximate LVN width in price units
272
273        Some(IndicatorOutput::with_extra(poc_price, extra))
274    }
275
276    fn alerts(&self) -> Vec<IndicatorAlert> {
277        self.alerts.clone()
278    }
279}
280
281/// Builds a [`VolumeProfileEngine`] from loosely-typed params, defaulting `lookback`/`num_bins` to
282/// `70`/`30` for missing, negative, non-finite, or fractional-truncating-to-zero values. Routed
283/// through [`VolumeProfileEngine::new`], so (per finding 06) an explicit `0` is clamped to `1`
284/// rather than reaching `on_bar` with an empty bin vector.
285pub fn build_volume_profile(params: &HashMap<String, f64>) -> VolumeProfileEngine {
286    let lookback = params
287        .get("lookback")
288        .copied()
289        .filter(|v| v.is_finite() && *v >= 0.0)
290        .map(|v| v as usize)
291        .unwrap_or(70);
292    let num_bins = params
293        .get("num_bins")
294        .copied()
295        .filter(|v| v.is_finite() && *v >= 0.0)
296        .map(|v| v as usize)
297        .unwrap_or(30);
298    VolumeProfileEngine::new(lookback, num_bins)
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::indicator::registry::build_checked;
305
306    /// A non-flat window: the min/max-price early return (`(max_p - min_p).abs() < 1e-8`) would
307    /// otherwise mask a `num_bins = 0` bug by never reaching the bin-slicing code at all.
308    fn non_flat_bars(n: usize) -> Vec<Bar> {
309        (0..n)
310            .map(|i| {
311                let base = 100.0 + i as f64;
312                Bar::new(i as i64, base, base + 2.0, base - 2.0, base + 0.5, 100.0)
313            })
314            .collect()
315    }
316
317    #[test]
318    fn test_new_clamps_zero_num_bins_and_does_not_panic() {
319        let mut engine = VolumeProfileEngine::new(5, 0);
320        assert_eq!(engine.num_bins, 1);
321        for bar in non_flat_bars(10) {
322            engine.on_bar(&bar); // must not panic
323        }
324    }
325
326    #[test]
327    fn test_new_clamps_zero_lookback() {
328        let engine = VolumeProfileEngine::new(0, 10);
329        assert_eq!(engine.lookback, 1);
330        assert_eq!(engine.warmup_period(), 1);
331    }
332
333    #[test]
334    fn test_try_new_rejects_zero_lookback() {
335        let err = match VolumeProfileEngine::try_new(0, 10) {
336            Err(e) => e,
337            Ok(_) => panic!("Expected error for zero lookback"),
338        };
339        assert_eq!(err, VolumeProfileConfigError::ZeroLookback);
340    }
341
342    #[test]
343    fn test_try_new_rejects_zero_num_bins() {
344        let err = match VolumeProfileEngine::try_new(10, 0) {
345            Err(e) => e,
346            Ok(_) => panic!("Expected error for zero num_bins"),
347        };
348        assert_eq!(err, VolumeProfileConfigError::ZeroNumBins);
349    }
350
351    #[test]
352    fn test_try_new_accepts_valid_config() {
353        let engine = VolumeProfileEngine::try_new(10, 5).unwrap();
354        assert_eq!(engine.lookback, 10);
355        assert_eq!(engine.num_bins, 5);
356    }
357
358    #[test]
359    fn test_build_volume_profile_handles_invalid_values_without_panicking() {
360        for (lookback, num_bins) in [
361            (0.0, 0.0),
362            (-5.0, -5.0),
363            (f64::NAN, f64::NAN),
364            (f64::INFINITY, f64::INFINITY),
365            (5.9, 5.9),
366        ] {
367            let mut engine = build_volume_profile(&HashMap::from([
368                ("lookback".to_string(), lookback),
369                ("num_bins".to_string(), num_bins),
370            ]));
371            for bar in non_flat_bars(10) {
372                engine.on_bar(&bar); // must not panic for any of these inputs
373            }
374        }
375    }
376
377    #[test]
378    fn test_build_volume_profile_zero_is_clamped_not_defaulted() {
379        // Zero is a valid-looking (finite, non-negative) input, distinct from "missing"; it must
380        // clamp to 1 via the constructor, not silently fall back to the unrelated 70/30 default.
381        let engine = build_volume_profile(&HashMap::from([
382            ("lookback".to_string(), 0.0),
383            ("num_bins".to_string(), 0.0),
384        ]));
385        assert_eq!(engine.lookback, 1);
386        assert_eq!(engine.num_bins, 1);
387    }
388
389    /// `build_checked` (registry, strict), `build_volume_profile` (loose builder), and
390    /// `VolumeProfileEngine::new` (direct) must all agree for the same valid parameters.
391    #[test]
392    fn test_registry_builder_and_constructor_are_equivalent_for_valid_params() {
393        let bars = non_flat_bars(20);
394        let params = HashMap::from([
395            ("lookback".to_string(), 10.0),
396            ("num_bins".to_string(), 5.0),
397        ]);
398
399        let mut via_registry = build_checked("volume_profile", &params).unwrap();
400        let mut via_builder = build_volume_profile(&params);
401        let mut via_constructor = VolumeProfileEngine::new(10, 5);
402
403        for bar in &bars {
404            let a = via_registry.on_bar(bar).map(|o| o.value);
405            let b = via_builder.on_bar(bar).map(|o| o.value);
406            let c = via_constructor.on_bar(bar).map(|o| o.value);
407            assert_eq!(a, b);
408            assert_eq!(a, c);
409        }
410    }
411}