Skip to main content

quantwave_core/indicators/
volume_profile.rs

1//! Levels & Auction Theory: Volume Profile (POC, VAH, VAL)
2//!
3//! # Concept & Theoretical Foundation
4//! Volume Profile is an Auction Market Theory study originally conceived by
5//! J. Peter Steidlmayer (1984, Chicago Board of Trade Market Profile) and later
6//! adapted into modern volume-at-price histograms.
7//!
8//! While standard volume indicators plot volume over time (x-axis), Volume Profile
9//! plots traded volume across discrete price buckets (y-axis) over a rolling lookback
10//! window. This reveals:
11//! - **Point of Control (POC)**: The price level with the highest traded volume in
12//!   the window, representing maximum market consensus / acceptance.
13//! - **Value Area (VA)**: The price range surrounding the POC that contains a specified
14//!   percentage of total volume (conventionally 70%, reflecting one standard deviation
15//!   in a normal distribution).
16//! - **Value Area High (VAH)**: The upper boundary of the Value Area.
17//! - **Value Area Low (VAL)**: The lower boundary of the Value Area.
18//! - **Profile High / Low**: The highest and lowest prices traded across the window.
19//!
20//! # Formula References
21//! - J. Peter Steidlmayer & Kevin Koy (1986). *Markets & Market Logic*.
22//! - CBOT Market Profile Handbook (1984, CME Group).
23//! - TradingView Visible Range Volume Profile (VPVR):
24//!   <https://www.tradingview.com/support/solutions/43000703076-visible-range-volume-profile/>
25
26use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
27use crate::traits::Next;
28use serde::{Deserialize, Serialize};
29use std::collections::VecDeque;
30
31/// Calculation method used to expand the Value Area (VAH and VAL) from the Point of Control.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
33pub enum ValueAreaMethod {
34    /// Modern greedy single-bin comparison (standard in TradingView and modern platforms).
35    ///
36    /// Starting at the POC bin, compares the volume of the next adjacent bin above with
37    /// the next adjacent bin below. Expands into whichever has higher volume until the
38    /// accumulated volume reaches the target percentage (`value_area_pct`).
39    #[default]
40    Greedy,
41    /// Steidlmayer classic two-row comparison (CBOT Market Profile 1986).
42    ///
43    /// Compares the sum of volume in the next two rows above versus the next two rows below.
44    /// Expands into the side with greater combined volume.
45    SteidlmayerTwoRow,
46    /// Symmetrical expansion in both directions simultaneously from the POC.
47    Symmetrical,
48}
49
50/// Strategy for allocating a bar's volume across overlapping price bins.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
52pub enum VolumeAllocationMethod {
53    /// Allocates volume proportionally to the exact geometric overlap between the bar's
54    /// `[low, high]` price range and each price bin.
55    #[default]
56    ProportionalOverlap,
57    /// Distributes volume equally among all bins overlapped by `[low, high]`.
58    UniformAcrossBins,
59    /// Allocates the bar's entire volume into the price bin containing the close price.
60    ClosePrice,
61    /// Allocates the bar's entire volume into the price bin containing the typical price `(high + low + close) / 3`.
62    TypicalPrice,
63}
64
65/// A single OHLCV bar input for Volume Profile computation.
66#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
67pub struct BarData {
68    /// Bar high price.
69    pub high: f64,
70    /// Bar low price.
71    pub low: f64,
72    /// Bar close price.
73    pub close: f64,
74    /// Bar volume.
75    pub volume: f64,
76}
77
78impl BarData {
79    /// Creates a new `BarData` instance with validated prices and volume.
80    ///
81    /// Ensures `high >= low` and volume is non-negative and finite.
82    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/// A discrete price bin within the volume profile histogram.
132#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
133pub struct PriceBin {
134    /// Lower price bound of the bin (inclusive).
135    pub lower: f64,
136    /// Upper price bound of the bin (exclusive, except for uppermost bin).
137    pub upper: f64,
138    /// Midpoint / center price of the bin.
139    pub center: f64,
140    /// Accumulated volume in this bin.
141    pub volume: f64,
142}
143
144/// Output state emitted by `VolumeProfile` for each bar.
145#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
146pub struct VolumeProfileState {
147    /// Point of Control (POC): center price of the bin with the highest volume.
148    pub poc: f64,
149    /// Value Area High (VAH): upper price boundary of the Value Area.
150    pub vah: f64,
151    /// Value Area Low (VAL): lower price boundary of the Value Area.
152    pub val: f64,
153    /// Highest price observed in the rolling window.
154    pub profile_high: f64,
155    /// Lowest price observed in the rolling window.
156    pub profile_low: f64,
157    /// Total traded volume accumulated across the rolling window.
158    pub total_volume: f64,
159    /// Zero-based index of the most recently processed bar.
160    pub bar_index: usize,
161}
162
163impl VolumeProfileState {
164    /// Creates an empty/uninitialized state with `NaN` prices and zero volume.
165    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    /// Price span of the Value Area (`vah - val`).
178    #[inline]
179    pub fn value_area_width(&self) -> f64 {
180        (self.vah - self.val).max(0.0)
181    }
182
183    /// Price span of the entire profile (`profile_high - profile_low`).
184    #[inline]
185    pub fn profile_range(&self) -> f64 {
186        (self.profile_high - self.profile_low).max(0.0)
187    }
188
189    /// Returns `true` if `price` is currently inside the Value Area `[val, vah]`.
190    #[inline]
191    pub fn is_inside_value_area(&self, price: f64) -> bool {
192        price >= self.val && price <= self.vah
193    }
194}
195
196/// Auction Market Theory Volume Profile indicator.
197///
198/// Tracks volume distribution across price bins over a rolling lookback window.
199#[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    /// Creates a new `VolumeProfile` with default 70% Value Area and Greedy expansion.
212    ///
213    /// # Arguments
214    /// * `period` - Sliding window size in bars (minimum 1).
215    /// * `bins` - Number of price buckets in the histogram (minimum 1).
216    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    /// Sets the target percentage of total volume to include in the Value Area.
230    ///
231    /// Value is clamped between 0.0 and 1.0 (default 0.70).
232    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    /// Sets the Value Area expansion method and volume allocation method.
238    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    /// Creates a `VolumeProfile` with full parameter configuration.
249    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    /// Sliding window size in bars.
262    #[inline]
263    pub fn period(&self) -> usize {
264        self.period
265    }
266
267    /// Number of histogram price bins.
268    #[inline]
269    pub fn bins(&self) -> usize {
270        self.bins
271    }
272
273    /// Configured Value Area percentage (e.g. 0.70).
274    #[inline]
275    pub fn value_area_pct(&self) -> f64 {
276        self.value_area_pct
277    }
278
279    /// Configured Value Area expansion method.
280    #[inline]
281    pub fn value_area_method(&self) -> ValueAreaMethod {
282        self.va_method
283    }
284
285    /// Configured volume allocation method.
286    #[inline]
287    pub fn allocation_method(&self) -> VolumeAllocationMethod {
288        self.alloc_method
289    }
290
291    /// Number of bars currently in the rolling window.
292    #[inline]
293    pub fn window_len(&self) -> usize {
294        self.window.len()
295    }
296
297    /// Total number of bars ingested since initialization or last reset.
298    #[inline]
299    pub fn bar_count(&self) -> usize {
300        self.bar_count
301    }
302
303    /// Returns `true` if the rolling window has reached full capacity (`>= period`).
304    #[inline]
305    pub fn is_ready(&self) -> bool {
306        self.window.len() >= self.period
307    }
308
309    /// Resets the sliding window and bar counter to initial state.
310    pub fn reset(&mut self) {
311        self.window.clear();
312        self.bar_count = 0;
313    }
314
315    /// Ingests a new bar and computes the updated `VolumeProfileState`.
316    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        // Find min low, max high, and total volume across window
331        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        // Handle flat market edge-case (min_low == max_high)
350        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        // Allocate each bar's volume into histogram bins
366        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        // Identify POC (bin with maximum volume)
378        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        // Calculate Value Area (VAH and VAL)
390        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        // Invariant guard: val <= poc <= vah unconditionally
402        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    /// Computes and returns the full discrete price bins for the current window.
417    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
476/// Helper function to allocate a bar's volume across price bins.
477fn 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                // Point-like bar: allocate to the single bin containing the midpoint
495                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
550/// Helper function to determine the lower and upper bin indices of the Value Area.
551fn 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                        // Tie: expand in both directions
589                        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
673// =============================================================================
674// Universal Indicator Next Implementations
675// =============================================================================
676
677/// Full OHLCV input: `(high, low, close, volume)`.
678impl 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
687/// Convenience HLV input: `(high, low, volume)`. Close defaults to `(high + low) / 2`.
688impl 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
697/// Direct `BarData` input.
698impl 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
707/// Reference `&BarData` input.
708impl 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
717/// Reference `&(f64, f64, f64, f64)` input.
718impl 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
727/// Legacy single price & volume input: `(price, volume)`.
728///
729/// Returns the Point of Control (POC) as `f64` for backward compatibility with PyO3 bindings.
730impl 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
739// =============================================================================
740// Metadata Registry Constant
741// =============================================================================
742
743pub 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// =============================================================================
794// Tests & Proptests
795// =============================================================================
796
797#[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        // All volume at 100
807        for _ in 0..5 {
808            vp.next((100.0, 10.0));
809        }
810        // Some volume at 110
811        let res = vp.next((110.0, 5.0));
812        assert!((100.0..=105.0).contains(&res)); // POC should still be in the 100 bin
813
814        // More volume at 110
815        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); // POC should shift to higher bin
819    }
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        // 10 bins over range [100, 110]. Bins are width 1.0:
852        // [100,101), [101,102), ..., [107,108), [108,109), [109,110]
853        let mut vp = VolumeProfile::new(10, 10);
854
855        // Put light volume across lower prices
856        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        // Put massive volume around [107, 108]
861        vp.next((108.0, 107.0, 107.5, 500.0));
862
863        // Put light volume at top
864        let state = vp.next((110.0, 109.0, 109.5, 10.0));
865
866        // POC center should be in bin [107, 108], i.e., 107.5
867        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        // Bars 1..3 in high range [200, 210]
975        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        // Bars 4..6 in low range [100, 110]
981        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        // The old bars [200, 210] must have been evicted from the sliding window
986        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        // Test 4-tuple
1016        let s4 = vp.next((105.0, 95.0, 100.0, 50.0));
1017        assert_eq!(s4.bar_index, 0);
1018
1019        // Test 3-tuple
1020        let s3 = vp.next((106.0, 96.0, 50.0));
1021        assert_eq!(s3.bar_index, 1);
1022
1023        // Test BarData struct
1024        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        // Test &BarData
1029        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    // Batch simulation function for parity assertion
1047    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            // Batch run
1076            let batch_results = volume_profile_batch(&bars, period, bins);
1077
1078            // Streaming run
1079            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            // Assert bit-for-bit parity
1086            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                // Invariant checks
1091                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}