use std::collections::VecDeque;
use crate::artifact::{ProfileArtifact, ProfileBin, ZoneArtifact};
use crate::intrabar::IntrabarGroup;
use crate::model::Bar;
use super::volume_flow_hires::estimate_aggressor_from_ohlc;
use super::{Indicator, IndicatorAlert, IndicatorOutput};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VolumeNodeClass {
Hvn,
Avn,
Lvn,
}
pub struct ExtendedVolumeProfileEngine {
lookback: usize,
num_bins: usize,
hvn_multiplier: f64,
lvn_multiplier: f64,
records: VecDeque<Bar>,
alerts: Vec<IndicatorAlert>,
}
impl ExtendedVolumeProfileEngine {
pub fn new(lookback: usize, num_bins: usize) -> Self {
Self {
lookback: lookback.max(1),
num_bins: num_bins.max(1),
hvn_multiplier: 1.5,
lvn_multiplier: 0.5,
records: VecDeque::new(),
alerts: Vec::new(),
}
}
pub fn with_thresholds(mut self, hvn_multiplier: f64, lvn_multiplier: f64) -> Self {
self.hvn_multiplier = hvn_multiplier;
self.lvn_multiplier = lvn_multiplier;
self
}
fn push_record(&mut self, bar: Bar) {
if self.records.len() >= self.lookback {
self.records.pop_front();
}
self.records.push_back(bar);
}
pub fn on_intrabar_group(&mut self, group: &IntrabarGroup) -> Option<IndicatorOutput> {
for child in &group.children {
self.push_record(child.clone());
}
self.compute()
}
fn compute(&mut self) -> Option<IndicatorOutput> {
self.alerts.clear();
if self.records.len() < self.lookback {
return None;
}
let mut min_p = f64::MAX;
let mut max_p = f64::MIN;
for b in &self.records {
min_p = min_p.min(b.low);
max_p = max_p.max(b.high);
}
if (max_p - min_p).abs() < 1e-8 {
return None;
}
let step = (max_p - min_p) / self.num_bins as f64;
let mut volumes = vec![0.0f64; self.num_bins];
let mut buy_volumes = vec![0.0f64; self.num_bins];
let mut sell_volumes = vec![0.0f64; self.num_bins];
for b in &self.records {
let bar_vol = if b.volume > 0.0 {
b.volume
} else {
b.high - b.low
};
let aggressor = estimate_aggressor_from_ohlc(b);
let (buy_frac, sell_frac) = if b.volume > 0.0 {
(
aggressor.buy_volume / b.volume,
aggressor.sell_volume / b.volume,
)
} else {
(0.5, 0.5)
};
let b_start = (((b.low - min_p) / step).floor() as usize).min(self.num_bins - 1);
let b_end = (((b.high - min_p) / step).floor() as usize).min(self.num_bins - 1);
let bin_count = (b_end - b_start + 1) as f64;
let vol_per_bin = bar_vol / bin_count;
for bin_idx in b_start..=b_end {
volumes[bin_idx] += vol_per_bin;
buy_volumes[bin_idx] += vol_per_bin * buy_frac;
sell_volumes[bin_idx] += vol_per_bin * sell_frac;
}
}
let total_vol: f64 = volumes.iter().sum();
let mean_bin_vol = total_vol / self.num_bins as f64;
let classify = |v: f64| -> VolumeNodeClass {
if v >= mean_bin_vol * self.hvn_multiplier {
VolumeNodeClass::Hvn
} else if v <= mean_bin_vol * self.lvn_multiplier {
VolumeNodeClass::Lvn
} else {
VolumeNodeClass::Avn
}
};
let classes: Vec<VolumeNodeClass> = volumes.iter().map(|&v| classify(v)).collect();
let (poc_idx, _) =
volumes
.iter()
.enumerate()
.fold(
(0usize, 0.0f64),
|(bi, bv), (i, &v)| {
if v > bv {
(i, v)
} else {
(bi, bv)
}
},
);
let poc_price = min_p + (poc_idx as f64 + 0.5) * step;
let target_vol = total_vol * 0.70;
let mut accumulated = volumes[poc_idx];
let mut val_idx = poc_idx;
let mut vah_idx = poc_idx;
while accumulated < target_vol && (val_idx > 0 || vah_idx < self.num_bins - 1) {
let next_down = if val_idx > 0 {
volumes[val_idx - 1]
} else {
-1.0
};
let next_up = if vah_idx < self.num_bins - 1 {
volumes[vah_idx + 1]
} else {
-1.0
};
if next_up >= next_down && vah_idx < self.num_bins - 1 {
vah_idx += 1;
accumulated += volumes[vah_idx];
} else if val_idx > 0 {
val_idx -= 1;
accumulated += volumes[val_idx];
} else if vah_idx < self.num_bins - 1 {
vah_idx += 1;
accumulated += volumes[vah_idx];
}
}
let vah_price = min_p + (vah_idx as f64 + 1.0) * step;
let val_price = min_p + val_idx as f64 * step;
let bin_price = |i: usize| ProfileBin {
price_low: min_p + i as f64 * step,
price_high: min_p + (i as f64 + 1.0) * step,
value: volumes[i],
};
let bins: Vec<ProfileBin> = (0..self.num_bins).map(bin_price).collect();
let delta_bins: Vec<ProfileBin> = (0..self.num_bins)
.map(|i| ProfileBin {
price_low: min_p + i as f64 * step,
price_high: min_p + (i as f64 + 1.0) * step,
value: buy_volumes[i] - sell_volumes[i],
})
.collect();
let mut zones = Vec::new();
let mut i = 0;
while i < self.num_bins {
let class = classes[i];
if class == VolumeNodeClass::Avn {
i += 1;
continue;
}
let start = i;
while i < self.num_bins && classes[i] == class {
i += 1;
}
let end = i;
let zone_vol: f64 = volumes[start..end].iter().sum();
zones.push(ZoneArtifact {
kind: match class {
VolumeNodeClass::Hvn => "hvn_zone".to_string(),
VolumeNodeClass::Lvn => "lvn_zone".to_string(),
VolumeNodeClass::Avn => unreachable!("filtered above"),
},
price_top: min_p + end as f64 * step,
price_bottom: min_p + start as f64 * step,
strength: if total_vol > 0.0 {
zone_vol / total_vol
} else {
0.0
},
touches: 0,
});
}
let profile_artifact = ProfileArtifact {
kind: "volume_profile".to_string(),
bins,
poc: poc_price,
value_area_high: vah_price,
value_area_low: val_price,
};
let delta_artifact = ProfileArtifact {
kind: "delta_profile".to_string(),
bins: delta_bins,
poc: poc_price,
value_area_high: vah_price,
value_area_low: val_price,
};
let mut output = IndicatorOutput::new(poc_price)
.with_artifact(profile_artifact)
.with_artifact(delta_artifact);
for zone in zones {
output = output.with_artifact(zone);
}
Some(output)
}
}
impl Indicator for ExtendedVolumeProfileEngine {
fn name(&self) -> &str {
"extended_volume_profile"
}
fn warmup_period(&self) -> usize {
self.lookback
}
fn reset(&mut self) {
self.records.clear();
self.alerts.clear();
}
fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
self.push_record(bar.clone());
self.compute()
}
fn alerts(&self) -> Vec<IndicatorAlert> {
self.alerts.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bar_at(price: f64, volume: f64) -> Bar {
Bar::new(0, price, price + 1.0, price - 1.0, price, volume)
}
fn narrow_bar(price: f64, volume: f64) -> Bar {
Bar::new(0, price, price + 0.4, price - 0.4, price, volume)
}
#[test]
fn test_classifies_hvn_and_lvn_bins() {
let mut engine = ExtendedVolumeProfileEngine::new(10, 4).with_thresholds(1.5, 0.5);
for _ in 0..8 {
engine.on_bar(&narrow_bar(95.0, 500.0));
}
engine.on_bar(&bar_at(80.0, 1.0));
let out = engine.on_bar(&bar_at(120.0, 1.0)).unwrap();
let profile = out
.artifacts
.iter()
.find_map(|a| match a {
crate::artifact::Artifact::Profile(p) if p.kind == "volume_profile" => Some(p),
_ => None,
})
.expect("volume_profile artifact must be present");
assert_eq!(profile.bins.len(), 4);
let max_bin_volume = profile.bins.iter().map(|b| b.value).fold(0.0, f64::max);
let total: f64 = profile.bins.iter().map(|b| b.value).sum();
assert!(max_bin_volume / total > 0.5);
}
#[test]
fn test_delta_profile_reflects_buy_sell_split() {
let mut engine = ExtendedVolumeProfileEngine::new(3, 2);
engine.on_bar(&Bar::new(0, 100.0, 102.0, 98.0, 102.0, 100.0));
engine.on_bar(&Bar::new(60, 100.0, 102.0, 98.0, 102.0, 100.0));
let out = engine
.on_bar(&Bar::new(120, 100.0, 102.0, 98.0, 102.0, 100.0))
.unwrap();
let delta = out
.artifacts
.iter()
.find_map(|a| match a {
crate::artifact::Artifact::Profile(p) if p.kind == "delta_profile" => Some(p),
_ => None,
})
.unwrap();
let total_delta: f64 = delta.bins.iter().map(|b| b.value).sum();
assert!(
total_delta > 0.0,
"close-at-high bars must skew delta positive"
);
}
#[test]
fn test_zones_formed_from_contiguous_hvn_bins() {
let mut engine = ExtendedVolumeProfileEngine::new(8, 5).with_thresholds(1.5, 0.5);
engine.on_bar(&bar_at(50.0, 1.0));
for _ in 0..6 {
engine.on_bar(&narrow_bar(100.0, 1000.0));
}
let out = engine.on_bar(&bar_at(150.0, 1.0)).unwrap();
let zones: Vec<_> = out
.artifacts
.iter()
.filter_map(|a| match a {
crate::artifact::Artifact::Zone(z) => Some(z),
_ => None,
})
.collect();
assert!(
!zones.is_empty(),
"a tight volume cluster must form at least one zone"
);
assert!(zones.iter().any(|z| z.kind == "hvn_zone"));
}
#[test]
fn test_none_until_lookback_filled() {
let mut engine = ExtendedVolumeProfileEngine::new(5, 4);
for _ in 0..4 {
assert!(engine.on_bar(&bar_at(100.0, 100.0)).is_none());
}
assert!(engine.on_bar(&bar_at(100.0, 100.0)).is_some());
}
}