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