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