feat(family-16): add ValueArea + InitialBalance + OpeningRange (#52)

* feat(family-16): add ValueArea + InitialBalance + OpeningRange

Opens family #16 (Market Profile) with the three OHLCV-compatible scalar /
multi-output indicators:

- ValueArea(period, bin_count, value_area_pct) -> {poc, vah, val}.
  Rolling bin-approximation volume profile over the last `period`
  candles. Each candle's volume is spread uniformly across [low, high];
  POC is the bin with highest cumulative volume; the value area expands
  symmetrically from POC and always absorbs the higher-volume neighbour
  next, until `value_area_pct` (default 0.70) of total volume is
  enclosed. Defaults (20, 50, 0.70).

- InitialBalance(period) -> {high, low}. Tracks session-opening high
  and low over the first `period` bars, then locks. Default period = 12
  (one-hour IB on 5-minute bars for US equities). Callers MUST invoke
  reset() at every session boundary, otherwise IB stays fixed for the
  lifetime of the instance.

- OpeningRange(period) -> {high, low, breakout_distance}. Same
  lock-after-N-bars semantics as IB with a shorter default period
  (6 = 30 min on 5-minute bars) and a third output that tracks
  close - or_mid (positive above the range mid, negative below).

Histogram-output Market Profile variants (Volume Profile, VPVR,
Composite Profile) are deferred because they need a new histogram
output API layer rather than fixed-arity scalars. Tick-data-only
variants (TPO Profile, Single Print, Order Flow Delta, Cumulative
Delta, Volume-Weighted Open) are out of scope because `wickra-data`
does not currently expose tick / L2 data.

All four bindings (Rust core, Python, Node, WASM) ship the new
indicators with parity tests; benches added; fuzz target extended.
Counter 71 -> 74 across 8 -> 9 families. cargo check --workspace
--all-features green.

* fix(family-16): cover cold paths in InitialBalance + ValueArea

InitialBalance::value() public getter had no test covering the post-update
Some(...) branch — extended accessors_and_metadata to call value() after one
update. ValueArea single-print bar path (c.high == c.low) was unreachable in
existing tests since the only single-print test used a uniform 100-price
window which exits early via the span == 0 guard; added a mixed-window test
that triggers the c.high <= c.low branch directly. The (None, None) arm of
the expansion match was by-construction unreachable (the loop condition
already requires at least one neighbour) and has been folded into an
if/else.
This commit is contained in:
kingchenc
2026-05-26 00:14:30 +02:00
committed by GitHub
parent 05fcdd9a5e
commit 9b8e1346ed
20 changed files with 1946 additions and 35 deletions
@@ -0,0 +1,254 @@
//! Initial Balance (IB): the high / low established over the first N bars of
//! a session.
//!
//! Tracks the running session high and session low across the first `period`
//! candles received since construction or [`InitialBalance::reset`]. Once the
//! `period`th candle has been ingested the value is frozen and every
//! subsequent call to [`Indicator::update`] returns the same locked
//! [`InitialBalanceOutput`] until the caller invokes `reset()` at the start of
//! a new session.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Initial Balance output: the high / low of the first N bars of a session.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct InitialBalanceOutput {
/// Session-opening high established over the IB window.
pub high: f64,
/// Session-opening low established over the IB window.
pub low: f64,
}
/// Session Initial Balance (first N bars).
///
/// `period` defaults to **12** — the canonical one-hour IB on 5-minute bars
/// for U.S. equities. Callers MUST invoke [`Indicator::reset`] at every new
/// session boundary; otherwise the IB locks after the first `period` bars and
/// stays fixed for the entire lifetime of the instance.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, InitialBalance, Indicator};
///
/// let mut ib = InitialBalance::new(3).unwrap();
/// let bars = [
/// Candle::new(100.0, 102.0, 99.0, 101.0, 10.0, 0).unwrap(),
/// Candle::new(101.0, 103.0, 100.0, 102.0, 10.0, 1).unwrap(),
/// Candle::new(102.0, 104.0, 101.0, 103.0, 10.0, 2).unwrap(),
/// // Locked after period bars — subsequent bars do not modify IB.
/// Candle::new(103.0, 120.0, 80.0, 105.0, 10.0, 3).unwrap(),
/// ];
/// for b in bars {
/// ib.update(b);
/// }
/// let v = ib.value().unwrap();
/// assert_eq!(v.high, 104.0);
/// assert_eq!(v.low, 99.0);
/// ```
#[derive(Debug, Clone)]
pub struct InitialBalance {
period: usize,
bars_seen: usize,
high: f64,
low: f64,
locked: bool,
}
impl InitialBalance {
/// Construct an Initial Balance indicator with the given window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
bars_seen: 0,
high: f64::NEG_INFINITY,
low: f64::INFINITY,
locked: false,
})
}
/// Classic 12-bar Initial Balance.
pub fn classic() -> Self {
Self::new(12).expect("classic IB period is valid")
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Most recent output if at least one bar has been seen.
pub fn value(&self) -> Option<InitialBalanceOutput> {
if self.bars_seen == 0 {
None
} else {
Some(InitialBalanceOutput {
high: self.high,
low: self.low,
})
}
}
/// True once `period` bars have been ingested and the IB is locked.
pub const fn is_locked(&self) -> bool {
self.locked
}
}
impl Indicator for InitialBalance {
type Input = Candle;
type Output = InitialBalanceOutput;
fn update(&mut self, candle: Candle) -> Option<InitialBalanceOutput> {
if self.locked {
return Some(InitialBalanceOutput {
high: self.high,
low: self.low,
});
}
if candle.high > self.high {
self.high = candle.high;
}
if candle.low < self.low {
self.low = candle.low;
}
self.bars_seen += 1;
if self.bars_seen >= self.period {
self.locked = true;
}
Some(InitialBalanceOutput {
high: self.high,
low: self.low,
})
}
fn reset(&mut self) {
self.bars_seen = 0;
self.high = f64::NEG_INFINITY;
self.low = f64::INFINITY;
self.locked = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.bars_seen > 0
}
fn name(&self) -> &'static str {
"InitialBalance"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, ts: i64) -> Candle {
// open / close pinned inside [low, high] so the candle validates.
let mid = f64::midpoint(high, low);
Candle::new(mid, high, low, mid, 10.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(InitialBalance::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut ib = InitialBalance::new(12).unwrap();
assert_eq!(ib.period(), 12);
assert_eq!(ib.name(), "InitialBalance");
assert_eq!(ib.warmup_period(), 1);
assert!(ib.value().is_none());
assert!(!ib.is_locked());
// After the first bar, value() returns Some with that bar's H/L.
ib.update(c(102.0, 100.0, 0));
let v = ib.value().unwrap();
assert_relative_eq!(v.high, 102.0);
assert_relative_eq!(v.low, 100.0);
}
#[test]
fn classic_is_constructible() {
let ib = InitialBalance::classic();
assert_eq!(ib.period(), 12);
}
#[test]
fn tracks_high_low_during_window() {
let mut ib = InitialBalance::new(3).unwrap();
let o1 = ib.update(c(102.0, 100.0, 0)).unwrap();
assert_relative_eq!(o1.high, 102.0);
assert_relative_eq!(o1.low, 100.0);
let o2 = ib.update(c(105.0, 99.0, 1)).unwrap();
assert_relative_eq!(o2.high, 105.0);
assert_relative_eq!(o2.low, 99.0);
let o3 = ib.update(c(103.0, 99.5, 2)).unwrap();
assert_relative_eq!(o3.high, 105.0);
assert_relative_eq!(o3.low, 99.0);
assert!(ib.is_locked());
}
#[test]
fn locks_after_period_and_ignores_subsequent_bars() {
let mut ib = InitialBalance::new(2).unwrap();
ib.update(c(102.0, 100.0, 0));
ib.update(c(103.0, 101.0, 1));
assert!(ib.is_locked());
// Wide bar after lock must not modify the IB.
let after = ib.update(c(200.0, 50.0, 2)).unwrap();
assert_relative_eq!(after.high, 103.0);
assert_relative_eq!(after.low, 100.0);
}
#[test]
fn reset_unlocks_and_clears_state() {
let mut ib = InitialBalance::new(2).unwrap();
ib.update(c(102.0, 100.0, 0));
ib.update(c(103.0, 101.0, 1));
assert!(ib.is_locked());
ib.reset();
assert!(!ib.is_locked());
assert!(!ib.is_ready());
// After reset the next session's first bar drives the IB anew.
let o = ib.update(c(50.0, 49.0, 2)).unwrap();
assert_relative_eq!(o.high, 50.0);
assert_relative_eq!(o.low, 49.0);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..20)
.map(|i| c(100.0 + i as f64, 99.0 + i as f64 * 0.5, i))
.collect();
let mut a = InitialBalance::new(5).unwrap();
let mut b = InitialBalance::new(5).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn is_ready_after_first_bar() {
let mut ib = InitialBalance::new(5).unwrap();
assert!(!ib.is_ready());
ib.update(c(101.0, 99.0, 0));
assert!(ib.is_ready());
}
}
+6
View File
@@ -75,6 +75,7 @@ mod hurst_channel;
mod hurst_exponent;
mod ichimoku;
mod inertia;
mod initial_balance;
mod instantaneous_trendline;
mod inverse_fisher_transform;
mod jma;
@@ -101,6 +102,7 @@ mod mom;
mod natr;
mod nvi;
mod obv;
mod opening_range;
mod parkinson;
mod pearson_correlation;
mod percent_b;
@@ -159,6 +161,7 @@ mod ttm_squeeze;
mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
mod value_area;
mod variance;
mod vertical_horizontal_filter;
mod vidya;
@@ -254,6 +257,7 @@ pub use hurst_channel::{HurstChannel, HurstChannelOutput};
pub use hurst_exponent::HurstExponent;
pub use ichimoku::{Ichimoku, IchimokuOutput};
pub use inertia::Inertia;
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
pub use instantaneous_trendline::InstantaneousTrendline;
pub use inverse_fisher_transform::InverseFisherTransform;
pub use jma::Jma;
@@ -280,6 +284,7 @@ pub use mom::Mom;
pub use natr::Natr;
pub use nvi::Nvi;
pub use obv::Obv;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use parkinson::ParkinsonVolatility;
pub use pearson_correlation::PearsonCorrelation;
pub use percent_b::PercentB;
@@ -338,6 +343,7 @@ pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
pub use value_area::{ValueArea, ValueAreaOutput};
pub use variance::Variance;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vidya::Vidya;
@@ -0,0 +1,270 @@
//! Opening Range (OR): high / low of the first N session bars plus the
//! current bar's breakout distance from the range midpoint.
//!
//! Conceptually identical to [`crate::InitialBalance`] but with two
//! differences: the default window is shorter (6 = 30 min on 5-minute bars)
//! and the output carries a third field, `breakout_distance`, which is the
//! signed distance from the current candle's close to the range midpoint —
//! positive for breakouts above the OR, negative for breakdowns. Callers
//! MUST invoke [`Indicator::reset`] at every new session boundary to start
//! a fresh OR.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Opening Range output: high, low and breakout distance from the OR midpoint.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OpeningRangeOutput {
/// Session-opening high established over the OR window.
pub high: f64,
/// Session-opening low established over the OR window.
pub low: f64,
/// Current bar's close minus the OR midpoint. Positive once price
/// trades above the range mid, negative below.
pub breakout_distance: f64,
}
/// Session Opening Range (first N bars + breakout distance).
///
/// `period` defaults to **6** — the canonical 30-minute opening range on
/// 5-minute bars. Callers MUST invoke [`Indicator::reset`] at session
/// boundaries; otherwise the OR locks after the first `period` bars and
/// stays fixed for the remainder of the instance's life.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, OpeningRange};
///
/// let mut or = OpeningRange::new(2).unwrap();
/// let bars = [
/// Candle::new(100.0, 102.0, 99.0, 101.0, 10.0, 0).unwrap(),
/// Candle::new(101.0, 103.0, 100.0, 102.0, 10.0, 1).unwrap(),
/// // Now locked — breakout distance reflects close - (high + low) / 2.
/// Candle::new(102.0, 110.0, 102.0, 105.0, 10.0, 2).unwrap(),
/// ];
/// for b in bars {
/// or.update(b);
/// }
/// let v = or.value().unwrap();
/// assert_eq!(v.high, 103.0);
/// assert_eq!(v.low, 99.0);
/// assert_eq!(v.breakout_distance, 105.0 - (103.0 + 99.0) / 2.0);
/// ```
#[derive(Debug, Clone)]
pub struct OpeningRange {
period: usize,
bars_seen: usize,
high: f64,
low: f64,
last_close: f64,
locked: bool,
last: Option<OpeningRangeOutput>,
}
impl OpeningRange {
/// Construct an Opening Range indicator with the given window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
bars_seen: 0,
high: f64::NEG_INFINITY,
low: f64::INFINITY,
last_close: 0.0,
locked: false,
last: None,
})
}
/// Classic 6-bar Opening Range.
pub fn classic() -> Self {
Self::new(6).expect("classic OR period is valid")
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Most recent output if at least one bar has been seen.
pub const fn value(&self) -> Option<OpeningRangeOutput> {
self.last
}
/// True once `period` bars have been ingested and the OR is locked.
pub const fn is_locked(&self) -> bool {
self.locked
}
fn snapshot(&self) -> OpeningRangeOutput {
let mid = f64::midpoint(self.high, self.low);
OpeningRangeOutput {
high: self.high,
low: self.low,
breakout_distance: self.last_close - mid,
}
}
}
impl Indicator for OpeningRange {
type Input = Candle;
type Output = OpeningRangeOutput;
fn update(&mut self, candle: Candle) -> Option<OpeningRangeOutput> {
if !self.locked {
if candle.high > self.high {
self.high = candle.high;
}
if candle.low < self.low {
self.low = candle.low;
}
self.bars_seen += 1;
if self.bars_seen >= self.period {
self.locked = true;
}
}
self.last_close = candle.close;
let out = self.snapshot();
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.bars_seen = 0;
self.high = f64::NEG_INFINITY;
self.low = f64::INFINITY;
self.last_close = 0.0;
self.locked = false;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.bars_seen > 0
}
fn name(&self) -> &'static str {
"OpeningRange"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
let open = f64::midpoint(high, low);
Candle::new(open, high, low, close, 10.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(OpeningRange::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let or = OpeningRange::new(6).unwrap();
assert_eq!(or.period(), 6);
assert_eq!(or.name(), "OpeningRange");
assert_eq!(or.warmup_period(), 1);
assert!(or.value().is_none());
assert!(!or.is_locked());
}
#[test]
fn classic_is_constructible() {
let or = OpeningRange::classic();
assert_eq!(or.period(), 6);
}
#[test]
fn tracks_range_during_window() {
let mut or = OpeningRange::new(3).unwrap();
let o1 = or.update(c(102.0, 100.0, 101.0, 0)).unwrap();
assert_relative_eq!(o1.high, 102.0);
assert_relative_eq!(o1.low, 100.0);
// close 101 vs mid 101 → breakout 0.
assert_relative_eq!(o1.breakout_distance, 0.0, epsilon = 1e-12);
let o2 = or.update(c(105.0, 99.0, 104.0, 1)).unwrap();
assert_relative_eq!(o2.high, 105.0);
assert_relative_eq!(o2.low, 99.0);
// close 104 vs mid 102 → breakout 2.
assert_relative_eq!(o2.breakout_distance, 2.0, epsilon = 1e-12);
}
#[test]
fn locks_after_period_and_breakout_reflects_close_minus_mid() {
let mut or = OpeningRange::new(2).unwrap();
or.update(c(102.0, 100.0, 101.0, 0));
or.update(c(103.0, 101.0, 102.0, 1));
assert!(or.is_locked());
// OR locked at high 103, low 100, mid 101.5.
// Bar 2: wide candle ignored for high/low; close 105 -> breakout 3.5.
let after = or.update(c(200.0, 50.0, 105.0, 2)).unwrap();
assert_relative_eq!(after.high, 103.0);
assert_relative_eq!(after.low, 100.0);
assert_relative_eq!(after.breakout_distance, 3.5, epsilon = 1e-12);
}
#[test]
fn breakout_distance_is_negative_below_range() {
let mut or = OpeningRange::new(2).unwrap();
or.update(c(102.0, 100.0, 101.0, 0));
or.update(c(103.0, 101.0, 102.0, 1));
// mid 101.5, close 90 -> -11.5.
let out = or.update(c(110.0, 89.0, 90.0, 2)).unwrap();
assert_relative_eq!(out.breakout_distance, -11.5, epsilon = 1e-12);
}
#[test]
fn reset_unlocks_and_clears_state() {
let mut or = OpeningRange::new(2).unwrap();
or.update(c(102.0, 100.0, 101.0, 0));
or.update(c(103.0, 101.0, 102.0, 1));
assert!(or.is_locked());
or.reset();
assert!(!or.is_locked());
assert!(!or.is_ready());
let o = or.update(c(50.0, 49.0, 49.5, 2)).unwrap();
assert_relative_eq!(o.high, 50.0);
assert_relative_eq!(o.low, 49.0);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..20)
.map(|i| {
let base = 100.0 + i as f64 * 0.25;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut a = OpeningRange::new(5).unwrap();
let mut b = OpeningRange::new(5).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn is_ready_after_first_bar() {
let mut or = OpeningRange::new(5).unwrap();
assert!(!or.is_ready());
or.update(c(101.0, 99.0, 100.0, 0));
assert!(or.is_ready());
}
}
@@ -0,0 +1,430 @@
//! Value Area (Point of Control + Value Area High / Low).
//!
//! Market-profile-style volume distribution over the last `period` candles,
//! bucketed into `bin_count` price bins. Each candle's volume is spread
//! uniformly across its `[low, high]` range (bin-approximation); single-print
//! bars (`low == high`) dump their whole volume into a single bin. The
//! Point of Control (POC) is the bin with the highest cumulative volume; the
//! Value Area expands outward from the POC, always absorbing the
//! higher-volume neighbour next, until the configured percentage of total
//! volume (default 70%) is enclosed.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Value Area output: Point of Control, Value Area High and Value Area Low.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ValueAreaOutput {
/// Point of Control — price of the bin with the highest cumulative volume.
pub poc: f64,
/// Value Area High — upper bound of the bins that together hold
/// `value_area_pct` of the rolling-window volume.
pub vah: f64,
/// Value Area Low — lower bound of those same bins.
pub val: f64,
}
/// Rolling Value Area indicator over the last `period` candles.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ValueArea};
///
/// let mut va = ValueArea::new(5, 50, 0.70).unwrap();
/// for i in 0..10 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap();
/// va.update(candle);
/// }
/// assert!(va.is_ready());
/// ```
#[allow(clippy::struct_field_names)]
#[derive(Debug, Clone)]
pub struct ValueArea {
period: usize,
bin_count: usize,
value_area_pct: f64,
window: VecDeque<Candle>,
last: Option<ValueAreaOutput>,
}
impl ValueArea {
/// Construct a Value Area indicator.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period` or `bin_count` is zero,
/// and [`Error::InvalidPeriod`] if `value_area_pct` is not in `(0, 1]`.
pub fn new(period: usize, bin_count: usize, value_area_pct: f64) -> Result<Self> {
if period == 0 || bin_count == 0 {
return Err(Error::PeriodZero);
}
if !value_area_pct.is_finite() || value_area_pct <= 0.0 || value_area_pct > 1.0 {
return Err(Error::InvalidPeriod {
message: "value_area_pct must be in (0, 1]",
});
}
Ok(Self {
period,
bin_count,
value_area_pct,
window: VecDeque::with_capacity(period),
last: None,
})
}
/// Classic Value Area: 20-bar rolling window, 50 bins, 70% concentration.
pub fn classic() -> Self {
Self::new(20, 50, 0.70).expect("classic ValueArea params are valid")
}
/// Configured `(period, bin_count, value_area_pct)`.
pub const fn params(&self) -> (usize, usize, f64) {
(self.period, self.bin_count, self.value_area_pct)
}
/// Most recent output if available.
pub const fn value(&self) -> Option<ValueAreaOutput> {
self.last
}
fn compute(&self) -> ValueAreaOutput {
// Window-wide low / high spans the histogram domain.
let mut win_low = f64::INFINITY;
let mut win_high = f64::NEG_INFINITY;
for c in &self.window {
if c.low < win_low {
win_low = c.low;
}
if c.high > win_high {
win_high = c.high;
}
}
let span = win_high - win_low;
let mut bins = vec![0.0_f64; self.bin_count];
// Distribute each candle's volume across its [low, high] range. A
// degenerate `low == high` bar drops its entire volume into one bin.
if span <= 0.0 {
// All bars are single-print at the same price — POC = that price,
// VAH = VAL = that price.
let total: f64 = self.window.iter().map(|c| c.volume).sum();
bins[0] = total;
return ValueAreaOutput {
poc: win_low,
vah: win_low,
val: win_low,
};
}
let bin_width = span / self.bin_count as f64;
for c in &self.window {
if c.volume == 0.0 {
continue;
}
if c.high <= c.low {
let idx = self.price_to_bin(c.low, win_low, bin_width);
bins[idx] += c.volume;
continue;
}
let lo_idx = self.price_to_bin(c.low, win_low, bin_width);
let hi_idx = self.price_to_bin(c.high, win_low, bin_width);
let touched = hi_idx - lo_idx + 1;
let share = c.volume / touched as f64;
for b in bins.iter_mut().take(hi_idx + 1).skip(lo_idx) {
*b += share;
}
}
let total: f64 = bins.iter().sum();
// POC = bin with highest volume.
let mut poc_idx = 0_usize;
let mut poc_vol = bins[0];
for (i, v) in bins.iter().enumerate().skip(1) {
if *v > poc_vol {
poc_vol = *v;
poc_idx = i;
}
}
// Expand Value Area outward from POC. At each step take the
// higher-volume neighbour (up or down). Equal volumes break upward,
// matching the CME convention. The loop condition guarantees at
// least one of `can_go_up` / `can_go_down` is true on every body
// entry, so the inner `else` branch is always reachable.
let target = total * self.value_area_pct;
let mut accumulated = poc_vol;
let mut lo = poc_idx;
let mut hi = poc_idx;
while accumulated < target && (lo > 0 || hi + 1 < self.bin_count) {
let can_go_up = hi + 1 < self.bin_count;
let can_go_down = lo > 0;
let up_v = if can_go_up {
bins[hi + 1]
} else {
f64::NEG_INFINITY
};
let down_v = if can_go_down {
bins[lo - 1]
} else {
f64::NEG_INFINITY
};
if can_go_up && (up_v >= down_v || !can_go_down) {
hi += 1;
accumulated += up_v;
} else {
lo -= 1;
accumulated += down_v;
}
}
let bin_mid = |i: usize| win_low + bin_width * (i as f64 + 0.5);
ValueAreaOutput {
poc: bin_mid(poc_idx),
vah: win_low + bin_width * (hi as f64 + 1.0),
val: win_low + bin_width * lo as f64,
}
}
fn price_to_bin(&self, price: f64, win_low: f64, bin_width: f64) -> usize {
// Clamp the float into [0, bin_count - 1] before casting so the
// `as usize` step cannot overflow or wrap.
let raw = ((price - win_low) / bin_width).floor();
let max = (self.bin_count - 1) as f64;
raw.clamp(0.0, max) as usize
}
}
impl Indicator for ValueArea {
type Input = Candle;
type Output = ValueAreaOutput;
fn update(&mut self, candle: Candle) -> Option<ValueAreaOutput> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(candle);
if self.window.len() < self.period {
return None;
}
let out = self.compute();
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"ValueArea"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, volume, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(ValueArea::new(0, 50, 0.7), Err(Error::PeriodZero)));
}
#[test]
fn rejects_zero_bin_count() {
assert!(matches!(ValueArea::new(20, 0, 0.7), Err(Error::PeriodZero)));
}
#[test]
fn rejects_invalid_value_area_pct() {
assert!(matches!(
ValueArea::new(20, 50, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ValueArea::new(20, 50, 1.5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ValueArea::new(20, 50, f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let v = ValueArea::new(20, 50, 0.7).unwrap();
assert_eq!(v.params(), (20, 50, 0.7));
assert_eq!(v.name(), "ValueArea");
assert_eq!(v.warmup_period(), 20);
assert!(v.value().is_none());
}
#[test]
fn classic_is_constructible() {
let v = ValueArea::classic();
assert_eq!(v.params(), (20, 50, 0.70));
}
#[test]
fn warmup_emits_after_period() {
let mut v = ValueArea::new(5, 10, 0.7).unwrap();
for i in 0..4 {
let base = 100.0;
assert!(v
.update(c(base, base + 1.0, base - 1.0, base, 10.0, i))
.is_none());
}
let out = v
.update(c(100.0, 101.0, 99.0, 100.0, 10.0, 4))
.expect("ready after period");
// All five bars are identical, so POC == bar mid; VAH/VAL bracket
// the window high/low.
assert!(out.vah >= out.poc);
assert!(out.poc >= out.val);
assert!(v.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + (i as f64).sin();
c(base, base + 1.0, base - 1.0, base, 10.0 + i as f64, i)
})
.collect();
let mut a = ValueArea::new(10, 20, 0.7).unwrap();
let mut b = ValueArea::new(10, 20, 0.7).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..20)
.map(|i| c(100.0, 101.0, 99.0, 100.0, 10.0, i))
.collect();
let mut v = ValueArea::new(5, 10, 0.7).unwrap();
v.batch(&candles);
assert!(v.is_ready());
v.reset();
assert!(!v.is_ready());
assert_eq!(v.update(candles[0]), None);
}
#[test]
fn constant_single_print_yields_collapsed_value_area() {
// Every bar trades at exactly 100 (low == high == 100) — the
// histogram has zero span so POC == VAH == VAL == 100.
let candles: Vec<Candle> = (0..10)
.map(|i| c(100.0, 100.0, 100.0, 100.0, 5.0, i))
.collect();
let mut v = ValueArea::new(5, 20, 0.7).unwrap();
let out = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(out.poc, 100.0, epsilon = 1e-12);
assert_relative_eq!(out.vah, 100.0, epsilon = 1e-12);
assert_relative_eq!(out.val, 100.0, epsilon = 1e-12);
}
#[test]
fn single_print_bar_in_mixed_window_dumps_volume_into_one_bin() {
// Mix of wide-range bars (drive the window's span > 0) and one
// single-print bar at price 102 with massive volume. The single-print
// bar must dump its entire volume into one bin, making the POC land
// exactly on the bin that contains 102.
let candles = vec![
c(100.0, 100.5, 99.5, 100.0, 1.0, 0),
c(100.0, 100.5, 99.5, 100.0, 1.0, 1),
c(102.0, 102.0, 102.0, 102.0, 1000.0, 2),
c(100.0, 100.5, 99.5, 100.0, 1.0, 3),
c(100.0, 100.5, 99.5, 100.0, 1.0, 4),
];
let mut v = ValueArea::new(5, 50, 0.70).unwrap();
let out = v.batch(&candles).into_iter().flatten().last().unwrap();
// POC must sit in the high-volume bin that holds price 102.
assert!(
(101.9..=102.1).contains(&out.poc),
"POC {} not near 102",
out.poc
);
}
#[test]
fn concentrated_volume_locates_poc_at_high_volume_bar() {
// Bars 0..3 sit at price 100 with volume 1; bar 4 dumps massive
// volume at price 110. POC must land near 110.
let mut candles = vec![
c(100.0, 100.5, 99.5, 100.0, 1.0, 0),
c(100.0, 100.5, 99.5, 100.0, 1.0, 1),
c(100.0, 100.5, 99.5, 100.0, 1.0, 2),
c(100.0, 100.5, 99.5, 100.0, 1.0, 3),
];
candles.push(c(110.0, 110.5, 109.5, 110.0, 1000.0, 4));
let mut v = ValueArea::new(5, 50, 0.70).unwrap();
let out = v.batch(&candles).into_iter().flatten().last().unwrap();
// POC must fall inside the high-volume bar's [low, high] range; ties
// among equal-volume bins resolve to the lowest index, so the POC
// sits on the left edge of bar 4's range rather than at its midpoint.
assert!(
(109.5..=110.5).contains(&out.poc),
"POC {} not inside [109.5, 110.5]",
out.poc
);
// VAH and VAL bracket POC.
assert!(out.vah >= out.poc);
assert!(out.val <= out.poc);
}
#[test]
fn value_area_brackets_point_of_control() {
let candles: Vec<Candle> = (0..30)
.map(|i| {
let base = 100.0 + (i as f64).cos() * 2.0;
c(base, base + 0.5, base - 0.5, base, 10.0, i)
})
.collect();
let mut v = ValueArea::new(15, 30, 0.70).unwrap();
for o in v.batch(&candles).into_iter().flatten() {
assert!(o.vah >= o.poc, "VAH {} < POC {}", o.vah, o.poc);
assert!(o.val <= o.poc, "VAL {} > POC {}", o.val, o.poc);
}
}
#[test]
fn zero_volume_bars_are_skipped_in_histogram() {
// Only bar 4 carries any volume — POC must land at its mid.
let candles = vec![
c(100.0, 100.5, 99.5, 100.0, 0.0, 0),
c(100.0, 100.5, 99.5, 100.0, 0.0, 1),
c(100.0, 100.5, 99.5, 100.0, 0.0, 2),
c(100.0, 100.5, 99.5, 100.0, 0.0, 3),
c(100.0, 100.5, 99.5, 100.0, 50.0, 4),
];
let mut v = ValueArea::new(5, 20, 0.7).unwrap();
let out = v.batch(&candles).into_iter().flatten().last().unwrap();
assert!(out.poc.is_finite());
assert!(out.vah.is_finite());
assert!(out.val.is_finite());
}
}