Add B9 Price Statistics deepening (5 indicators) (#197)

Deepens the **Price Statistics** family (B9) with five rolling-statistics indicators (447 -> 452):

- **ShannonEntropy** — Shannon entropy of a binned rolling value distribution.
- **SampleEntropy** — Richman-Moorman sample entropy (regularity/complexity of a window).
- **KendallTau** — Kendall rank correlation (tau-b) over paired observations (pairwise; distinct from Pearson/Spearman).
- **JarqueBera** — Jarque-Bera normality test statistic over a rolling window.
- **RollingMinMaxScaler** — maps the latest value to 0..1 over a rolling window.

All scalar f64 input except KendallTau (pairwise). Multi-arg scalars (Shannon/Sample entropy) use hand-written Python/Node bindings + the variadic wasm macro; KendallTau uses the pair macros. Verified locally: 3668 core lib + 410 doc tests, clippy clean, 527 node tests, 871 pytest, counter 452.
This commit is contained in:
kingchenc
2026-06-07 03:08:53 +02:00
committed by GitHub
parent 81406e7a1b
commit 389200f855
20 changed files with 1922 additions and 43 deletions
@@ -28,6 +28,10 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
SAMPLEENT: () => new wickra.SAMPLEENT(20, 2, 0.2),
SHANNONENT: () => new wickra.SHANNONENT(20, 8),
ROLLINGMINMAX: () => new wickra.ROLLINGMINMAX(20),
JARQUEBERA: () => new wickra.JARQUEBERA(20),
BipowerVariation: () => new wickra.BipowerVariation(20),
VolatilityOfVolatility: () => new wickra.VolatilityOfVolatility(20, 20),
Garch11: () => new wickra.Garch11(0.000002, 0.1, 0.88),
@@ -629,6 +633,7 @@ const pairFactories = {
VarianceRatio: () => new wickra.VarianceRatio(60, 2),
GrangerCausality: () => new wickra.GrangerCausality(60, 1),
SpreadAr1Coefficient: () => new wickra.SpreadAr1Coefficient(40),
KendallTau: () => new wickra.KendallTau(20),
};
for (const [name, make] of Object.entries(pairFactories)) {
+49
View File
@@ -1052,6 +1052,42 @@ export declare class BipowerVariation {
isReady(): boolean
warmupPeriod(): number
}
export type JarqueBeraNode = JARQUEBERA
export declare class JARQUEBERA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RollingMinMaxScalerNode = ROLLINGMINMAX
export declare class ROLLINGMINMAX {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ShannonEntropyNode = SHANNONENT
export declare class SHANNONENT {
constructor(period: number, bins: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SampleEntropyNode = SAMPLEENT
export declare class SAMPLEENT {
constructor(period: number, m: number, rFactor: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type EwmaVolatilityNode = EwmaVolatility
export declare class EwmaVolatility {
constructor(lambda: number)
@@ -1263,6 +1299,19 @@ export declare class DistanceSsd {
isReady(): boolean
warmupPeriod(): number
}
export type KendallTauNode = KendallTau
export declare class KendallTau {
constructor(period: number)
update(x: number, y: number): number | null
/**
* Batch over two equally-sized arrays. Returns a length-`n` array
* with `NaN` for warmup positions.
*/
batch(x: Array<number>, y: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BetaNeutralSpreadNode = BetaNeutralSpread
export declare class BetaNeutralSpread {
constructor(period: number)
File diff suppressed because one or more lines are too long
+81
View File
@@ -225,6 +225,86 @@ node_scalar_indicator!(
"BipowerVariation",
wc::BipowerVariation
);
node_scalar_indicator!(JarqueBeraNode, "JARQUEBERA", wc::JarqueBera);
node_scalar_indicator!(
RollingMinMaxScalerNode,
"ROLLINGMINMAX",
wc::RollingMinMaxScaler
);
// Shannon Entropy / Sample Entropy: multi-arg scalar ctors, hand-written
// (node_scalar_indicator! only generates a single-period constructor).
#[napi(js_name = "SHANNONENT")]
pub struct ShannonEntropyNode {
inner: wc::ShannonEntropy,
}
#[napi]
impl ShannonEntropyNode {
#[napi(constructor)]
pub fn new(period: u32, bins: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ShannonEntropy::new(period as usize, bins as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[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(js_name = "SAMPLEENT")]
pub struct SampleEntropyNode {
inner: wc::SampleEntropy,
}
#[napi]
impl SampleEntropyNode {
#[napi(constructor)]
pub fn new(period: u32, m: u32, r_factor: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::SampleEntropy::new(period as usize, m as usize, r_factor)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[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(js_name = "EwmaVolatility")]
pub struct EwmaVolatilityNode {
@@ -675,6 +755,7 @@ node_pair_indicator!(
node_pair_indicator!(OuHalfLifeNode, "OuHalfLife", wc::OuHalfLife);
node_pair_indicator!(SpreadHurstNode, "SpreadHurst", wc::SpreadHurst);
node_pair_indicator!(DistanceSsdNode, "DistanceSsd", wc::DistanceSsd);
node_pair_indicator!(KendallTauNode, "KendallTau", wc::KendallTau);
node_pair_indicator!(
BetaNeutralSpreadNode,
"BetaNeutralSpread",
+10
View File
@@ -25,6 +25,10 @@ from __future__ import annotations
from ._wickra import (
__version__,
SAMPLEENT,
SHANNONENT,
ROLLINGMINMAX,
JARQUEBERA,
TimeBasedStop,
ProjectionOscillator,
VolatilityCone,
@@ -221,6 +225,7 @@ from ._wickra import (
MarketFacilitationIndex,
EaseOfMovement,
# Statistics
KendallTau,
SpreadBollingerBands,
KalmanHedgeRatio,
GrangerCausality,
@@ -501,6 +506,10 @@ from ._wickra import (
)
__all__ = [
"SAMPLEENT",
"SHANNONENT",
"ROLLINGMINMAX",
"JARQUEBERA",
"TimeBasedStop",
"ProjectionOscillator",
"VolatilityCone",
@@ -698,6 +707,7 @@ __all__ = [
"MarketFacilitationIndex",
"EaseOfMovement",
# Statistics
"KendallTau",
"SpreadBollingerBands",
"KalmanHedgeRatio",
"GrangerCausality",
+271
View File
@@ -3700,6 +3700,102 @@ impl PyTimeBasedStop {
}
}
// ============================== JarqueBera ==============================
#[pyclass(name = "JARQUEBERA", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyJarqueBera {
inner: wc::JarqueBera,
}
#[pymethods]
impl PyJarqueBera {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::JarqueBera::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 s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(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!("JARQUEBERA(period={})", self.inner.period())
}
}
// ============================== RollingMinMaxScaler ==============================
#[pyclass(name = "ROLLINGMINMAX", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyRollingMinMaxScaler {
inner: wc::RollingMinMaxScaler,
}
#[pymethods]
impl PyRollingMinMaxScaler {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::RollingMinMaxScaler::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 s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(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!("ROLLINGMINMAX(period={})", self.inner.period())
}
}
// ============================== Stochastic ==============================
#[pyclass(name = "IMI", module = "wickra._wickra", skip_from_py_object)]
@@ -22735,6 +22831,176 @@ impl PyVolumeWeightedMacd {
}
}
// ============================== Shannon Entropy ==============================
#[pyclass(name = "SHANNONENT", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyShannonEntropy {
inner: wc::ShannonEntropy,
}
#[pymethods]
impl PyShannonEntropy {
#[new]
#[pyo3(signature = (period=20, bins=8))]
fn new(period: usize, bins: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::ShannonEntropy::new(period, bins).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(py))
}
#[getter]
fn params(&self) -> (usize, usize) {
self.inner.params()
}
#[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 {
let (period, bins) = self.inner.params();
format!("SHANNONENT(period={period}, bins={bins})")
}
}
// ============================== Sample Entropy ==============================
#[pyclass(name = "SAMPLEENT", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySampleEntropy {
inner: wc::SampleEntropy,
}
#[pymethods]
impl PySampleEntropy {
#[new]
#[pyo3(signature = (period=20, m=2, r_factor=0.2))]
fn new(period: usize, m: usize, r_factor: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::SampleEntropy::new(period, m, r_factor).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(py))
}
#[getter]
fn params(&self) -> (usize, usize, f64) {
self.inner.params()
}
#[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 {
let (period, m, r_factor) = self.inner.params();
format!("SAMPLEENT(period={period}, m={m}, r_factor={r_factor})")
}
}
// ============================== Kendall Tau ==============================
#[pyclass(name = "KendallTau", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyKendallTau {
inner: wc::KendallTau,
}
#[pymethods]
impl PyKendallTau {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::KendallTau::new(period).map_err(map_err)?,
})
}
fn update(&mut self, x: f64, y: f64) -> Option<f64> {
self.inner.update((x, y))
}
/// Batch over two equally-sized numpy arrays.
fn batch<'py>(
&mut self,
py: Python<'py>,
x: PyReadonlyArray1<'py, f64>,
y: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = x
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = y
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("x and y must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[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 {
format!("KendallTau(period={})", self.inner.period())
}
}
#[pymodule]
#[allow(clippy::too_many_lines)]
fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
@@ -23198,5 +23464,10 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyIntradayIntensity>()?;
m.add_class::<PyBetterVolume>()?;
m.add_class::<PyVolumeWeightedMacd>()?;
m.add_class::<PyShannonEntropy>()?;
m.add_class::<PySampleEntropy>()?;
m.add_class::<PyKendallTau>()?;
m.add_class::<PyJarqueBera>()?;
m.add_class::<PyRollingMinMaxScaler>()?;
Ok(())
}
@@ -45,6 +45,10 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.SAMPLEENT, (20, 2, 0.2)),
(ta.SHANNONENT, (20, 8)),
(ta.ROLLINGMINMAX, (20,)),
(ta.JARQUEBERA, (20,)),
(ta.BipowerVariation, (20,)),
(ta.VolatilityOfVolatility, (20, 20)),
(ta.Garch11, (0.000002, 0.1, 0.88)),
@@ -204,6 +208,7 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
# --- Two-series (asset, benchmark) indicators -----------------------------
PAIR = [
(ta.KendallTau, (20,)),
(ta.SpreadAr1Coefficient, (40,)),
(ta.GrangerCausality, (60, 1)),
(ta.VarianceRatio, (60, 2)),
@@ -3093,6 +3098,10 @@ def test_better_volume_reference():
def test_volume_weighted_macd_reference():
t = ta.VolumeWeightedMacd(12, 26, 9)
def test_kendall_tau_reference():
t = ta.KendallTau(20)
# --- Lifecycle ------------------------------------------------------------
+5
View File
@@ -557,6 +557,7 @@ wasm_pair_indicator!(
wasm_pair_indicator!(WasmOuHalfLife, "OuHalfLife", wc::OuHalfLife);
wasm_pair_indicator!(WasmSpreadHurst, "SpreadHurst", wc::SpreadHurst);
wasm_pair_indicator!(WasmDistanceSsd, "DistanceSsd", wc::DistanceSsd);
wasm_pair_indicator!(WasmKendallTau, "KendallTau", wc::KendallTau);
wasm_pair_indicator!(
WasmBetaNeutralSpread,
"BetaNeutralSpread",
@@ -11256,6 +11257,10 @@ wasm_scalar_indicator!(WasmBipowerVariation, "BipowerVariation", wc::BipowerVari
wasm_scalar_indicator!(WasmEwmaVolatility, "EwmaVolatility", wc::EwmaVolatility, lambda: f64);
wasm_scalar_indicator!(WasmGarch11, "Garch11", wc::Garch11, omega: f64, alpha: f64, beta: f64);
wasm_scalar_indicator!(WasmVolatilityOfVolatility, "VolatilityOfVolatility", wc::VolatilityOfVolatility, vol_window: usize, vov_window: usize);
wasm_scalar_indicator!(WasmJarqueBera, "JARQUEBERA", wc::JarqueBera, period: usize);
wasm_scalar_indicator!(WasmRollingMinMaxScaler, "ROLLINGMINMAX", wc::RollingMinMaxScaler, period: usize);
wasm_scalar_indicator!(WasmShannonEntropy, "SHANNONENT", wc::ShannonEntropy, period: usize, bins: usize);
wasm_scalar_indicator!(WasmSampleEntropy, "SAMPLEENT", wc::SampleEntropy, period: usize, m: usize, r_factor: f64);
// --- VolatilityCone: Candle in, struct out (current/min/median/max/percentile) ---