F10: add Chaikin Money Flow, Chaikin Oscillator, Force Index and Ease of Movement

- Rust core: cmf.rs (Chaikin Money Flow — summed money-flow volume over
  summed volume, bounded to [-1, +1]), chaikin_oscillator.rs (Chaikin
  Oscillator — the MACD of the ADL, EMA(ADL, fast) - EMA(ADL, slow)),
  force_index.rs (Elder's Force Index — EMA of price change scaled by
  volume), ease_of_movement.rs (Arms' Ease of Movement — SMA of distance
  travelled per unit of volume). Each with a full Indicator impl,
  runnable doctest and reference / property / warmup / reset /
  batch==streaming tests.
- Python: PyChaikinMoneyFlow / PyChaikinOscillator / PyForceIndex /
  PyEaseOfMovement PyO3 classes + module registration + .pyi stubs.
- Node: explicit ChaikinMoneyFlowNode / ChaikinOscillatorNode /
  ForceIndexNode / EaseOfMovementNode; index.d.ts and index.js updated.
- WASM: WasmChaikinMoneyFlow / WasmChaikinOscillator / WasmForceIndex /
  WasmEaseOfMovement.
- Wiki: Indicator-ChaikinMoneyFlow/ChaikinOscillator/ForceIndex/
  EaseOfMovement.md plus a new "Oscillators" sub-table in
  Indicators-Overview.md and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 402 core tests,
25 data tests and 57 doctests green.
This commit is contained in:
kingchenc
2026-05-22 19:25:32 +02:00
parent 81962485af
commit 0b11a523a0
17 changed files with 2372 additions and 8 deletions
+5 -1
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, NATR, StdDev, UlcerIndex, HistoricalVolatility, BollingerBandwidth, PercentB, ADL, VolumePriceTrend, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, AroonOscillator, Vortex, MassIndex, NATR, StdDev, UlcerIndex, HistoricalVolatility, BollingerBandwidth, PercentB, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -347,6 +347,10 @@ module.exports.BollingerBandwidth = BollingerBandwidth
module.exports.PercentB = PercentB
module.exports.ADL = ADL
module.exports.VolumePriceTrend = VolumePriceTrend
module.exports.ChaikinMoneyFlow = ChaikinMoneyFlow
module.exports.ChaikinOscillator = ChaikinOscillator
module.exports.ForceIndex = ForceIndex
module.exports.EaseOfMovement = EaseOfMovement
module.exports.MACD = MACD
module.exports.BollingerBands = BollingerBands
module.exports.ATR = ATR
+229
View File
@@ -1271,6 +1271,235 @@ impl VolumePriceTrendNode {
}
}
// ============================== Chaikin Money Flow ==============================
#[napi(js_name = "ChaikinMoneyFlow")]
pub struct ChaikinMoneyFlowNode {
inner: wc::ChaikinMoneyFlow,
}
#[napi]
impl ChaikinMoneyFlowNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ChaikinMoneyFlow::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, volume)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, close, volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Chaikin Oscillator ==============================
#[napi(js_name = "ChaikinOscillator")]
pub struct ChaikinOscillatorNode {
inner: wc::ChaikinOscillator,
}
#[napi]
impl ChaikinOscillatorNode {
#[napi(constructor)]
pub fn new(fast: u32, slow: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ChaikinOscillator::new(fast as usize, slow as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, volume)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, close, volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Force Index ==============================
#[napi(js_name = "ForceIndex")]
pub struct ForceIndexNode {
inner: wc::ForceIndex,
}
#[napi]
impl ForceIndexNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ForceIndex::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, close: f64, volume: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(close, close, close, volume)?))
}
#[napi]
pub fn batch(&mut self, close: Vec<f64>, volume: Vec<f64>) -> napi::Result<Vec<f64>> {
if close.len() != volume.len() {
return Err(NapiError::from_reason(
"close and volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd(close[i], close[i], close[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Ease of Movement ==============================
#[napi(js_name = "EaseOfMovement")]
pub struct EaseOfMovementNode {
inner: wc::EaseOfMovement,
}
#[napi]
impl EaseOfMovementNode {
#[napi(constructor)]
pub fn new(period: u32, divisor: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::EaseOfMovement::with_divisor(period as usize, divisor).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, volume: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, low, volume)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], low[i], volume[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Bollinger Bandwidth ==============================
#[napi(js_name = "BollingerBandwidth")]
@@ -106,6 +106,69 @@ class VolumePriceTrend:
@property
def value(self) -> Optional[float]: ...
class ChaikinMoneyFlow:
def __init__(self, period: int = 20) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class ChaikinOscillator:
def __init__(self, fast: int = 3, slow: int = 10) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int]: ...
class ForceIndex:
def __init__(self, period: int = 13) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
close: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
class EaseOfMovement:
def __init__(self, period: int = 14, divisor: float = 100000000.0) -> None: ...
def update(self, candle: CandleLike) -> Optional[float]: ...
def batch(
self,
high: NDArray[np.float64],
low: NDArray[np.float64],
volume: NDArray[np.float64],
) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def divisor(self) -> float: ...
class BollingerBandwidth:
def __init__(self, period: int = 20, multiplier: float = 2.0) -> None: ...
def update(self, value: float) -> Optional[float]: ...
+289
View File
@@ -2992,6 +2992,291 @@ impl PyTrima {
}
}
// ============================== Chaikin Money Flow ==============================
#[pyclass(name = "ChaikinMoneyFlow", module = "wickra._wickra")]
#[derive(Clone)]
struct PyChaikinMoneyFlow {
inner: wc::ChaikinMoneyFlow,
}
#[pymethods]
impl PyChaikinMoneyFlow {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::ChaikinMoneyFlow::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, volume (all equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() {
return Err(PyValueError::new_err(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(h.len());
for i in 0..h.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray_bound(py))
}
#[getter]
fn 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!("ChaikinMoneyFlow(period={})", self.inner.period())
}
}
// ============================== Chaikin Oscillator ==============================
#[pyclass(name = "ChaikinOscillator", module = "wickra._wickra")]
#[derive(Clone)]
struct PyChaikinOscillator {
inner: wc::ChaikinOscillator,
}
#[pymethods]
impl PyChaikinOscillator {
#[new]
#[pyo3(signature = (fast=3, slow=10))]
fn new(fast: usize, slow: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::ChaikinOscillator::new(fast, slow).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, volume (all equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() {
return Err(PyValueError::new_err(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(h.len());
for i in 0..h.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray_bound(py))
}
#[getter]
fn periods(&self) -> (usize, usize) {
self.inner.periods()
}
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 (fast, slow) = self.inner.periods();
format!("ChaikinOscillator(fast={fast}, slow={slow})")
}
}
// ============================== Force Index ==============================
#[pyclass(name = "ForceIndex", module = "wickra._wickra")]
#[derive(Clone)]
struct PyForceIndex {
inner: wc::ForceIndex,
}
#[pymethods]
impl PyForceIndex {
#[new]
#[pyo3(signature = (period=13))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::ForceIndex::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 close + volume arrays (both 1-D, equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if c.len() != v.len() {
return Err(PyValueError::new_err(
"close and volume must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray_bound(py))
}
#[getter]
fn 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!("ForceIndex(period={})", self.inner.period())
}
}
// ============================== Ease of Movement ==============================
#[pyclass(name = "EaseOfMovement", module = "wickra._wickra")]
#[derive(Clone)]
struct PyEaseOfMovement {
inner: wc::EaseOfMovement,
}
#[pymethods]
impl PyEaseOfMovement {
#[new]
#[pyo3(signature = (period=14, divisor=100_000_000.0))]
fn new(period: usize, divisor: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::EaseOfMovement::with_divisor(period, divisor).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, volume (all equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != v.len() {
return Err(PyValueError::new_err(
"high, low, volume 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], v[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray_bound(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn divisor(&self) -> f64 {
self.inner.divisor()
}
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!(
"EaseOfMovement(period={}, divisor={})",
self.inner.period(),
self.inner.divisor()
)
}
}
// ============================== Module ==============================
#[pymodule]
@@ -3047,5 +3332,9 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyPercentB>()?;
m.add_class::<PyAdl>()?;
m.add_class::<PyVolumePriceTrend>()?;
m.add_class::<PyChaikinMoneyFlow>()?;
m.add_class::<PyChaikinOscillator>()?;
m.add_class::<PyForceIndex>()?;
m.add_class::<PyEaseOfMovement>()?;
Ok(())
}
+168
View File
@@ -470,6 +470,174 @@ impl WasmVolumePriceTrend {
}
}
#[wasm_bindgen(js_name = ChaikinMoneyFlow)]
pub struct WasmChaikinMoneyFlow {
inner: wc::ChaikinMoneyFlow,
}
#[wasm_bindgen(js_class = ChaikinMoneyFlow)]
impl WasmChaikinMoneyFlow {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmChaikinMoneyFlow, JsError> {
Ok(Self {
inner: wc::ChaikinMoneyFlow::new(period).map_err(map_err)?,
})
}
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n || volume.len() != n {
return Err(JsError::new(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], volume[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = ChaikinOscillator)]
pub struct WasmChaikinOscillator {
inner: wc::ChaikinOscillator,
}
#[wasm_bindgen(js_class = ChaikinOscillator)]
impl WasmChaikinOscillator {
#[wasm_bindgen(constructor)]
pub fn new(fast: usize, slow: usize) -> Result<WasmChaikinOscillator, JsError> {
Ok(Self {
inner: wc::ChaikinOscillator::new(fast, slow).map_err(map_err)?,
})
}
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n || volume.len() != n {
return Err(JsError::new(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], volume[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = ForceIndex)]
pub struct WasmForceIndex {
inner: wc::ForceIndex,
}
#[wasm_bindgen(js_class = ForceIndex)]
impl WasmForceIndex {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmForceIndex, JsError> {
Ok(Self {
inner: wc::ForceIndex::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, close: f64, volume: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(close, close, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
if close.len() != volume.len() {
return Err(JsError::new("close and volume must be equal length"));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(close[i], close[i], close[i], volume[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = EaseOfMovement)]
pub struct WasmEaseOfMovement {
inner: wc::EaseOfMovement,
}
#[wasm_bindgen(js_class = EaseOfMovement)]
impl WasmEaseOfMovement {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, divisor: f64) -> Result<WasmEaseOfMovement, JsError> {
Ok(Self {
inner: wc::EaseOfMovement::with_divisor(period, divisor).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, low, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || volume.len() != n {
return Err(JsError::new("high, low, volume must be equal length"));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
let c = make_candle(high[i], low[i], low[i], volume[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = NATR)]
pub struct WasmNatr {
inner: wc::Natr,
@@ -0,0 +1,233 @@
//! Chaikin Oscillator.
use crate::error::{Error, Result};
use crate::indicators::adl::Adl;
use crate::indicators::ema::Ema;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Chaikin Oscillator — the MACD of the Accumulation/Distribution Line.
///
/// ```text
/// ChaikinOsc_t = EMA(ADL, fast)_t EMA(ADL, slow)_t
/// ```
///
/// It turns the unbounded, ever-drifting [`Adl`](crate::Adl) into a
/// zero-centred momentum oscillator: positive when short-term accumulation
/// outpaces the longer trend, negative when distribution leads. Because the
/// ADL emits from the very first candle, the slow EMA gates the first output —
/// the warmup period is exactly `slow`. Chaikin's classic configuration is
/// `fast = 3`, `slow = 10`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ChaikinOscillator};
///
/// let mut indicator = ChaikinOscillator::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 ChaikinOscillator {
adl: Adl,
fast: Ema,
slow: Ema,
fast_period: usize,
slow_period: usize,
}
impl ChaikinOscillator {
/// Construct a Chaikin Oscillator with explicit fast / slow EMA periods.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if either period is zero, or
/// [`Error::InvalidPeriod`] if `fast >= slow`.
pub fn new(fast: usize, slow: usize) -> Result<Self> {
if fast == 0 || slow == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "Chaikin Oscillator needs fast < slow",
});
}
Ok(Self {
adl: Adl::new(),
fast: Ema::new(fast)?,
slow: Ema::new(slow)?,
fast_period: fast,
slow_period: slow,
})
}
/// Chaikin's classic configuration: `EMA(ADL, 3) EMA(ADL, 10)`.
pub fn classic() -> Self {
Self::new(3, 10).expect("classic Chaikin Oscillator params are valid")
}
/// Configured `(fast, slow)` periods.
pub const fn periods(&self) -> (usize, usize) {
(self.fast_period, self.slow_period)
}
}
impl Indicator for ChaikinOscillator {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
// The ADL emits a value from the very first candle, so both EMAs are
// fed on every bar and warm up in parallel.
let adl = self.adl.update(candle)?;
let fast = self.fast.update(adl);
let slow = self.slow.update(adl);
Some(fast? - slow?)
}
fn reset(&mut self) {
self.adl.reset();
self.fast.reset();
self.slow.reset();
}
fn warmup_period(&self) -> usize {
// ADL is ready at candle 1; the slow EMA gates the first emission.
self.slow_period
}
fn is_ready(&self) -> bool {
self.fast.is_ready() && self.slow.is_ready()
}
fn name(&self) -> &'static str {
"ChaikinOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn cdl(base: f64, volume: f64, ts: i64) -> Candle {
Candle::new(base, base + 1.0, base - 1.0, base, volume, ts).unwrap()
}
fn flat(price: f64, ts: i64) -> Candle {
Candle::new(price, price, price, price, 100.0, ts).unwrap()
}
#[test]
fn matches_independent_adl_and_emas() {
// The oscillator must equal feeding a standalone ADL into two
// standalone EMAs and differencing them once both are ready.
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.2).sin() * 6.0;
Candle::new(
mid,
mid + 1.5,
mid - 1.5,
mid + 0.3,
10.0 + (i % 6) as f64,
i,
)
.unwrap()
})
.collect();
let mut osc = ChaikinOscillator::classic();
let mut adl = Adl::new();
let mut fast = Ema::new(3).unwrap();
let mut slow = Ema::new(10).unwrap();
for (i, candle) in candles.iter().enumerate() {
let got = osc.update(*candle);
let a = adl.update(*candle).expect("ADL emits from candle 1");
let f = fast.update(a);
let s = slow.update(a);
match (f, s) {
(Some(fv), Some(sv)) => {
assert_relative_eq!(
got.expect("oscillator ready once slow EMA is"),
fv - sv,
epsilon = 1e-9
);
}
_ => assert!(got.is_none(), "must be None until slow EMA ready (i={i})"),
}
}
}
#[test]
fn flat_market_yields_zero() {
// A flat candle has zero money-flow volume, so the ADL never moves and
// both EMAs of a constant-zero series stay at zero.
let candles: Vec<Candle> = (0..60).map(|i| flat(10.0, i)).collect();
let mut osc = ChaikinOscillator::classic();
for v in osc.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect();
let mut osc = ChaikinOscillator::classic();
let out = osc.batch(&candles);
assert_eq!(osc.warmup_period(), 10);
for (i, v) in out.iter().enumerate().take(9) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[9].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_invalid_params() {
assert!(ChaikinOscillator::new(0, 10).is_err());
assert!(ChaikinOscillator::new(3, 0).is_err());
assert!(ChaikinOscillator::new(10, 3).is_err());
assert!(ChaikinOscillator::new(5, 5).is_err());
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..40).map(|i| cdl(100.0 + i as f64, 50.0, i)).collect();
let mut osc = ChaikinOscillator::classic();
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..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
Candle::new(
mid,
mid + 2.0,
mid - 2.0,
mid + 0.5,
10.0 + (i % 5) as f64,
i,
)
.unwrap()
})
.collect();
let mut a = ChaikinOscillator::classic();
let mut b = ChaikinOscillator::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+252
View File
@@ -0,0 +1,252 @@
//! Chaikin Money Flow (CMF).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Chaikin Money Flow — Marc Chaikin's `period`-window money-flow oscillator.
///
/// Each bar produces a *money-flow volume*: the bar's volume weighted by where
/// the close fell within its range (the same money-flow multiplier the
/// [`Adl`](crate::Adl) uses). CMF is the ratio of summed money-flow volume to
/// summed volume over the lookback window:
///
/// ```text
/// MFM_t = ((close low) (high close)) / (high low) (1..+1)
/// MFV_t = MFM_t · volume_t
/// CMF_t = Σ(MFV, period) / Σ(volume, period)
/// ```
///
/// The result lives in `[1, +1]`: sustained closes near the high push CMF
/// toward `+1` (accumulation), near the low toward `1` (distribution). A bar
/// with `high == low` carries no positional information and contributes a
/// money-flow volume of `0`; a window whose total volume is zero yields `0.0`
/// by convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ChaikinMoneyFlow};
///
/// let mut indicator = ChaikinMoneyFlow::new(20).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 ChaikinMoneyFlow {
period: usize,
mfv_window: VecDeque<f64>,
vol_window: VecDeque<f64>,
mfv_sum: f64,
vol_sum: f64,
}
impl ChaikinMoneyFlow {
/// Construct a new Chaikin Money Flow over `period` bars.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
mfv_window: VecDeque::with_capacity(period),
vol_window: VecDeque::with_capacity(period),
mfv_sum: 0.0,
vol_sum: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ChaikinMoneyFlow {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
let mfv = if range == 0.0 {
// A zero-range bar carries no positional information.
0.0
} else {
let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range;
mfm * candle.volume
};
if self.mfv_window.len() == self.period {
self.mfv_sum -= self.mfv_window.pop_front().expect("non-empty");
self.vol_sum -= self.vol_window.pop_front().expect("non-empty");
}
self.mfv_window.push_back(mfv);
self.vol_window.push_back(candle.volume);
self.mfv_sum += mfv;
self.vol_sum += candle.volume;
if self.mfv_window.len() < self.period {
return None;
}
if self.vol_sum == 0.0 {
// No volume traded across the whole window — no flow to report.
return Some(0.0);
}
Some(self.mfv_sum / self.vol_sum)
}
fn reset(&mut self) {
self.mfv_window.clear();
self.vol_window.clear();
self.mfv_sum = 0.0;
self.vol_sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.mfv_window.len() == self.period
}
fn name(&self) -> &'static str {
"CMF"
}
}
#[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, volume: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, volume, ts).unwrap()
}
#[test]
fn reference_values() {
// CMF(2): bar 1 closes at the high -> MFM = +1, MFV = +100.
// bar 2 closes mid-range -> MFM = 0, MFV = 0.
// CMF = (100 + 0) / (100 + 100) = 0.5.
let mut cmf = ChaikinMoneyFlow::new(2).unwrap();
let out = cmf.batch(&[
candle(8.0, 10.0, 8.0, 10.0, 100.0, 0),
candle(10.0, 12.0, 8.0, 10.0, 100.0, 1),
]);
assert!(out[0].is_none());
assert_relative_eq!(out[1].unwrap(), 0.5, epsilon = 1e-12);
}
#[test]
fn stays_within_unit_range() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.25).sin() * 10.0;
candle(
mid,
mid + 3.0,
mid - 3.0,
mid + (i as f64 * 0.5).cos() * 2.0,
10.0 + (i % 7) as f64,
i,
)
})
.collect();
let mut cmf = ChaikinMoneyFlow::new(20).unwrap();
for v in cmf.batch(&candles).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "CMF {v} outside [-1, 1]");
}
}
#[test]
fn closes_at_high_yield_cmf_one() {
// Every bar closes on its high -> MFM = +1 -> CMF saturates at +1.
let candles: Vec<Candle> = (0..30)
.map(|i| candle(9.0, 10.0, 8.0, 10.0, 50.0, i))
.collect();
let mut cmf = ChaikinMoneyFlow::new(14).unwrap();
for v in cmf.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 1.0, epsilon = 1e-12);
}
}
#[test]
fn zero_volume_window_yields_zero() {
// A window with no traded volume divides 0/0 — defined as 0.0.
let candles: Vec<Candle> = (0..20)
.map(|i| candle(9.0, 10.0, 8.0, 10.0, 0.0, i))
.collect();
let mut cmf = ChaikinMoneyFlow::new(10).unwrap();
for v in cmf.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn first_value_on_period_th_candle() {
let candles: Vec<Candle> = (0..10)
.map(|i| candle(9.0, 10.0, 8.0, 9.5, 50.0, i))
.collect();
let mut cmf = ChaikinMoneyFlow::new(5).unwrap();
let out = cmf.batch(&candles);
for (i, v) in out.iter().enumerate().take(4) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[4].is_some(), "first CMF lands at index period - 1");
assert_eq!(cmf.warmup_period(), 5);
}
#[test]
fn rejects_zero_period() {
assert!(matches!(ChaikinMoneyFlow::new(0), Err(Error::PeriodZero)));
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..20)
.map(|i| candle(9.0, 11.0, 8.0, 10.0, 50.0, i))
.collect();
let mut cmf = ChaikinMoneyFlow::new(10).unwrap();
cmf.batch(&candles);
assert!(cmf.is_ready());
cmf.reset();
assert!(!cmf.is_ready());
assert_eq!(cmf.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;
candle(
mid,
mid + 2.0,
mid - 2.0,
mid + 0.5,
10.0 + (i % 5) as f64,
i,
)
})
.collect();
let mut a = ChaikinMoneyFlow::new(20).unwrap();
let mut b = ChaikinMoneyFlow::new(20).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,277 @@
//! Ease of Movement (Arms).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Richard Arms' Ease of Movement — how far price travels per unit of volume.
///
/// ```text
/// distance_t = (high_t + low_t)/2 (high_{t1} + low_{t1})/2
/// EMV_t = distance_t · (high_t low_t) · divisor / volume_t
/// EOM_t = SMA(EMV, period)_t
/// ```
///
/// A large positive EMV means price climbed a long way on light volume — it
/// moved "easily"; a value near zero means heavy volume was needed to shift
/// price at all. The `divisor` only rescales the output: the conventional
/// `1e8` keeps `EMV` in a readable range for typical share volumes. A bar with
/// zero volume contributes `EMV = 0` (no trading carries no signal), as does a
/// zero-range bar. The first candle only seeds the previous midpoint, so the
/// first value appears on candle `period + 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, EaseOfMovement};
///
/// let mut indicator = EaseOfMovement::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 EaseOfMovement {
period: usize,
divisor: f64,
prev_mid: Option<f64>,
window: VecDeque<f64>,
sum: f64,
}
impl EaseOfMovement {
/// Construct an Ease of Movement with the conventional `1e8` volume divisor.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Self::with_divisor(period, 100_000_000.0)
}
/// Construct an Ease of Movement with an explicit volume divisor. The
/// divisor is a pure output-scaling constant; pick whatever keeps `EMV`
/// readable for your instrument's volume magnitude.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::NonPositiveMultiplier`] if `divisor` is not strictly positive
/// and finite.
pub fn with_divisor(period: usize, divisor: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !divisor.is_finite() || divisor <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
divisor,
prev_mid: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured volume divisor.
pub const fn divisor(&self) -> f64 {
self.divisor
}
}
impl Indicator for EaseOfMovement {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let mid = (candle.high + candle.low) / 2.0;
let Some(prev_mid) = self.prev_mid else {
// The first candle only establishes the previous midpoint.
self.prev_mid = Some(mid);
return None;
};
let distance = mid - prev_mid;
let range = candle.high - candle.low;
let emv = if candle.volume == 0.0 {
// No volume traded — the move carries no ease-of-movement signal.
0.0
} else {
distance * range * self.divisor / candle.volume
};
self.prev_mid = Some(mid);
if self.window.len() == self.period {
self.sum -= self.window.pop_front().expect("non-empty");
}
self.window.push_back(emv);
self.sum += emv;
if self.window.len() < self.period {
return None;
}
Some(self.sum / self.period as f64)
}
fn reset(&mut self) {
self.prev_mid = None;
self.window.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
// One seed candle establishes the first previous midpoint, then
// `period` EMV values fill the averaging window.
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"EaseOfMovement"
}
}
#[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, volume: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, volume, ts).unwrap()
}
#[test]
fn reference_values() {
// EOM(period = 1, divisor = 1): one EMV value is its own average.
// candle 1: midpoint (10 + 8)/2 = 9 only seeds the previous mid.
// candle 2: mid = (14 + 10)/2 = 12, distance = 3, range = 4,
// EMV = 3 * 4 * 1 / 100 = 0.12.
let mut eom = EaseOfMovement::with_divisor(1, 1.0).unwrap();
let out = eom.batch(&[
candle(9.0, 10.0, 8.0, 9.0, 50.0, 0),
candle(12.0, 14.0, 10.0, 12.0, 100.0, 1),
]);
assert!(out[0].is_none());
assert_relative_eq!(out[1].unwrap(), 0.12, epsilon = 1e-12);
}
#[test]
fn rising_midpoints_yield_positive_eom() {
// Strictly rising midpoints on constant volume -> every EMV is
// positive, so the averaged EOM is positive.
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
candle(base, base + 1.0, base - 1.0, base, 100.0, i)
})
.collect();
let mut eom = EaseOfMovement::new(14).unwrap();
for v in eom.batch(&candles).into_iter().flatten() {
assert!(v > 0.0, "EOM {v} should be positive on a rising series");
}
}
#[test]
fn constant_series_yields_zero() {
// Unchanging candles -> zero distance -> EMV is zero throughout.
let candles: Vec<Candle> = (0..30)
.map(|i| candle(10.0, 11.0, 9.0, 10.0, 50.0, i))
.collect();
let mut eom = EaseOfMovement::new(10).unwrap();
for v in eom.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn zero_volume_contributes_zero() {
// A zero-volume bar yields EMV = 0 instead of dividing by zero.
let candles: Vec<Candle> = (0..20)
.map(|i| {
let base = 100.0 + i as f64;
candle(base, base + 1.0, base - 1.0, base, 0.0, i)
})
.collect();
let mut eom = EaseOfMovement::new(10).unwrap();
for v in eom.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn first_value_on_period_plus_one_candle() {
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + i as f64;
candle(base, base + 1.0, base - 1.0, base, 50.0, i)
})
.collect();
let mut eom = EaseOfMovement::new(5).unwrap();
let out = eom.batch(&candles);
for (i, v) in out.iter().enumerate().take(5) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[5].is_some(), "first EOM lands at index period");
assert_eq!(eom.warmup_period(), 6);
}
#[test]
fn rejects_invalid_input() {
assert!(EaseOfMovement::new(0).is_err());
assert!(EaseOfMovement::with_divisor(14, 0.0).is_err());
assert!(EaseOfMovement::with_divisor(14, -1.0).is_err());
assert!(EaseOfMovement::with_divisor(14, f64::NAN).is_err());
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..30)
.map(|i| {
let base = 100.0 + i as f64;
candle(base, base + 1.0, base - 1.0, base, 50.0, i)
})
.collect();
let mut eom = EaseOfMovement::new(10).unwrap();
eom.batch(&candles);
assert!(eom.is_ready());
eom.reset();
assert!(!eom.is_ready());
assert_eq!(eom.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;
candle(
mid,
mid + 2.0,
mid - 2.0,
mid + 0.5,
10.0 + (i % 5) as f64,
i,
)
})
.collect();
let mut a = EaseOfMovement::new(14).unwrap();
let mut b = EaseOfMovement::new(14).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,189 @@
//! Force Index (Elder).
use crate::error::Result;
use crate::indicators::ema::Ema;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Alexander Elder's Force Index — price change scaled by volume, EMA-smoothed.
///
/// ```text
/// raw_t = (close_t close_{t1}) · volume_t
/// Force_t = EMA(raw, period)_t
/// ```
///
/// The raw force is positive on an up-close and negative on a down-close, and
/// its magnitude grows with the volume that backed the move — a big move on
/// heavy volume registers a large force. Smoothing the raw series with an EMA
/// gives a tradeable line; Elder's classic period is `13`. The first candle
/// only establishes the previous close, so the first raw value appears on
/// candle 2 and the first smoothed value on candle `period + 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ForceIndex};
///
/// let mut indicator = ForceIndex::new(13).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 ForceIndex {
period: usize,
prev_close: Option<f64>,
ema: Ema,
}
impl ForceIndex {
/// Construct a new Force Index with the given EMA smoothing period.
///
/// # Errors
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
prev_close: None,
ema: Ema::new(period)?,
})
}
/// Configured smoothing period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ForceIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev_close else {
// The first candle only establishes the previous close.
self.prev_close = Some(candle.close);
return None;
};
let raw = (candle.close - prev) * candle.volume;
self.prev_close = Some(candle.close);
self.ema.update(raw)
}
fn reset(&mut self) {
self.prev_close = None;
self.ema.reset();
}
fn warmup_period(&self) -> usize {
// One seed candle establishes the first previous close, then the EMA
// needs `period` raw values.
self.period + 1
}
fn is_ready(&self) -> bool {
self.ema.is_ready()
}
fn name(&self) -> &'static str {
"ForceIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(close: f64, volume: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, volume, ts).unwrap()
}
#[test]
fn reference_values() {
// ForceIndex(1): EMA(1) has alpha = 1, so it passes raw force through.
// candle 1 (close 10) only seeds the previous close -> None.
// candle 2: raw = (12 - 10) * 100 = +200.
// candle 3: raw = (11 - 12) * 200 = -200.
let mut fi = ForceIndex::new(1).unwrap();
let out = fi.batch(&[c(10.0, 100.0, 0), c(12.0, 100.0, 1), c(11.0, 200.0, 2)]);
assert!(out[0].is_none());
assert_relative_eq!(out[1].unwrap(), 200.0, epsilon = 1e-9);
assert_relative_eq!(out[2].unwrap(), -200.0, epsilon = 1e-9);
}
#[test]
fn pure_uptrend_is_positive() {
// Strictly rising closes on constant volume -> every raw force is
// positive, so the smoothed force is positive too.
let candles: Vec<Candle> = (1..40)
.map(|i| c(f64::from(i), 100.0, i64::from(i)))
.collect();
let mut fi = ForceIndex::new(13).unwrap();
for v in fi.batch(&candles).into_iter().flatten() {
assert!(v > 0.0, "force {v} should be positive in an uptrend");
}
}
#[test]
fn pure_downtrend_is_negative() {
let candles: Vec<Candle> = (1..40)
.rev()
.map(|i| c(f64::from(i), 100.0, i64::from(i)))
.collect();
let mut fi = ForceIndex::new(13).unwrap();
for v in fi.batch(&candles).into_iter().flatten() {
assert!(v < 0.0, "force {v} should be negative in a downtrend");
}
}
#[test]
fn first_value_on_period_plus_one_candle() {
let candles: Vec<Candle> = (0..12).map(|i| c(10.0 + i as f64, 50.0, i)).collect();
let mut fi = ForceIndex::new(5).unwrap();
let out = fi.batch(&candles);
for (i, v) in out.iter().enumerate().take(5) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[5].is_some(), "first force lands at index period");
assert_eq!(fi.warmup_period(), 6);
}
#[test]
fn rejects_zero_period() {
assert!(ForceIndex::new(0).is_err());
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..30).map(|i| c(10.0 + i as f64, 50.0, i)).collect();
let mut fi = ForceIndex::new(13).unwrap();
fi.batch(&candles);
assert!(fi.is_ready());
fi.reset();
assert!(!fi.is_ready());
assert_eq!(fi.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let close = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(close, 10.0 + (i % 5) as f64, i)
})
.collect();
let mut a = ForceIndex::new(13).unwrap();
let mut b = ForceIndex::new(13).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+8
View File
@@ -13,12 +13,16 @@ mod awesome_oscillator;
mod bollinger;
mod bollinger_bandwidth;
mod cci;
mod chaikin_oscillator;
mod cmf;
mod cmo;
mod coppock;
mod dema;
mod donchian;
mod dpo;
mod ease_of_movement;
mod ema;
mod force_index;
mod historical_volatility;
mod hma;
mod kama;
@@ -64,12 +68,16 @@ pub use awesome_oscillator::AwesomeOscillator;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use cci::Cci;
pub use chaikin_oscillator::ChaikinOscillator;
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coppock::Coppock;
pub use dema::Dema;
pub use donchian::{Donchian, DonchianOutput};
pub use dpo::Dpo;
pub use ease_of_movement::EaseOfMovement;
pub use ema::Ema;
pub use force_index::ForceIndex;
pub use historical_volatility::HistoricalVolatility;
pub use hma::Hma;
pub use kama::Kama;
+6 -6
View File
@@ -45,12 +45,12 @@ pub mod indicators;
pub use error::{Error, Result};
pub use indicators::{
Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, AwesomeOscillator,
BollingerBands, BollingerBandwidth, BollingerOutput, Cci, Cmo, Coppock, Dema, Donchian,
DonchianOutput, Dpo, Ema, HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput,
MacdIndicator, MacdOutput, MassIndex, Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc,
RollingVwap, Rsi, Sma, Smma, StdDev, StochRsi, Stochastic, StochasticOutput, Tema, Trima, Trix,
Tsi, UlcerIndex, UltimateOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma,
WilliamsR, Wma, Zlema, T3,
BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator,
Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema, ForceIndex,
HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, MacdIndicator, MacdOutput, MassIndex,
Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma, StdDev,
StochRsi, Stochastic, StochasticOutput, Tema, Trima, Trix, Tsi, UlcerIndex, UltimateOscillator,
VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, WilliamsR, Wma, Zlema, T3,
};
pub use ohlcv::{Candle, Tick};
pub use traits::{BatchExt, Chain, Indicator};
+4
View File
@@ -131,6 +131,10 @@ Rust / Python / Node examples. They are grouped by family, mirroring the
- [Indicator-Vwap.md](indicators/volume/Indicator-Vwap.md)
- [Indicator-Adl.md](indicators/volume/Indicator-Adl.md)
- [Indicator-VolumePriceTrend.md](indicators/volume/Indicator-VolumePriceTrend.md)
- [Indicator-ChaikinMoneyFlow.md](indicators/volume/Indicator-ChaikinMoneyFlow.md)
- [Indicator-ChaikinOscillator.md](indicators/volume/Indicator-ChaikinOscillator.md)
- [Indicator-ForceIndex.md](indicators/volume/Indicator-ForceIndex.md)
- [Indicator-EaseOfMovement.md](indicators/volume/Indicator-EaseOfMovement.md)
## See also
+13 -1
View File
@@ -1,6 +1,6 @@
# Indicators Overview
Wickra ships 50 indicators, organised in source under the four classical
Wickra ships 54 indicators, organised in source under the four classical
families — trend, momentum, volatility, volume — that map directly to the
directory structure of `crates/wickra-core/src/indicators/`. The same family
labels are used here, plus a second-level grouping that reflects how the
@@ -169,6 +169,18 @@ Volume indicators all take `Candle` input because they need `close` and
|---------------|-----------|-------|--------|-------|----------|--------|-----------|
| `RollingVwap` | VWAP over a sliding window instead of since-start; useful for session-independent VWAP. | `Candle` | `f64` | unbounded (price scale) | `period` | `period` | [Indicator-Vwap.md → RollingVwap](indicators/volume/Indicator-Vwap.md#rollingvwap-finite-window) |
### Oscillators
Volume-flow oscillators: bounded or zero-centred readings derived from where
price closes within each bar and how much volume backed the move.
| Indicator | One-liner | Input | Output | Range | Defaults | Warmup | Deep dive |
|-----------|-----------|-------|--------|-------|----------|--------|-----------|
| `ChaikinMoneyFlow` | Summed money-flow volume divided by summed volume over `period` bars. | `Candle` | `f64` | `[1, +1]` | `period = 20` (Python) | `period` | [Indicator-ChaikinMoneyFlow.md](indicators/volume/Indicator-ChaikinMoneyFlow.md) |
| `ChaikinOscillator` | `EMA(ADL, fast) EMA(ADL, slow)`; the MACD of the ADL. | `Candle` | `f64` | unbounded around zero | `(fast=3, slow=10)` (Python) | `slow` | [Indicator-ChaikinOscillator.md](indicators/volume/Indicator-ChaikinOscillator.md) |
| `ForceIndex` | `EMA((close prev_close) · volume, period)`; the conviction behind a move. | `Candle` | `f64` | unbounded around zero | `period = 13` (Python) | `period + 1` | [Indicator-ForceIndex.md](indicators/volume/Indicator-ForceIndex.md) |
| `EaseOfMovement` | `SMA` of distance travelled per unit of volume. | `Candle` | `f64` | unbounded around zero | `(period=14, divisor=1e8)` (Python) | `period + 1` | [Indicator-EaseOfMovement.md](indicators/volume/Indicator-EaseOfMovement.md) |
## Pick the right indicator for…
A short cheat-sheet of "I want X, which indicator?" answers, grounded in
@@ -0,0 +1,159 @@
# ChaikinMoneyFlow
> Chaikin Money Flow (CMF) — the ratio of money-flow volume to total
> volume over a rolling window, bounded to `[1, +1]`.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Volume |
| Sub-category | Oscillators |
| Input type | `Candle` (uses `high`, `low`, `close`, `volume`) |
| Output type | `f64` |
| Output range | `[1, +1]` |
| Default parameters | `period = 20` (Python) |
| Warmup period | `period` |
| Interpretation | Window accumulation/distribution balance; sign and magnitude both matter. |
## Formula
```
MFM_t = ((close low) (high close)) / (high low) (money-flow multiplier, 1..+1)
MFV_t = MFM_t · volume_t (money-flow volume)
CMF_t = Σ(MFV, period) / Σ(volume, period)
```
CMF is the [`Adl`](Indicator-Adl.md) increment averaged the way RSI averages
gains: rather than a running total, it divides the *summed* money-flow volume
of the last `period` bars by the *summed* volume of those bars. The result is
volume-normalised, so it lives in `[1, +1]` regardless of how heavily the
instrument trades. A bar with `high == low` carries no positional information
and contributes a money-flow volume of `0`.
## Parameters
`period` — the lookback window. The Python binding defaults it to `20`; the
Rust and Node constructors require it explicitly.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/cmf.rs`:
```rust
impl Indicator for ChaikinMoneyFlow {
type Input = Candle;
type Output = f64;
// update(&mut self, input: Candle) -> Option<f64>
}
```
`ChaikinMoneyFlow` is a **candle-input** indicator: it reads `high`, `low`,
`close` and `volume`. In Python the streaming `update` accepts a 6-tuple or a
dict; the batch helper takes `high`, `low`, `close`, `volume` numpy arrays.
Node and WASM expose `update(high, low, close, volume)` and the matching
`batch`.
## Warmup
`ChaikinMoneyFlow::new(20).warmup_period() == 20`. The first value lands once
the window holds a full `period` bars — on input index `period 1`.
## Edge cases
- **Zero-range bar.** A bar with `high == low` contributes `MFV = 0`.
- **Empty-volume window.** If the whole window traded zero volume, the
`0/0` ratio is defined as `0.0` (`zero_volume_window_yields_zero` pins this).
- **Saturated flow.** Every bar closing on its high gives `MFM = +1`, so CMF
saturates at `+1` (`closes_at_high_yield_cmf_one` pins this).
- **Reset.** `cmf.reset()` clears the window and both running sums.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, ChaikinMoneyFlow};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut cmf = ChaikinMoneyFlow::new(2)?;
let out = cmf.batch(&[
Candle::new(8.0, 10.0, 8.0, 10.0, 100.0, 0)?, // close at high -> MFV +100
Candle::new(10.0, 12.0, 8.0, 10.0, 100.0, 1)?, // close mid-range -> MFV 0
]);
println!("{:?}", out);
Ok(())
}
```
Output:
```
[None, Some(0.5)]
```
Bar 1 closes at its high (`MFM = +1`, `MFV = +100`); bar 2 closes mid-range
(`MFM = 0`, `MFV = 0`). `CMF(2) = (100 + 0) / (100 + 100) = 0.5`. This matches
the `reference_values` test in `crates/wickra-core/src/indicators/cmf.rs`.
### Python
```python
import numpy as np
import wickra as ta
cmf = ta.ChaikinMoneyFlow(2)
high = np.array([10.0, 12.0])
low = np.array([8.0, 8.0])
close = np.array([10.0, 10.0])
volume = np.array([100.0, 100.0])
print(cmf.batch(high, low, close, volume))
```
Output:
```
[nan 0.5]
```
### Node
```javascript
const ta = require('wickra');
const cmf = new ta.ChaikinMoneyFlow(2);
console.log(cmf.batch([10, 12], [8, 8], [10, 10], [100, 100]));
```
Output:
```
[ NaN, 0.5 ]
```
## Interpretation
CMF reads as a balance: sustained positive values mean closes are clustering
near bar highs on real volume (accumulation), sustained negative values mean
the opposite (distribution). Crosses of the zero line are the textbook signal;
the `±0.05` band is often treated as a neutral zone. Because CMF is
volume-normalised it is comparable across instruments — unlike the raw
[`Adl`](Indicator-Adl.md), whose level is arbitrary.
## Common pitfalls
- **Confusing it with the ADL.** CMF is a *bounded ratio*; the ADL is an
*unbounded running total*. They share the money-flow multiplier and nothing
else.
- **Feeding it scalar prices.** It needs the full OHLCV bar.
## References
Marc Chaikin's Chaikin Money Flow; the money-flow-multiplier formulation here
matches the standard definition (StockCharts).
## See also
- [Indicator-Adl.md](Indicator-Adl.md) — the cumulative line CMF is built on.
- [Indicator-ChaikinOscillator.md](Indicator-ChaikinOscillator.md) — the
EMA-difference oscillator on the ADL.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,161 @@
# ChaikinOscillator
> Chaikin Oscillator — the MACD of the Accumulation/Distribution Line:
> a fast EMA of the ADL minus a slow EMA of the ADL.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Volume |
| Sub-category | Oscillators |
| Input type | `Candle` (uses `high`, `low`, `close`, `volume`) |
| Output type | `f64` |
| Output range | unbounded around zero |
| Default parameters | `fast = 3`, `slow = 10` (Python) |
| Warmup period | `slow` |
| Interpretation | Momentum of accumulation/distribution; zero-line crossings are the signal. |
## Formula
```
ChaikinOsc_t = EMA(ADL, fast)_t EMA(ADL, slow)_t
```
The [`Adl`](Indicator-Adl.md) is an unbounded line that drifts with cumulative
volume — useful for its slope but awkward to trade directly. The Chaikin
Oscillator applies the MACD construction to it: difference a fast and a slow
EMA of the ADL to get a zero-centred momentum reading. Positive values mean
short-term accumulation is outrunning the longer trend; negative values mean
distribution leads.
## Parameters
- `fast` — period of the fast EMA on the ADL (classic `3`).
- `slow` — period of the slow EMA on the ADL (classic `10`).
`fast` must be strictly less than `slow`. `ChaikinOscillator::classic()`
returns the `(3, 10)` configuration.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/chaikin_oscillator.rs`:
```rust
impl Indicator for ChaikinOscillator {
type Input = Candle;
type Output = f64;
// update(&mut self, input: Candle) -> Option<f64>
}
```
It is a **candle-input** indicator (the ADL inside it needs `high`, `low`,
`close`, `volume`). Python's streaming `update` accepts a 6-tuple or a dict;
the batch helper takes `high`, `low`, `close`, `volume` numpy arrays. Node and
WASM expose `update(high, low, close, volume)` and the matching `batch`.
## Warmup
`ChaikinOscillator::classic().warmup_period() == 10`. The ADL emits a value
from the very first candle, so both EMAs are fed every bar and the slow EMA
gates the first output — the warmup is exactly `slow`.
## Edge cases
- **Flat market.** A flat candle has zero money-flow volume, so the ADL never
moves and both EMAs of the constant-zero series stay at zero — the
oscillator sits at `0.0` (`flat_market_yields_zero` pins this).
- **`fast >= slow`.** Rejected at construction with an error.
- **Reset.** `osc.reset()` clears the ADL and both EMAs.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, ChaikinOscillator};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut osc = ChaikinOscillator::classic(); // EMA(ADL, 3) EMA(ADL, 10)
// A flat market: the ADL never moves, so the oscillator sits at zero.
let candles: Vec<Candle> = (0..20)
.map(|i| Candle::new(10.0, 10.0, 10.0, 10.0, 100.0, i).unwrap())
.collect();
let out = osc.batch(&candles);
println!("{:?}", out.last().unwrap());
Ok(())
}
```
Output:
```
Some(0.0)
```
A flat series produces a flat ADL and therefore a zero oscillator. This
matches the `flat_market_yields_zero` test in
`crates/wickra-core/src/indicators/chaikin_oscillator.rs`.
### Python
```python
import numpy as np
import wickra as ta
osc = ta.ChaikinOscillator(3, 10)
n = 20
flat = np.full(n, 10.0)
print(osc.batch(flat, flat, flat, np.full(n, 100.0))[-1])
```
Output:
```
0.0
```
### Node
```javascript
const ta = require('wickra');
const osc = new ta.ChaikinOscillator(3, 10);
const flat = Array(20).fill(10);
const vol = Array(20).fill(100);
const out = osc.batch(flat, flat, flat, vol);
console.log(out[out.length - 1]);
```
Output:
```
0
```
## Interpretation
Trade the Chaikin Oscillator like any MACD-style line: a cross above zero is a
bullish accumulation signal, a cross below is bearish. Divergence between the
oscillator and price is the higher-conviction setup — for example, price
making a new high while the oscillator does not is the same warning the raw
ADL gives, but packaged as a bounded, zero-centred series.
## Common pitfalls
- **Treating the level as meaningful.** Only the sign and the slope carry
information; the magnitude scales with the instrument's volume.
- **Feeding it scalar prices.** It needs the full OHLCV bar.
## References
Marc Chaikin's Chaikin Oscillator — the MACD construction applied to his
Accumulation/Distribution Line (StockCharts).
## See also
- [Indicator-Adl.md](Indicator-Adl.md) — the cumulative line this oscillates.
- [Indicator-ChaikinMoneyFlow.md](Indicator-ChaikinMoneyFlow.md) — a bounded
ratio built from the same money-flow volume.
- [Indicator-MacdIndicator.md](../momentum/Indicator-MacdIndicator.md) — the
same fast/slow EMA-difference construction on price.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,161 @@
# EaseOfMovement
> Ease of Movement (EOM) — Richard Arms' measure of how far price travels
> per unit of volume, averaged over a window.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Volume |
| Sub-category | Oscillators |
| Input type | `Candle` (uses `high`, `low`, `volume`) |
| Output type | `f64` |
| Output range | unbounded around zero (scaled by `divisor`) |
| Default parameters | `period = 14`, `divisor = 1e8` (Python) |
| Warmup period | `period + 1` |
| Interpretation | Light-volume moves push it away from zero; sign tracks direction. |
## Formula
```
distance_t = (high_t + low_t)/2 (high_{t1} + low_{t1})/2
EMV_t = distance_t · (high_t low_t) · divisor / volume_t
EOM_t = SMA(EMV, period)_t
```
Arms' question is *how easily did price move?* A bar whose midpoint jumped a
long way on a wide range but light volume gets a large `EMV`; a bar that
needed heavy volume to budge gets a small one. The `divisor` is a pure
output-scaling constant — the conventional `1e8` keeps `EMV` readable for
typical share volumes; smaller markets want a smaller divisor. The window SMA
smooths the noisy per-bar `EMV` into a tradeable line.
## Parameters
- `period` — the SMA averaging window (Python default `14`).
- `divisor` — the volume-scaling constant (Python default `1e8`). Rust exposes
`EaseOfMovement::new(period)` for the `1e8` default and
`EaseOfMovement::with_divisor(period, divisor)` for an explicit value.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/ease_of_movement.rs`:
```rust
impl Indicator for EaseOfMovement {
type Input = Candle;
type Output = f64;
// update(&mut self, input: Candle) -> Option<f64>
}
```
`EaseOfMovement` is a **candle-input** indicator that reads `high`, `low` and
`volume`. In Python the streaming `update` accepts a 6-tuple or a dict; the
batch helper takes `high`, `low`, `volume` numpy arrays. Node and WASM expose
`update(high, low, volume)` and the matching `batch`.
## Warmup
`EaseOfMovement::new(14).warmup_period() == 15`. The first candle only seeds
the previous midpoint, so the first `EMV` appears on candle 2 and the first
averaged value on candle `period + 1`.
## Edge cases
- **Zero-volume bar.** Contributes `EMV = 0` instead of dividing by zero
(`zero_volume_contributes_zero` pins this).
- **Zero-range bar.** `high == low` makes the `(high low)` factor zero, so
`EMV = 0`.
- **Constant series.** Unchanging midpoints give zero distance, so EOM stays
at `0.0` (`constant_series_yields_zero` pins this).
- **Reset.** `eom.reset()` clears the previous midpoint and the SMA window.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, EaseOfMovement};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// EOM(period = 1, divisor = 1): one EMV value is its own average.
let mut eom = EaseOfMovement::with_divisor(1, 1.0)?;
let out = eom.batch(&[
Candle::new(9.0, 10.0, 8.0, 9.0, 50.0, 0)?, // seeds the previous midpoint (9)
Candle::new(12.0, 14.0, 10.0, 12.0, 100.0, 1)?, // mid 12, distance 3, range 4
]);
println!("{:?}", out);
Ok(())
}
```
Output:
```
[None, Some(0.12)]
```
Bar 2: `EMV = distance · range · divisor / volume = 3 · 4 · 1 / 100 = 0.12`.
This matches the `reference_values` test in
`crates/wickra-core/src/indicators/ease_of_movement.rs`.
### Python
```python
import numpy as np
import wickra as ta
eom = ta.EaseOfMovement(1, 1.0)
high = np.array([10.0, 14.0])
low = np.array([8.0, 10.0])
volume = np.array([50.0, 100.0])
print(eom.batch(high, low, volume))
```
Output:
```
[ nan 0.12]
```
### Node
```javascript
const ta = require('wickra');
const eom = new ta.EaseOfMovement(1, 1.0);
console.log(eom.batch([10, 14], [8, 10], [50, 100]));
```
Output:
```
[ NaN, 0.12 ]
```
## Interpretation
EOM crossing above zero says price is drifting up *without* needing much
volume — an easy, low-resistance advance; below zero is the same for a
decline. A reading hovering near zero means volume is heavy relative to the
distance covered, i.e. price is grinding. The sign tracks direction; the
distance from zero tracks how freely the move is happening.
## Common pitfalls
- **Reading the raw magnitude.** It depends entirely on the `divisor` you
chose — only the sign and relative size are portable.
- **Feeding it scalar prices.** It needs `high`, `low` *and* `volume`.
## References
Richard W. Arms Jr.'s Ease of Movement; the box-ratio formulation here matches
the standard definition.
## See also
- [Indicator-ForceIndex.md](Indicator-ForceIndex.md) — a different
price-change-vs-volume gauge.
- [Indicator-ChaikinMoneyFlow.md](Indicator-ChaikinMoneyFlow.md) — bounded
money-flow balance.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.
@@ -0,0 +1,155 @@
# ForceIndex
> Force Index — Alexander Elder's price change scaled by volume, then
> smoothed with an EMA.
## Quick reference
| Field | Value |
|-------|-------|
| Family | Volume |
| Sub-category | Oscillators |
| Input type | `Candle` (uses `close`, `volume`) |
| Output type | `f64` |
| Output range | unbounded around zero |
| Default parameters | `period = 13` (Python) |
| Warmup period | `period + 1` |
| Interpretation | Conviction behind a move; sign and zero-crossings are the signal. |
## Formula
```
raw_t = (close_t close_{t1}) · volume_t
Force_t = EMA(raw, period)_t
```
The raw force is positive on an up-close and negative on a down-close, with a
magnitude that grows with the volume backing the move — a large move on heavy
volume registers a large force, a large move on thin volume does not.
Smoothing the raw series with an EMA turns the noisy per-bar reading into a
tradeable line; Elder's classic period is `13`.
## Parameters
`period` — the EMA smoothing period. The Python binding defaults it to `13`;
the Rust and Node constructors require it explicitly.
## Inputs / Outputs
From `crates/wickra-core/src/indicators/force_index.rs`:
```rust
impl Indicator for ForceIndex {
type Input = Candle;
type Output = f64;
// update(&mut self, input: Candle) -> Option<f64>
}
```
`ForceIndex` is a **candle-input** indicator that reads `close` and `volume`.
In Python the streaming `update` accepts a 6-tuple or a dict; the batch helper
takes `close`, `volume` numpy arrays. Node and WASM expose
`update(close, volume)` and the matching `batch`.
## Warmup
`ForceIndex::new(13).warmup_period() == 14`. The first candle only establishes
the previous close, so the first raw force appears on candle 2 and the first
smoothed value on candle `period + 1`.
## Edge cases
- **First candle.** Establishes the previous close only; emits `None`.
- **Up- vs down-trend.** A strictly rising series gives a positive force, a
strictly falling series a negative one (`pure_uptrend_is_positive` and
`pure_downtrend_is_negative` pin this).
- **`period = 1`.** `EMA(1)` has `alpha = 1`, so the Force Index passes the
raw force through unsmoothed.
- **Reset.** `fi.reset()` clears the previous close and the EMA.
## Examples
### Rust
```rust
use wickra::{BatchExt, Candle, Indicator, ForceIndex};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// ForceIndex(1): EMA(1) passes the raw force through.
let mut fi = ForceIndex::new(1)?;
let out = fi.batch(&[
Candle::new(10.0, 10.0, 10.0, 10.0, 100.0, 0)?, // seeds the previous close
Candle::new(12.0, 12.0, 12.0, 12.0, 100.0, 1)?, // raw = (12-10)·100
Candle::new(11.0, 11.0, 11.0, 11.0, 200.0, 2)?, // raw = (11-12)·200
]);
println!("{:?}", out);
Ok(())
}
```
Output:
```
[None, Some(200.0), Some(-200.0)]
```
This matches the `reference_values` test in
`crates/wickra-core/src/indicators/force_index.rs`.
### Python
```python
import numpy as np
import wickra as ta
fi = ta.ForceIndex(1)
close = np.array([10.0, 12.0, 11.0])
volume = np.array([100.0, 100.0, 200.0])
print(fi.batch(close, volume))
```
Output:
```
[ nan 200. -200.]
```
### Node
```javascript
const ta = require('wickra');
const fi = new ta.ForceIndex(1);
console.log(fi.batch([10, 12, 11], [100, 100, 200]));
```
Output:
```
[ NaN, 200, -200 ]
```
## Interpretation
Elder reads the Force Index on two horizons. A short period (the classic `2`)
is a sensitive entry timer — it crosses zero often. A longer period (`13`)
tracks the conviction behind the prevailing trend: it staying above zero
confirms buyers are in control. Divergence between a `13`-period Force Index
and price flags an exhausting move.
## Common pitfalls
- **Comparing levels across instruments.** The force scales with raw volume,
so a value of `200` means nothing without knowing the instrument.
- **Feeding it scalar prices.** It needs `close` *and* `volume`.
## References
Alexander Elder's Force Index, introduced in *Trading for a Living* (1993).
## See also
- [Indicator-Obv.md](Indicator-Obv.md) — cumulative signed volume, a coarser
volume-conviction gauge.
- [Indicator-VolumePriceTrend.md](Indicator-VolumePriceTrend.md) — cumulative
volume scaled by percentage move.
- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.