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
@@ -214,6 +214,10 @@ const multi = {
SuperTrend: { make: () => new wickra.SuperTrend(10, 3), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ChandelierExit: { make: () => new wickra.ChandelierExit(22, 3), fields: ['longStop', 'shortStop'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ChandeKrollStop: { make: () => new wickra.ChandeKrollStop(10, 1, 9), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
// Family 16: Market Profile
ValueArea: { make: () => new wickra.ValueArea(20, 50, 0.70), fields: ['poc', 'vah', 'val'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
InitialBalance: { make: () => new wickra.InitialBalance(12), fields: ['high', 'low'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
OpeningRange: { make: () => new wickra.OpeningRange(6), fields: ['high', 'low', 'breakoutDistance'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
DonchianStop: { make: () => new wickra.DonchianStop(10), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
// Family 05: bands & channels
MaEnvelope: { make: () => new wickra.MaEnvelope(20, 0.025), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
@@ -371,6 +375,32 @@ test('LinRegAngle of a unit-slope series is 45 degrees', () => {
assert.ok(Math.abs(out[4] - 45) < 1e-9);
});
test('InitialBalance(2) locks after period and ignores subsequent bars', () => {
const ib = new wickra.InitialBalance(2);
let v = ib.update(102, 100);
assert.equal(v.high, 102);
assert.equal(v.low, 100);
v = ib.update(103, 99);
assert.equal(v.high, 103);
assert.equal(v.low, 99);
assert.equal(ib.isLocked(), true);
// Extreme bar after lock must not modify the IB.
v = ib.update(200, 50);
assert.equal(v.high, 103);
assert.equal(v.low, 99);
});
test('OpeningRange(2) breakout distance is signed close minus midpoint', () => {
const or = new wickra.OpeningRange(2);
or.update(102, 100, 101);
or.update(103, 101, 102);
// OR locked at high 103 / low 100 / mid 101.5. Close 105 -> +3.5.
const v = or.update(110, 102, 105);
assert.equal(v.high, 103);
assert.equal(v.low, 100);
assert.ok(Math.abs(v.breakoutDistance - 3.5) < 1e-9);
});
// --- Family 12: two-series indicators (Pearson / Beta / Spearman) ---
const pairFactories = {
+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)
}
}