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:
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
@@ -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) -------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user