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:
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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_{t−1} + low_{t−1})/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_{t−1}) · 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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user