1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone, PartialEq)]
16pub struct VolumeProfileOutput {
17 pub price_low: f64,
19 pub price_high: f64,
21 pub bins: Vec<f64>,
23}
24
25#[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 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 pub fn classic() -> Self {
84 Self::new(20, 50).expect("classic VolumeProfile params are valid")
85 }
86
87 pub const fn params(&self) -> (usize, usize) {
89 (self.period, self.bin_count)
90 }
91
92 pub fn value(&self) -> Option<&VolumeProfileOutput> {
94 self.last.as_ref()
95 }
96
97 fn price_to_bin(&self, price: f64, win_low: f64, bin_width: f64) -> usize {
98 let raw = ((price - win_low) / bin_width).floor();
99 let max = (self.bin_count - 1) as f64;
100 raw.clamp(0.0, max) as usize
101 }
102
103 fn compute(&self) -> VolumeProfileOutput {
104 let mut win_low = f64::INFINITY;
105 let mut win_high = f64::NEG_INFINITY;
106 for candle in &self.window {
107 if candle.low < win_low {
108 win_low = candle.low;
109 }
110 if candle.high > win_high {
111 win_high = candle.high;
112 }
113 }
114 let span = win_high - win_low;
115 let mut bins = vec![0.0_f64; self.bin_count];
116
117 if span <= 0.0 {
118 let total: f64 = self.window.iter().map(|candle| candle.volume).sum();
120 bins[0] = total;
121 return VolumeProfileOutput {
122 price_low: win_low,
123 price_high: win_low,
124 bins,
125 };
126 }
127
128 let bin_width = span / self.bin_count as f64;
129 for candle in &self.window {
130 if candle.volume == 0.0 {
131 continue;
132 }
133 if candle.high <= candle.low {
134 let idx = self.price_to_bin(candle.low, win_low, bin_width);
135 bins[idx] += candle.volume;
136 continue;
137 }
138 let lo_idx = self.price_to_bin(candle.low, win_low, bin_width);
139 let hi_idx = self.price_to_bin(candle.high, win_low, bin_width);
140 let touched = hi_idx - lo_idx + 1;
141 let share = candle.volume / touched as f64;
142 for bin in bins.iter_mut().take(hi_idx + 1).skip(lo_idx) {
143 *bin += share;
144 }
145 }
146
147 VolumeProfileOutput {
148 price_low: win_low,
149 price_high: win_high,
150 bins,
151 }
152 }
153}
154
155impl Indicator for VolumeProfile {
156 type Input = Candle;
157 type Output = VolumeProfileOutput;
158
159 fn update(&mut self, candle: Candle) -> Option<VolumeProfileOutput> {
160 if self.window.len() == self.period {
161 self.window.pop_front();
162 }
163 self.window.push_back(candle);
164 if self.window.len() < self.period {
165 return None;
166 }
167 let out = self.compute();
168 self.last = Some(out.clone());
169 Some(out)
170 }
171
172 fn reset(&mut self) {
173 self.window.clear();
174 self.last = None;
175 }
176
177 fn warmup_period(&self) -> usize {
178 self.period
179 }
180
181 fn is_ready(&self) -> bool {
182 self.last.is_some()
183 }
184
185 fn name(&self) -> &'static str {
186 "VolumeProfile"
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::traits::BatchExt;
194 use approx::assert_relative_eq;
195
196 fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
197 Candle::new(open, high, low, close, volume, ts).unwrap()
198 }
199
200 #[test]
201 fn rejects_zero_period() {
202 assert!(matches!(VolumeProfile::new(0, 50), Err(Error::PeriodZero)));
203 }
204
205 #[test]
206 fn rejects_zero_bin_count() {
207 assert!(matches!(VolumeProfile::new(20, 0), Err(Error::PeriodZero)));
208 }
209
210 #[test]
211 fn accessors_and_metadata() {
212 let vp = VolumeProfile::new(20, 50).unwrap();
213 assert_eq!(vp.name(), "VolumeProfile");
214 assert_eq!(vp.warmup_period(), 20);
215 assert_eq!(vp.params(), (20, 50));
216 assert!(vp.value().is_none());
217 assert!(!vp.is_ready());
218 }
219
220 #[test]
221 fn classic_params() {
222 let vp = VolumeProfile::classic();
223 assert_eq!(vp.params(), (20, 50));
224 }
225
226 #[test]
227 fn warms_up_over_period() {
228 let mut vp = VolumeProfile::new(3, 4).unwrap();
229 assert!(vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0)).is_none());
230 assert!(vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1)).is_none());
231 assert!(vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 2)).is_some());
232 assert!(vp.is_ready());
233 }
234
235 #[test]
236 fn reference_distribution() {
237 let mut vp = VolumeProfile::new(2, 4).unwrap();
242 assert!(vp.update(c(10.0, 10.0, 10.0, 10.0, 100.0, 0)).is_none());
243 let out = vp.update(c(10.0, 14.0, 10.0, 12.0, 80.0, 1)).unwrap();
244 assert_relative_eq!(out.price_low, 10.0, epsilon = 1e-12);
245 assert_relative_eq!(out.price_high, 14.0, epsilon = 1e-12);
246 assert_eq!(out.bins.len(), 4);
247 assert_relative_eq!(out.bins[0], 120.0, epsilon = 1e-9);
248 assert_relative_eq!(out.bins[1], 20.0, epsilon = 1e-9);
249 assert_relative_eq!(out.bins[2], 20.0, epsilon = 1e-9);
250 assert_relative_eq!(out.bins[3], 20.0, epsilon = 1e-9);
251 }
252
253 #[test]
254 fn conserves_total_volume() {
255 let mut vp = VolumeProfile::new(4, 8).unwrap();
256 let candles = [
257 c(10.0, 12.0, 9.0, 11.0, 30.0, 0),
258 c(11.0, 13.0, 10.0, 12.0, 40.0, 1),
259 c(12.0, 14.0, 11.0, 13.0, 50.0, 2),
260 c(13.0, 15.0, 12.0, 14.0, 60.0, 3),
261 ];
262 let out = vp.batch(&candles).pop().unwrap().unwrap();
263 let total: f64 = out.bins.iter().sum();
264 assert_relative_eq!(total, 180.0, epsilon = 1e-9);
265 }
266
267 #[test]
268 fn degenerate_single_price_window() {
269 let mut vp = VolumeProfile::new(2, 4).unwrap();
271 vp.update(c(50.0, 50.0, 50.0, 50.0, 10.0, 0));
272 let out = vp.update(c(50.0, 50.0, 50.0, 50.0, 20.0, 1)).unwrap();
273 assert_relative_eq!(out.price_low, 50.0, epsilon = 1e-12);
274 assert_relative_eq!(out.price_high, 50.0, epsilon = 1e-12);
275 assert_relative_eq!(out.bins[0], 30.0, epsilon = 1e-9);
276 assert_relative_eq!(out.bins[1], 0.0, epsilon = 1e-12);
277 }
278
279 #[test]
280 fn zero_volume_bars_are_skipped() {
281 let mut vp = VolumeProfile::new(2, 4).unwrap();
282 vp.update(c(10.0, 14.0, 10.0, 12.0, 0.0, 0));
283 let out = vp.update(c(10.0, 14.0, 10.0, 12.0, 40.0, 1)).unwrap();
284 let total: f64 = out.bins.iter().sum();
285 assert_relative_eq!(total, 40.0, epsilon = 1e-9);
286 }
287
288 #[test]
289 fn rolling_window_drops_oldest() {
290 let mut vp = VolumeProfile::new(2, 4).unwrap();
291 vp.update(c(100.0, 100.0, 100.0, 100.0, 99.0, 0));
292 vp.update(c(10.0, 14.0, 10.0, 12.0, 40.0, 1));
293 let out = vp.update(c(10.0, 14.0, 10.0, 12.0, 40.0, 2)).unwrap();
295 assert_relative_eq!(out.price_high, 14.0, epsilon = 1e-12);
296 let total: f64 = out.bins.iter().sum();
297 assert_relative_eq!(total, 80.0, epsilon = 1e-9);
298 }
299
300 #[test]
301 fn reset_clears_state() {
302 let mut vp = VolumeProfile::new(2, 4).unwrap();
303 vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0));
304 vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1));
305 assert!(vp.is_ready());
306 vp.reset();
307 assert!(!vp.is_ready());
308 assert!(vp.value().is_none());
309 }
310
311 #[test]
312 fn batch_equals_streaming() {
313 let candles: Vec<Candle> = (0..30)
314 .map(|i| {
315 let base = 100.0 + f64::from(i % 7);
316 c(
317 base,
318 base + 2.0,
319 base - 2.0,
320 base,
321 10.0 + f64::from(i),
322 i64::from(i),
323 )
324 })
325 .collect();
326 let mut a = VolumeProfile::new(10, 16).unwrap();
327 let mut b = VolumeProfile::new(10, 16).unwrap();
328 assert_eq!(
329 a.batch(&candles),
330 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
331 );
332 }
333}