F13a: add Accelerator Oscillator, Balance of Power, Choppiness Index and Vertical Horizontal Filter

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

- Rust core: accelerator_oscillator.rs (AcceleratorOscillator — AO minus a
  short SMA of itself), balance_of_power.rs (BalanceOfPower — per-bar
  (close-open)/(high-low)), choppiness_index.rs (ChoppinessIndex — summed
  true range over the high-low span, log-scaled) and
  vertical_horizontal_filter.rs (VerticalHorizontalFilter — net move over
  total move). 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
  (BalanceOfPower carries an explicit open column; VHF rides the scalar
  macros) 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 in F13c once F13b's four indicators are in.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 481 core tests,
25 data tests and 70 doctests green.
This commit is contained in:
kingchenc
2026-05-22 20:57:52 +02:00
parent 27f37f5347
commit e452d35a27
16 changed files with 2001 additions and 11 deletions
+5 -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, MOM, CMO, DPO, StdDev, UlcerIndex, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -330,6 +330,7 @@ module.exports.CMO = CMO
module.exports.DPO = DPO
module.exports.StdDev = StdDev
module.exports.UlcerIndex = UlcerIndex
module.exports.VerticalHorizontalFilter = VerticalHorizontalFilter
module.exports.MACD = MACD
module.exports.BollingerBands = BollingerBands
module.exports.ATR = ATR
@@ -364,6 +365,9 @@ module.exports.MedianPrice = MedianPrice
module.exports.WeightedClose = WeightedClose
module.exports.LinearRegression = LinearRegression
module.exports.LinRegSlope = LinRegSlope
module.exports.AcceleratorOscillator = AcceleratorOscillator
module.exports.BalanceOfPower = BalanceOfPower
module.exports.ChoppinessIndex = ChoppinessIndex
module.exports.BollingerBandwidth = BollingerBandwidth
module.exports.PercentB = PercentB
module.exports.NATR = NATR
+182
View File
@@ -110,6 +110,11 @@ node_scalar_indicator!(CmoNode, "CMO", wc::Cmo);
node_scalar_indicator!(DpoNode, "DPO", wc::Dpo);
node_scalar_indicator!(StdDevNode, "StdDev", wc::StdDev);
node_scalar_indicator!(UlcerIndexNode, "UlcerIndex", wc::UlcerIndex);
node_scalar_indicator!(
VerticalHorizontalFilterNode,
"VerticalHorizontalFilter",
wc::VerticalHorizontalFilter
);
// ============================== MACD ==============================
@@ -2034,6 +2039,183 @@ impl LinRegSlopeNode {
}
}
// ============================== Accelerator Oscillator ==============================
#[napi(js_name = "AcceleratorOscillator")]
pub struct AcceleratorOscillatorNode {
inner: wc::AcceleratorOscillator,
}
#[napi]
impl AcceleratorOscillatorNode {
#[napi(constructor)]
pub fn new(ao_fast: u32, ao_slow: u32, signal_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::AcceleratorOscillator::new(
ao_fast as usize,
ao_slow as usize,
signal_period as usize,
)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, low, 0.0)?))
}
#[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 mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], low[i], 0.0)?)
.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
}
}
// ============================== Balance of Power ==============================
#[napi(js_name = "BalanceOfPower")]
pub struct BalanceOfPowerNode {
inner: wc::BalanceOfPower,
}
impl Default for BalanceOfPowerNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl BalanceOfPowerNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::BalanceOfPower::new(),
}
}
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<f64>> {
let candle = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
Ok(self.inner.update(candle))
}
#[napi]
pub fn batch(
&mut self,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"open, high, low, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(open.len());
for i in 0..open.len() {
let candle =
wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).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
}
}
// ============================== Choppiness Index ==============================
#[napi(js_name = "ChoppinessIndex")]
pub struct ChoppinessIndexNode {
inner: wc::ChoppinessIndex,
}
#[napi]
impl ChoppinessIndexNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ChoppinessIndex::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
}
#[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 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], 0.0)?)
.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")]
@@ -62,6 +62,10 @@ from ._wickra import (
AroonOscillator,
Vortex,
MassIndex,
AcceleratorOscillator,
BalanceOfPower,
ChoppinessIndex,
VerticalHorizontalFilter,
# Volatility
BollingerBands,
ATR,
@@ -134,6 +138,10 @@ __all__ = [
"AroonOscillator",
"Vortex",
"MassIndex",
"AcceleratorOscillator",
"BalanceOfPower",
"ChoppinessIndex",
"VerticalHorizontalFilter",
# Volatility
"BollingerBands",
"ATR",
@@ -295,6 +295,61 @@ class LinRegSlope:
@property
def period(self) -> int: ...
class AcceleratorOscillator:
def __init__(
self, ao_fast: int = 5, ao_slow: int = 34, signal_period: int = 5
) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def params(self) -> Tuple[int, int, int]: ...
class BalanceOfPower:
def __init__(self) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
open: NDArray[np.float64],
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
class ChoppinessIndex:
def __init__(self, period: int = 14) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class VerticalHorizontalFilter:
def __init__(self, period: int = 28) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class BollingerBandwidth:
def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ...
def update(self, value: float) -> Optional[float]: ...
+251
View File
@@ -3856,6 +3856,253 @@ impl PyLinRegSlope {
}
}
// ============================== Accelerator Oscillator ==============================
#[pyclass(name = "AcceleratorOscillator", module = "wickra._wickra")]
#[derive(Clone)]
struct PyAcceleratorOscillator {
inner: wc::AcceleratorOscillator,
}
#[pymethods]
impl PyAcceleratorOscillator {
#[new]
#[pyo3(signature = (ao_fast=5, ao_slow=34, signal_period=5))]
fn new(ao_fast: usize, ao_slow: usize, signal_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::AcceleratorOscillator::new(ao_fast, ao_slow, signal_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 params(&self) -> (usize, usize, usize) {
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 (f, s, sig) = self.inner.params();
format!("AcceleratorOscillator(ao_fast={f}, ao_slow={s}, signal_period={sig})")
}
}
// ============================== Balance of Power ==============================
#[pyclass(name = "BalanceOfPower", module = "wickra._wickra")]
#[derive(Clone)]
struct PyBalanceOfPower {
inner: wc::BalanceOfPower,
}
#[pymethods]
impl PyBalanceOfPower {
#[new]
fn new() -> Self {
Self {
inner: wc::BalanceOfPower::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 open, high, low, close (all equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let o = open
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
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 o.len() != h.len() || h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"open, high, low, close must be equal length",
));
}
let mut out = Vec::with_capacity(o.len());
for i in 0..o.len() {
let candle = wc::Candle::new(o[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 {
"BalanceOfPower()".to_string()
}
}
// ============================== Choppiness Index ==============================
#[pyclass(name = "ChoppinessIndex", module = "wickra._wickra")]
#[derive(Clone)]
struct PyChoppinessIndex {
inner: wc::ChoppinessIndex,
}
#[pymethods]
impl PyChoppinessIndex {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::ChoppinessIndex::new(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, 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))
}
#[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!("ChoppinessIndex(period={})", self.inner.period())
}
}
// ============================== Vertical Horizontal Filter ==============================
#[pyclass(name = "VerticalHorizontalFilter", module = "wickra._wickra")]
#[derive(Clone)]
struct PyVerticalHorizontalFilter {
inner: wc::VerticalHorizontalFilter,
}
#[pymethods]
impl PyVerticalHorizontalFilter {
#[new]
#[pyo3(signature = (period=28))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::VerticalHorizontalFilter::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!("VerticalHorizontalFilter(period={})", self.inner.period())
}
}
// ============================== Module ==============================
#[pymodule]
@@ -3924,5 +4171,9 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyWeightedClose>()?;
m.add_class::<PyLinearRegression>()?;
m.add_class::<PyLinRegSlope>()?;
m.add_class::<PyAcceleratorOscillator>()?;
m.add_class::<PyBalanceOfPower>()?;
m.add_class::<PyChoppinessIndex>()?;
m.add_class::<PyVerticalHorizontalFilter>()?;
Ok(())
}
+130
View File
@@ -94,6 +94,7 @@ wasm_scalar_indicator!(WasmBollingerBandwidth, "BollingerBandwidth", wc::Bolling
wasm_scalar_indicator!(WasmPercentB, "PercentB", wc::PercentB, period: usize, multiplier: f64);
wasm_scalar_indicator!(WasmLinearRegression, "LinearRegression", wc::LinearRegression, period: usize);
wasm_scalar_indicator!(WasmLinRegSlope, "LinRegSlope", wc::LinRegSlope, period: usize);
wasm_scalar_indicator!(WasmVerticalHorizontalFilter, "VerticalHorizontalFilter", wc::VerticalHorizontalFilter, period: usize);
// ---------- KAMA (three params) ----------
@@ -970,6 +971,135 @@ impl WasmWeightedClose {
}
}
#[wasm_bindgen(js_name = AcceleratorOscillator)]
pub struct WasmAcceleratorOscillator {
inner: wc::AcceleratorOscillator,
}
#[wasm_bindgen(js_class = AcceleratorOscillator)]
impl WasmAcceleratorOscillator {
#[wasm_bindgen(constructor)]
pub fn new(
ao_fast: usize,
ao_slow: usize,
signal_period: usize,
) -> Result<WasmAcceleratorOscillator, JsError> {
Ok(Self {
inner: wc::AcceleratorOscillator::new(ao_fast, ao_slow, signal_period)
.map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, low, 0.0)?;
Ok(self.inner.update(c))
}
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 mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], low[i], 0.0)?;
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 = BalanceOfPower)]
pub struct WasmBalanceOfPower {
inner: wc::BalanceOfPower,
}
impl Default for WasmBalanceOfPower {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = BalanceOfPower)]
impl WasmBalanceOfPower {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmBalanceOfPower {
Self {
inner: wc::BalanceOfPower::new(),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = open.len();
if high.len() != n || low.len() != n || close.len() != n {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?;
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 = ChoppinessIndex)]
pub struct WasmChoppinessIndex {
inner: wc::ChoppinessIndex,
}
#[wasm_bindgen(js_class = ChoppinessIndex)]
impl WasmChoppinessIndex {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmChoppinessIndex, JsError> {
Ok(Self {
inner: wc::ChoppinessIndex::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close 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], 0.0)?;
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,
@@ -0,0 +1,194 @@
//! Accelerator Oscillator (Bill Williams).
use crate::error::Result;
use crate::indicators::awesome_oscillator::AwesomeOscillator;
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Accelerator Oscillator — Bill Williams' gauge of *momentum's acceleration*.
///
/// ```text
/// AO = SMA(median, fast) SMA(median, slow) (the Awesome Oscillator)
/// AC = AO SMA(AO, signal)
/// ```
///
/// Where the [`AwesomeOscillator`](crate::AwesomeOscillator) tracks momentum,
/// the Accelerator tracks the *change* in momentum: it is the AO minus a short
/// moving average of itself. Because acceleration leads speed, `AC` tends to
/// turn before the `AO` does. Bill Williams' classic configuration is the
/// `(5, 34)` AO with a `5`-period signal average.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AcceleratorOscillator};
///
/// let mut indicator = AcceleratorOscillator::classic();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AcceleratorOscillator {
ao: AwesomeOscillator,
signal: Sma,
ao_fast: usize,
ao_slow: usize,
signal_period: usize,
}
impl AcceleratorOscillator {
/// Construct an Accelerator Oscillator with explicit AO and signal periods.
///
/// # Errors
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) for a zero
/// period and [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if the
/// AO `fast` period is not strictly below `slow`.
pub fn new(ao_fast: usize, ao_slow: usize, signal_period: usize) -> Result<Self> {
Ok(Self {
ao: AwesomeOscillator::new(ao_fast, ao_slow)?,
signal: Sma::new(signal_period)?,
ao_fast,
ao_slow,
signal_period,
})
}
/// Bill Williams' classic configuration: `AO(5, 34)` with a `5`-period signal.
pub fn classic() -> Self {
Self::new(5, 34, 5).expect("classic Accelerator Oscillator params are valid")
}
/// Configured `(ao_fast, ao_slow, signal_period)`.
pub const fn params(&self) -> (usize, usize, usize) {
(self.ao_fast, self.ao_slow, self.signal_period)
}
}
impl Indicator for AcceleratorOscillator {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let ao = self.ao.update(candle)?;
let signal = self.signal.update(ao)?;
Some(ao - signal)
}
fn reset(&mut self) {
self.ao.reset();
self.signal.reset();
}
fn warmup_period(&self) -> usize {
// The AO emits at candle `ao_slow`; the signal SMA then needs
// `signal_period` AO values.
self.ao_slow + self.signal_period - 1
}
fn is_ready(&self) -> bool {
self.signal.is_ready()
}
fn name(&self) -> &'static str {
"AcceleratorOscillator"
}
}
#[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 {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn constant_series_yields_zero() {
// A flat market gives AO = 0, so its signal average and AC are 0 too.
let candles: Vec<Candle> = (0..80).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ac = AcceleratorOscillator::classic();
for v in ac.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn matches_independent_ao_and_signal() {
let candles: Vec<Candle> = (0..90)
.map(|i| {
let m = 100.0 + (i as f64 * 0.2).sin() * 6.0;
c(m + 1.5, m - 1.5, m + 0.3, i)
})
.collect();
let mut ac = AcceleratorOscillator::classic();
let mut ao = AwesomeOscillator::classic();
let mut signal = Sma::new(5).unwrap();
for (i, candle) in candles.iter().enumerate() {
let got = ac.update(*candle);
match ao.update(*candle) {
Some(ao_val) => match signal.update(ao_val) {
Some(sig) => {
assert_relative_eq!(got.unwrap(), ao_val - sig, epsilon = 1e-9);
}
None => assert!(got.is_none(), "i={i}"),
},
None => assert!(got.is_none(), "i={i}"),
}
}
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ac = AcceleratorOscillator::classic();
let out = ac.batch(&candles);
assert_eq!(ac.warmup_period(), 38);
for (i, v) in out.iter().enumerate().take(37) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[37].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_invalid_params() {
assert!(AcceleratorOscillator::new(0, 34, 5).is_err());
assert!(AcceleratorOscillator::new(5, 34, 0).is_err());
assert!(AcceleratorOscillator::new(34, 5, 5).is_err());
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ac = AcceleratorOscillator::classic();
ac.batch(&candles);
assert!(ac.is_ready());
ac.reset();
assert!(!ac.is_ready());
assert_eq!(ac.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..90)
.map(|i| {
let m = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(m + 1.5, m - 1.5, m + 0.5, i)
})
.collect();
let mut a = AcceleratorOscillator::classic();
let mut b = AcceleratorOscillator::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,168 @@
//! Balance of Power.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Balance of Power — where the close settled within the bar's range relative
/// to the open.
///
/// ```text
/// BOP = (close open) / (high low)
/// ```
///
/// The result lives in `[1, +1]`: `+1` is a bar that opened on its low and
/// closed on its high (buyers in full control), `1` the mirror image. It is
/// a stateless per-bar reading — a quick gauge of intrabar conviction. A
/// zero-range bar carries no information and yields `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, BalanceOfPower};
///
/// let mut indicator = BalanceOfPower::new();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct BalanceOfPower {
has_emitted: bool,
}
impl BalanceOfPower {
/// Construct a new Balance of Power transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for BalanceOfPower {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
let bop = if range == 0.0 {
// A zero-range bar carries no directional information.
0.0
} else {
(candle.close - candle.open) / range
};
Some(bop)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"BalanceOfPower"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value() {
// (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
let mut bop = BalanceOfPower::new();
assert_relative_eq!(
bop.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(),
0.5,
epsilon = 1e-12
);
}
#[test]
fn close_on_high_after_open_on_low_is_plus_one() {
let mut bop = BalanceOfPower::new();
// open == low, close == high -> BOP = +1.
assert_relative_eq!(
bop.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(),
1.0,
epsilon = 1e-12
);
}
#[test]
fn stays_within_unit_range() {
let candles: Vec<Candle> = (0..100)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.2).sin() * 8.0;
let close = mid + (i as f64 * 0.5).cos() * 2.0;
candle(mid, mid + 3.0, mid - 3.0, close, i)
})
.collect();
let mut bop = BalanceOfPower::new();
for v in bop.batch(&candles).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "BOP {v} outside [-1, 1]");
}
}
#[test]
fn zero_range_bar_yields_zero() {
let mut bop = BalanceOfPower::new();
assert_relative_eq!(
bop.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn emits_from_first_candle() {
let mut bop = BalanceOfPower::new();
assert_eq!(bop.warmup_period(), 1);
assert!(!bop.is_ready());
assert!(bop.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(bop.is_ready());
}
#[test]
fn reset_clears_state() {
let mut bop = BalanceOfPower::new();
bop.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(bop.is_ready());
bop.reset();
assert!(!bop.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
candle(base, base + 2.0, base - 2.0, base + 1.0, i)
})
.collect();
let mut a = BalanceOfPower::new();
let mut b = BalanceOfPower::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,220 @@
//! Choppiness Index.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Choppiness Index — is the market trending or just chopping sideways?
///
/// ```text
/// CI = 100 · log10( Σ(TR, n) / (highest_high(n) lowest_low(n)) ) / log10(n)
/// ```
///
/// The ratio compares the *distance price actually travelled* (the summed true
/// range) with the *net ground it covered* (the high-low span of the window).
/// A clean trend travels almost exactly its span, so the ratio is near `1` and
/// `CI` near `0`; a choppy market criss-crosses far more than its span, so the
/// ratio is large and `CI` climbs toward `100`. The conventional reading is
/// `CI > 61.8` ranging, `CI < 38.2` trending. A perfectly flat window yields
/// `100` by convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ChoppinessIndex};
///
/// let mut indicator = ChoppinessIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ChoppinessIndex {
period: usize,
log_n: f64,
prev_close: Option<f64>,
tr_window: VecDeque<f64>,
tr_sum: f64,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
}
impl ChoppinessIndex {
/// Construct a new Choppiness Index over `period` bars.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — the `log10(period)`
/// denominator is zero for `period == 1` and undefined for `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "choppiness index needs period >= 2",
});
}
Ok(Self {
period,
log_n: (period as f64).log10(),
prev_close: None,
tr_window: VecDeque::with_capacity(period),
tr_sum: 0.0,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ChoppinessIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let tr = candle.true_range(self.prev_close);
self.prev_close = Some(candle.close);
if self.tr_window.len() == self.period {
self.tr_sum -= self.tr_window.pop_front().expect("non-empty");
self.highs.pop_front();
self.lows.pop_front();
}
self.tr_window.push_back(tr);
self.tr_sum += tr;
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
if self.tr_window.len() < self.period {
return None;
}
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
let span = highest - lowest;
if span == 0.0 {
// A perfectly flat window: maximal choppiness by convention.
return Some(100.0);
}
Some(100.0 * (self.tr_sum / span).log10() / self.log_n)
}
fn reset(&mut self) {
self.prev_close = None;
self.tr_window.clear();
self.tr_sum = 0.0;
self.highs.clear();
self.lows.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.tr_window.len() == self.period
}
fn name(&self) -> &'static str {
"ChoppinessIndex"
}
}
#[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 {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value_equal_range_bars() {
// Two H=11 L=9 C=10 bars: TR = 2 each, ΣTR = 4; span = 11 - 9 = 2.
// CI = 100 · log10(4 / 2) / log10(2) = 100.
let mut ci = ChoppinessIndex::new(2).unwrap();
let out = ci.batch(&[c(11.0, 9.0, 10.0, 0), c(11.0, 9.0, 10.0, 1)]);
assert!(out[0].is_none());
assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
}
#[test]
fn flat_window_yields_hundred() {
let candles: Vec<Candle> = (0..20).map(|i| c(10.0, 10.0, 10.0, i)).collect();
let mut ci = ChoppinessIndex::new(14).unwrap();
for v in ci.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 100.0, epsilon = 1e-9);
}
}
#[test]
fn steady_trend_reads_low() {
// A clean one-directional march travels close to its span -> low CI.
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ci = ChoppinessIndex::new(14).unwrap();
for v in ci.batch(&candles).into_iter().flatten() {
assert!(v < 50.0, "a steady trend should read below 50, got {v}");
assert!(v >= 0.0, "CI must be non-negative, got {v}");
}
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ci = ChoppinessIndex::new(8).unwrap();
let out = ci.batch(&candles);
assert_eq!(ci.warmup_period(), 8);
for (i, v) in out.iter().enumerate().take(7) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_period_below_two() {
assert!(ChoppinessIndex::new(0).is_err());
assert!(ChoppinessIndex::new(1).is_err());
assert!(ChoppinessIndex::new(2).is_ok());
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ci = ChoppinessIndex::new(14).unwrap();
ci.batch(&candles);
assert!(ci.is_ready());
ci.reset();
assert!(!ci.is_ready());
assert_eq!(ci.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = ChoppinessIndex::new(14).unwrap();
let mut b = ChoppinessIndex::new(14).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+8
View File
@@ -4,6 +4,7 @@
//! volume) but every public name is also re-exported flat from this module and
//! from the crate root for convenience.
mod accelerator_oscillator;
mod adl;
mod adx;
mod aroon;
@@ -11,12 +12,14 @@ mod aroon_oscillator;
mod atr;
mod atr_trailing_stop;
mod awesome_oscillator;
mod balance_of_power;
mod bollinger;
mod bollinger_bandwidth;
mod cci;
mod chaikin_oscillator;
mod chande_kroll_stop;
mod chandelier_exit;
mod choppiness_index;
mod cmf;
mod cmo;
mod coppock;
@@ -59,6 +62,7 @@ mod tsi;
mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
mod vertical_horizontal_filter;
mod vortex;
mod vpt;
mod vwap;
@@ -68,6 +72,7 @@ mod williams_r;
mod wma;
mod zlema;
pub use accelerator_oscillator::AcceleratorOscillator;
pub use adl::Adl;
pub use adx::{Adx, AdxOutput};
pub use aroon::{Aroon, AroonOutput};
@@ -75,12 +80,14 @@ pub use aroon_oscillator::AroonOscillator;
pub use atr::Atr;
pub use atr_trailing_stop::AtrTrailingStop;
pub use awesome_oscillator::AwesomeOscillator;
pub use balance_of_power::BalanceOfPower;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use cci::Cci;
pub use chaikin_oscillator::ChaikinOscillator;
pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput};
pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput};
pub use choppiness_index::ChoppinessIndex;
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coppock::Coppock;
@@ -123,6 +130,7 @@ pub use tsi::Tsi;
pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vortex::{Vortex, VortexOutput};
pub use vpt::VolumePriceTrend;
pub use vwap::{RollingVwap, Vwap};
@@ -0,0 +1,202 @@
//! Vertical Horizontal Filter.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Vertical Horizontal Filter — Adam White's trend-versus-range gauge.
///
/// ```text
/// VHF = (highest_close(n) lowest_close(n)) / Σ|close close_prev|(n)
/// ```
///
/// The numerator is the *net* distance price covered over the window; the
/// denominator is the *total* distance it walked. Their ratio lives in
/// `[0, 1]`: a clean trend walks almost only in its net direction, so `VHF`
/// approaches `1`; a choppy market doubles back constantly, inflating the
/// denominator and pushing `VHF` toward `0`. It answers the same question as
/// the [`ChoppinessIndex`](crate::ChoppinessIndex) on an inverted scale.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, VerticalHorizontalFilter};
///
/// let mut indicator = VerticalHorizontalFilter::new(28).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VerticalHorizontalFilter {
period: usize,
closes: VecDeque<f64>,
prev_close: Option<f64>,
diffs: VecDeque<f64>,
diff_sum: f64,
}
impl VerticalHorizontalFilter {
/// Construct a new Vertical Horizontal Filter over `period` closes.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
closes: VecDeque::with_capacity(period),
prev_close: None,
diffs: VecDeque::with_capacity(period),
diff_sum: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for VerticalHorizontalFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.closes.len() == self.period {
self.closes.pop_front();
}
self.closes.push_back(value);
if let Some(prev) = self.prev_close {
let diff = (value - prev).abs();
if self.diffs.len() == self.period {
self.diff_sum -= self.diffs.pop_front().expect("non-empty");
}
self.diffs.push_back(diff);
self.diff_sum += diff;
}
self.prev_close = Some(value);
if self.closes.len() < self.period || self.diffs.len() < self.period {
return None;
}
let highest = self
.closes
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let lowest = self.closes.iter().copied().fold(f64::INFINITY, f64::min);
if self.diff_sum == 0.0 {
// A flat window walked nowhere — no trend to filter.
return Some(0.0);
}
Some((highest - lowest) / self.diff_sum)
}
fn reset(&mut self) {
self.closes.clear();
self.prev_close = None;
self.diffs.clear();
self.diff_sum = 0.0;
}
fn warmup_period(&self) -> usize {
// `period` closes fill the high/low window; the `period`-th diff needs
// one extra input because the first input has nothing to diff against.
self.period + 1
}
fn is_ready(&self) -> bool {
self.diffs.len() == self.period
}
fn name(&self) -> &'static str {
"VerticalHorizontalFilter"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn reference_values_pure_uptrend() {
// Closes 1,2,…: every diff is 1 (Σ = period), the n-close span is
// period 1, so VHF = (period 1) / period. For period 5: 4/5 = 0.8.
let mut vhf = VerticalHorizontalFilter::new(5).unwrap();
let out = vhf.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
for (i, v) in out.iter().enumerate().take(5) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert_relative_eq!(out[5].unwrap(), 0.8, epsilon = 1e-12);
assert_eq!(vhf.warmup_period(), 6);
}
#[test]
fn choppy_series_reads_low() {
// A market that oscillates between two prices covers a tiny net span
// while walking a long way -> VHF near zero.
let prices: Vec<f64> = (0..40)
.map(|i| if i % 2 == 0 { 10.0 } else { 11.0 })
.collect();
let mut vhf = VerticalHorizontalFilter::new(10).unwrap();
for v in vhf.batch(&prices).into_iter().flatten() {
assert!(v < 0.2, "a choppy series should read low, got {v}");
}
}
#[test]
fn flat_series_yields_zero() {
let mut vhf = VerticalHorizontalFilter::new(8).unwrap();
for v in vhf.batch(&[50.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn stays_within_unit_range() {
let prices: Vec<f64> = (0..120)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut vhf = VerticalHorizontalFilter::new(28).unwrap();
for v in vhf.batch(&prices).into_iter().flatten() {
assert!((0.0..=1.0).contains(&v), "VHF {v} outside [0, 1]");
}
}
#[test]
fn rejects_zero_period() {
assert!(VerticalHorizontalFilter::new(0).is_err());
}
#[test]
fn reset_clears_state() {
let mut vhf = VerticalHorizontalFilter::new(8).unwrap();
vhf.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]);
assert!(vhf.is_ready());
vhf.reset();
assert!(!vhf.is_ready());
assert_eq!(vhf.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut a = VerticalHorizontalFilter::new(28).unwrap();
let mut b = VerticalHorizontalFilter::new(28).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+10 -10
View File
@@ -44,16 +44,16 @@ pub mod indicators;
pub use error::{Error, Result};
pub use indicators::{
Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, AtrTrailingStop,
AwesomeOscillator, BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow,
ChaikinOscillator, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema,
ForceIndex, HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, LinRegSlope,
LinearRegression, MacdIndicator, MacdOutput, MassIndex, MedianPrice, Mfi, Mom, Natr, Obv,
PercentB, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma, StdDev, StochRsi, Stochastic,
StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Trima, Trix, Tsi, TypicalPrice,
UlcerIndex, UltimateOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma,
WeightedClose, WilliamsR, Wma, Zlema, T3,
AcceleratorOscillator, Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr,
AtrTrailingStop, AwesomeOscillator, BalanceOfPower, BollingerBands, BollingerBandwidth,
BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChandeKrollStop,
ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, Cmo, Coppock,
Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema, ForceIndex, HistoricalVolatility,
Hma, Kama, Keltner, KeltnerOutput, LinRegSlope, LinearRegression, MacdIndicator, MacdOutput,
MassIndex, MedianPrice, Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi,
Sma, Smma, StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput, Tema,
Trima, Trix, Tsi, TypicalPrice, UlcerIndex, UltimateOscillator, VerticalHorizontalFilter,
VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, WeightedClose, WilliamsR, Wma, Zlema, T3,
};
pub use ohlcv::{Candle, Tick};
pub use traits::{BatchExt, Chain, Indicator};
@@ -0,0 +1,144 @@
# AcceleratorOscillator
> Accelerator Oscillator (AC) — Bill Williams' measure of how fast
> momentum itself is changing.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Price Oscillators |
| Input type | `Candle` (uses `high`, `low`) |
| Output type | `f64` |
| Output range | unbounded around zero |
| Default parameters | `ao_fast = 5`, `ao_slow = 34`, `signal_period = 5` (Python) |
| Warmup period | `ao_slow + signal_period 1` |
| Interpretation | Acceleration of momentum; zero-line crossings lead the Awesome Oscillator. |
## Formula
```
AO = SMA(median, ao_fast) SMA(median, ao_slow) (the Awesome Oscillator)
AC = AO SMA(AO, signal_period)
```
Where the [`AwesomeOscillator`](Indicator-AwesomeOscillator.md) measures
momentum, the Accelerator measures the *change* in momentum — it is the AO
minus a short moving average of itself. Because acceleration leads speed, the
`AC` tends to turn before the `AO` does. Bill Williams' classic configuration
is the `(5, 34)` AO with a `5`-period signal average.
## Parameters
- `ao_fast`, `ao_slow` — the underlying Awesome Oscillator periods (`5`, `34`).
- `signal_period` — the moving average of the AO subtracted from it (`5`).
`AcceleratorOscillator::classic()` returns the `(5, 34, 5)` configuration.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/accelerator_oscillator.rs`:
```rust
impl Indicator for AcceleratorOscillator {
type Input = Candle;
type Output = f64;
// update(&mut self, input: Candle) -> Option<f64>
}
```
It is a **candle-input** indicator — the inner Awesome Oscillator reads the
median price `(high + low) / 2`. Python's streaming `update` accepts a 6-tuple
or a dict; the batch helper takes `high`, `low` numpy arrays. Node and WASM
expose `update(high, low)` and the matching `batch`.
## Warmup
`AcceleratorOscillator::classic().warmup_period() == 38`. The AO first emits at
candle `ao_slow`; the signal average then needs `signal_period` AO values.
## Edge cases
- **Flat market.** A flat series gives `AO = 0`, so `AC = 0` throughout.
- **`ao_fast >= ao_slow`.** Rejected at construction.
- **Reset.** `ac.reset()` clears the AO and the signal average.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, AcceleratorOscillator};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut ac = AcceleratorOscillator::classic();
let candles: Vec<Candle> = (0..60)
.map(|i| Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, i).unwrap())
.collect();
println!("{:?}", ac.batch(&candles).last().unwrap());
Ok(())
}
```
Output:
```
Some(0.0)
```
A flat market produces a flat AO and therefore a zero Accelerator.
### Python
```python
import numpy as np
import wickra as ta
ac = ta.AcceleratorOscillator(5, 34, 5)
n = 60
print(ac.batch(np.full(n, 11.0), np.full(n, 9.0))[-1])
```
Output:
```
0.0
```
### Node
```javascript
const ta = require('wickra');
const ac = new ta.AcceleratorOscillator(5, 34, 5);
const out = ac.batch(Array(60).fill(11), Array(60).fill(9));
console.log(out[out.length - 1]);
```
Output:
```
0
```
## Interpretation
Trade the Accelerator like a momentum-acceleration gauge: bars rising above
the zero line mean momentum is building, bars falling below mean it is fading.
Because it leads the Awesome Oscillator, a colour change in the AC is an early
warning that the AO — and price momentum — is about to turn.
## Common pitfalls
- **Reading the level.** Only the sign and the slope matter; the magnitude
scales with the instrument.
- **Feeding it scalar prices.** It needs the `high`/`low` bar.
## References
Bill Williams' Accelerator Oscillator, from *Trading Chaos*.
## See also
- [Indicator-AwesomeOscillator.md](Indicator-AwesomeOscillator.md) — the
momentum oscillator the Accelerator is built on.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,138 @@
# BalanceOfPower
> Balance of Power (BOP) — where the bar closed within its range relative
> to where it opened.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Price Oscillators |
| Input type | `Candle` (uses `open`, `high`, `low`, `close`) |
| Output type | `f64` |
| Output range | `[1, +1]` |
| Default parameters | none (no parameters) |
| Warmup period | `1` |
| Interpretation | Intrabar buyer/seller control; `+1` buyers, `1` sellers. |
## Formula
```
BOP = (close open) / (high low)
```
Balance of Power asks a single question per bar: did buyers or sellers win it?
A bar that opened on its low and closed on its high scores `+1` (buyers in
total control); the mirror image scores `1`. It is a stateless per-bar
reading. A zero-range bar carries no information and yields `0`.
## Parameters
`BalanceOfPower` takes **no parameters**`BalanceOfPower::new()` in Rust,
`wickra.BalanceOfPower()` in Python, `new ta.BalanceOfPower()` in Node.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/balance_of_power.rs`:
```rust
impl Indicator for BalanceOfPower {
type Input = Candle;
type Output = f64;
// update(&mut self, input: Candle) -> Option<f64>
}
```
`BalanceOfPower` is a **candle-input** indicator that reads all four of
`open`, `high`, `low`, `close`. Python's streaming `update` accepts a 6-tuple
or a dict; the batch helper takes `open`, `high`, `low`, `close` numpy arrays.
Node and WASM expose `update(open, high, low, close)` and the matching
`batch`.
## Warmup
`BalanceOfPower::new().warmup_period() == 1`. It is a stateless per-bar
transform — it emits a value from the very first candle.
## Edge cases
- **Zero-range bar.** `high == low` yields `0` instead of dividing by zero.
- **Close on high, open on low.** Scores exactly `+1`.
- **Reset.** `bop.reset()` only clears the `is_ready` flag.
## Examples
### Rust
```rust
use wickra::{Candle, Indicator, BalanceOfPower};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut bop = BalanceOfPower::new();
// open 10, high 14, low 10, close 12 -> (12 - 10) / (14 - 10) = 0.5.
let v = bop.update(Candle::new(10.0, 14.0, 10.0, 12.0, 1.0, 0)?);
println!("{:?}", v);
Ok(())
}
```
Output:
```
Some(0.5)
```
### Python
```python
import numpy as np
import wickra as ta
bop = ta.BalanceOfPower()
print(bop.batch(
np.array([10.0]), np.array([14.0]), np.array([10.0]), np.array([12.0])
))
```
Output:
```
[0.5]
```
### Node
```javascript
const ta = require('wickra');
const bop = new ta.BalanceOfPower();
console.log(bop.batch([10], [14], [10], [12]));
```
Output:
```
[ 0.5 ]
```
## Interpretation
A BOP holding above zero says buyers are consistently winning the bars — a
healthy uptrend; below zero is the seller's mirror. Because the raw per-bar
value is noisy, it is commonly smoothed with a short moving average before
trading the zero-line crossings, or read for divergence against price.
## Common pitfalls
- **Using the raw value as a trend signal.** Per-bar BOP whipsaws; smooth it.
- **Feeding it scalar prices.** It needs the full OHLC bar — including `open`.
## References
Balance of Power, popularised by Igor Livshin; the `(close open) /
(high low)` definition is the standard one.
## See also
- [Indicator-AwesomeOscillator.md](Indicator-AwesomeOscillator.md) — another
Bill Williams-era price oscillator.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,146 @@
# ChoppinessIndex
> Choppiness Index — is the market trending or just chopping sideways?
## Quick reference
| Field | Value |
|-------|-------|
| Family | Trend & Directional |
| Input type | `Candle` (uses `high`, `low`, `close`) |
| Output type | `f64` |
| Output range | `[0, 100]` (typical) |
| Default parameters | `period = 14` (Python) |
| Warmup period | `period` |
| Interpretation | High = choppy/ranging, low = trending; `61.8` / `38.2` thresholds. |
## Formula
```
CI = 100 · log10( Σ(TR, n) / (highest_high(n) lowest_low(n)) ) / log10(n)
```
The ratio compares the distance price *actually travelled* (the summed true
range) with the *net ground it covered* (the high-low span of the window). A
clean trend travels almost exactly its span, so the ratio is near `1` and `CI`
near `0`; a choppy market criss-crosses far more than its span, so the ratio
is large and `CI` climbs toward `100`. The conventional reading is `CI > 61.8`
ranging, `CI < 38.2` trending.
## Parameters
`period` — the lookback window. Must be at least `2` (the `log10(period)`
denominator is zero for `period == 1`). The Python binding defaults it to `14`.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/choppiness_index.rs`:
```rust
impl Indicator for ChoppinessIndex {
type Input = Candle;
type Output = f64;
// update(&mut self, input: Candle) -> Option<f64>
}
```
`ChoppinessIndex` is a **candle-input** indicator that reads `high`, `low` and
`close` (the close drives the true range across bar gaps). Python's streaming
`update` accepts a 6-tuple or a dict; the batch helper takes `high`, `low`,
`close` numpy arrays. Node and WASM expose `update(high, low, close)` and the
matching `batch`.
## Warmup
`ChoppinessIndex::new(14).warmup_period() == 14`. The first value lands once
the window holds a full `period` bars.
## Edge cases
- **Flat window.** A window with `high == low` everywhere has a zero span;
`CI` is defined as `100` (maximal choppiness).
- **Steady trend.** A one-directional march reads well below `50`.
- **`period < 2`.** Rejected at construction.
- **Reset.** `ci.reset()` clears the true-range and high/low windows.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, ChoppinessIndex};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut ci = ChoppinessIndex::new(2)?;
// Two H=11 L=9 C=10 bars: ΣTR = 4, span = 2 -> CI = 100·log10(2)/log10(2).
let out = ci.batch(&[
Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0)?,
Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 1)?,
]);
println!("{:?}", out);
Ok(())
}
```
Output:
```
[None, Some(100.0)]
```
### Python
```python
import numpy as np
import wickra as ta
ci = ta.ChoppinessIndex(2)
high = np.array([11.0, 11.0])
low = np.array([9.0, 9.0])
close = np.array([10.0, 10.0])
print(ci.batch(high, low, close))
```
Output:
```
[ nan 100.]
```
### Node
```javascript
const ta = require('wickra');
const ci = new ta.ChoppinessIndex(2);
console.log(ci.batch([11, 11], [9, 9], [10, 10]));
```
Output:
```
[ NaN, 100 ]
```
## Interpretation
The Choppiness Index is not directional — it does not say *which way* price is
going, only *whether* it is going anywhere. Use it as a regime filter: above
`61.8` favour mean-reversion / range tactics; below `38.2` favour
trend-following. It pairs naturally with a directional indicator that picks
the side once a trend is confirmed.
## Common pitfalls
- **Expecting a direction.** It has none — combine it with a trend indicator.
- **Tiny periods.** `period = 2` is allowed but noisy; `14` is conventional.
## References
E. W. Dreiss' Choppiness Index; the summed-true-range formulation here is the
standard one.
## See also
- [Indicator-VerticalHorizontalFilter.md](Indicator-VerticalHorizontalFilter.md)
— the same trending-vs-ranging question on an inverted scale.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,140 @@
# VerticalHorizontalFilter
> Vertical Horizontal Filter (VHF) — net distance covered divided by total
> distance walked; a trend-versus-range gauge.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Trend & Directional |
| Input type | `f64` (close price) |
| Output type | `f64` |
| Output range | `[0, 1]` |
| Default parameters | `period = 28` (Python) |
| Warmup period | `period + 1` |
| Interpretation | Near `1` = trending, near `0` = choppy. |
## Formula
```
VHF = (highest_close(n) lowest_close(n)) / Σ|close close_prev|(n)
```
The numerator is the *net* distance price covered over the window; the
denominator is the *total* distance it walked. Their ratio lives in `[0, 1]`:
a clean trend walks almost only in its net direction, so `VHF` approaches `1`;
a choppy market doubles back constantly, inflating the denominator and pushing
`VHF` toward `0`. It answers the same question as the
[`ChoppinessIndex`](Indicator-ChoppinessIndex.md) on an inverted scale.
## Parameters
`period` — the lookback window. The Python binding defaults it to `28`; the
Rust and Node constructors require it explicitly.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/vertical_horizontal_filter.rs`:
```rust
impl Indicator for VerticalHorizontalFilter {
type Input = f64;
type Output = f64;
// update(&mut self, input: f64) -> Option<f64>
}
```
`VerticalHorizontalFilter` is a **scalar** indicator: it consumes one `f64`
close per step. Because `Input = f64` it can sit inside a
[`Chain`](../../Indicator-Chaining.md).
## Warmup
`VerticalHorizontalFilter::new(28).warmup_period() == 29`. The high/low window
fills at `period` closes, but the `period`-th difference needs one extra input
because the first close has nothing to diff against.
## Edge cases
- **Flat series.** A window that walked nowhere has a zero denominator; `VHF`
is defined as `0`.
- **Pure trend.** A series rising by a fixed step reads `(period 1) / period`.
- **Choppy series.** An oscillating series reads near `0`.
- **Reset.** `vhf.reset()` clears the close and difference windows.
## Examples
### Rust
```rust
use wickra::{BatchExt, Indicator, VerticalHorizontalFilter};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut vhf = VerticalHorizontalFilter::new(5)?;
// Closes 1..6: each diff is 1 (Σ = 5), the 5-close span is 4 -> 4/5.
let out = vhf.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
println!("{:?}", out);
Ok(())
}
```
Output:
```
[None, None, None, None, None, Some(0.8)]
```
### Python
```python
import numpy as np
import wickra as ta
vhf = ta.VerticalHorizontalFilter(5)
print(vhf.batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])))
```
Output:
```
[ nan nan nan nan nan 0.8]
```
### Node
```javascript
const ta = require('wickra');
const vhf = new ta.VerticalHorizontalFilter(5);
console.log(vhf.batch([1, 2, 3, 4, 5, 6]));
```
Output:
```
[ NaN, NaN, NaN, NaN, NaN, 0.8 ]
```
## Interpretation
Use the VHF as a regime filter: a high, rising VHF says a trend is in force —
favour trend-following entries; a low VHF says price is ranging — favour
mean-reversion. A VHF turning down from a high level is an early hint the
trend is losing its grip.
## Common pitfalls
- **Expecting a direction.** Like the Choppiness Index it is non-directional —
pair it with a trend indicator.
- **Reading a single bar.** It is a regime gauge; read its level and slope.
## References
Adam White's Vertical Horizontal Filter; the net-over-total formulation here
is the standard one.
## See also
- [Indicator-ChoppinessIndex.md](Indicator-ChoppinessIndex.md) — the same
trending-vs-ranging question on an inverted `[0, 100]` scale.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.