From 9b8e1346ed12fff69cfe592ca02fb3f13cb5b0fb Mon Sep 17 00:00:00 2001 From: kingchenc Date: Tue, 26 May 2026 00:14:30 +0200 Subject: [PATCH] feat(family-16): add ValueArea + InitialBalance + OpeningRange (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- CHANGELOG.md | 25 + README.md | 5 +- bindings/node/__tests__/indicators.test.js | 30 ++ bindings/node/src/lib.rs | 221 +++++++++ bindings/python/python/wickra/__init__.py | 8 + bindings/python/src/lib.rs | 241 ++++++++++ .../python/tests/test_input_validation.py | 32 ++ bindings/python/tests/test_known_values.py | 39 ++ bindings/python/tests/test_lifecycle.py | 26 ++ bindings/python/tests/test_new_indicators.py | 51 +++ bindings/python/tests/test_smoke.py | 19 + .../python/tests/test_streaming_vs_batch.py | 42 ++ bindings/wasm/src/lib.rs | 198 ++++++++ .../src/indicators/initial_balance.rs | 254 +++++++++++ crates/wickra-core/src/indicators/mod.rs | 6 + .../src/indicators/opening_range.rs | 270 +++++++++++ .../wickra-core/src/indicators/value_area.rs | 430 ++++++++++++++++++ crates/wickra-core/src/lib.rs | 31 +- crates/wickra/benches/indicators.rs | 31 +- fuzz/fuzz_targets/indicator_update_candle.rs | 22 +- 20 files changed, 1946 insertions(+), 35 deletions(-) create mode 100644 crates/wickra-core/src/indicators/initial_balance.rs create mode 100644 crates/wickra-core/src/indicators/opening_range.rs create mode 100644 crates/wickra-core/src/indicators/value_area.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 08f1f43f..02b67ed1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Market Profile family** (3 new indicators, opens family #9 across the + catalogue): + - `ValueArea(period, bin_count, value_area_pct)` — rolling + bin-approximation volume profile over the last `period` candles. + Outputs `{poc, vah, val}`: Point of Control is the bin with the highest + cumulative volume; the Value Area expands symmetrically from POC and + always absorbs the higher-volume neighbour next, until the configured + percentage of total volume (default 70%) is enclosed. Each candle's + volume is spread uniformly across its `[low, high]` range; single-print + bars (`low == high`) drop their entire volume into one bin. + - `InitialBalance(period)` — first-N-bar session high / low, frozen + once `period` bars have been ingested. Outputs `{high, low}`. Default + `period = 12` (one-hour IB on 5-minute bars for US equities). Callers + MUST invoke `reset()` at every session boundary, otherwise the IB + locks and stays fixed for the lifetime of the instance. + - `OpeningRange(period)` — same lock-after-N-bars semantics as IB but + with a smaller default window (`period = 6`, 30 min on 5-minute + bars) and a third output `breakout_distance` = `close - or_mid`, + signed (positive above the range, negative below). +- Histogram-output Market Profile variants (Volume Profile / VPVR / + Composite Profile) and tick-data-only variants (TPO / Single Print / + Cumulative Delta / Order Flow Delta / Volume-Weighted Open) are + deliberately out of scope of this PR: the former need a new + histogram-output API layer, the latter need tick / L2 data which + `wickra-data` does not yet expose. - **Family 12 — Statistik / Regression (13 indicators).** A complete statistical toolkit for analysing rolling price distributions and cross-series relationships. Every indicator ships in the Rust core diff --git a/README.md b/README.md index 780b13ff..71a8bc0d 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -178 streaming-first indicators across thirteen families. Every one passes the +181 streaming-first indicators across fourteen families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -128,6 +128,7 @@ semantics tests. | Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag | | DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level | | Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi | +| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range | Adding a new indicator means implementing one trait in Rust; all four bindings inherit it automatically. @@ -200,7 +201,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 178 indicators +│ ├── wickra-core/ core engine + all 181 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index d617b632..3d663394 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -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 = { diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 642fb2b0..086f3934 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -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 { + 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> { + 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, + low: Vec, + volume: Vec, + ) -> napi::Result> { + 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 { + 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> { + 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, low: Vec) -> napi::Result> { + 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 { + 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> { + 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, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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) + } +} diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 8aba987d..8ce9f4f2 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -215,6 +215,10 @@ from ._wickra import ( # Ichimoku & alternative charts Ichimoku, HeikinAshi, + # Market Profile + ValueArea, + InitialBalance, + OpeningRange, ) __all__ = [ @@ -409,4 +413,8 @@ __all__ = [ # Ichimoku & alternative charts "Ichimoku", "HeikinAshi", + # Market Profile + "ValueArea", + "InitialBalance", + "OpeningRange", ] diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 917377bd..5701fd20 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -10816,6 +10816,244 @@ impl PySpearmanCorrelation { } } +// ============================== ValueArea ============================== + +#[pyclass(name = "ValueArea", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyValueArea { + inner: wc::ValueArea, +} + +#[pymethods] +impl PyValueArea { + #[new] + #[pyo3(signature = (period=20, bin_count=50, value_area_pct=0.70))] + fn new(period: usize, bin_count: usize, value_area_pct: f64) -> PyResult { + Ok(Self { + inner: wc::ValueArea::new(period, bin_count, value_area_pct).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.poc, o.vah, o.val))) + } + /// Batch over numpy columns high, low, volume. Returns shape `(n, 3)` + /// with columns `[poc, vah, val]`; warmup rows are `NaN`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != v.len() { + return Err(PyValueError::new_err( + "high, low, volume must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + // open / close pinned to the midpoint so the candle validates. + let mid = (h[i] + l[i]) / 2.0; + let candle = wc::Candle::new(mid, h[i], l[i], mid, v[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(numpy::ndarray::Array2::from_shape_vec((n, 3), out) + .expect("shape consistent") + .into_pyarray(py)) + } + #[getter] + fn params(&self) -> (usize, usize, f64) { + self.inner.params() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (period, bin_count, pct) = self.inner.params(); + format!("ValueArea(period={period}, bin_count={bin_count}, value_area_pct={pct})") + } +} + +// ============================== InitialBalance ============================== + +#[pyclass( + name = "InitialBalance", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyInitialBalance { + inner: wc::InitialBalance, +} + +#[pymethods] +impl PyInitialBalance { + #[new] + #[pyo3(signature = (period=12))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::InitialBalance::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c).map(|o| (o.high, o.low))) + } + /// Batch over numpy columns high, low. Returns shape `(n, 2)` with + /// columns `[high, low]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() { + return Err(PyValueError::new_err("high and low must be equal length")); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let mid = (h[i] + l[i]) / 2.0; + let candle = wc::Candle::new(mid, h[i], l[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(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn is_locked(&self) -> bool { + self.inner.is_locked() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("InitialBalance(period={})", self.inner.period()) + } +} + +// ============================== OpeningRange ============================== + +#[pyclass(name = "OpeningRange", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyOpeningRange { + inner: wc::OpeningRange, +} + +#[pymethods] +impl PyOpeningRange { + #[new] + #[pyo3(signature = (period=6))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::OpeningRange::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self + .inner + .update(c) + .map(|o| (o.high, o.low, o.breakout_distance))) + } + /// Batch over numpy columns high, low, close. Returns shape `(n, 3)` + /// with columns `[high, low, breakout_distance]`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let n = h.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + let candle = wc::Candle::new(c[i], h[i], l[i], c[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(numpy::ndarray::Array2::from_shape_vec((n, 3), out) + .expect("shape consistent") + .into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn is_locked(&self) -> bool { + self.inner.is_locked() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("OpeningRange(period={})", self.inner.period()) + } +} + // ============================== Module ============================== #[pymodule] @@ -11004,5 +11242,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/python/tests/test_input_validation.py b/bindings/python/tests/test_input_validation.py index cb7721ce..9732436e 100644 --- a/bindings/python/tests/test_input_validation.py +++ b/bindings/python/tests/test_input_validation.py @@ -41,6 +41,38 @@ def test_roc_and_trix_have_default_periods(): assert ta.TRIX() is not None +def test_value_area_rejects_zero_period(): + with pytest.raises(ValueError): + ta.ValueArea(0, 50, 0.7) + with pytest.raises(ValueError): + ta.ValueArea(20, 0, 0.7) + + +def test_value_area_rejects_invalid_pct(): + with pytest.raises(ValueError): + ta.ValueArea(20, 50, 0.0) + with pytest.raises(ValueError): + ta.ValueArea(20, 50, 1.5) + + +def test_initial_balance_rejects_zero_period(): + with pytest.raises(ValueError): + ta.InitialBalance(0) + + +def test_opening_range_rejects_zero_period(): + with pytest.raises(ValueError): + ta.OpeningRange(0) + + +def test_value_area_unequal_length_raises(): + high = np.array([1.0, 2.0, 3.0]) + low = np.array([0.5, 1.5]) + volume = np.array([10.0, 10.0, 10.0]) + with pytest.raises(ValueError): + ta.ValueArea(2, 10, 0.7).batch(high, low, volume) + + def test_ichimoku_rejects_zero_and_non_increasing_periods(): with pytest.raises(ValueError): ta.Ichimoku(0, 26, 52, 26) diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index 824eb58f..e410c4a6 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -332,6 +332,45 @@ def test_obv_cumulative_known_sequence(): np.testing.assert_allclose(out, [0.0, 20.0, -10.0, -10.0, 0.0]) +def test_value_area_concentrated_volume_locates_poc(): + # Bars 0..3 sit at price 100 with low volume; bar 4 dumps massive volume + # at price 110. POC must fall inside the high-volume bar's [low, high] + # range; ties resolve to the lowest-index bin, so the POC may sit on the + # left edge of bar 4's range rather than at its midpoint. + high = np.array([100.5, 100.5, 100.5, 100.5, 110.5]) + low = np.array([99.5, 99.5, 99.5, 99.5, 109.5]) + volume = np.array([1.0, 1.0, 1.0, 1.0, 1000.0]) + out = ta.ValueArea(5, 50, 0.70).batch(high, low, volume) + poc = out[-1, 0] + assert 109.5 <= poc <= 110.5 + # VAH >= POC >= VAL. + assert out[-1, 1] >= poc >= out[-1, 2] + + +def test_initial_balance_locks_after_period(): + # First two bars set IB = [99, 103]. Third bar (extreme) must be ignored. + high = np.array([102.0, 103.0, 200.0]) + low = np.array([100.0, 99.0, 50.0]) + out = ta.InitialBalance(2).batch(high, low) + # Bar 0: IB = [100, 102]; Bar 1: IB locked at [99, 103]; Bar 2: unchanged. + np.testing.assert_allclose(out[0], [102.0, 100.0]) + np.testing.assert_allclose(out[1], [103.0, 99.0]) + np.testing.assert_allclose(out[2], [103.0, 99.0]) + + +def test_opening_range_breakout_distance_signed(): + # OR locks after 2 bars at high 103 / low 100; mid 101.5. Third bar + # closes at 105 -> breakout +3.5; fourth bar closes at 95 -> -6.5. + high = np.array([102.0, 103.0, 110.0, 110.0]) + low = np.array([100.0, 101.0, 102.0, 90.0]) + close = np.array([101.0, 102.0, 105.0, 95.0]) + out = ta.OpeningRange(2).batch(high, low, close) + assert math.isclose(out[2, 0], 103.0) + assert math.isclose(out[2, 1], 100.0) + assert math.isclose(out[2, 2], 105.0 - 101.5) + assert math.isclose(out[3, 2], 95.0 - 101.5) + + # --- Family 10 — Ehlers / Cycle reference values --- diff --git a/bindings/python/tests/test_lifecycle.py b/bindings/python/tests/test_lifecycle.py index 5e4c767c..cab8616f 100644 --- a/bindings/python/tests/test_lifecycle.py +++ b/bindings/python/tests/test_lifecycle.py @@ -88,6 +88,32 @@ def test_candle_tuple_input_supported(): assert v is not None +def test_initial_balance_reset_unlocks(): + ib = ta.InitialBalance(2) + assert not ib.is_ready() + ib.update((101.0, 102.0, 100.0, 101.0, 0.0, 0)) + ib.update((102.0, 103.0, 101.0, 102.0, 0.0, 1)) + assert ib.is_ready() + assert ib.is_locked() + ib.reset() + assert not ib.is_ready() + assert not ib.is_locked() + + +def test_opening_range_reset_unlocks(): + or_ind = ta.OpeningRange(2) + or_ind.update((101.0, 102.0, 100.0, 101.0, 0.0, 0)) + or_ind.update((102.0, 103.0, 101.0, 102.0, 0.0, 1)) + assert or_ind.is_locked() + or_ind.reset() + assert not or_ind.is_locked() + + +def test_value_area_warmup_equals_period(): + assert ta.ValueArea(20, 50, 0.70).warmup_period() == 20 + assert ta.ValueArea(10, 30, 0.80).warmup_period() == 10 + + def test_ehlers_indicators_lifecycle(): # Spot-check a few Family-10 entries beyond what test_new_indicators covers. series = np.linspace(1.0, 200.0, 200) + np.sin(np.arange(200) * 0.3) * 5.0 diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index c6bb6105..feb634f0 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -581,6 +581,57 @@ def test_multi_scalar_streaming_matches_batch(name, ohlcv): assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch" +# --- Market Profile (multi-output with variable shapes) ------------------ + + +def test_value_area_shape_and_streaming(ohlcv): + high, low, _close, volume = ohlcv + batch = ta.ValueArea(20, 50, 0.70).batch(high, low, volume) + assert batch.shape == (high.size, 3) + streamer = ta.ValueArea(20, 50, 0.70) + rows = [] + for i in range(high.size): + mid = float((high[i] + low[i]) / 2.0) + candle = (mid, float(high[i]), float(low[i]), mid, float(volume[i]), i) + v = streamer.update(candle) + rows.append([math.nan, math.nan, math.nan] if v is None else list(v)) + assert _eq_nan(batch, np.array(rows, dtype=np.float64)) + + +def test_initial_balance_shape_and_streaming(ohlcv): + high, low, _close, _volume = ohlcv + batch = ta.InitialBalance(12).batch(high, low) + assert batch.shape == (high.size, 2) + streamer = ta.InitialBalance(12) + rows = [] + for i in range(high.size): + mid = float((high[i] + low[i]) / 2.0) + candle = (mid, float(high[i]), float(low[i]), mid, 0.0, i) + v = streamer.update(candle) + rows.append([math.nan, math.nan] if v is None else list(v)) + assert _eq_nan(batch, np.array(rows, dtype=np.float64)) + + +def test_opening_range_shape_and_streaming(ohlcv): + high, low, close, _volume = ohlcv + batch = ta.OpeningRange(6).batch(high, low, close) + assert batch.shape == (high.size, 3) + streamer = ta.OpeningRange(6) + rows = [] + for i in range(high.size): + candle = ( + float(close[i]), + float(high[i]), + float(low[i]), + float(close[i]), + 0.0, + i, + ) + v = streamer.update(candle) + rows.append([math.nan, math.nan, math.nan] if v is None else list(v)) + assert _eq_nan(batch, np.array(rows, dtype=np.float64)) + + # --- TD Pressure (OHLCV-input) ------------------------------------------- diff --git a/bindings/python/tests/test_smoke.py b/bindings/python/tests/test_smoke.py index 95c46f3f..389bfde4 100644 --- a/bindings/python/tests/test_smoke.py +++ b/bindings/python/tests/test_smoke.py @@ -57,6 +57,25 @@ def test_obv_batch_shape(ohlc_series): assert out.shape == close.shape +def test_value_area_batch_shape(ohlc_series): + high, low, close = ohlc_series + volume = np.ones_like(close) + out = ta.ValueArea(20, 50, 0.70).batch(high, low, volume) + assert out.shape == (close.size, 3) + + +def test_initial_balance_batch_shape(ohlc_series): + high, low, _close = ohlc_series + out = ta.InitialBalance(12).batch(high, low) + assert out.shape == (high.size, 2) + + +def test_opening_range_batch_shape(ohlc_series): + high, low, close = ohlc_series + out = ta.OpeningRange(6).batch(high, low, close) + assert out.shape == (close.size, 3) + + def test_ichimoku_batch_returns_n_by_5(ohlc_series): high, low, close = ohlc_series out = ta.Ichimoku().batch(high, low, close) diff --git a/bindings/python/tests/test_streaming_vs_batch.py b/bindings/python/tests/test_streaming_vs_batch.py index 96e3a0a5..7993647f 100644 --- a/bindings/python/tests/test_streaming_vs_batch.py +++ b/bindings/python/tests/test_streaming_vs_batch.py @@ -159,3 +159,45 @@ def test_rolling_vwap_streaming_matches_batch(ohlc_series): assert streamer.is_ready() streamer.reset() assert not streamer.is_ready() + + +def test_value_area_streaming_matches_batch(ohlc_series): + high, low, close = ohlc_series + volume = np.linspace(100.0, 200.0, num=close.size, dtype=np.float64) + batch = ta.ValueArea(20, 50, 0.70).batch(high, low, volume) + + streamer = ta.ValueArea(20, 50, 0.70) + rows = [] + for h, l, v in zip(high, low, volume): + mid = float((h + l) / 2.0) + out = streamer.update((mid, float(h), float(l), mid, float(v), 0)) + rows.append([math.nan, math.nan, math.nan] if out is None else list(out)) + streamed = np.array(rows, dtype=np.float64) + assert _equal_with_nan(batch, streamed) + + +def test_initial_balance_streaming_matches_batch(ohlc_series): + high, low, _close = ohlc_series + batch = ta.InitialBalance(12).batch(high, low) + + streamer = ta.InitialBalance(12) + rows = [] + for h, l in zip(high, low): + mid = float((h + l) / 2.0) + out = streamer.update((mid, float(h), float(l), mid, 0.0, 0)) + rows.append([math.nan, math.nan] if out is None else list(out)) + streamed = np.array(rows, dtype=np.float64) + assert _equal_with_nan(batch, streamed) + + +def test_opening_range_streaming_matches_batch(ohlc_series): + high, low, close = ohlc_series + batch = ta.OpeningRange(6).batch(high, low, close) + + streamer = ta.OpeningRange(6) + rows = [] + for h, l, c in zip(high, low, close): + out = streamer.update((float(c), float(h), float(l), float(c), 0.0, 0)) + rows.append([math.nan, math.nan, math.nan] if out is None else list(out)) + streamed = np.array(rows, dtype=np.float64) + assert _equal_with_nan(batch, streamed) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 7f87d35b..9899c71a 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -5748,6 +5748,204 @@ impl WasmHeikinAshi { } } +#[wasm_bindgen(js_name = ValueArea)] +pub struct WasmValueArea { + inner: wc::ValueArea, +} + +#[wasm_bindgen(js_class = ValueArea)] +impl WasmValueArea { + #[wasm_bindgen(constructor)] + pub fn new( + period: usize, + bin_count: usize, + value_area_pct: f64, + ) -> Result { + Ok(Self { + inner: wc::ValueArea::new(period, bin_count, value_area_pct).map_err(map_err)?, + }) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + volume: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != volume.len() { + return Err(JsError::new("high, low, volume must be equal length")); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + let mid = f64::midpoint(high[i], low[i]); + let c = wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0).map_err(map_err)?; + if let Some(o) = self.inner.update(c) { + out[i * 3] = o.poc; + out[i * 3 + 1] = o.vah; + out[i * 3 + 2] = o.val; + } + } + Ok(Float64Array::from(out.as_slice())) + } + /// Streaming update. Returns `{ poc, vah, val }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result { + let mid = f64::midpoint(high, low); + let c = wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"poc".into(), &o.poc.into()).ok(); + Reflect::set(&obj, &"vah".into(), &o.vah.into()).ok(); + Reflect::set(&obj, &"val".into(), &o.val.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = InitialBalance)] +pub struct WasmInitialBalance { + inner: wc::InitialBalance, +} + +#[wasm_bindgen(js_class = InitialBalance)] +impl WasmInitialBalance { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::InitialBalance::new(period).map_err(map_err)?, + }) + } + pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result { + if high.len() != low.len() { + return Err(JsError::new("high and low must be equal length")); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 2]; + for i in 0..n { + let mid = f64::midpoint(high[i], low[i]); + let c = wc::Candle::new(mid, high[i], low[i], mid, 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(c) { + out[i * 2] = o.high; + out[i * 2 + 1] = o.low; + } + } + Ok(Float64Array::from(out.as_slice())) + } + /// Streaming update. Returns `{ high, low }`. + pub fn update(&mut self, high: f64, low: f64) -> Result { + let mid = f64::midpoint(high, low); + let c = wc::Candle::new(mid, high, low, mid, 0.0, 0).map_err(map_err)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"high".into(), &o.high.into()).ok(); + Reflect::set(&obj, &"low".into(), &o.low.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = isLocked)] + pub fn is_locked(&self) -> bool { + self.inner.is_locked() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = OpeningRange)] +pub struct WasmOpeningRange { + inner: wc::OpeningRange, +} + +#[wasm_bindgen(js_class = OpeningRange)] +impl WasmOpeningRange { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::OpeningRange::new(period).map_err(map_err)?, + }) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + let c = + 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(c) { + out[i * 3] = o.high; + out[i * 3 + 1] = o.low; + out[i * 3 + 2] = o.breakout_distance; + } + } + Ok(Float64Array::from(out.as_slice())) + } + /// Streaming update. Returns `{ high, low, breakoutDistance }`. + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = wc::Candle::new(close, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"high".into(), &o.high.into()).ok(); + Reflect::set(&obj, &"low".into(), &o.low.into()).ok(); + Reflect::set( + &obj, + &"breakoutDistance".into(), + &o.breakout_distance.into(), + ) + .ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = isLocked)] + pub fn is_locked(&self) -> bool { + self.inner.is_locked() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/wickra-core/src/indicators/initial_balance.rs b/crates/wickra-core/src/indicators/initial_balance.rs new file mode 100644 index 00000000..d9fee982 --- /dev/null +++ b/crates/wickra-core/src/indicators/initial_balance.rs @@ -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 { + 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 { + 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 { + 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 = (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::>() + ); + } + + #[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()); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index bf6fe202..2c99d672 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -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; diff --git a/crates/wickra-core/src/indicators/opening_range.rs b/crates/wickra-core/src/indicators/opening_range.rs new file mode 100644 index 00000000..f0f53f1b --- /dev/null +++ b/crates/wickra-core/src/indicators/opening_range.rs @@ -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, +} + +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 { + 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 { + 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 { + 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 = (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::>() + ); + } + + #[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()); + } +} diff --git a/crates/wickra-core/src/indicators/value_area.rs b/crates/wickra-core/src/indicators/value_area.rs new file mode 100644 index 00000000..be823688 --- /dev/null +++ b/crates/wickra-core/src/indicators/value_area.rs @@ -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, + last: Option, +} + +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 { + 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 { + 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 { + 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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let candles: Vec = (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 = (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 = (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()); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index f76d3f35..b5446402 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -59,21 +59,22 @@ pub use indicators::{ FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, GarmanKlassVolatility, HeikinAshi, HeikinAshiOutput, HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, - IchimokuOutput, Inertia, InstantaneousTrendline, InverseFisherTransform, Jma, Kama, Keltner, - KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, - LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput, - MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, MassIndex, - McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, Natr, Nvi, Obv, - ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo, Pmo, Ppo, Psar, - Pvi, RSquared, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter, - Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SineWave, Skewness, Sma, Smi, Smma, - SpearmanCorrelation, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands, - StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic, StochasticOutput, - SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, TdDifferential, - TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, - TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, Tii, Trima, - Trix, TrueRange, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex, - UltimateOscillator, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator, + IchimokuOutput, Inertia, InitialBalance, InitialBalanceOutput, InstantaneousTrendline, + InverseFisherTransform, Jma, Kama, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, + LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, + MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput, + MarketFacilitationIndex, MassIndex, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, + Mom, Natr, Nvi, Obv, OpeningRange, OpeningRangeOutput, ParkinsonVolatility, PearsonCorrelation, + PercentB, PercentageTrailingStop, Pgo, Pmo, Ppo, Psar, Pvi, RSquared, RenkoTrailingStop, Roc, + RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, + SineWave, Skewness, Sma, Smi, Smma, SpearmanCorrelation, StandardError, StandardErrorBands, + StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, + StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo, + TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, + TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, + TdSequential, TdSequentialOutput, TdSetup, Tema, Tii, Trima, Trix, TrueRange, Tsi, Tsv, + TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea, + ValueAreaOutput, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index aae2fc32..831f30a5 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -25,16 +25,16 @@ use wickra::{ DemarkPivots, DetrendedStdDev, DonchianStop, DoubleBollinger, EhlersStochastic, Ema, EmpiricalModeDecomposition, Fama, FibonacciPivots, FisherTransform, FractalChaosBands, Frama, GarmanKlassVolatility, HeikinAshi, HiLoActivator, HilbertDominantCycle, HurstChannel, - HurstExponent, Ichimoku, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, Kst, - Kurtosis, Kvo, LinRegChannel, MaEnvelope, MacdIndicator, Mama, MarketFacilitationIndex, - McGinleyDynamic, MedianAbsoluteDeviation, Nvi, Obv, ParkinsonVolatility, - PercentageTrailingStop, Pgo, Pvi, RSquared, RenkoTrailingStop, RogersSatchellVolatility, - RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, SineWave, Skewness, Sma, StandardError, - StandardErrorBands, StarcBands, StepTrailingStop, Stochastic, SuperSmoother, TdCombo, - TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, - TdRiskLevel, TdSequential, TdSetup, Tii, Tsv, TtmSqueeze, Variance, Vidya, VoltyStop, - VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, WilliamsFractals, Wma, WoodiePivots, - YangZhangVolatility, YoyoExit, ZigZag, + HurstExponent, Ichimoku, Indicator, InitialBalance, InstantaneousTrendline, + InverseFisherTransform, Jma, Kst, Kurtosis, Kvo, LinRegChannel, MaEnvelope, MacdIndicator, + Mama, MarketFacilitationIndex, McGinleyDynamic, MedianAbsoluteDeviation, Nvi, Obv, + OpeningRange, ParkinsonVolatility, PercentageTrailingStop, Pgo, Pvi, RSquared, + RenkoTrailingStop, RogersSatchellVolatility, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, + SineWave, Skewness, Sma, StandardError, StandardErrorBands, StarcBands, StepTrailingStop, + Stochastic, SuperSmoother, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, + TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, Tii, Tsv, TtmSqueeze, + ValueArea, Variance, Vidya, VoltyStop, VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, + WilliamsFractals, Wma, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag, }; use wickra_data::csv::CandleReader; @@ -387,6 +387,17 @@ fn benches(c: &mut Criterion) { bench_scalar(c, "hurst_exponent", &closes, || { HurstExponent::new(100, 4).unwrap() }); + + // --- Family 16: Market Profile --- + bench_candle_input(c, "value_area", &candles, || { + ValueArea::new(20, 50, 0.70).unwrap() + }); + bench_candle_input(c, "initial_balance", &candles, || { + InitialBalance::new(12).unwrap() + }); + bench_candle_input(c, "opening_range", &candles, || { + OpeningRange::new(6).unwrap() + }); } /// Variant of `bench_scalar` for scalar-input indicators whose output is *not* diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index a046fef6..e8288ae4 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -28,15 +28,16 @@ use wickra_core::{ AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, DemandIndex, DemarkPivots, Donchian, DonchianStop, - EaseOfMovement, Evwma, FibonacciPivots, ForceIndex, FractalChaosBands, - GarmanKlassVolatility, HeikinAshi, HiLoActivator, HurstChannel, Ichimoku, Indicator, - Inertia, Keltner, Kvo, MarketFacilitationIndex, MassIndex, MedianPrice, Mfi, Natr, Nvi, - Obv, ParkinsonVolatility, Pgo, Psar, Pvi, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, - Smi, StarcBands, Stochastic, SuperTrend, TdCombo, TdCountdown, TdDeMarker, TdDifferential, + EaseOfMovement, Evwma, FibonacciPivots, ForceIndex, FractalChaosBands, GarmanKlassVolatility, + HeikinAshi, HiLoActivator, HurstChannel, Ichimoku, Indicator, Inertia, InitialBalance, + Keltner, Kvo, MarketFacilitationIndex, MassIndex, MedianPrice, Mfi, Natr, Nvi, Obv, + OpeningRange, ParkinsonVolatility, Pgo, Psar, Pvi, RogersSatchellVolatility, RollingVwap, Rvi, + Rwi, Smi, StarcBands, Stochastic, SuperTrend, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, - TrueRange, Tsv, TtmSqueeze, TypicalPrice, UltimateOscillator, VoltyStop, VolumeOscillator, - VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, - WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag, + TrueRange, Tsv, TtmSqueeze, TypicalPrice, UltimateOscillator, ValueArea, VoltyStop, + VolumeOscillator, VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, + WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, + ZigZag, }; /// Convert a flat `f64` stream into a `Vec` by chunking it into @@ -166,6 +167,11 @@ fuzz_target!(|data: Vec| { let _ = Stochastic::new(14, 3).unwrap().batch(&candles); } + // --- Market Profile (multi-output) --- + drive(|| ValueArea::new(20, 50, 0.70).unwrap(), &candles); + drive(|| InitialBalance::new(12).unwrap(), &candles); + drive(|| OpeningRange::new(6).unwrap(), &candles); + // --- Ichimoku (5 lines, hand-rolled because of multi-Option output) --- { let mut ichi = Ichimoku::classic();