Skip to main content

kestrel_chartkit/indicator/
volume_profile.rs

1use std::collections::HashMap;
2
3use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
4use crate::model::Bar;
5
6/// Volume Profile Engine.
7/// Computes Volume-by-Price distribution over lookback window, identifying POC (Point of Control), VAH (Value Area High), and VAL (Value Area Low).
8pub struct VolumeProfileEngine {
9    lookback: usize,
10    num_bins: usize,
11    bars: Vec<Bar>,
12    alerts: Vec<IndicatorAlert>,
13}
14
15impl VolumeProfileEngine {
16    pub fn new(lookback: usize, num_bins: usize) -> Self {
17        Self {
18            lookback,
19            num_bins,
20            bars: Vec::new(),
21            alerts: Vec::new(),
22        }
23    }
24}
25
26impl Indicator for VolumeProfileEngine {
27    fn name(&self) -> &str {
28        "volume_profile"
29    }
30
31    fn warmup_period(&self) -> usize {
32        self.lookback
33    }
34
35    fn reset(&mut self) {
36        self.bars.clear();
37        self.alerts.clear();
38    }
39
40    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
41        self.bars.push(bar.clone());
42        if self.bars.len() > self.lookback {
43            self.bars.remove(0);
44        }
45
46        self.alerts.clear();
47
48        if self.bars.len() < self.lookback {
49            return None;
50        }
51
52        // Find min and max price across window
53        let mut min_p = f64::MAX;
54        let mut max_p = f64::MIN;
55        for b in &self.bars {
56            if b.low < min_p {
57                min_p = b.low;
58            }
59            if b.high > max_p {
60                max_p = b.high;
61            }
62        }
63
64        if (max_p - min_p).abs() < 1e-8 {
65            return Some(IndicatorOutput::new(bar.close));
66        }
67
68        let step = (max_p - min_p) / (self.num_bins as f64);
69        let mut bins = vec![0.0f64; self.num_bins];
70        let mut total_vol = 0.0f64;
71
72        for b in &self.bars {
73            let bar_vol = if b.volume > 0.0 {
74                b.volume
75            } else {
76                b.high - b.low
77            };
78            total_vol += bar_vol;
79
80            // Distribute volume proportionally across bins overlapping bar.low..bar.high
81            let b_start = (((b.low - min_p) / step).floor() as usize).min(self.num_bins - 1);
82            let b_end = (((b.high - min_p) / step).floor() as usize).min(self.num_bins - 1);
83            let bin_count = (b_end - b_start + 1) as f64;
84            let vol_per_bin = bar_vol / bin_count;
85
86            #[allow(clippy::needless_range_loop)]
87            for bin_idx in b_start..=b_end {
88                bins[bin_idx] += vol_per_bin;
89            }
90        }
91
92        // Find POC (bin with max volume)
93        let mut max_bin_vol = 0.0f64;
94        let mut poc_idx = 0;
95        for (i, &v) in bins.iter().enumerate() {
96            if v > max_bin_vol {
97                max_bin_vol = v;
98                poc_idx = i;
99            }
100        }
101
102        let poc_price = min_p + (poc_idx as f64 + 0.5) * step;
103
104        // Calculate 70% Value Area (VAH & VAL)
105        let target_vol = total_vol * 0.70;
106        let mut accumulated_vol = bins[poc_idx];
107        let mut val_idx = poc_idx;
108        let mut vah_idx = poc_idx;
109
110        while accumulated_vol < target_vol && (val_idx > 0 || vah_idx < self.num_bins - 1) {
111            let next_down_vol = if val_idx > 0 { bins[val_idx - 1] } else { -1.0 };
112            let next_up_vol = if vah_idx < self.num_bins - 1 {
113                bins[vah_idx + 1]
114            } else {
115                -1.0
116            };
117
118            if next_up_vol >= next_down_vol && vah_idx < self.num_bins - 1 {
119                vah_idx += 1;
120                accumulated_vol += bins[vah_idx];
121            } else if val_idx > 0 {
122                val_idx -= 1;
123                accumulated_vol += bins[val_idx];
124            } else if vah_idx < self.num_bins - 1 {
125                vah_idx += 1;
126                accumulated_vol += bins[vah_idx];
127            }
128        }
129
130        let vah_price = min_p + (vah_idx as f64 + 1.0) * step;
131        let val_price = min_p + (val_idx as f64) * step;
132
133        // Evaluate current close relative to Volume Profile
134        let close = bar.close;
135        let dist_to_poc = (close - poc_price).abs();
136        let rel_dist_poc = dist_to_poc / close;
137
138        if rel_dist_poc <= 0.003 {
139            self.alerts.push(IndicatorAlert::new(
140                "price_at_poc",
141                format!("Price at Point of Control (POC: ${:.2})", poc_price),
142                0.85,
143            ));
144        } else if close > vah_price {
145            self.alerts.push(IndicatorAlert::new(
146                "price_above_vah",
147                format!(
148                    "Price Above Value Area High (${:.2} > VAH ${:.2})",
149                    close, vah_price
150                ),
151                0.80,
152            ));
153        } else if close < val_price {
154            self.alerts.push(IndicatorAlert::new(
155                "price_below_val",
156                format!(
157                    "Price Below Value Area Low (${:.2} < VAL ${:.2})",
158                    close, val_price
159                ),
160                0.80,
161            ));
162        }
163
164        let curr_bin_idx = (((close - min_p) / step).floor() as usize).min(self.num_bins - 1);
165        let curr_bin_vol = bins[curr_bin_idx];
166        let curr_density = if total_vol > 0.0 {
167            curr_bin_vol / total_vol
168        } else {
169            0.0
170        };
171        let vpoc_density = if total_vol > 0.0 {
172            max_bin_vol / total_vol
173        } else {
174            0.0
175        };
176
177        let mut extra = HashMap::new();
178        extra.insert("vpoc".to_string(), poc_price);
179        extra.insert("vah".to_string(), vah_price);
180        extra.insert("val".to_string(), val_price);
181        extra.insert("total_volume".to_string(), total_vol);
182        extra.insert("vpoc_density".to_string(), vpoc_density);
183        extra.insert("current_density".to_string(), curr_density);
184        extra.insert("lvn_width".to_string(), step * 2.0); // Approximate LVN width in price units
185
186        Some(IndicatorOutput::with_extra(poc_price, extra))
187    }
188
189    fn alerts(&self) -> Vec<IndicatorAlert> {
190        self.alerts.clone()
191    }
192}
193
194pub fn build_volume_profile(params: &HashMap<String, f64>) -> VolumeProfileEngine {
195    let lookback = params.get("lookback").copied().unwrap_or(70.0) as usize;
196    let num_bins = params.get("num_bins").copied().unwrap_or(30.0) as usize;
197    VolumeProfileEngine::new(lookback, num_bins)
198}