kestrel_chartkit/indicator/
volume_profile.rs1use std::collections::HashMap;
2
3use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
4use crate::model::Bar;
5
6pub 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 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 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 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 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 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); 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}