feat: Family 05 Bands & Channels - 11 new price-envelope indicators (#43)

* feat(bands-channels): add Family 05 with 11 indicators

Eleven price-envelope overlays organised into a new "Bands & Channels"
family, exposed across all four bindings (Rust core, Python, Node, WASM)
plus fuzz/test/bench/docs coverage:

- MaEnvelope - SMA centerline with fixed-percent envelope (the oldest
  band overlay still in regular use).
- AccelerationBands (Price Headley) - momentum-biased bands that widen
  with the bar's relative range (H - L) / (H + L).
- StarcBands (Stoller Average Range Channel) - SMA(close) +/- k*ATR;
  Keltner's SMA-centerline sibling.
- AtrBands - close-anchored envelope of width k*ATR; the standard
  volatility-targeting stop/target band.
- HurstChannel - SMA centerline wrapped by the rolling high-low range
  (Brian Millard / Hurst-cycle channel).
- LinRegChannel - rolling OLS endpoint +/- k * population stddev of the
  residuals; dispersion about the trend rather than the mean.
- StandardErrorBands - regression line +/- k * OLS standard error
  (denominator n - 2) for prediction-interval bands.
- DoubleBollinger (Kathy Lien) - two concentric BB envelopes
  (typically +/- 1 sigma and +/- 2 sigma) for the zone-partition setup.
- TtmSqueeze (John Carter) - BB-inside-KC squeeze flag paired with a
  detrended-close linear-regression momentum reading.
- FractalChaosBands - Bill Williams 5-bar fractal high/low envelope.
- VwapStdDevBands - cumulative VWAP with volume-weighted population
  standard deviation bands.

Each indicator ships:
- Core impl with the full Indicator trait, classic() where applicable,
  and unit tests (rejects_zero_period / multiplier, accessors, flat
  market, monotonic ordering, batch == streaming, reset, plus
  algebraically verifiable reference values).
- Python PyO3 binding with multi-column NumPy batch (PyArray2).
- Node napi binding with #[napi(object)] struct + interleaved flat
  batch.
- WASM wasm-bindgen binding via Object/Reflect for update +
  Float64Array for batch.
- Fuzz coverage in fuzz_targets/indicator_update{,_candle}.rs.
- Python streaming-vs-batch parametric test + reference test.
- Node streaming-vs-interleaved-batch test + reference test.
- Criterion microbench under crates/wickra/benches/indicators.rs.

README family table, README indicator-count line, and CHANGELOG
Unreleased entry updated: indicator total rises from 71 to 82 across
nine families. Wiki pages are updated in a separate commit in the
wickra.wiki repo.

* test(acceleration-bands): cover sum_hl==0 zero-price guard

Exercises line 104 (`0.0` branch of the `sum_hl == 0.0` guard) which
was the last patch-coverage miss on the family-05 PR. `Candle::new`
accepts a fully-zero bar so the branch is reachable in principle —
add a degenerate-candle unit test to hit it.
This commit is contained in:
kingchenc
2026-05-25 18:37:12 +02:00
committed by GitHub
parent 3ea0f12b7a
commit 54194a4ff8
25 changed files with 5482 additions and 33 deletions
+24
View File
@@ -127,6 +127,18 @@ from ._wickra import (
LinRegSlope,
ZScore,
LinRegAngle,
# Bands & Channels
MaEnvelope,
AccelerationBands,
StarcBands,
AtrBands,
HurstChannel,
LinRegChannel,
StandardErrorBands,
DoubleBollinger,
TtmSqueeze,
FractalChaosBands,
VwapStdDevBands,
)
__all__ = [
@@ -233,4 +245,16 @@ __all__ = [
"LinRegSlope",
"ZScore",
"LinRegAngle",
# Bands & Channels
"MaEnvelope",
"AccelerationBands",
"StarcBands",
"AtrBands",
"HurstChannel",
"LinRegChannel",
"StandardErrorBands",
"DoubleBollinger",
"TtmSqueeze",
"FractalChaosBands",
"VwapStdDevBands",
]
+738
View File
@@ -5949,9 +5949,736 @@ impl PyRviVolatility {
}
}
// ============================== MA Envelope ==============================
#[pyclass(name = "MaEnvelope", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyMaEnvelope {
inner: wc::MaEnvelope,
}
#[pymethods]
impl PyMaEnvelope {
#[new]
#[pyo3(signature = (period=20, percent=0.025))]
fn new(period: usize, percent: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::MaEnvelope::new(period, percent).map_err(map_err)?,
})
}
/// Returns `(upper, middle, lower)` or `None` during warmup.
fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> {
self.inner
.update(value)
.map(|o| (o.upper, o.middle, o.lower))
}
/// Batch returns shape `(n, 3)` columns `[upper, middle, lower]`.
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let n = slice.len();
let mut out = vec![f64::NAN; n * 3];
for (i, p) in slice.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
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()
}
}
// ============================== Acceleration Bands ==============================
#[pyclass(
name = "AccelerationBands",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyAccelerationBands {
inner: wc::AccelerationBands,
}
#[pymethods]
impl PyAccelerationBands {
#[new]
#[pyo3(signature = (period=20, factor=0.001))]
fn new(period: usize, factor: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::AccelerationBands::new(period, factor).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.upper, o.middle, o.lower)))
}
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.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
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()
}
}
// ============================== STARC Bands ==============================
#[pyclass(name = "StarcBands", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyStarcBands {
inner: wc::StarcBands,
}
#[pymethods]
impl PyStarcBands {
#[new]
#[pyo3(signature = (sma_period=6, atr_period=15, multiplier=2.0))]
fn new(sma_period: usize, atr_period: usize, multiplier: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::StarcBands::new(sma_period, atr_period, multiplier).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.upper, o.middle, o.lower)))
}
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.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
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()
}
}
// ============================== ATR Bands ==============================
#[pyclass(name = "AtrBands", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyAtrBands {
inner: wc::AtrBands,
}
#[pymethods]
impl PyAtrBands {
#[new]
#[pyo3(signature = (period=14, multiplier=3.0))]
fn new(period: usize, multiplier: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::AtrBands::new(period, multiplier).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.upper, o.middle, o.lower)))
}
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.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
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()
}
}
// ============================== Hurst Channel ==============================
#[pyclass(name = "HurstChannel", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyHurstChannel {
inner: wc::HurstChannel,
}
#[pymethods]
impl PyHurstChannel {
#[new]
#[pyo3(signature = (period=10, multiplier=0.5))]
fn new(period: usize, multiplier: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::HurstChannel::new(period, multiplier).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.upper, o.middle, o.lower)))
}
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.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
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()
}
}
// ============================== LinReg Channel ==============================
#[pyclass(name = "LinRegChannel", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyLinRegChannel {
inner: wc::LinRegChannel,
}
#[pymethods]
impl PyLinRegChannel {
#[new]
#[pyo3(signature = (period=20, multiplier=2.0))]
fn new(period: usize, multiplier: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::LinRegChannel::new(period, multiplier).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> {
self.inner
.update(value)
.map(|o| (o.upper, o.middle, o.lower))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let n = slice.len();
let mut out = vec![f64::NAN; n * 3];
for (i, p) in slice.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
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()
}
}
// ============================== Standard Error Bands ==============================
#[pyclass(
name = "StandardErrorBands",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyStandardErrorBands {
inner: wc::StandardErrorBands,
}
#[pymethods]
impl PyStandardErrorBands {
#[new]
#[pyo3(signature = (period=21, multiplier=2.0))]
fn new(period: usize, multiplier: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::StandardErrorBands::new(period, multiplier).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> {
self.inner
.update(value)
.map(|o| (o.upper, o.middle, o.lower))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let n = slice.len();
let mut out = vec![f64::NAN; n * 3];
for (i, p) in slice.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
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()
}
}
// ============================== Double Bollinger ==============================
#[pyclass(
name = "DoubleBollinger",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyDoubleBollinger {
inner: wc::DoubleBollinger,
}
#[pymethods]
impl PyDoubleBollinger {
#[new]
#[pyo3(signature = (period=20, k_inner=1.0, k_outer=2.0))]
fn new(period: usize, k_inner: f64, k_outer: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::DoubleBollinger::new(period, k_inner, k_outer).map_err(map_err)?,
})
}
/// Returns `(upper_outer, upper_inner, middle, lower_inner, lower_outer)`.
fn update(&mut self, value: f64) -> Option<(f64, f64, f64, f64, f64)> {
self.inner.update(value).map(|o| {
(
o.upper_outer,
o.upper_inner,
o.middle,
o.lower_inner,
o.lower_outer,
)
})
}
/// Returns shape `(n, 5)` columns
/// `[upper_outer, upper_inner, middle, lower_inner, lower_outer]`.
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let n = slice.len();
let mut out = vec![f64::NAN; n * 5];
for (i, p) in slice.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 5] = o.upper_outer;
out[i * 5 + 1] = o.upper_inner;
out[i * 5 + 2] = o.middle;
out[i * 5 + 3] = o.lower_inner;
out[i * 5 + 4] = o.lower_outer;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 5), out)
.expect("shape consistent")
.into_pyarray(py))
}
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()
}
}
// ============================== TTM Squeeze ==============================
#[pyclass(name = "TtmSqueeze", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTtmSqueeze {
inner: wc::TtmSqueeze,
}
#[pymethods]
impl PyTtmSqueeze {
#[new]
#[pyo3(signature = (period=20, bb_mult=2.0, kc_mult=1.5))]
fn new(period: usize, bb_mult: f64, kc_mult: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::TtmSqueeze::new(period, bb_mult, kc_mult).map_err(map_err)?,
})
}
/// Returns `(squeeze, momentum)` or `None` during warmup. `squeeze` is
/// `1.0` while BB ⊂ KC, `0.0` otherwise.
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.squeeze, o.momentum)))
}
/// Returns shape `(n, 2)` columns `[squeeze, momentum]`.
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 * 2];
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 * 2] = o.squeeze;
out[i * 2 + 1] = o.momentum;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
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()
}
}
// ============================== Fractal Chaos Bands ==============================
#[pyclass(
name = "FractalChaosBands",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyFractalChaosBands {
inner: wc::FractalChaosBands,
}
#[pymethods]
impl PyFractalChaosBands {
#[new]
#[pyo3(signature = (k=2))]
fn new(k: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::FractalChaosBands::new(k).map_err(map_err)?,
})
}
/// Returns `(upper, lower)` or `None` until both fractals have confirmed.
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.upper, o.lower)))
}
/// Returns shape `(n, 2)` columns `[upper, lower]`.
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 candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.upper;
out[i * 2 + 1] = o.lower;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
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()
}
}
// ============================== VWAP StdDev Bands ==============================
#[pyclass(
name = "VwapStdDevBands",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyVwapStdDevBands {
inner: wc::VwapStdDevBands,
}
#[pymethods]
impl PyVwapStdDevBands {
#[new]
#[pyo3(signature = (multiplier=2.0))]
fn new(multiplier: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::VwapStdDevBands::new(multiplier).map_err(map_err)?,
})
}
/// Returns `(upper, middle, lower, stddev)` or `None` until volume is non-zero.
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64, f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self
.inner
.update(c)
.map(|o| (o.upper, o.middle, o.lower, o.stddev)))
}
/// Returns shape `(n, 4)` columns `[upper, middle, lower, stddev]`.
#[allow(clippy::many_single_char_names)]
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: 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 c = close
.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() != c.len() || c.len() != v.len() {
return Err(PyValueError::new_err(
"high, low, close, volume must be equal length",
));
}
let n = h.len();
let mut out = vec![f64::NAN; n * 4];
for i in 0..n {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 4] = o.upper;
out[i * 4 + 1] = o.middle;
out[i * 4 + 2] = o.lower;
out[i * 4 + 3] = o.stddev;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 4), out)
.expect("shape consistent")
.into_pyarray(py))
}
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()
}
}
// ============================== Module ==============================
#[pymodule]
#[allow(clippy::too_many_lines)]
fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
m.add_class::<PySma>()?;
@@ -6051,5 +6778,16 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyGarmanKlassVolatility>()?;
m.add_class::<PyRogersSatchellVolatility>()?;
m.add_class::<PyYangZhangVolatility>()?;
m.add_class::<PyMaEnvelope>()?;
m.add_class::<PyAccelerationBands>()?;
m.add_class::<PyStarcBands>()?;
m.add_class::<PyAtrBands>()?;
m.add_class::<PyHurstChannel>()?;
m.add_class::<PyLinRegChannel>()?;
m.add_class::<PyStandardErrorBands>()?;
m.add_class::<PyDoubleBollinger>()?;
m.add_class::<PyTtmSqueeze>()?;
m.add_class::<PyFractalChaosBands>()?;
m.add_class::<PyVwapStdDevBands>()?;
Ok(())
}
@@ -78,6 +78,16 @@ SCALAR = [
]
# Family 05 band/channel indicators with scalar input and multi-output.
# `cols` is the expected number of band columns from `batch`.
SCALAR_MULTI = {
"MaEnvelope": (lambda: ta.MaEnvelope(20, 0.025), 3),
"LinRegChannel": (lambda: ta.LinRegChannel(20, 2.0), 3),
"StandardErrorBands": (lambda: ta.StandardErrorBands(21, 2.0), 3),
"DoubleBollinger": (lambda: ta.DoubleBollinger(20, 1.0, 2.0), 5),
}
@pytest.mark.parametrize("cls, args", SCALAR, ids=[c.__name__ for c, _ in SCALAR])
def test_scalar_streaming_matches_batch(cls, args, sine_prices):
batch = cls(*args).batch(sine_prices)
@@ -247,6 +257,26 @@ MULTI = {
lambda: ta.ChandeKrollStop(10, 1.0, 9),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
# Family 05 candle-input bands. Each entry is
# `(factory, batch_call, output_arity, streaming_fields)` where
# `streaming_fields` is the tuple shape returned by `update(...)`.
"TtmSqueeze": (
lambda: ta.TtmSqueeze(20, 2.0, 1.5),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"FractalChaosBands": (
lambda: ta.FractalChaosBands(2),
lambda ind, h, l, c, v: ind.batch(h, l),
),
}
# Bands with 3 outputs upper/middle/lower from a candle (h, l, c).
HLC_BAND3 = {
"AccelerationBands": lambda: ta.AccelerationBands(20, 0.001),
"StarcBands": lambda: ta.StarcBands(6, 15, 2.0),
"AtrBands": lambda: ta.AtrBands(14, 3.0),
"HurstChannel": lambda: ta.HurstChannel(10, 0.5),
}
# --- Scalar-input, multi-output indicators --------------------------------
@@ -286,6 +316,73 @@ def test_multi_streaming_matches_batch(name, ohlcv):
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch"
# --- Family 05: scalar-input multi-output band/channel indicators ----------
@pytest.mark.parametrize("name", list(SCALAR_MULTI))
def test_scalar_multi_streaming_matches_batch(name, sine_prices):
make, cols = SCALAR_MULTI[name]
batch = make().batch(sine_prices)
assert batch.shape == (sine_prices.size, cols)
streamer = make()
rows = []
for p in sine_prices:
v = streamer.update(float(p))
rows.append([math.nan] * cols if v is None else list(v))
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch"
# --- Family 05: 3-band candle-input indicators ------------------------------
@pytest.mark.parametrize("name", list(HLC_BAND3))
def test_hlc_band3_streaming_matches_batch(name, ohlcv):
high, low, close, _ = ohlcv
make = HLC_BAND3[name]
batch = make().batch(high, low, close)
assert batch.shape == (close.size, 3)
streamer = make()
rows = []
for i in range(close.size):
candle = (
float(close[i]),
float(high[i]),
float(low[i]),
float(close[i]),
1.0,
i,
)
v = streamer.update(candle)
rows.append([math.nan] * 3 if v is None else list(v))
assert _eq_nan(batch, np.array(rows, dtype=np.float64)), f"{name} mismatch"
# --- VWAP StdDev Bands (4 outputs, needs volume) ----------------------------
def test_vwap_stddev_bands_streaming_matches_batch(ohlcv):
high, low, close, volume = ohlcv
batch = ta.VwapStdDevBands(2.0).batch(high, low, close, volume)
assert batch.shape == (close.size, 4)
streamer = ta.VwapStdDevBands(2.0)
rows = []
for i in range(close.size):
candle = (
float(close[i]),
float(high[i]),
float(low[i]),
float(close[i]),
float(volume[i]),
i,
)
v = streamer.update(candle)
rows.append([math.nan] * 4 if v is None else list(v))
assert _eq_nan(batch, np.array(rows, dtype=np.float64))
@pytest.mark.parametrize("name", list(MULTI_SCALAR_INPUT))
def test_multi_scalar_streaming_matches_batch(name, ohlcv):
_, _, close, _ = ohlcv
@@ -399,6 +496,108 @@ def test_z_score_reference():
assert out[1] == pytest.approx(1.0)
# --- Family 05 reference values ---------------------------------------------
def test_ma_envelope_reference():
# SMA([10, 20, 30]) = 20; with percent = 0.10: upper = 22, lower = 18.
out = ta.MaEnvelope(3, 0.10).batch(np.array([10.0, 20.0, 30.0]))
assert math.isnan(out[0, 0]) and math.isnan(out[1, 0])
assert out[2, 0] == pytest.approx(22.0) # upper
assert out[2, 1] == pytest.approx(20.0) # middle
assert out[2, 2] == pytest.approx(18.0) # lower
def test_acceleration_bands_reference():
# Single bar: high=12, low=8, close=10, factor=0.5, period=1.
# ratio = 4/20 = 0.2; raw_up = 12·1.1 = 13.2; raw_lo = 8·0.9 = 7.2.
v = ta.AccelerationBands(1, 0.5).update((10.0, 12.0, 8.0, 10.0, 1.0, 0))
assert v == pytest.approx((13.2, 10.0, 7.2))
def test_atr_bands_reference():
# Five identical bars (h=11, l=9, c=10) → ATR=2, close=10, mult=3:
# upper=16, middle=10, lower=4.
out = ta.AtrBands(5, 3.0).batch(
np.array([11.0] * 5), np.array([9.0] * 5), np.array([10.0] * 5)
)
assert math.isnan(out[3, 0])
assert out[4, 0] == pytest.approx(16.0)
assert out[4, 1] == pytest.approx(10.0)
assert out[4, 2] == pytest.approx(4.0)
def test_hurst_channel_reference():
# Five identical (h=12, l=8, c=10): SMA(close)=10, range=4, mult=0.5.
out = ta.HurstChannel(5, 0.5).batch(
np.array([12.0] * 5), np.array([8.0] * 5), np.array([10.0] * 5)
)
assert out[4, 0] == pytest.approx(12.0)
assert out[4, 1] == pytest.approx(10.0)
assert out[4, 2] == pytest.approx(8.0)
def test_linreg_channel_reference():
# period 3 over [1, 2, 9]: line y=4x, endpoint=8, residuals=[1, -2, 1],
# population sigma=sqrt(2); mult=2 → upper=8+2√2, lower=8-2√2.
out = ta.LinRegChannel(3, 2.0).batch(np.array([1.0, 2.0, 9.0]))
s = math.sqrt(2.0)
assert out[2, 0] == pytest.approx(8.0 + 2.0 * s)
assert out[2, 1] == pytest.approx(8.0)
assert out[2, 2] == pytest.approx(8.0 - 2.0 * s)
def test_standard_error_bands_reference():
# Same [1, 2, 9] with n=3: SSE=6, n-2=1, stderr=sqrt(6); mult=2 →
# upper=8+2√6, lower=8-2√6.
out = ta.StandardErrorBands(3, 2.0).batch(np.array([1.0, 2.0, 9.0]))
s = math.sqrt(6.0)
assert out[2, 0] == pytest.approx(8.0 + 2.0 * s)
assert out[2, 1] == pytest.approx(8.0)
assert out[2, 2] == pytest.approx(8.0 - 2.0 * s)
def test_double_bollinger_orders_bands():
# On a non-trivial dispersion, outer >= inner >= middle >= -inner >= -outer.
out = ta.DoubleBollinger(5, 1.0, 2.0).batch(
np.array([1.0, 5.0, 2.0, 4.0, 3.0, 6.0])
)
v = out[5]
assert v[0] >= v[1] >= v[2] >= v[3] >= v[4]
def test_vwap_stddev_bands_reference():
# Two equal-volume bars with tp=8, tp=12: vwap=10, σ=2, mult=1.5 →
# upper=13, lower=7.
v = ta.VwapStdDevBands(1.5)
v.update((8.0, 8.0, 8.0, 8.0, 1.0, 0))
out = v.update((12.0, 12.0, 12.0, 12.0, 1.0, 1))
assert out[0] == pytest.approx(13.0)
assert out[1] == pytest.approx(10.0)
assert out[2] == pytest.approx(7.0)
assert out[3] == pytest.approx(2.0)
def test_ttm_squeeze_flat_market():
# Zero volatility: BB and KC both collapse to a point → squeeze=1.0,
# momentum=0.0.
candles_h = np.array([10.0] * 25)
out = ta.TtmSqueeze(20, 2.0, 1.5).batch(candles_h, candles_h, candles_h)
assert out[24, 0] == pytest.approx(1.0)
assert out[24, 1] == pytest.approx(0.0)
def test_fractal_chaos_bands_detects_peak_and_trough():
# Sequence that creates one fractal high (i=2) and one low (i=3).
h = np.array([1.0, 2.0, 5.0, 3.0, 2.0, 1.0, 2.0])
l = np.array([1.0, 2.0, 3.0, 0.5, 2.0, 1.0, 2.0])
out = ta.FractalChaosBands(2).batch(h, l)
# First bar with both bands set is index 5.
assert math.isnan(out[4, 0])
assert out[5, 0] == pytest.approx(5.0)
assert out[5, 1] == pytest.approx(0.5)
# --- Lifecycle ------------------------------------------------------------
@@ -407,6 +606,9 @@ def test_new_indicators_expose_lifecycle():
instances += [make() for make, _ in MULTI.values()]
instances += [make() for make, _ in MULTI_SCALAR_INPUT.values()]
instances += [cls(*args) for cls, args in SCALAR]
instances += [make() for make, _ in SCALAR_MULTI.values()]
instances += [make() for make in HLC_BAND3.values()]
instances += [ta.VwapStdDevBands(2.0)]
instances.append(ta.Alligator(13, 8, 5))
instances.append(ta.ZeroLagMACD(12, 26, 9))
for ind in instances: