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:
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user