1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
27use crate::traits::Next;
28use serde::{Deserialize, Serialize};
29use std::collections::VecDeque;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
33pub enum ValueAreaMethod {
34 #[default]
40 Greedy,
41 SteidlmayerTwoRow,
46 Symmetrical,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
52pub enum VolumeAllocationMethod {
53 #[default]
56 ProportionalOverlap,
57 UniformAcrossBins,
59 ClosePrice,
61 TypicalPrice,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
67pub struct BarData {
68 pub high: f64,
70 pub low: f64,
72 pub close: f64,
74 pub volume: f64,
76}
77
78impl BarData {
79 pub fn new(high: f64, low: f64, close: f64, volume: f64) -> Self {
83 let (high, low) = if high < low { (low, high) } else { (high, low) };
84 let volume = if volume.is_finite() && volume > 0.0 {
85 volume
86 } else {
87 0.0
88 };
89 Self {
90 high,
91 low,
92 close,
93 volume,
94 }
95 }
96}
97
98impl Default for BarData {
99 fn default() -> Self {
100 Self {
101 high: 0.0,
102 low: 0.0,
103 close: 0.0,
104 volume: 0.0,
105 }
106 }
107}
108
109impl From<(f64, f64, f64, f64)> for BarData {
110 #[inline]
111 fn from((high, low, close, volume): (f64, f64, f64, f64)) -> Self {
112 Self::new(high, low, close, volume)
113 }
114}
115
116impl From<(f64, f64, f64)> for BarData {
117 #[inline]
118 fn from((high, low, volume): (f64, f64, f64)) -> Self {
119 let close = (high + low) * 0.5;
120 Self::new(high, low, close, volume)
121 }
122}
123
124impl From<(f64, f64)> for BarData {
125 #[inline]
126 fn from((price, volume): (f64, f64)) -> Self {
127 Self::new(price, price, price, volume)
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
133pub struct PriceBin {
134 pub lower: f64,
136 pub upper: f64,
138 pub center: f64,
140 pub volume: f64,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
146pub struct VolumeProfileState {
147 pub poc: f64,
149 pub vah: f64,
151 pub val: f64,
153 pub profile_high: f64,
155 pub profile_low: f64,
157 pub total_volume: f64,
159 pub bar_index: usize,
161}
162
163impl VolumeProfileState {
164 pub fn empty(bar_index: usize) -> Self {
166 Self {
167 poc: f64::NAN,
168 vah: f64::NAN,
169 val: f64::NAN,
170 profile_high: f64::NAN,
171 profile_low: f64::NAN,
172 total_volume: 0.0,
173 bar_index,
174 }
175 }
176
177 #[inline]
179 pub fn value_area_width(&self) -> f64 {
180 (self.vah - self.val).max(0.0)
181 }
182
183 #[inline]
185 pub fn profile_range(&self) -> f64 {
186 (self.profile_high - self.profile_low).max(0.0)
187 }
188
189 #[inline]
191 pub fn is_inside_value_area(&self, price: f64) -> bool {
192 price >= self.val && price <= self.vah
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200pub struct VolumeProfile {
201 period: usize,
202 bins: usize,
203 value_area_pct: f64,
204 va_method: ValueAreaMethod,
205 alloc_method: VolumeAllocationMethod,
206 window: VecDeque<BarData>,
207 bar_count: usize,
208}
209
210impl VolumeProfile {
211 pub fn new(period: usize, bins: usize) -> Self {
217 let period = period.max(1);
218 Self {
219 period,
220 bins: bins.max(1),
221 value_area_pct: 0.70,
222 va_method: ValueAreaMethod::Greedy,
223 alloc_method: VolumeAllocationMethod::ProportionalOverlap,
224 window: VecDeque::with_capacity(period),
225 bar_count: 0,
226 }
227 }
228
229 pub fn with_value_area_pct(mut self, pct: f64) -> Self {
233 self.value_area_pct = pct.clamp(0.0, 1.0);
234 self
235 }
236
237 pub fn with_methods(
239 mut self,
240 va_method: ValueAreaMethod,
241 alloc_method: VolumeAllocationMethod,
242 ) -> Self {
243 self.va_method = va_method;
244 self.alloc_method = alloc_method;
245 self
246 }
247
248 pub fn with_params(
250 period: usize,
251 bins: usize,
252 value_area_pct: f64,
253 va_method: ValueAreaMethod,
254 alloc_method: VolumeAllocationMethod,
255 ) -> Self {
256 Self::new(period, bins)
257 .with_value_area_pct(value_area_pct)
258 .with_methods(va_method, alloc_method)
259 }
260
261 #[inline]
263 pub fn period(&self) -> usize {
264 self.period
265 }
266
267 #[inline]
269 pub fn bins(&self) -> usize {
270 self.bins
271 }
272
273 #[inline]
275 pub fn value_area_pct(&self) -> f64 {
276 self.value_area_pct
277 }
278
279 #[inline]
281 pub fn value_area_method(&self) -> ValueAreaMethod {
282 self.va_method
283 }
284
285 #[inline]
287 pub fn allocation_method(&self) -> VolumeAllocationMethod {
288 self.alloc_method
289 }
290
291 #[inline]
293 pub fn window_len(&self) -> usize {
294 self.window.len()
295 }
296
297 #[inline]
299 pub fn bar_count(&self) -> usize {
300 self.bar_count
301 }
302
303 #[inline]
305 pub fn is_ready(&self) -> bool {
306 self.window.len() >= self.period
307 }
308
309 pub fn reset(&mut self) {
311 self.window.clear();
312 self.bar_count = 0;
313 }
314
315 pub fn next_bar(&mut self, bar: impl Into<BarData>) -> VolumeProfileState {
317 let bar = bar.into();
318 self.bar_count = self.bar_count.wrapping_add(1);
319 let current_bar_idx = self.bar_count.saturating_sub(1);
320
321 self.window.push_back(bar);
322 if self.window.len() > self.period {
323 self.window.pop_front();
324 }
325
326 if self.window.is_empty() {
327 return VolumeProfileState::empty(current_bar_idx);
328 }
329
330 let mut min_low = f64::INFINITY;
332 let mut max_high = f64::NEG_INFINITY;
333 let mut total_vol = 0.0;
334
335 for b in &self.window {
336 if b.low < min_low {
337 min_low = b.low;
338 }
339 if b.high > max_high {
340 max_high = b.high;
341 }
342 total_vol += b.volume;
343 }
344
345 if !min_low.is_finite() || !max_high.is_finite() {
346 return VolumeProfileState::empty(current_bar_idx);
347 }
348
349 if (max_high - min_low).abs() < 1e-12 {
351 return VolumeProfileState {
352 poc: min_low,
353 vah: min_low,
354 val: min_low,
355 profile_high: min_low,
356 profile_low: min_low,
357 total_volume: total_vol,
358 bar_index: current_bar_idx,
359 };
360 }
361
362 let bin_size = (max_high - min_low) / (self.bins as f64);
363 let mut histogram = vec![0.0; self.bins];
364
365 for b in &self.window {
367 allocate_bar_volume(
368 b,
369 min_low,
370 bin_size,
371 self.bins,
372 self.alloc_method,
373 &mut histogram,
374 );
375 }
376
377 let mut max_v = -1.0;
379 let mut poc_idx = 0;
380 for (i, &v) in histogram.iter().enumerate() {
381 if v > max_v {
382 max_v = v;
383 poc_idx = i;
384 }
385 }
386
387 let poc = min_low + (poc_idx as f64 + 0.5) * bin_size;
388
389 let (va_low_idx, va_high_idx) = compute_value_area_indices(
391 &histogram,
392 poc_idx,
393 total_vol,
394 self.value_area_pct,
395 self.va_method,
396 );
397
398 let val_calc = min_low + (va_low_idx as f64) * bin_size;
399 let vah_calc = (min_low + ((va_high_idx + 1) as f64) * bin_size).min(max_high);
400
401 let val = val_calc.min(poc);
403 let vah = vah_calc.max(poc);
404
405 VolumeProfileState {
406 poc,
407 vah,
408 val,
409 profile_high: max_high,
410 profile_low: min_low,
411 total_volume: total_vol,
412 bar_index: current_bar_idx,
413 }
414 }
415
416 pub fn current_bins(&self) -> Vec<PriceBin> {
418 if self.window.is_empty() {
419 return Vec::new();
420 }
421
422 let mut min_low = f64::INFINITY;
423 let mut max_high = f64::NEG_INFINITY;
424
425 for b in &self.window {
426 if b.low < min_low {
427 min_low = b.low;
428 }
429 if b.high > max_high {
430 max_high = b.high;
431 }
432 }
433
434 if !min_low.is_finite() || !max_high.is_finite() {
435 return Vec::new();
436 }
437
438 let bin_size = if (max_high - min_low).abs() < 1e-12 {
439 0.0
440 } else {
441 (max_high - min_low) / (self.bins as f64)
442 };
443
444 let mut histogram = vec![0.0; self.bins];
445 for b in &self.window {
446 allocate_bar_volume(
447 b,
448 min_low,
449 bin_size.max(1e-12),
450 self.bins,
451 self.alloc_method,
452 &mut histogram,
453 );
454 }
455
456 (0..self.bins)
457 .map(|i| {
458 let lower = min_low + (i as f64) * bin_size;
459 let upper = if i == self.bins - 1 {
460 max_high
461 } else {
462 lower + bin_size
463 };
464 let center = lower + 0.5 * bin_size;
465 PriceBin {
466 lower,
467 upper,
468 center,
469 volume: histogram[i],
470 }
471 })
472 .collect()
473 }
474}
475
476fn allocate_bar_volume(
478 bar: &BarData,
479 min_low: f64,
480 bin_size: f64,
481 num_bins: usize,
482 method: VolumeAllocationMethod,
483 histogram: &mut [f64],
484) {
485 if bar.volume <= 0.0 || !bar.volume.is_finite() || bin_size <= 0.0 {
486 return;
487 }
488 let num_bins_f = num_bins as f64;
489
490 match method {
491 VolumeAllocationMethod::ProportionalOverlap => {
492 let bar_range = bar.high - bar.low;
493 if bar_range <= 1e-12 || !bar_range.is_finite() {
494 let mid = (bar.high + bar.low) * 0.5;
496 let bin_idx = ((mid - min_low) / bin_size)
497 .floor()
498 .clamp(0.0, num_bins_f - 1.0) as usize;
499 histogram[bin_idx] += bar.volume;
500 } else {
501 let start_idx = ((bar.low - min_low) / bin_size)
502 .floor()
503 .clamp(0.0, num_bins_f - 1.0) as usize;
504 let end_idx = ((bar.high - min_low) / bin_size)
505 .floor()
506 .clamp(0.0, num_bins_f - 1.0) as usize;
507
508 for i in start_idx..=end_idx {
509 let bin_lower = min_low + (i as f64) * bin_size;
510 let bin_upper = bin_lower + bin_size;
511 let overlap_start = bar.low.max(bin_lower);
512 let overlap_end = bar.high.min(bin_upper);
513 let overlap = (overlap_end - overlap_start).max(0.0);
514 if overlap > 0.0 {
515 let frac = overlap / bar_range;
516 histogram[i] += bar.volume * frac;
517 }
518 }
519 }
520 }
521 VolumeAllocationMethod::UniformAcrossBins => {
522 let start_idx = ((bar.low - min_low) / bin_size)
523 .floor()
524 .clamp(0.0, num_bins_f - 1.0) as usize;
525 let end_idx = ((bar.high - min_low) / bin_size)
526 .floor()
527 .clamp(0.0, num_bins_f - 1.0) as usize;
528 let count = (end_idx - start_idx + 1) as f64;
529 let vol_per_bin = bar.volume / count;
530 for i in start_idx..=end_idx {
531 histogram[i] += vol_per_bin;
532 }
533 }
534 VolumeAllocationMethod::ClosePrice => {
535 let bin_idx = ((bar.close - min_low) / bin_size)
536 .floor()
537 .clamp(0.0, num_bins_f - 1.0) as usize;
538 histogram[bin_idx] += bar.volume;
539 }
540 VolumeAllocationMethod::TypicalPrice => {
541 let tp = (bar.high + bar.low + bar.close) / 3.0;
542 let bin_idx = ((tp - min_low) / bin_size)
543 .floor()
544 .clamp(0.0, num_bins_f - 1.0) as usize;
545 histogram[bin_idx] += bar.volume;
546 }
547 }
548}
549
550fn compute_value_area_indices(
552 histogram: &[f64],
553 poc_idx: usize,
554 total_vol: f64,
555 value_area_pct: f64,
556 method: ValueAreaMethod,
557) -> (usize, usize) {
558 let num_bins = histogram.len();
559 if num_bins == 0 {
560 return (0, 0);
561 }
562 if total_vol <= 0.0 || value_area_pct <= 0.0 {
563 return (poc_idx, poc_idx);
564 }
565
566 let target_vol = total_vol * value_area_pct;
567 let mut va_low_idx = poc_idx;
568 let mut va_high_idx = poc_idx;
569 let mut current_va_vol = histogram[poc_idx];
570
571 match method {
572 ValueAreaMethod::Greedy => {
573 while current_va_vol < target_vol && (va_low_idx > 0 || va_high_idx + 1 < num_bins) {
574 let can_go_up = va_high_idx + 1 < num_bins;
575 let can_go_down = va_low_idx > 0;
576
577 if can_go_up && can_go_down {
578 let vol_up = histogram[va_high_idx + 1];
579 let vol_down = histogram[va_low_idx - 1];
580
581 if vol_up > vol_down {
582 va_high_idx += 1;
583 current_va_vol += vol_up;
584 } else if vol_down > vol_up {
585 va_low_idx -= 1;
586 current_va_vol += vol_down;
587 } else {
588 va_high_idx += 1;
590 va_low_idx -= 1;
591 current_va_vol += vol_up + vol_down;
592 }
593 } else if can_go_up {
594 va_high_idx += 1;
595 current_va_vol += histogram[va_high_idx];
596 } else if can_go_down {
597 va_low_idx -= 1;
598 current_va_vol += histogram[va_low_idx];
599 } else {
600 break;
601 }
602 }
603 }
604 ValueAreaMethod::SteidlmayerTwoRow => {
605 while current_va_vol < target_vol && (va_low_idx > 0 || va_high_idx + 1 < num_bins) {
606 let can_go_up = va_high_idx + 1 < num_bins;
607 let can_go_down = va_low_idx > 0;
608
609 if can_go_up && can_go_down {
610 let mut sum_up = histogram[va_high_idx + 1];
611 if va_high_idx + 2 < num_bins {
612 sum_up += histogram[va_high_idx + 2];
613 }
614
615 let mut sum_down = histogram[va_low_idx - 1];
616 if va_low_idx >= 2 {
617 sum_down += histogram[va_low_idx - 2];
618 }
619
620 if sum_up > sum_down {
621 va_high_idx += 1;
622 current_va_vol += histogram[va_high_idx];
623 if current_va_vol < target_vol && va_high_idx + 1 < num_bins {
624 va_high_idx += 1;
625 current_va_vol += histogram[va_high_idx];
626 }
627 } else if sum_down > sum_up {
628 va_low_idx -= 1;
629 current_va_vol += histogram[va_low_idx];
630 if current_va_vol < target_vol && va_low_idx > 0 {
631 va_low_idx -= 1;
632 current_va_vol += histogram[va_low_idx];
633 }
634 } else {
635 va_high_idx += 1;
636 va_low_idx -= 1;
637 current_va_vol += histogram[va_high_idx] + histogram[va_low_idx];
638 }
639 } else if can_go_up {
640 va_high_idx += 1;
641 current_va_vol += histogram[va_high_idx];
642 } else if can_go_down {
643 va_low_idx -= 1;
644 current_va_vol += histogram[va_low_idx];
645 } else {
646 break;
647 }
648 }
649 }
650 ValueAreaMethod::Symmetrical => {
651 while current_va_vol < target_vol && (va_low_idx > 0 || va_high_idx + 1 < num_bins) {
652 let mut added_any = false;
653 if va_high_idx + 1 < num_bins {
654 va_high_idx += 1;
655 current_va_vol += histogram[va_high_idx];
656 added_any = true;
657 }
658 if va_low_idx > 0 {
659 va_low_idx -= 1;
660 current_va_vol += histogram[va_low_idx];
661 added_any = true;
662 }
663 if !added_any {
664 break;
665 }
666 }
667 }
668 }
669
670 (va_low_idx, va_high_idx)
671}
672
673impl Next<(f64, f64, f64, f64)> for VolumeProfile {
679 type Output = VolumeProfileState;
680
681 #[inline]
682 fn next(&mut self, (high, low, close, volume): (f64, f64, f64, f64)) -> Self::Output {
683 self.next_bar((high, low, close, volume))
684 }
685}
686
687impl Next<(f64, f64, f64)> for VolumeProfile {
689 type Output = VolumeProfileState;
690
691 #[inline]
692 fn next(&mut self, (high, low, volume): (f64, f64, f64)) -> Self::Output {
693 self.next_bar((high, low, volume))
694 }
695}
696
697impl Next<BarData> for VolumeProfile {
699 type Output = VolumeProfileState;
700
701 #[inline]
702 fn next(&mut self, bar: BarData) -> Self::Output {
703 self.next_bar(bar)
704 }
705}
706
707impl Next<&BarData> for VolumeProfile {
709 type Output = VolumeProfileState;
710
711 #[inline]
712 fn next(&mut self, bar: &BarData) -> Self::Output {
713 self.next_bar(*bar)
714 }
715}
716
717impl Next<&(f64, f64, f64, f64)> for VolumeProfile {
719 type Output = VolumeProfileState;
720
721 #[inline]
722 fn next(&mut self, input: &(f64, f64, f64, f64)) -> Self::Output {
723 self.next_bar(*input)
724 }
725}
726
727impl Next<(f64, f64)> for VolumeProfile {
731 type Output = f64;
732
733 #[inline]
734 fn next(&mut self, (price, volume): (f64, f64)) -> Self::Output {
735 self.next_bar((price, volume)).poc
736 }
737}
738
739pub const VOLUME_PROFILE_METADATA: IndicatorMetadata = IndicatorMetadata {
744 name: "Volume Profile",
745 description: "Calculates price distribution of traded volume, Point of Control (POC), and Value Area High/Low (VAH/VAL) over a sliding window.",
746 usage: "Identifies institutional high-volume acceptance nodes (POC) and unfair value boundaries (VAH/VAL) for mean-reversion, breakout targets, and dynamic support/resistance in Auction Market Theory.",
747 keywords: &[
748 "volume",
749 "volume-profile",
750 "poc",
751 "point-of-control",
752 "vah",
753 "val",
754 "value-area",
755 "auction-market-theory",
756 "steidlmayer",
757 "market-profile",
758 "support-resistance",
759 ],
760 ehlers_summary: "Volume Profile displays trading activity over a rolling window at specified price levels. Originating from J. Peter Steidlmayer's Market Profile (1984), the Point of Control (POC) marks the price of maximum liquidity and acceptance, while the Value Area encompasses 70% of total volume traded to delineate fair market value.",
761 params: &[
762 ParamDef {
763 name: "period",
764 default: "200",
765 description: "Sliding window size in bars",
766 },
767 ParamDef {
768 name: "bins",
769 default: "50",
770 description: "Number of discrete price bins in the histogram",
771 },
772 ParamDef {
773 name: "value_area_pct",
774 default: "0.70",
775 description: "Target fraction of total window volume contained in the Value Area [0.0, 1.0]",
776 },
777 ],
778 formula_source: "https://www.tradingview.com/support/solutions/43000703076-visible-range-volume-profile/",
779 formula_latex: r#"
780\begin{aligned}
781\Delta P &= \frac{P_{\max} - P_{\min}}{N_{\text{bins}}} \\
782\text{Bin}(P) &= \min\left(N_{\text{bins}}-1, \; \left\lfloor \frac{P - P_{\min}}{\Delta P} \right\rfloor \right) \\
783\text{POC} &= P_{\min} + \left( \arg\max_{i} V_i + 0.5 \right) \cdot \Delta P \\
784\sum_{i=i_{\text{VAL}}}^{i_{\text{VAH}}} V_i &\ge \alpha \cdot V_{\text{total}}, \quad \text{where } \alpha = 0.70 \\
785\text{VAL} &= P_{\min} + i_{\text{VAL}} \cdot \Delta P \\
786\text{VAH} &= P_{\min} + (i_{\text{VAH}} + 1) \cdot \Delta P
787\end{aligned}
788"#,
789 gold_standard_file: "volume_profile.json",
790 category: "Volume",
791};
792
793#[cfg(test)]
798mod tests {
799 use super::*;
800 use approx::assert_relative_eq;
801 use proptest::prelude::*;
802
803 #[test]
804 fn test_volume_profile_basic_backward_compatibility() {
805 let mut vp = VolumeProfile::new(10, 5);
806 for _ in 0..5 {
808 vp.next((100.0, 10.0));
809 }
810 let res = vp.next((110.0, 5.0));
812 assert!((100.0..=105.0).contains(&res)); vp.next((110.0, 20.0));
816 vp.next((110.0, 20.0));
817 let res2 = vp.next((110.0, 20.0));
818 assert!(res2 >= 105.0); }
820
821 #[test]
822 fn test_single_bar_profile() {
823 let mut vp = VolumeProfile::new(10, 5);
824 let state = vp.next((105.0, 95.0, 100.0, 1000.0));
825
826 assert_eq!(state.bar_index, 0);
827 assert_relative_eq!(state.total_volume, 1000.0, epsilon = 1e-6);
828 assert_relative_eq!(state.profile_high, 105.0, epsilon = 1e-6);
829 assert_relative_eq!(state.profile_low, 95.0, epsilon = 1e-6);
830 assert!(state.val <= state.poc);
831 assert!(state.poc <= state.vah);
832 assert!(state.val >= state.profile_low);
833 assert!(state.vah <= state.profile_high);
834 }
835
836 #[test]
837 fn test_flat_market() {
838 let mut vp = VolumeProfile::new(5, 10);
839 for _ in 0..5 {
840 let state = vp.next((100.0, 100.0, 100.0, 50.0));
841 assert_relative_eq!(state.poc, 100.0, epsilon = 1e-6);
842 assert_relative_eq!(state.vah, 100.0, epsilon = 1e-6);
843 assert_relative_eq!(state.val, 100.0, epsilon = 1e-6);
844 assert_relative_eq!(state.profile_high, 100.0, epsilon = 1e-6);
845 assert_relative_eq!(state.profile_low, 100.0, epsilon = 1e-6);
846 }
847 }
848
849 #[test]
850 fn test_poc_selection_skewed_distribution() {
851 let mut vp = VolumeProfile::new(10, 10);
854
855 vp.next((101.0, 100.0, 100.5, 10.0));
857 vp.next((102.0, 101.0, 101.5, 10.0));
858 vp.next((103.0, 102.0, 102.5, 10.0));
859
860 vp.next((108.0, 107.0, 107.5, 500.0));
862
863 let state = vp.next((110.0, 109.0, 109.5, 10.0));
865
866 assert_relative_eq!(state.poc, 107.5, epsilon = 1e-6);
868 assert!(state.val <= state.poc);
869 assert!(state.poc <= state.vah);
870 assert!(state.is_inside_value_area(107.5));
871 }
872
873 #[test]
874 fn test_value_area_containment_invariant() {
875 let mut vp = VolumeProfile::new(20, 15).with_value_area_pct(0.70);
876
877 let data = [
878 (102.0, 98.0, 100.0, 150.0),
879 (104.0, 99.0, 103.0, 200.0),
880 (106.0, 101.0, 105.0, 350.0),
881 (108.0, 103.0, 107.0, 500.0),
882 (107.0, 102.0, 104.0, 400.0),
883 (105.0, 100.0, 101.0, 300.0),
884 (103.0, 97.0, 98.0, 250.0),
885 (101.0, 96.0, 99.0, 180.0),
886 (105.0, 98.0, 103.0, 220.0),
887 (110.0, 104.0, 109.0, 600.0),
888 ];
889
890 for (h, l, c, v) in data {
891 let state = vp.next((h, l, c, v));
892 assert!(
893 state.val <= state.poc,
894 "VAL ({}) must be <= POC ({})",
895 state.val,
896 state.poc
897 );
898 assert!(
899 state.poc <= state.vah,
900 "POC ({}) must be <= VAH ({})",
901 state.poc,
902 state.vah
903 );
904 assert!(
905 state.val >= state.profile_low,
906 "VAL ({}) must be >= profile_low ({})",
907 state.val,
908 state.profile_low
909 );
910 assert!(
911 state.vah <= state.profile_high,
912 "VAH ({}) must be <= profile_high ({})",
913 state.vah,
914 state.profile_high
915 );
916 assert!(state.value_area_width() >= 0.0);
917 assert!(state.profile_range() >= state.value_area_width());
918 }
919 }
920
921 #[test]
922 fn test_value_area_methods_comparison() {
923 let bars = vec![
924 (110.0, 100.0, 105.0, 100.0),
925 (115.0, 105.0, 110.0, 250.0),
926 (120.0, 110.0, 115.0, 500.0),
927 (125.0, 115.0, 120.0, 300.0),
928 (118.0, 108.0, 112.0, 150.0),
929 ];
930
931 let methods = [
932 ValueAreaMethod::Greedy,
933 ValueAreaMethod::SteidlmayerTwoRow,
934 ValueAreaMethod::Symmetrical,
935 ];
936
937 for method in methods {
938 let mut vp = VolumeProfile::new(10, 20)
939 .with_methods(method, VolumeAllocationMethod::ProportionalOverlap);
940
941 for &bar in &bars {
942 let state = vp.next(bar);
943 assert!(state.val <= state.poc);
944 assert!(state.poc <= state.vah);
945 }
946 }
947 }
948
949 #[test]
950 fn test_volume_allocation_methods() {
951 let alloc_methods = [
952 VolumeAllocationMethod::ProportionalOverlap,
953 VolumeAllocationMethod::UniformAcrossBins,
954 VolumeAllocationMethod::ClosePrice,
955 VolumeAllocationMethod::TypicalPrice,
956 ];
957
958 for alloc in alloc_methods {
959 let mut vp = VolumeProfile::new(5, 10).with_methods(ValueAreaMethod::Greedy, alloc);
960
961 let s1 = vp.next((110.0, 100.0, 105.0, 200.0));
962 assert_relative_eq!(s1.total_volume, 200.0, epsilon = 1e-6);
963
964 let s2 = vp.next((115.0, 105.0, 110.0, 300.0));
965 assert_relative_eq!(s2.total_volume, 500.0, epsilon = 1e-6);
966 }
967 }
968
969 #[test]
970 fn test_sliding_window_eviction() {
971 let period = 3;
972 let mut vp = VolumeProfile::new(period, 10);
973
974 vp.next((210.0, 200.0, 205.0, 100.0));
976 vp.next((210.0, 200.0, 205.0, 100.0));
977 let s3 = vp.next((210.0, 200.0, 205.0, 100.0));
978 assert!(s3.profile_low >= 200.0);
979
980 vp.next((110.0, 100.0, 105.0, 100.0));
982 vp.next((110.0, 100.0, 105.0, 100.0));
983 let s6 = vp.next((110.0, 100.0, 105.0, 100.0));
984
985 assert_relative_eq!(s6.profile_high, 110.0, epsilon = 1e-6);
987 assert_relative_eq!(s6.profile_low, 100.0, epsilon = 1e-6);
988 assert_relative_eq!(s6.total_volume, 300.0, epsilon = 1e-6);
989 }
990
991 #[test]
992 fn test_current_bins_inspection() {
993 let mut vp = VolumeProfile::new(10, 5);
994 vp.next((110.0, 100.0, 105.0, 100.0));
995
996 let bins = vp.current_bins();
997 assert_eq!(bins.len(), 5);
998
999 let total_binned_vol: f64 = bins.iter().map(|b| b.volume).sum();
1000 assert_relative_eq!(total_binned_vol, 100.0, epsilon = 1e-6);
1001
1002 for (i, b) in bins.iter().enumerate() {
1003 assert!(b.lower < b.upper);
1004 assert_relative_eq!(b.center, (b.lower + b.upper) * 0.5, epsilon = 1e-6);
1005 if i > 0 {
1006 assert_relative_eq!(b.lower, bins[i - 1].upper, epsilon = 1e-6);
1007 }
1008 }
1009 }
1010
1011 #[test]
1012 fn test_convenience_trait_inputs() {
1013 let mut vp = VolumeProfile::new(5, 10);
1014
1015 let s4 = vp.next((105.0, 95.0, 100.0, 50.0));
1017 assert_eq!(s4.bar_index, 0);
1018
1019 let s3 = vp.next((106.0, 96.0, 50.0));
1021 assert_eq!(s3.bar_index, 1);
1022
1023 let bar = BarData::new(107.0, 97.0, 102.0, 60.0);
1025 let sb = vp.next(bar);
1026 assert_eq!(sb.bar_index, 2);
1027
1028 let sref = vp.next(&bar);
1030 assert_eq!(sref.bar_index, 3);
1031 }
1032
1033 #[test]
1034 fn test_reset() {
1035 let mut vp = VolumeProfile::new(5, 10);
1036 vp.next((105.0, 95.0, 100.0, 50.0));
1037 assert_eq!(vp.window_len(), 1);
1038 assert_eq!(vp.bar_count(), 1);
1039
1040 vp.reset();
1041 assert_eq!(vp.window_len(), 0);
1042 assert_eq!(vp.bar_count(), 0);
1043 assert!(vp.current_bins().is_empty());
1044 }
1045
1046 fn volume_profile_batch(
1048 bars: &[(f64, f64, f64, f64)],
1049 period: usize,
1050 bins: usize,
1051 ) -> Vec<VolumeProfileState> {
1052 let mut vp = VolumeProfile::new(period, bins);
1053 bars.iter().map(|&bar| vp.next(bar)).collect()
1054 }
1055
1056 proptest! {
1057 #![proptest_config(ProptestConfig::with_cases(50))]
1058
1059 #[test]
1060 fn proptest_streaming_batch_parity(
1061 bars in prop::collection::vec(
1062 (50.0..150.0, 1.0..50.0, 0.0..100.0, 10.0..1000.0)
1063 .prop_map(|(base, span, offset, vol)| {
1064 let low = base;
1065 let high = base + span;
1066 let close = low + (high - low) * (offset / 100.0);
1067 (high, low, close, vol)
1068 }),
1069 1..60
1070 )
1071 ) {
1072 let period = 10;
1073 let bins = 20;
1074
1075 let batch_results = volume_profile_batch(&bars, period, bins);
1077
1078 let mut streaming_vp = VolumeProfile::new(period, bins);
1080 let mut streaming_results = Vec::with_capacity(bars.len());
1081 for &bar in &bars {
1082 streaming_results.push(streaming_vp.next(bar));
1083 }
1084
1085 prop_assert_eq!(batch_results.len(), streaming_results.len());
1087 for (b, s) in batch_results.iter().zip(streaming_results.iter()) {
1088 prop_assert_eq!(b, s);
1089
1090 prop_assert!(s.val <= s.poc, "VAL ({}) must be <= POC ({})", s.val, s.poc);
1092 prop_assert!(s.poc <= s.vah, "POC ({}) must be <= VAH ({})", s.poc, s.vah);
1093 prop_assert!(s.val >= s.profile_low - 1e-6);
1094 prop_assert!(s.vah <= s.profile_high + 1e-6);
1095 prop_assert!(s.total_volume >= 0.0);
1096 }
1097 }
1098 }
1099}