kestrel_chartkit/indicator/
volume_profile.rs1use std::collections::HashMap;
2use std::fmt;
3
4use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
5use crate::model::Bar;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum VolumeProfileConfigError {
12 ZeroLookback,
14 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
29pub struct VolumeProfileEngine {
47 lookback: usize,
48 num_bins: usize,
49 bars: Vec<Bar>,
50 alerts: Vec<IndicatorAlert>,
51}
52
53impl VolumeProfileEngine {
54 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 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 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 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 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 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 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 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); Some(IndicatorOutput::with_extra(poc_price, extra))
274 }
275
276 fn alerts(&self) -> Vec<IndicatorAlert> {
277 self.alerts.clone()
278 }
279}
280
281pub 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 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); }
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); }
374 }
375 }
376
377 #[test]
378 fn test_build_volume_profile_zero_is_clamped_not_defaulted() {
379 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 #[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", ¶ms).unwrap();
400 let mut via_builder = build_volume_profile(¶ms);
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}