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
+221
View File
@@ -8096,3 +8096,224 @@ impl HeikinAshiNode {
self.inner.warmup_period() as u32
}
}
// ============================== ValueArea ==============================
#[napi(object)]
pub struct ValueAreaValue {
pub poc: f64,
pub vah: f64,
pub val: f64,
}
#[napi(js_name = "ValueArea")]
pub struct ValueAreaNode {
inner: wc::ValueArea,
}
#[napi]
impl ValueAreaNode {
#[napi(constructor)]
pub fn new(period: u32, bin_count: u32, value_area_pct: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::ValueArea::new(period as usize, bin_count as usize, value_area_pct)
.map_err(map_err)?,
})
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
volume: f64,
) -> napi::Result<Option<ValueAreaValue>> {
let mid = (high + low) / 2.0;
let candle = wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?;
Ok(self.inner.update(candle).map(|o| ValueAreaValue {
poc: o.poc,
vah: o.vah,
val: o.val,
}))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, volume must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let mid = (high[i] + low[i]) / 2.0;
let candle =
wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 3] = o.poc;
out[i * 3 + 1] = o.vah;
out[i * 3 + 2] = o.val;
}
}
Ok(out)
}
}
// ============================== InitialBalance ==============================
#[napi(object)]
pub struct InitialBalanceValue {
pub high: f64,
pub low: f64,
}
#[napi(js_name = "InitialBalance")]
pub struct InitialBalanceNode {
inner: wc::InitialBalance,
}
#[napi]
impl InitialBalanceNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::InitialBalance::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "isLocked")]
pub fn is_locked(&self) -> bool {
self.inner.is_locked()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
#[napi]
pub fn update(&mut self, high: f64, low: f64) -> napi::Result<Option<InitialBalanceValue>> {
let mid = (high + low) / 2.0;
let candle = wc::Candle::new(mid, high, low, mid, 0.0, 0).map_err(map_err)?;
Ok(self.inner.update(candle).map(|o| InitialBalanceValue {
high: o.high,
low: o.low,
}))
}
#[napi]
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
if high.len() != low.len() {
return Err(NapiError::from_reason(
"high and low must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let mid = (high[i] + low[i]) / 2.0;
let candle = wc::Candle::new(mid, high[i], low[i], mid, 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.high;
out[i * 2 + 1] = o.low;
}
}
Ok(out)
}
}
// ============================== OpeningRange ==============================
#[napi(object)]
pub struct OpeningRangeValue {
pub high: f64,
pub low: f64,
pub breakout_distance: f64,
}
#[napi(js_name = "OpeningRange")]
pub struct OpeningRangeNode {
inner: wc::OpeningRange,
}
#[napi]
impl OpeningRangeNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::OpeningRange::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "isLocked")]
pub fn is_locked(&self) -> bool {
self.inner.is_locked()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<OpeningRangeValue>> {
let candle = wc::Candle::new(close, high, low, close, 0.0, 0).map_err(map_err)?;
Ok(self.inner.update(candle).map(|o| OpeningRangeValue {
high: o.high,
low: o.low,
breakout_distance: o.breakout_distance,
}))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let candle =
wc::Candle::new(close[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 3] = o.high;
out[i * 3 + 1] = o.low;
out[i * 3 + 2] = o.breakout_distance;
}
}
Ok(out)
}
}