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:
@@ -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
|
||||
|
||||
@@ -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]: ...
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user