F13b: add True Range, Chaikin Volatility, Z-Score and Linear Regression Angle

Second half of the eight indicators that fill out the new family taxonomy.

- Rust core: true_range.rs (TrueRange — the raw single-bar volatility ATR
  averages), chaikin_volatility.rs (ChaikinVolatility — rate of change of a
  smoothed high-low spread), z_score.rs (ZScore — price normalised against
  its rolling mean and standard deviation) and linreg_angle.rs (LinRegAngle
  — the rolling regression slope as a degree angle). Each with a full
  Indicator impl, runnable doctest and reference / property / warmup /
  reset / batch==streaming tests.
- Python / Node / WASM: classes wired through all three bindings (ZScore
  and LinRegAngle ride the scalar macros where possible) plus .pyi stubs
  and __init__.py / __all__ entries.
- Wiki: four new Indicator-*.md pages.

The eight-family taxonomy restructure (Overview / Home / README / folder
layout) lands next in F13c.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 508 core tests,
25 data tests and 74 doctests green.
This commit is contained in:
kingchenc
2026-05-22 21:06:36 +02:00
parent e452d35a27
commit 6643f7a81d
16 changed files with 1837 additions and 7 deletions
+226
View File
@@ -4103,6 +4103,228 @@ impl PyVerticalHorizontalFilter {
}
}
// ============================== True Range ==============================
#[pyclass(name = "TrueRange", module = "wickra._wickra")]
#[derive(Clone)]
struct PyTrueRange {
inner: wc::TrueRange,
}
#[pymethods]
impl PyTrueRange {
#[new]
fn new() -> Self {
Self {
inner: wc::TrueRange::new(),
}
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy columns high, low, close (all equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<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 mut out = Vec::with_capacity(h.len());
for i in 0..h.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray_bound(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()
}
fn __repr__(&self) -> String {
"TrueRange()".to_string()
}
}
// ============================== Chaikin Volatility ==============================
#[pyclass(name = "ChaikinVolatility", module = "wickra._wickra")]
#[derive(Clone)]
struct PyChaikinVolatility {
inner: wc::ChaikinVolatility,
}
#[pymethods]
impl PyChaikinVolatility {
#[new]
#[pyo3(signature = (ema_period=10, roc_period=10))]
fn new(ema_period: usize, roc_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::ChaikinVolatility::new(ema_period, roc_period).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy columns high, low (both equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<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 mut out = Vec::with_capacity(h.len());
for i in 0..h.len() {
let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray_bound(py))
}
#[getter]
fn periods(&self) -> (usize, usize) {
self.inner.periods()
}
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 (ema, roc) = self.inner.periods();
format!("ChaikinVolatility(ema_period={ema}, roc_period={roc})")
}
}
// ============================== Z-Score ==============================
#[pyclass(name = "ZScore", module = "wickra._wickra")]
#[derive(Clone)]
struct PyZScore {
inner: wc::ZScore,
}
#[pymethods]
impl PyZScore {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::ZScore::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
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!("ZScore(period={})", self.inner.period())
}
}
// ============================== Linear Regression Angle ==============================
#[pyclass(name = "LinRegAngle", module = "wickra._wickra")]
#[derive(Clone)]
struct PyLinRegAngle {
inner: wc::LinRegAngle,
}
#[pymethods]
impl PyLinRegAngle {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::LinRegAngle::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
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!("LinRegAngle(period={})", self.inner.period())
}
}
// ============================== Module ==============================
#[pymodule]
@@ -4175,5 +4397,9 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyBalanceOfPower>()?;
m.add_class::<PyChoppinessIndex>()?;
m.add_class::<PyVerticalHorizontalFilter>()?;
m.add_class::<PyTrueRange>()?;
m.add_class::<PyChaikinVolatility>()?;
m.add_class::<PyZScore>()?;
m.add_class::<PyLinRegAngle>()?;
Ok(())
}