F6: add Aroon Oscillator, Vortex and Mass Index
Completes the F6 family (Trend strength) end to end: - Rust core: aroon_oscillator.rs (AroonUp - AroonDown, one-line trend gauge), vortex.rs (Vortex Indicator VI+/VI- with the VortexOutput struct), mass_index.rs (Dorsey's range-expansion sum of the EMA-of-range ratio). Each with a full Indicator impl, runnable doctest and reference / saturation / warmup / reset / batch==streaming tests. - Python: PyAroonOscillator / PyVortex / PyMassIndex PyO3 classes + module registration + .pyi stubs (defaults Aroon=14, Vortex=14, MassIndex=(9,25)). - Node: explicit AroonOscillatorNode, VortexNode (with VortexValue object) and MassIndexNode; index.d.ts and index.js updated. - WASM: WasmAroonOscillator, WasmVortex, WasmMassIndex. - Wiki: Indicator-AroonOscillator/Vortex/MassIndex.md plus rows in Indicators-Overview.md and entries in Home.md. cargo fmt + clippy (core/wickra/data/wasm/node) clean; 320 core tests, 25 data tests and 45 doctests green.
This commit is contained in:
@@ -310,7 +310,7 @@ if (!nativeBinding) {
|
|||||||
throw new Error(`Failed to load native binding`)
|
throw new Error(`Failed to load native binding`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding
|
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding
|
||||||
|
|
||||||
module.exports.version = version
|
module.exports.version = version
|
||||||
module.exports.SMA = SMA
|
module.exports.SMA = SMA
|
||||||
@@ -336,6 +336,9 @@ module.exports.UltimateOscillator = UltimateOscillator
|
|||||||
module.exports.PPO = PPO
|
module.exports.PPO = PPO
|
||||||
module.exports.DPO = DPO
|
module.exports.DPO = DPO
|
||||||
module.exports.Coppock = Coppock
|
module.exports.Coppock = Coppock
|
||||||
|
module.exports.AroonOscillator = AroonOscillator
|
||||||
|
module.exports.Vortex = Vortex
|
||||||
|
module.exports.MassIndex = MassIndex
|
||||||
module.exports.MACD = MACD
|
module.exports.MACD = MACD
|
||||||
module.exports.BollingerBands = BollingerBands
|
module.exports.BollingerBands = BollingerBands
|
||||||
module.exports.ATR = ATR
|
module.exports.ATR = ATR
|
||||||
|
|||||||
@@ -1145,6 +1145,175 @@ impl PmoNode {
|
|||||||
|
|
||||||
// ============================== VWMA ==============================
|
// ============================== VWMA ==============================
|
||||||
|
|
||||||
|
// ============================== Aroon Oscillator ==============================
|
||||||
|
|
||||||
|
#[napi(js_name = "AroonOscillator")]
|
||||||
|
pub struct AroonOscillatorNode {
|
||||||
|
inner: wc::AroonOscillator,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
impl AroonOscillatorNode {
|
||||||
|
#[napi(constructor)]
|
||||||
|
pub fn new(period: u32) -> napi::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
inner: wc::AroonOscillator::new(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== Vortex ==============================
|
||||||
|
|
||||||
|
/// Vortex Indicator pair: `VI+` and `VI-`.
|
||||||
|
#[napi(object)]
|
||||||
|
pub struct VortexValue {
|
||||||
|
pub plus: f64,
|
||||||
|
pub minus: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi(js_name = "Vortex")]
|
||||||
|
pub struct VortexNode {
|
||||||
|
inner: wc::Vortex,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
impl VortexNode {
|
||||||
|
#[napi(constructor)]
|
||||||
|
pub fn new(period: u32) -> napi::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
inner: wc::Vortex::new(period as usize).map_err(map_err)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
#[napi]
|
||||||
|
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<VortexValue>> {
|
||||||
|
Ok(self
|
||||||
|
.inner
|
||||||
|
.update(cnd(high, low, close, 0.0)?)
|
||||||
|
.map(|o| VortexValue {
|
||||||
|
plus: o.plus,
|
||||||
|
minus: o.minus,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/// Returns `[plus0, minus0, plus1, minus1, ...]`, length `2 * n`. Warmup is NaN.
|
||||||
|
#[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 n = high.len();
|
||||||
|
let mut out = vec![f64::NAN; n * 2];
|
||||||
|
for i in 0..n {
|
||||||
|
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
|
||||||
|
out[i * 2] = o.plus;
|
||||||
|
out[i * 2 + 1] = o.minus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== Mass Index ==============================
|
||||||
|
|
||||||
|
#[napi(js_name = "MassIndex")]
|
||||||
|
pub struct MassIndexNode {
|
||||||
|
inner: wc::MassIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
impl MassIndexNode {
|
||||||
|
#[napi(constructor)]
|
||||||
|
pub fn new(ema_period: u32, sum_period: u32) -> napi::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
inner: wc::MassIndex::new(ema_period as usize, sum_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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================== StochRSI ==============================
|
// ============================== StochRSI ==============================
|
||||||
|
|
||||||
#[napi(js_name = "StochRSI")]
|
#[napi(js_name = "StochRSI")]
|
||||||
|
|||||||
@@ -76,6 +76,55 @@ class TRIMA:
|
|||||||
@property
|
@property
|
||||||
def value(self) -> Optional[float]: ...
|
def value(self) -> Optional[float]: ...
|
||||||
|
|
||||||
|
class AroonOscillator:
|
||||||
|
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],
|
||||||
|
) -> NDArray[np.float64]: ...
|
||||||
|
def reset(self) -> None: ...
|
||||||
|
def is_ready(self) -> bool: ...
|
||||||
|
def warmup_period(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def period(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def value(self) -> Optional[float]: ...
|
||||||
|
|
||||||
|
class Vortex:
|
||||||
|
def __init__(self, period: int = 14) -> None: ...
|
||||||
|
def update(self, candle: CandleLike) -> Optional[Tuple[float, float]]: ...
|
||||||
|
def batch(
|
||||||
|
self,
|
||||||
|
high: NDArray[np.float64],
|
||||||
|
low: NDArray[np.float64],
|
||||||
|
close: NDArray[np.float64],
|
||||||
|
) -> NDArray[np.float64]:
|
||||||
|
"""Returns shape ``(n, 2)`` with columns ``[plus, minus]``."""
|
||||||
|
...
|
||||||
|
def reset(self) -> None: ...
|
||||||
|
def is_ready(self) -> bool: ...
|
||||||
|
def warmup_period(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def period(self) -> int: ...
|
||||||
|
|
||||||
|
class MassIndex:
|
||||||
|
def __init__(self, ema_period: int = 9, sum_period: int = 25) -> 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 periods(self) -> Tuple[int, int]: ...
|
||||||
|
@property
|
||||||
|
def value(self) -> Optional[float]: ...
|
||||||
|
|
||||||
class PPO:
|
class PPO:
|
||||||
def __init__(self, fast: int = 12, slow: int = 26) -> None: ...
|
def __init__(self, fast: int = 12, slow: int = 26) -> None: ...
|
||||||
def update(self, value: float) -> Optional[float]: ...
|
def update(self, value: float) -> Optional[float]: ...
|
||||||
|
|||||||
@@ -1519,6 +1519,214 @@ impl PyAroon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================== Aroon Oscillator ==============================
|
||||||
|
|
||||||
|
#[pyclass(name = "AroonOscillator", module = "wickra._wickra")]
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PyAroonOscillator {
|
||||||
|
inner: wc::AroonOscillator,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pymethods]
|
||||||
|
impl PyAroonOscillator {
|
||||||
|
#[new]
|
||||||
|
#[pyo3(signature = (period=14))]
|
||||||
|
fn new(period: usize) -> PyResult<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
inner: wc::AroonOscillator::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 high + low columns (both 1-D, 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 period(&self) -> usize {
|
||||||
|
self.inner.period()
|
||||||
|
}
|
||||||
|
#[getter]
|
||||||
|
fn value(&self) -> Option<f64> {
|
||||||
|
self.inner.value()
|
||||||
|
}
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.inner.reset();
|
||||||
|
}
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.inner.is_ready()
|
||||||
|
}
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
self.inner.warmup_period()
|
||||||
|
}
|
||||||
|
fn __repr__(&self) -> String {
|
||||||
|
format!("AroonOscillator(period={})", self.inner.period())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== Vortex ==============================
|
||||||
|
|
||||||
|
#[pyclass(name = "Vortex", module = "wickra._wickra")]
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PyVortex {
|
||||||
|
inner: wc::Vortex,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pymethods]
|
||||||
|
impl PyVortex {
|
||||||
|
#[new]
|
||||||
|
#[pyo3(signature = (period=14))]
|
||||||
|
fn new(period: usize) -> PyResult<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
inner: wc::Vortex::new(period).map_err(map_err)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/// Returns `(plus, minus)` or `None` during warmup.
|
||||||
|
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
|
||||||
|
let c = extract_candle(candle)?;
|
||||||
|
Ok(self.inner.update(c).map(|o| (o.plus, o.minus)))
|
||||||
|
}
|
||||||
|
/// Batch over high/low/close numpy columns. Returns shape `(n, 2)` for `[plus, minus]`.
|
||||||
|
fn batch<'py>(
|
||||||
|
&mut self,
|
||||||
|
py: Python<'py>,
|
||||||
|
high: PyReadonlyArray1<'py, f64>,
|
||||||
|
low: PyReadonlyArray1<'py, f64>,
|
||||||
|
close: PyReadonlyArray1<'py, f64>,
|
||||||
|
) -> PyResult<Bound<'py, PyArray2<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 n = h.len();
|
||||||
|
let mut out = vec![f64::NAN; n * 2];
|
||||||
|
for i in 0..n {
|
||||||
|
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
|
||||||
|
if let Some(o) = self.inner.update(candle) {
|
||||||
|
out[i * 2] = o.plus;
|
||||||
|
out[i * 2 + 1] = o.minus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
|
||||||
|
.expect("shape consistent")
|
||||||
|
.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!("Vortex(period={})", self.inner.period())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== Mass Index ==============================
|
||||||
|
|
||||||
|
#[pyclass(name = "MassIndex", module = "wickra._wickra")]
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PyMassIndex {
|
||||||
|
inner: wc::MassIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pymethods]
|
||||||
|
impl PyMassIndex {
|
||||||
|
#[new]
|
||||||
|
#[pyo3(signature = (ema_period=9, sum_period=25))]
|
||||||
|
fn new(ema_period: usize, sum_period: usize) -> PyResult<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
inner: wc::MassIndex::new(ema_period, sum_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 high + low columns (both 1-D, equal length).
|
||||||
|
fn batch<'py>(
|
||||||
|
&mut self,
|
||||||
|
py: Python<'py>,
|
||||||
|
high: PyReadonlyArray1<'py, f64>,
|
||||||
|
low: PyReadonlyArray1<'py, f64>,
|
||||||
|
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||||
|
let h = high
|
||||||
|
.as_slice()
|
||||||
|
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||||
|
let l = low
|
||||||
|
.as_slice()
|
||||||
|
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||||
|
if h.len() != l.len() {
|
||||||
|
return Err(PyValueError::new_err("high and low must be equal length"));
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(h.len());
|
||||||
|
for i in 0..h.len() {
|
||||||
|
let candle = wc::Candle::new(l[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?;
|
||||||
|
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||||
|
}
|
||||||
|
Ok(out.into_pyarray_bound(py))
|
||||||
|
}
|
||||||
|
#[getter]
|
||||||
|
fn periods(&self) -> (usize, usize) {
|
||||||
|
self.inner.periods()
|
||||||
|
}
|
||||||
|
#[getter]
|
||||||
|
fn value(&self) -> Option<f64> {
|
||||||
|
self.inner.value()
|
||||||
|
}
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.inner.reset();
|
||||||
|
}
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.inner.is_ready()
|
||||||
|
}
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
self.inner.warmup_period()
|
||||||
|
}
|
||||||
|
fn __repr__(&self) -> String {
|
||||||
|
let (e, s) = self.inner.periods();
|
||||||
|
format!("MassIndex(ema_period={e}, sum_period={s})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================== PPO ==============================
|
// ============================== PPO ==============================
|
||||||
|
|
||||||
#[pyclass(name = "PPO", module = "wickra._wickra")]
|
#[pyclass(name = "PPO", module = "wickra._wickra")]
|
||||||
@@ -2345,5 +2553,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|||||||
m.add_class::<PyPpo>()?;
|
m.add_class::<PyPpo>()?;
|
||||||
m.add_class::<PyDpo>()?;
|
m.add_class::<PyDpo>()?;
|
||||||
m.add_class::<PyCoppock>()?;
|
m.add_class::<PyCoppock>()?;
|
||||||
|
m.add_class::<PyAroonOscillator>()?;
|
||||||
|
m.add_class::<PyVortex>()?;
|
||||||
|
m.add_class::<PyMassIndex>()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,6 +372,123 @@ impl WasmUltimateOscillator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen(js_name = AroonOscillator)]
|
||||||
|
pub struct WasmAroonOscillator {
|
||||||
|
inner: wc::AroonOscillator,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen(js_class = AroonOscillator)]
|
||||||
|
impl WasmAroonOscillator {
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new(period: usize) -> Result<WasmAroonOscillator, JsError> {
|
||||||
|
Ok(Self {
|
||||||
|
inner: wc::AroonOscillator::new(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 = Vortex)]
|
||||||
|
pub struct WasmVortex {
|
||||||
|
inner: wc::Vortex,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen(js_class = Vortex)]
|
||||||
|
impl WasmVortex {
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new(period: usize) -> Result<WasmVortex, JsError> {
|
||||||
|
Ok(Self {
|
||||||
|
inner: wc::Vortex::new(period).map_err(map_err)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
|
||||||
|
let c = make_candle(high, low, close, 0.0)?;
|
||||||
|
Ok(match self.inner.update(c) {
|
||||||
|
Some(o) => {
|
||||||
|
let obj = Object::new();
|
||||||
|
Reflect::set(&obj, &"plus".into(), &o.plus.into()).ok();
|
||||||
|
Reflect::set(&obj, &"minus".into(), &o.minus.into()).ok();
|
||||||
|
obj.into()
|
||||||
|
}
|
||||||
|
None => JsValue::NULL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/// Returns `[plus0, minus0, plus1, minus1, ...]`, length `2 * n`. Warmup is NaN.
|
||||||
|
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![f64::NAN; n * 2];
|
||||||
|
for i in 0..n {
|
||||||
|
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||||
|
if let Some(o) = self.inner.update(c) {
|
||||||
|
out[i * 2] = o.plus;
|
||||||
|
out[i * 2 + 1] = o.minus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Float64Array::from(out.as_slice()))
|
||||||
|
}
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.inner.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen(js_name = MassIndex)]
|
||||||
|
pub struct WasmMassIndex {
|
||||||
|
inner: wc::MassIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen(js_class = MassIndex)]
|
||||||
|
impl WasmMassIndex {
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new(ema_period: usize, sum_period: usize) -> Result<WasmMassIndex, JsError> {
|
||||||
|
Ok(Self {
|
||||||
|
inner: wc::MassIndex::new(ema_period, sum_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 = VWMA)]
|
#[wasm_bindgen(js_name = VWMA)]
|
||||||
pub struct WasmVwma {
|
pub struct WasmVwma {
|
||||||
inner: wc::Vwma,
|
inner: wc::Vwma,
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
//! Aroon Oscillator.
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::ohlcv::Candle;
|
||||||
|
use crate::traits::Indicator;
|
||||||
|
|
||||||
|
use super::Aroon;
|
||||||
|
|
||||||
|
/// Aroon Oscillator — the single-line difference `AroonUp − AroonDown`.
|
||||||
|
///
|
||||||
|
/// The [`Aroon`] indicator reports two `[0, 100]` lines; the Aroon Oscillator
|
||||||
|
/// collapses them into one value in `[−100, 100]`:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// AroonOscillator = AroonUp − AroonDown
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Strongly positive means the most recent high is much fresher than the most
|
||||||
|
/// recent low (an up-trend); strongly negative is the mirror image. Readings
|
||||||
|
/// near zero mean neither extreme is recent — a range.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wickra_core::{Candle, Indicator, AroonOscillator};
|
||||||
|
///
|
||||||
|
/// let mut indicator = AroonOscillator::new(5).unwrap();
|
||||||
|
/// let mut last = None;
|
||||||
|
/// for i in 0..80 {
|
||||||
|
/// let base = 100.0 + i as f64;
|
||||||
|
/// 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_eq!(last, Some(100.0)); // pure uptrend
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AroonOscillator {
|
||||||
|
aroon: Aroon,
|
||||||
|
last: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AroonOscillator {
|
||||||
|
/// Construct a new Aroon Oscillator with the given period.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||||
|
pub fn new(period: usize) -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
aroon: Aroon::new(period)?,
|
||||||
|
last: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured period.
|
||||||
|
pub const fn period(&self) -> usize {
|
||||||
|
self.aroon.period()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current value if available.
|
||||||
|
pub const fn value(&self) -> Option<f64> {
|
||||||
|
self.last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for AroonOscillator {
|
||||||
|
type Input = Candle;
|
||||||
|
type Output = f64;
|
||||||
|
|
||||||
|
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||||
|
let osc = self.aroon.update(candle).map(|o| o.up - o.down)?;
|
||||||
|
self.last = Some(osc);
|
||||||
|
Some(osc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.aroon.reset();
|
||||||
|
self.last = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
self.aroon.warmup_period()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.last.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"AroonOscillator"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::traits::BatchExt;
|
||||||
|
use approx::assert_relative_eq;
|
||||||
|
|
||||||
|
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||||
|
Candle::new(close, high, low, close, 1.0, ts).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_rejects_zero_period() {
|
||||||
|
assert!(AroonOscillator::new(0).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pure_uptrend_yields_plus_100() {
|
||||||
|
// Every bar a fresh high, no fresh low: AroonUp = 100, AroonDown = 0.
|
||||||
|
let mut osc = AroonOscillator::new(5).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..30)
|
||||||
|
.map(|i| {
|
||||||
|
let p = 100.0 + i as f64;
|
||||||
|
candle(p + 1.0, p - 1.0, p, i)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for v in osc.batch(&candles).into_iter().flatten() {
|
||||||
|
assert_relative_eq!(v, 100.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pure_downtrend_yields_minus_100() {
|
||||||
|
let mut osc = AroonOscillator::new(5).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..30)
|
||||||
|
.map(|i| {
|
||||||
|
let p = 100.0 - i as f64;
|
||||||
|
candle(p + 1.0, p - 1.0, p, i)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for v in osc.batch(&candles).into_iter().flatten() {
|
||||||
|
assert_relative_eq!(v, -100.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_stays_within_minus_100_and_100() {
|
||||||
|
let mut osc = AroonOscillator::new(14).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..200)
|
||||||
|
.map(|i| {
|
||||||
|
let mid = 100.0 + (i as f64 * 0.25).sin() * 12.0;
|
||||||
|
candle(mid + 2.0, mid - 2.0, mid, i)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for v in osc.batch(&candles).into_iter().flatten() {
|
||||||
|
assert!((-100.0..=100.0).contains(&v), "out of range: {v}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warmup_period_matches_aroon() {
|
||||||
|
let osc = AroonOscillator::new(7).unwrap();
|
||||||
|
assert_eq!(osc.warmup_period(), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut osc = AroonOscillator::new(5).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..20)
|
||||||
|
.map(|i| candle(100.0 + i as f64, 90.0, 95.0, i))
|
||||||
|
.collect();
|
||||||
|
osc.batch(&candles);
|
||||||
|
assert!(osc.is_ready());
|
||||||
|
osc.reset();
|
||||||
|
assert!(!osc.is_ready());
|
||||||
|
assert_eq!(osc.update(candles[0]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_equals_streaming() {
|
||||||
|
let candles: Vec<Candle> = (0..60)
|
||||||
|
.map(|i| {
|
||||||
|
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||||
|
candle(mid + 2.0, mid - 2.0, mid, i)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let batch = AroonOscillator::new(14).unwrap().batch(&candles);
|
||||||
|
let mut b = AroonOscillator::new(14).unwrap();
|
||||||
|
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||||
|
assert_eq!(batch, streamed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! Mass Index.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::ohlcv::Candle;
|
||||||
|
use crate::traits::Indicator;
|
||||||
|
|
||||||
|
use super::Ema;
|
||||||
|
|
||||||
|
/// Mass Index — Donald Dorsey's range-expansion indicator.
|
||||||
|
///
|
||||||
|
/// The Mass Index watches the high–low range, not direction. It smooths the
|
||||||
|
/// range with an EMA, smooths that again, takes the ratio of the two, and sums
|
||||||
|
/// the ratio over a window:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// range_t = high_t − low_t
|
||||||
|
/// ratio_t = EMA(range, ema_period) / EMA(EMA(range, ema_period), ema_period)
|
||||||
|
/// MassIndex = Σ ratio over sum_period
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// When the range widens, the single EMA pulls ahead of the double EMA, the
|
||||||
|
/// ratio rises above `1`, and the sum climbs. Dorsey's "reversal bulge" is the
|
||||||
|
/// Mass Index rising above `27` and then falling back below `26.5` — a sign
|
||||||
|
/// that a range expansion is about to resolve into a trend reversal. With the
|
||||||
|
/// conventional `(ema_period = 9, sum_period = 25)` a flat-range market sits at
|
||||||
|
/// `25`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wickra_core::{Candle, Indicator, MassIndex};
|
||||||
|
///
|
||||||
|
/// let mut indicator = MassIndex::new(9, 25).unwrap();
|
||||||
|
/// let mut last = None;
|
||||||
|
/// for i in 0..80 {
|
||||||
|
/// let base = 100.0 + i as f64;
|
||||||
|
/// let candle =
|
||||||
|
/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap();
|
||||||
|
/// last = indicator.update(candle);
|
||||||
|
/// }
|
||||||
|
/// assert!(last.is_some());
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MassIndex {
|
||||||
|
ema_period: usize,
|
||||||
|
sum_period: usize,
|
||||||
|
ema1: Ema,
|
||||||
|
ema2: Ema,
|
||||||
|
/// Rolling window of the last `sum_period` EMA ratios.
|
||||||
|
window: VecDeque<f64>,
|
||||||
|
sum: f64,
|
||||||
|
last: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MassIndex {
|
||||||
|
/// Construct a new Mass Index with the EMA smoothing period and the sum
|
||||||
|
/// window length.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::PeriodZero`] if either period is `0`.
|
||||||
|
pub fn new(ema_period: usize, sum_period: usize) -> Result<Self> {
|
||||||
|
if ema_period == 0 || sum_period == 0 {
|
||||||
|
return Err(Error::PeriodZero);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
ema_period,
|
||||||
|
sum_period,
|
||||||
|
ema1: Ema::new(ema_period)?,
|
||||||
|
ema2: Ema::new(ema_period)?,
|
||||||
|
window: VecDeque::with_capacity(sum_period),
|
||||||
|
sum: 0.0,
|
||||||
|
last: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `(ema_period, sum_period)` pair.
|
||||||
|
pub const fn periods(&self) -> (usize, usize) {
|
||||||
|
(self.ema_period, self.sum_period)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current value if available.
|
||||||
|
pub const fn value(&self) -> Option<f64> {
|
||||||
|
self.last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for MassIndex {
|
||||||
|
type Input = Candle;
|
||||||
|
type Output = f64;
|
||||||
|
|
||||||
|
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||||
|
let range = candle.high - candle.low;
|
||||||
|
let single = self.ema1.update(range)?;
|
||||||
|
let double = self.ema2.update(single)?;
|
||||||
|
let ratio = if double == 0.0 {
|
||||||
|
// A zero-range market: no expansion, neutral ratio.
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
single / double
|
||||||
|
};
|
||||||
|
if self.window.len() == self.sum_period {
|
||||||
|
self.sum -= self.window.pop_front().expect("window is non-empty");
|
||||||
|
}
|
||||||
|
self.window.push_back(ratio);
|
||||||
|
self.sum += ratio;
|
||||||
|
if self.window.len() < self.sum_period {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.last = Some(self.sum);
|
||||||
|
Some(self.sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.ema1.reset();
|
||||||
|
self.ema2.reset();
|
||||||
|
self.window.clear();
|
||||||
|
self.sum = 0.0;
|
||||||
|
self.last = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
// ema1 seeds at `ema_period`, ema2 at `2·ema_period − 1`, then the sum
|
||||||
|
// window needs `sum_period` ratios.
|
||||||
|
2 * self.ema_period + self.sum_period - 2
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.last.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"MassIndex"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::traits::BatchExt;
|
||||||
|
use approx::assert_relative_eq;
|
||||||
|
|
||||||
|
/// A candle with a fixed high–low range `span` centred on `mid`.
|
||||||
|
fn candle(mid: f64, span: f64, ts: i64) -> Candle {
|
||||||
|
Candle::new(mid, mid + span / 2.0, mid - span / 2.0, mid, 1.0, ts).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_rejects_zero_period() {
|
||||||
|
assert!(matches!(MassIndex::new(0, 25), Err(Error::PeriodZero)));
|
||||||
|
assert!(matches!(MassIndex::new(9, 0), Err(Error::PeriodZero)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warmup_period_formula() {
|
||||||
|
let mi = MassIndex::new(9, 25).unwrap();
|
||||||
|
assert_eq!(mi.warmup_period(), 2 * 9 + 25 - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_emission_at_warmup_period() {
|
||||||
|
let mut mi = MassIndex::new(3, 4).unwrap();
|
||||||
|
let warmup = mi.warmup_period(); // 2*3 + 4 - 2 = 8
|
||||||
|
assert_eq!(warmup, 8);
|
||||||
|
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
|
||||||
|
let out = mi.batch(&candles);
|
||||||
|
for v in out.iter().take(warmup - 1) {
|
||||||
|
assert!(v.is_none());
|
||||||
|
}
|
||||||
|
assert!(out[warmup - 1].is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_range_sums_to_sum_period() {
|
||||||
|
// A constant high–low range makes both EMAs converge to the same
|
||||||
|
// value, so every ratio is 1 and the Mass Index equals `sum_period`.
|
||||||
|
let mut mi = MassIndex::new(3, 4).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
|
||||||
|
for v in mi.batch(&candles).into_iter().flatten() {
|
||||||
|
assert_relative_eq!(v, 4.0, epsilon = 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_range_market_sums_to_sum_period() {
|
||||||
|
let mut mi = MassIndex::new(3, 4).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0, 0.0, i)).collect();
|
||||||
|
for v in mi.batch(&candles).into_iter().flatten() {
|
||||||
|
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut mi = MassIndex::new(3, 4).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
|
||||||
|
mi.batch(&candles);
|
||||||
|
assert!(mi.is_ready());
|
||||||
|
mi.reset();
|
||||||
|
assert!(!mi.is_ready());
|
||||||
|
assert_eq!(mi.update(candles[0]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_equals_streaming() {
|
||||||
|
let candles: Vec<Candle> = (0..120)
|
||||||
|
.map(|i| {
|
||||||
|
let span = 2.0 + (i as f64 * 0.3).sin().abs() * 3.0;
|
||||||
|
candle(100.0 + (i as f64 * 0.2).cos() * 5.0, span, i)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let batch = MassIndex::new(9, 25).unwrap().batch(&candles);
|
||||||
|
let mut b = MassIndex::new(9, 25).unwrap();
|
||||||
|
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||||
|
assert_eq!(batch, streamed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
mod adx;
|
mod adx;
|
||||||
mod aroon;
|
mod aroon;
|
||||||
|
mod aroon_oscillator;
|
||||||
mod atr;
|
mod atr;
|
||||||
mod awesome_oscillator;
|
mod awesome_oscillator;
|
||||||
mod bollinger;
|
mod bollinger;
|
||||||
@@ -20,6 +21,7 @@ mod hma;
|
|||||||
mod kama;
|
mod kama;
|
||||||
mod keltner;
|
mod keltner;
|
||||||
mod macd;
|
mod macd;
|
||||||
|
mod mass_index;
|
||||||
mod mfi;
|
mod mfi;
|
||||||
mod mom;
|
mod mom;
|
||||||
mod obv;
|
mod obv;
|
||||||
@@ -38,6 +40,7 @@ mod trima;
|
|||||||
mod trix;
|
mod trix;
|
||||||
mod tsi;
|
mod tsi;
|
||||||
mod ultimate_oscillator;
|
mod ultimate_oscillator;
|
||||||
|
mod vortex;
|
||||||
mod vwap;
|
mod vwap;
|
||||||
mod vwma;
|
mod vwma;
|
||||||
mod williams_r;
|
mod williams_r;
|
||||||
@@ -46,6 +49,7 @@ mod zlema;
|
|||||||
|
|
||||||
pub use adx::{Adx, AdxOutput};
|
pub use adx::{Adx, AdxOutput};
|
||||||
pub use aroon::{Aroon, AroonOutput};
|
pub use aroon::{Aroon, AroonOutput};
|
||||||
|
pub use aroon_oscillator::AroonOscillator;
|
||||||
pub use atr::Atr;
|
pub use atr::Atr;
|
||||||
pub use awesome_oscillator::AwesomeOscillator;
|
pub use awesome_oscillator::AwesomeOscillator;
|
||||||
pub use bollinger::{BollingerBands, BollingerOutput};
|
pub use bollinger::{BollingerBands, BollingerOutput};
|
||||||
@@ -60,6 +64,7 @@ pub use hma::Hma;
|
|||||||
pub use kama::Kama;
|
pub use kama::Kama;
|
||||||
pub use keltner::{Keltner, KeltnerOutput};
|
pub use keltner::{Keltner, KeltnerOutput};
|
||||||
pub use macd::{MacdIndicator, MacdOutput};
|
pub use macd::{MacdIndicator, MacdOutput};
|
||||||
|
pub use mass_index::MassIndex;
|
||||||
pub use mfi::Mfi;
|
pub use mfi::Mfi;
|
||||||
pub use mom::Mom;
|
pub use mom::Mom;
|
||||||
pub use obv::Obv;
|
pub use obv::Obv;
|
||||||
@@ -78,6 +83,7 @@ pub use trima::Trima;
|
|||||||
pub use trix::Trix;
|
pub use trix::Trix;
|
||||||
pub use tsi::Tsi;
|
pub use tsi::Tsi;
|
||||||
pub use ultimate_oscillator::UltimateOscillator;
|
pub use ultimate_oscillator::UltimateOscillator;
|
||||||
|
pub use vortex::{Vortex, VortexOutput};
|
||||||
pub use vwap::{RollingVwap, Vwap};
|
pub use vwap::{RollingVwap, Vwap};
|
||||||
pub use vwma::Vwma;
|
pub use vwma::Vwma;
|
||||||
pub use williams_r::WilliamsR;
|
pub use williams_r::WilliamsR;
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
//! Vortex Indicator.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::ohlcv::Candle;
|
||||||
|
use crate::traits::Indicator;
|
||||||
|
|
||||||
|
/// Vortex Indicator output: the two directional movement lines.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct VortexOutput {
|
||||||
|
/// `VI+` — strength of upward (positive) vortex movement.
|
||||||
|
pub plus: f64,
|
||||||
|
/// `VI−` — strength of downward (negative) vortex movement.
|
||||||
|
pub minus: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vortex Indicator — Botes & Siepman's pair of oscillators (`VI+`, `VI−`) that
|
||||||
|
/// capture the relationship between two consecutive bars.
|
||||||
|
///
|
||||||
|
/// Two "vortex movements" measure how far price travelled against the opposite
|
||||||
|
/// extreme of the previous bar; each is normalised by the summed true range:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// VM+_t = |high_t − low_{t−1}|
|
||||||
|
/// VM−_t = |low_t − high_{t−1}|
|
||||||
|
/// VI+ = Σ VM+ over n / Σ TR over n
|
||||||
|
/// VI− = Σ VM− over n / Σ TR over n
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// `VI+` crossing above `VI−` is a bullish signal, the reverse a bearish one;
|
||||||
|
/// the wider the gap, the stronger the trend. A fully flat window (zero true
|
||||||
|
/// range) reports `(0, 0)`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wickra_core::{Candle, Indicator, Vortex};
|
||||||
|
///
|
||||||
|
/// let mut indicator = Vortex::new(14).unwrap();
|
||||||
|
/// let mut last = None;
|
||||||
|
/// for i in 0..80 {
|
||||||
|
/// let base = 100.0 + i as f64;
|
||||||
|
/// 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 Vortex {
|
||||||
|
period: usize,
|
||||||
|
prev: Option<Candle>,
|
||||||
|
/// Rolling window of `(VM+, VM−, TR)` triples.
|
||||||
|
window: VecDeque<(f64, f64, f64)>,
|
||||||
|
sum_vm_plus: f64,
|
||||||
|
sum_vm_minus: f64,
|
||||||
|
sum_tr: f64,
|
||||||
|
last: Option<VortexOutput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vortex {
|
||||||
|
/// Construct a new Vortex Indicator with the given period.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||||
|
pub fn new(period: usize) -> Result<Self> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(Error::PeriodZero);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
period,
|
||||||
|
prev: None,
|
||||||
|
window: VecDeque::with_capacity(period),
|
||||||
|
sum_vm_plus: 0.0,
|
||||||
|
sum_vm_minus: 0.0,
|
||||||
|
sum_tr: 0.0,
|
||||||
|
last: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured period.
|
||||||
|
pub const fn period(&self) -> usize {
|
||||||
|
self.period
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current value if available.
|
||||||
|
pub const fn value(&self) -> Option<VortexOutput> {
|
||||||
|
self.last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Indicator for Vortex {
|
||||||
|
type Input = Candle;
|
||||||
|
type Output = VortexOutput;
|
||||||
|
|
||||||
|
fn update(&mut self, candle: Candle) -> Option<VortexOutput> {
|
||||||
|
let Some(prev) = self.prev else {
|
||||||
|
// The first bar has no predecessor to measure against.
|
||||||
|
self.prev = Some(candle);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let vm_plus = (candle.high - prev.low).abs();
|
||||||
|
let vm_minus = (candle.low - prev.high).abs();
|
||||||
|
let tr = candle.true_range(Some(prev.close));
|
||||||
|
self.prev = Some(candle);
|
||||||
|
|
||||||
|
if self.window.len() == self.period {
|
||||||
|
let (old_p, old_m, old_tr) = self.window.pop_front().expect("window is non-empty");
|
||||||
|
self.sum_vm_plus -= old_p;
|
||||||
|
self.sum_vm_minus -= old_m;
|
||||||
|
self.sum_tr -= old_tr;
|
||||||
|
}
|
||||||
|
self.window.push_back((vm_plus, vm_minus, tr));
|
||||||
|
self.sum_vm_plus += vm_plus;
|
||||||
|
self.sum_vm_minus += vm_minus;
|
||||||
|
self.sum_tr += tr;
|
||||||
|
|
||||||
|
if self.window.len() < self.period {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let out = if self.sum_tr == 0.0 {
|
||||||
|
// A perfectly flat window has no range to normalise against.
|
||||||
|
VortexOutput {
|
||||||
|
plus: 0.0,
|
||||||
|
minus: 0.0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VortexOutput {
|
||||||
|
plus: self.sum_vm_plus / self.sum_tr,
|
||||||
|
minus: self.sum_vm_minus / self.sum_tr,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.last = Some(out);
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.prev = None;
|
||||||
|
self.window.clear();
|
||||||
|
self.sum_vm_plus = 0.0;
|
||||||
|
self.sum_vm_minus = 0.0;
|
||||||
|
self.sum_tr = 0.0;
|
||||||
|
self.last = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warmup_period(&self) -> usize {
|
||||||
|
// The first VM/TR triple needs a previous bar, then the window fills.
|
||||||
|
self.period + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(&self) -> bool {
|
||||||
|
self.last.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Vortex"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 new_rejects_zero_period() {
|
||||||
|
assert!(matches!(Vortex::new(0), Err(Error::PeriodZero)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_values() {
|
||||||
|
// Vortex(2) over three explicit candles (high, low, close):
|
||||||
|
// c1 = (10, 8, 9), c2 = (12, 9, 11), c3 = (13, 11, 12).
|
||||||
|
// bar 2: VM+ = |12-8| = 4, VM- = |9-10| = 1, TR = 3.
|
||||||
|
// bar 3: VM+ = |13-9| = 4, VM- = |11-12| = 1, TR = 2.
|
||||||
|
// window sums: VM+ = 8, VM- = 2, TR = 5 -> VI+ = 1.6, VI- = 0.4.
|
||||||
|
let candles = [
|
||||||
|
candle(9.0, 10.0, 8.0, 9.0, 0),
|
||||||
|
candle(10.0, 12.0, 9.0, 11.0, 1),
|
||||||
|
candle(12.0, 13.0, 11.0, 12.0, 2),
|
||||||
|
];
|
||||||
|
let mut v = Vortex::new(2).unwrap();
|
||||||
|
let out = v.batch(&candles);
|
||||||
|
assert_eq!(v.warmup_period(), 3);
|
||||||
|
assert_eq!(out[0], None);
|
||||||
|
assert_eq!(out[1], None);
|
||||||
|
let o = out[2].unwrap();
|
||||||
|
assert_relative_eq!(o.plus, 1.6, epsilon = 1e-12);
|
||||||
|
assert_relative_eq!(o.minus, 0.4, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn perfectly_flat_market_yields_zero() {
|
||||||
|
let mut v = Vortex::new(5).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..20).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect();
|
||||||
|
for o in v.batch(&candles).into_iter().flatten() {
|
||||||
|
assert_relative_eq!(o.plus, 0.0, epsilon = 1e-12);
|
||||||
|
assert_relative_eq!(o.minus, 0.0, epsilon = 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outputs_are_non_negative() {
|
||||||
|
let mut v = Vortex::new(14).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..120)
|
||||||
|
.map(|i| {
|
||||||
|
let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0;
|
||||||
|
candle(mid, mid + 3.0, mid - 3.0, mid + 1.0, i)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for o in v.batch(&candles).into_iter().flatten() {
|
||||||
|
assert!(o.plus >= 0.0 && o.minus >= 0.0, "negative VI: {o:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut v = Vortex::new(5).unwrap();
|
||||||
|
let candles: Vec<Candle> = (0..20)
|
||||||
|
.map(|i| candle(100.0, 102.0, 98.0, 101.0, i))
|
||||||
|
.collect();
|
||||||
|
v.batch(&candles);
|
||||||
|
assert!(v.is_ready());
|
||||||
|
v.reset();
|
||||||
|
assert!(!v.is_ready());
|
||||||
|
assert_eq!(v.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.35).sin() * 9.0;
|
||||||
|
candle(mid, mid + 2.5, mid - 2.5, mid + 0.5, i)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let batch = Vortex::new(14).unwrap().batch(&candles);
|
||||||
|
let mut b = Vortex::new(14).unwrap();
|
||||||
|
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||||
|
assert_eq!(batch, streamed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,11 +44,11 @@ pub mod indicators;
|
|||||||
|
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
pub use indicators::{
|
pub use indicators::{
|
||||||
Adx, AdxOutput, Aroon, AroonOutput, Atr, AwesomeOscillator, BollingerBands, BollingerOutput,
|
Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, AwesomeOscillator, BollingerBands,
|
||||||
Cci, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, Ema, Hma, Kama, Keltner, KeltnerOutput,
|
BollingerOutput, Cci, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, Ema, Hma, Kama,
|
||||||
MacdIndicator, MacdOutput, Mfi, Mom, Obv, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma,
|
Keltner, KeltnerOutput, MacdIndicator, MacdOutput, MassIndex, Mfi, Mom, Obv, Pmo, Ppo, Psar,
|
||||||
StochRsi, Stochastic, StochasticOutput, Tema, Trima, Trix, Tsi, UltimateOscillator, Vwap, Vwma,
|
Roc, RollingVwap, Rsi, Sma, Smma, StochRsi, Stochastic, StochasticOutput, Tema, Trima, Trix,
|
||||||
WilliamsR, Wma, Zlema, T3,
|
Tsi, UltimateOscillator, Vortex, VortexOutput, Vwap, Vwma, WilliamsR, Wma, Zlema, T3,
|
||||||
};
|
};
|
||||||
pub use ohlcv::{Candle, Tick};
|
pub use ohlcv::{Candle, Tick};
|
||||||
pub use traits::{BatchExt, Chain, Indicator};
|
pub use traits::{BatchExt, Chain, Indicator};
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ Rust / Python / Node examples. They are grouped by family, mirroring the
|
|||||||
- [Indicator-Ppo.md](indicators/momentum/Indicator-Ppo.md)
|
- [Indicator-Ppo.md](indicators/momentum/Indicator-Ppo.md)
|
||||||
- [Indicator-Dpo.md](indicators/momentum/Indicator-Dpo.md)
|
- [Indicator-Dpo.md](indicators/momentum/Indicator-Dpo.md)
|
||||||
- [Indicator-Coppock.md](indicators/momentum/Indicator-Coppock.md)
|
- [Indicator-Coppock.md](indicators/momentum/Indicator-Coppock.md)
|
||||||
|
- [Indicator-AroonOscillator.md](indicators/momentum/Indicator-AroonOscillator.md)
|
||||||
|
- [Indicator-Vortex.md](indicators/momentum/Indicator-Vortex.md)
|
||||||
|
- [Indicator-MassIndex.md](indicators/momentum/Indicator-MassIndex.md)
|
||||||
|
|
||||||
**Volatility** — envelope width and per-bar dispersion measures.
|
**Volatility** — envelope width and per-bar dispersion measures.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Indicators Overview
|
# Indicators Overview
|
||||||
|
|
||||||
Wickra ships 39 indicators, organised in source under the four classical
|
Wickra ships 42 indicators, organised in source under the four classical
|
||||||
families — trend, momentum, volatility, volume — that map directly to the
|
families — trend, momentum, volatility, volume — that map directly to the
|
||||||
directory structure of `crates/wickra-core/src/indicators/`. The same family
|
directory structure of `crates/wickra-core/src/indicators/`. The same family
|
||||||
labels are used here, plus a second-level grouping that reflects how the
|
labels are used here, plus a second-level grouping that reflects how the
|
||||||
@@ -112,6 +112,9 @@ Centered on zero or driven by raw price differences; no fixed cap.
|
|||||||
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive |
|
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive |
|
||||||
|-----------|-----------|-------|--------|-------|----------|--------|-----------|
|
|-----------|-----------|-------|--------|-------|----------|--------|-----------|
|
||||||
| `Adx` | Wilder's directional system: `+DI`, `−DI` (each `[0, 100]`) and `ADX` trend-strength index. | `Candle` | `(plus_di, minus_di, adx)` | each in `[0, 100]` | `period = 14` (Python) | `2·period` | [Indicator-Adx.md](indicators/momentum/Indicator-Adx.md) |
|
| `Adx` | Wilder's directional system: `+DI`, `−DI` (each `[0, 100]`) and `ADX` trend-strength index. | `Candle` | `(plus_di, minus_di, adx)` | each in `[0, 100]` | `period = 14` (Python) | `2·period` | [Indicator-Adx.md](indicators/momentum/Indicator-Adx.md) |
|
||||||
|
| `AroonOscillator` | `AroonUp − AroonDown`; the two Aroon lines as one trend gauge. | `Candle` | `f64` | `[−100, 100]` | `period = 14` (Python) | `period + 1` | [Indicator-AroonOscillator.md](indicators/momentum/Indicator-AroonOscillator.md) |
|
||||||
|
| `Vortex` | Vortex Indicator `VI+` / `VI−`; crossings mark trend onset. | `Candle` | `(plus, minus)` | each `>= 0` | `period = 14` (Python) | `period + 1` | [Indicator-Vortex.md](indicators/momentum/Indicator-Vortex.md) |
|
||||||
|
| `MassIndex` | Dorsey's range-expansion sum of the EMA-of-range ratio. | `Candle` | `f64` | `> 0` (around `sum_period`) | `(ema_period=9, sum_period=25)` (Python) | `2·ema_period + sum_period − 2` | [Indicator-MassIndex.md](indicators/momentum/Indicator-MassIndex.md) |
|
||||||
|
|
||||||
## Volatility
|
## Volatility
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# AroonOscillator
|
||||||
|
|
||||||
|
> Aroon Oscillator — the single-line difference `AroonUp − AroonDown`,
|
||||||
|
> condensing the two Aroon lines into one trend gauge.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Family | Momentum (trend strength) |
|
||||||
|
| Sub-category | Bounded oscillators |
|
||||||
|
| Input type | `Candle` (uses `high`, `low`) |
|
||||||
|
| Output type | `f64` |
|
||||||
|
| Output range | `[−100, 100]` |
|
||||||
|
| Default parameters | `period = 14` (Python) |
|
||||||
|
| Warmup period | `period + 1` |
|
||||||
|
| Interpretation | Positive = up-trend, negative = down-trend, near zero = range. |
|
||||||
|
|
||||||
|
## Formula
|
||||||
|
|
||||||
|
```
|
||||||
|
AroonOscillator = AroonUp − AroonDown
|
||||||
|
```
|
||||||
|
|
||||||
|
where [`Aroon`](Indicator-Aroon.md) reports two `[0, 100]` lines measuring
|
||||||
|
how recently the window's highest high and lowest low occurred. Their
|
||||||
|
difference lives in `[−100, 100]`: strongly positive means the most recent
|
||||||
|
high is much fresher than the most recent low (an up-trend); strongly
|
||||||
|
negative is the mirror image; near zero means neither extreme is recent.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
| Name | Type | Default | Valid range | Description |
|
||||||
|
|----------|---------|---------------|-------------|-------------|
|
||||||
|
| `period` | `usize` | `14` (Python) | `>= 1` | Aroon lookback window. `0` errors with `Error::PeriodZero`. |
|
||||||
|
|
||||||
|
The Python binding defaults `period` to `14`.
|
||||||
|
|
||||||
|
## Inputs / Outputs
|
||||||
|
|
||||||
|
From `crates/wickra-core/src/indicators/aroon_oscillator.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl Indicator for AroonOscillator {
|
||||||
|
type Input = Candle;
|
||||||
|
type Output = f64;
|
||||||
|
// update(&mut self, input: Candle) -> Option<f64>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`AroonOscillator` is a **candle-input** indicator: it reads `high` and
|
||||||
|
`low`. In Python the streaming `update` accepts a 6-tuple or a dict; the
|
||||||
|
batch helper takes `high` and `low` numpy arrays. Node and WASM expose
|
||||||
|
`update(high, low)` and `batch(high, low)`.
|
||||||
|
|
||||||
|
## Warmup
|
||||||
|
|
||||||
|
`AroonOscillator::new(period).warmup_period() == period + 1` — identical
|
||||||
|
to the underlying `Aroon`, which needs a `period + 1`-bar window before
|
||||||
|
the first reading.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- **Pure trend.** A series of fresh highs gives `AroonUp = 100`,
|
||||||
|
`AroonDown = 0`, so the oscillator is `+100`; a series of fresh lows is
|
||||||
|
`−100` (`pure_uptrend_yields_plus_100` /
|
||||||
|
`pure_downtrend_yields_minus_100` pin this).
|
||||||
|
- **Bounds.** The output is always within `[−100, 100]`
|
||||||
|
(`output_stays_within_minus_100_and_100` pins this).
|
||||||
|
- **Candle validation.** `Candle::new` rejects invalid bars before
|
||||||
|
`update` ever sees them.
|
||||||
|
- **Reset.** `osc.reset()` clears the underlying Aroon window.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wickra::{BatchExt, Candle, Indicator, AroonOscillator};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut osc = AroonOscillator::new(5)?;
|
||||||
|
// 30 bars, each a fresh high.
|
||||||
|
let candles: Vec<Candle> = (0..30)
|
||||||
|
.map(|i| {
|
||||||
|
let p = 100.0 + f64::from(i);
|
||||||
|
Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i64::from(i)).unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let out = osc.batch(&candles);
|
||||||
|
println!("last = {:?}", out.last().unwrap());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```
|
||||||
|
last = Some(100.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Every bar is a fresh high and never a fresh low, so the oscillator pins at
|
||||||
|
`+100`. This matches the `pure_uptrend_yields_plus_100` test in
|
||||||
|
`crates/wickra-core/src/indicators/aroon_oscillator.rs`.
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import wickra as ta
|
||||||
|
|
||||||
|
osc = ta.AroonOscillator(14)
|
||||||
|
high = np.arange(100.0, 140.0)
|
||||||
|
low = high - 2.0
|
||||||
|
print(osc.batch(high, low)[-1]) # steady uptrend -> 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```
|
||||||
|
100.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const ta = require('wickra');
|
||||||
|
const osc = new ta.AroonOscillator(14);
|
||||||
|
const high = Array.from({ length: 40 }, (_, i) => 100 + i);
|
||||||
|
const low = high.map((h) => h - 2);
|
||||||
|
console.log(osc.batch(high, low).at(-1)); // 100
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interpretation
|
||||||
|
|
||||||
|
`AroonOscillator` is a compact trend gauge. The two canonical reads are
|
||||||
|
the zero-line cross (`AroonUp` overtaking `AroonDown` or vice versa — a
|
||||||
|
trend change) and the magnitude (values pinned near `±100` confirm a
|
||||||
|
strong, uninterrupted trend; values oscillating near zero confirm a
|
||||||
|
range). Use it where the two-line `Aroon` is more detail than you need.
|
||||||
|
|
||||||
|
## Common pitfalls
|
||||||
|
|
||||||
|
- **Feeding it scalar prices.** It needs `high`/`low`; it takes a
|
||||||
|
`Candle`, not an `f64`.
|
||||||
|
- **Expecting the `[0, 100]` Aroon scale.** The oscillator is signed and
|
||||||
|
spans `[−100, 100]`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
Tushar Chande's Aroon system (1995); the oscillator is the standard
|
||||||
|
`AroonUp − AroonDown` difference.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Indicator-Aroon.md](Indicator-Aroon.md) — the two-line indicator this
|
||||||
|
collapses.
|
||||||
|
- [Indicator-Adx.md](Indicator-Adx.md) — another trend-strength gauge.
|
||||||
|
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# MassIndex
|
||||||
|
|
||||||
|
> Mass Index — Donald Dorsey's range-expansion indicator: it watches the
|
||||||
|
> high–low range widen and contract to anticipate reversals.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Family | Momentum (trend strength) |
|
||||||
|
| Sub-category | Range expansion |
|
||||||
|
| Input type | `Candle` (uses `high`, `low`) |
|
||||||
|
| Output type | `f64` |
|
||||||
|
| Output range | `> 0`, oscillates around `sum_period` |
|
||||||
|
| Default parameters | `(ema_period = 9, sum_period = 25)` (Python) |
|
||||||
|
| Warmup period | `2·ema_period + sum_period − 2` |
|
||||||
|
| Interpretation | A rise above `27` then fall below `26.5` flags a reversal. |
|
||||||
|
|
||||||
|
## Formula
|
||||||
|
|
||||||
|
```
|
||||||
|
range_t = high_t − low_t
|
||||||
|
single_t = EMA(range, ema_period)_t
|
||||||
|
double_t = EMA(single, ema_period)_t
|
||||||
|
ratio_t = single_t / double_t
|
||||||
|
MassIndex = Σ ratio over sum_period
|
||||||
|
```
|
||||||
|
|
||||||
|
The Mass Index ignores direction entirely — it tracks **volatility shape**.
|
||||||
|
When the high–low range widens, the single EMA pulls ahead of the double
|
||||||
|
EMA, the ratio climbs above `1`, and the windowed sum rises. Dorsey's
|
||||||
|
"reversal bulge" is the classic pattern: the Mass Index rising above `27`
|
||||||
|
and then falling back below `26.5` warns that a range expansion is about
|
||||||
|
to resolve — often into a trend reversal.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
| Name | Type | Default | Valid range | Description |
|
||||||
|
|--------------|---------|---------------|-------------|-------------|
|
||||||
|
| `ema_period` | `usize` | `9` (Python) | `>= 1` | Period of both EMAs in the cascade. `0` errors with `Error::PeriodZero`. |
|
||||||
|
| `sum_period` | `usize` | `25` (Python) | `>= 1` | Length of the summation window. |
|
||||||
|
|
||||||
|
The Python binding defaults the pair to `(9, 25)`. The `periods` property
|
||||||
|
returns `(ema_period, sum_period)`.
|
||||||
|
|
||||||
|
## Inputs / Outputs
|
||||||
|
|
||||||
|
From `crates/wickra-core/src/indicators/mass_index.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl Indicator for MassIndex {
|
||||||
|
type Input = Candle;
|
||||||
|
type Output = f64;
|
||||||
|
// update(&mut self, input: Candle) -> Option<f64>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`MassIndex` is a **candle-input** indicator: it reads `high` and `low`. In
|
||||||
|
Python the streaming `update` accepts a 6-tuple or a dict; the batch
|
||||||
|
helper takes `high` and `low` numpy arrays. Node and WASM expose
|
||||||
|
`update(high, low)` and `batch(high, low)`.
|
||||||
|
|
||||||
|
## Warmup
|
||||||
|
|
||||||
|
`warmup_period() == 2·ema_period + sum_period − 2`. The first EMA seeds at
|
||||||
|
input `ema_period`; the second EMA, stacked on it, seeds at
|
||||||
|
`2·ema_period − 1`; the summation window then needs `sum_period` ratios.
|
||||||
|
For the default `(9, 25)` that is `41` bars.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- **Constant range.** When every bar has the same high–low range, both
|
||||||
|
EMAs converge to the same value, every ratio is `1`, and the Mass Index
|
||||||
|
equals `sum_period` (`constant_range_sums_to_sum_period` pins this).
|
||||||
|
- **Zero-range market.** A flat market (`high == low`) drives both EMAs to
|
||||||
|
`0`; the `0 / 0` is guarded with the neutral ratio `1`, so the Mass
|
||||||
|
Index again equals `sum_period`
|
||||||
|
(`zero_range_market_sums_to_sum_period` pins this).
|
||||||
|
- **Candle validation.** `Candle::new` rejects invalid bars upstream.
|
||||||
|
- **Reset.** `mi.reset()` clears both EMAs, the window and the sum.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wickra::{BatchExt, Candle, Indicator, MassIndex};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut mi = MassIndex::new(3, 4)?;
|
||||||
|
// Constant high-low range of 2.0; the Mass Index settles at sum_period.
|
||||||
|
let candles: Vec<Candle> = (0..40)
|
||||||
|
.map(|i| {
|
||||||
|
let mid = 100.0 + f64::from(i);
|
||||||
|
Candle::new(mid, mid + 1.0, mid - 1.0, mid, 1.0, i64::from(i)).unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let out = mi.batch(&candles);
|
||||||
|
println!("warmup_period = {}", mi.warmup_period());
|
||||||
|
println!("last = {:?}", out.last().unwrap());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```
|
||||||
|
warmup_period = 8
|
||||||
|
last = Some(4.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
A constant range makes every ratio `1`, so the sum equals `sum_period`
|
||||||
|
(`4`). This matches the `constant_range_sums_to_sum_period` test in
|
||||||
|
`crates/wickra-core/src/indicators/mass_index.rs`.
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import wickra as ta
|
||||||
|
|
||||||
|
mi = ta.MassIndex() # (ema_period=9, sum_period=25)
|
||||||
|
mid = np.arange(100.0, 160.0)
|
||||||
|
high = mid + 1.0
|
||||||
|
low = mid - 1.0
|
||||||
|
print(mi.batch(high, low)[-1]) # constant range -> 25
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```
|
||||||
|
25.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const ta = require('wickra');
|
||||||
|
const mi = new ta.MassIndex(9, 25);
|
||||||
|
const mid = Array.from({ length: 60 }, (_, i) => 100 + i);
|
||||||
|
const high = mid.map((m) => m + 1);
|
||||||
|
const low = mid.map((m) => m - 1);
|
||||||
|
console.log(mi.batch(high, low).at(-1)); // 25
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interpretation
|
||||||
|
|
||||||
|
`MassIndex` is a *reversal-warning* tool, not a direction tool — it never
|
||||||
|
tells you which way price will go, only that a turn is likely. The textbook
|
||||||
|
use is the "reversal bulge" on the default `(9, 25)` settings: watch for
|
||||||
|
the index to push above `27`, then act when it drops back under `26.5`,
|
||||||
|
using a directional indicator (a moving average, ADX) to pick the side.
|
||||||
|
|
||||||
|
## Common pitfalls
|
||||||
|
|
||||||
|
- **Expecting a direction.** The Mass Index is direction-blind; always
|
||||||
|
pair it with a trend indicator.
|
||||||
|
- **Feeding it scalar prices.** It needs `high`/`low`; it takes a
|
||||||
|
`Candle`, not an `f64`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
Donald Dorsey, "The Mass Index", *Technical Analysis of Stocks &
|
||||||
|
Commodities* (1992). The double-EMA-of-range construction and the `(9,
|
||||||
|
25)` defaults follow Dorsey's original.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Indicator-Atr.md](../volatility/Indicator-Atr.md) — directional-free
|
||||||
|
volatility in price units.
|
||||||
|
- [Indicator-BollingerBands.md](../volatility/Indicator-BollingerBands.md)
|
||||||
|
— another range-expansion lens.
|
||||||
|
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Vortex
|
||||||
|
|
||||||
|
> Vortex Indicator — a pair of oscillators (`VI+`, `VI−`) whose crossings
|
||||||
|
> identify the start of a new trend.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Family | Momentum (trend strength) |
|
||||||
|
| Sub-category | Directional |
|
||||||
|
| Input type | `Candle` (uses `high`, `low`, `close`) |
|
||||||
|
| Output type | `VortexOutput { plus, minus }` |
|
||||||
|
| Output range | each line `>= 0`, typically around `1.0` |
|
||||||
|
| Default parameters | `period = 14` (Python) |
|
||||||
|
| Warmup period | `period + 1` |
|
||||||
|
| Interpretation | `VI+` above `VI−` = up-trend; the cross marks the turn. |
|
||||||
|
|
||||||
|
## Formula
|
||||||
|
|
||||||
|
```
|
||||||
|
VM+_t = |high_t − low_{t−1}| (positive vortex movement)
|
||||||
|
VM−_t = |low_t − high_{t−1}| (negative vortex movement)
|
||||||
|
TR_t = true range
|
||||||
|
VI+ = Σ VM+ over period / Σ TR over period
|
||||||
|
VI− = Σ VM− over period / Σ TR over period
|
||||||
|
```
|
||||||
|
|
||||||
|
Each vortex movement measures how far this bar reached against the
|
||||||
|
*opposite* extreme of the previous bar; dividing the running sums by the
|
||||||
|
running true range normalises both lines to a comparable scale around
|
||||||
|
`1.0`. `VI+` crossing above `VI−` signals a new up-trend; the reverse, a
|
||||||
|
down-trend.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
| Name | Type | Default | Valid range | Description |
|
||||||
|
|----------|---------|---------------|-------------|-------------|
|
||||||
|
| `period` | `usize` | `14` (Python) | `>= 1` | Summation window. `0` errors with `Error::PeriodZero`. |
|
||||||
|
|
||||||
|
The Python binding defaults `period` to `14`.
|
||||||
|
|
||||||
|
## Inputs / Outputs
|
||||||
|
|
||||||
|
From `crates/wickra-core/src/indicators/vortex.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct VortexOutput { pub plus: f64, pub minus: f64 }
|
||||||
|
|
||||||
|
impl Indicator for Vortex {
|
||||||
|
type Input = Candle;
|
||||||
|
type Output = VortexOutput;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Vortex` is a **candle-input** indicator reading `high`, `low` and
|
||||||
|
`close`. The streaming `update` returns `VortexOutput` (Rust),
|
||||||
|
`(plus, minus)` (Python), or `{ plus, minus }` (Node/WASM). The batch
|
||||||
|
helper returns one row per input — a `(n, 2)` numpy array in Python, a
|
||||||
|
flat `[plus, minus, …]` array of length `2·n` in Node/WASM, with `NaN`
|
||||||
|
during warmup.
|
||||||
|
|
||||||
|
## Warmup
|
||||||
|
|
||||||
|
`Vortex::new(period).warmup_period() == period + 1`. The first VM/TR
|
||||||
|
triple needs a previous bar, so it forms on bar 2; the summation window
|
||||||
|
then needs `period` triples — the first output lands on input
|
||||||
|
`period + 1`.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- **Flat market.** A window with zero total true range cannot be
|
||||||
|
normalised; both lines are reported as `0.0`
|
||||||
|
(`perfectly_flat_market_yields_zero` pins this).
|
||||||
|
- **Non-negative.** Both `VI+` and `VI−` are sums of absolute values over
|
||||||
|
a non-negative range, so neither is ever negative
|
||||||
|
(`outputs_are_non_negative` pins this).
|
||||||
|
- **Candle validation.** `Candle::new` rejects invalid bars upstream.
|
||||||
|
- **Reset.** `vortex.reset()` clears the previous bar, the window and the
|
||||||
|
three running sums.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wickra::{BatchExt, Candle, Indicator, Vortex};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let candles = [
|
||||||
|
Candle::new(9.0, 10.0, 8.0, 9.0, 1.0, 0)?,
|
||||||
|
Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 1)?,
|
||||||
|
Candle::new(12.0, 13.0, 11.0, 12.0, 1.0, 2)?,
|
||||||
|
];
|
||||||
|
let mut v = Vortex::new(2)?;
|
||||||
|
let out = v.batch(&candles);
|
||||||
|
println!("{:?}", out[2]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```
|
||||||
|
Some(VortexOutput { plus: 1.6, minus: 0.4 })
|
||||||
|
```
|
||||||
|
|
||||||
|
Over the two formed bars `Σ VM+ = 8`, `Σ VM− = 2`, `Σ TR = 5`, giving
|
||||||
|
`VI+ = 1.6` and `VI− = 0.4`. This matches the `reference_values` test in
|
||||||
|
`crates/wickra-core/src/indicators/vortex.rs`.
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import wickra as ta
|
||||||
|
|
||||||
|
v = ta.Vortex(14)
|
||||||
|
high = np.array([10.0, 12.0, 13.0])
|
||||||
|
low = np.array([8.0, 9.0, 11.0])
|
||||||
|
close = np.array([9.0, 11.0, 12.0])
|
||||||
|
# v.batch(high, low, close) -> (3, 2) array of [plus, minus], NaN during warmup
|
||||||
|
print(v.update((9.0, 10.0, 8.0, 9.0, 1.0, 0)))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const ta = require('wickra');
|
||||||
|
const v = new ta.Vortex(14);
|
||||||
|
console.log(v.update(12, 9, 11)); // { plus, minus } or null during warmup
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interpretation
|
||||||
|
|
||||||
|
`Vortex` is a trend-onset detector. The signal is the **crossing**: when
|
||||||
|
`VI+` rises above `VI−`, a new up-trend is starting; when `VI−` rises
|
||||||
|
above `VI+`, a down-trend. The gap between the lines measures conviction —
|
||||||
|
a wide, widening gap is a strong trend, converging lines warn of a stall.
|
||||||
|
Unlike a lagging moving-average cross, the vortex movements react to the
|
||||||
|
*reach* of each bar, so the cross tends to fire early.
|
||||||
|
|
||||||
|
## Common pitfalls
|
||||||
|
|
||||||
|
- **Reading the lines in isolation.** A `VI+` of `1.1` means nothing on
|
||||||
|
its own — what matters is its position relative to `VI−`.
|
||||||
|
- **Feeding it scalar prices.** It needs `high`/`low`/`close`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
Etienne Botes and Douglas Siepman, "The Vortex Indicator", *Technical
|
||||||
|
Analysis of Stocks & Commodities* (2010). The `VM±` / true-range
|
||||||
|
definition here follows their original.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Indicator-Adx.md](Indicator-Adx.md) — Wilder's directional system.
|
||||||
|
- [Indicator-Atr.md](../volatility/Indicator-Atr.md) — the true range
|
||||||
|
Vortex normalises against.
|
||||||
|
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
|
||||||
Reference in New Issue
Block a user