F9: add Accumulation/Distribution Line and Volume-Price Trend

Completes the F9 family (Cumulative volume) end to end:

- Rust core: adl.rs (Accumulation/Distribution Line — cumulative
  range-weighted volume) and vpt.rs (Volume-Price Trend — cumulative
  volume scaled by percentage price change). Each with a full Indicator
  impl, runnable doctest and reference / cumulative-property / warmup /
  reset / batch==streaming tests.
- Python: PyAdl / PyVolumePriceTrend PyO3 classes + module registration
  + .pyi stubs (no parameters, like OBV/VWAP).
- Node: explicit AdlNode and VolumePriceTrendNode; index.d.ts and
  index.js updated.
- WASM: WasmAdl and WasmVolumePriceTrend.
- Wiki: Indicator-Adl.md and Indicator-VolumePriceTrend.md plus rows in
  Indicators-Overview.md and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 373 core tests,
25 data tests and 53 doctests green.
This commit is contained in:
kingchenc
2026-05-22 18:38:21 +02:00
parent 99dd144576
commit 81962485af
13 changed files with 1088 additions and 8 deletions
+3 -1
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, NATR, StdDev, UlcerIndex, HistoricalVolatility, BollingerBandwidth, PercentB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, NATR, StdDev, UlcerIndex, HistoricalVolatility, BollingerBandwidth, PercentB, ADL, VolumePriceTrend, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -345,6 +345,8 @@ module.exports.UlcerIndex = UlcerIndex
module.exports.HistoricalVolatility = HistoricalVolatility
module.exports.BollingerBandwidth = BollingerBandwidth
module.exports.PercentB = PercentB
module.exports.ADL = ADL
module.exports.VolumePriceTrend = VolumePriceTrend
module.exports.MACD = MACD
module.exports.BollingerBands = BollingerBands
module.exports.ATR = ATR
+124
View File
@@ -1147,6 +1147,130 @@ impl PmoNode {
// ============================== VWMA ==============================
// ============================== ADL ==============================
#[napi(js_name = "ADL")]
pub struct AdlNode {
inner: wc::Adl,
}
impl Default for AdlNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl AdlNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::Adl::new(),
}
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, volume)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, close, volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[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
}
}
// ============================== Volume-Price Trend ==============================
#[napi(js_name = "VolumePriceTrend")]
pub struct VolumePriceTrendNode {
inner: wc::VolumePriceTrend,
}
impl Default for VolumePriceTrendNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl VolumePriceTrendNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::VolumePriceTrend::new(),
}
}
#[napi]
pub fn update(&mut self, close: f64, volume: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(close, close, close, volume)?))
}
#[napi]
pub fn batch(&mut self, close: Vec<f64>, volume: Vec<f64>) -> napi::Result<Vec<f64>> {
if close.len() != volume.len() {
return Err(NapiError::from_reason(
"close and volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd(close[i], close[i], close[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[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
}
}
// ============================== Bollinger Bandwidth ==============================
#[napi(js_name = "BollingerBandwidth")]
@@ -76,6 +76,36 @@ class TRIMA:
@property
def value(self) -> Optional[float]: ...
class ADL:
def __init__(self) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class VolumePriceTrend:
def __init__(self) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class BollingerBandwidth:
def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ...
def update(self, value: float) -> Optional[float]: ...
+136
View File
@@ -1519,6 +1519,140 @@ impl PyAroon {
}
}
// ============================== ADL ==============================
#[pyclass(name = "ADL", module = "wickra._wickra")]
#[derive(Clone)]
struct PyAdl {
inner: wc::Adl,
}
#[pymethods]
impl PyAdl {
#[new]
fn new() -> Self {
Self {
inner: wc::Adl::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, volume (all equal length).
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, 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))?;
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 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], v[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray_bound(py))
}
#[getter]
fn value(&self) -> Option<f64> {
self.inner.value()
}
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 {
"ADL()".to_string()
}
}
// ============================== Volume-Price Trend ==============================
#[pyclass(name = "VolumePriceTrend", module = "wickra._wickra")]
#[derive(Clone)]
struct PyVolumePriceTrend {
inner: wc::VolumePriceTrend,
}
#[pymethods]
impl PyVolumePriceTrend {
#[new]
fn new() -> Self {
Self {
inner: wc::VolumePriceTrend::new(),
}
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy close + volume arrays (both 1-D, equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
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 c.len() != v.len() {
return Err(PyValueError::new_err(
"close and volume must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray_bound(py))
}
#[getter]
fn value(&self) -> Option<f64> {
self.inner.value()
}
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 {
"VolumePriceTrend()".to_string()
}
}
// ============================== Bollinger Bandwidth ==============================
#[pyclass(name = "BollingerBandwidth", module = "wickra._wickra")]
@@ -2911,5 +3045,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyHistoricalVolatility>()?;
m.add_class::<PyBollingerBandwidth>()?;
m.add_class::<PyPercentB>()?;
m.add_class::<PyAdl>()?;
m.add_class::<PyVolumePriceTrend>()?;
Ok(())
}
+93
View File
@@ -377,6 +377,99 @@ impl WasmUltimateOscillator {
}
}
#[wasm_bindgen(js_name = ADL)]
pub struct WasmAdl {
inner: wc::Adl,
}
impl Default for WasmAdl {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = ADL)]
impl WasmAdl {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmAdl {
Self {
inner: wc::Adl::new(),
}
}
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n || volume.len() != n {
return Err(JsError::new(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], volume[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = VolumePriceTrend)]
pub struct WasmVolumePriceTrend {
inner: wc::VolumePriceTrend,
}
impl Default for WasmVolumePriceTrend {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = VolumePriceTrend)]
impl WasmVolumePriceTrend {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmVolumePriceTrend {
Self {
inner: wc::VolumePriceTrend::new(),
}
}
pub fn update(&mut self, close: f64, volume: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(close, close, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
if close.len() != volume.len() {
return Err(JsError::new("close and volume must be equal length"));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(close[i], close[i], close[i], volume[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = NATR)]
pub struct WasmNatr {
inner: wc::Natr,