F6: add Aroon Oscillator, Vortex and Mass Index
Completes the F6 family (Trend strength) end to end: - Rust core: aroon_oscillator.rs (AroonUp - AroonDown, one-line trend gauge), vortex.rs (Vortex Indicator VI+/VI- with the VortexOutput struct), mass_index.rs (Dorsey's range-expansion sum of the EMA-of-range ratio). Each with a full Indicator impl, runnable doctest and reference / saturation / warmup / reset / batch==streaming tests. - Python: PyAroonOscillator / PyVortex / PyMassIndex PyO3 classes + module registration + .pyi stubs (defaults Aroon=14, Vortex=14, MassIndex=(9,25)). - Node: explicit AroonOscillatorNode, VortexNode (with VortexValue object) and MassIndexNode; index.d.ts and index.js updated. - WASM: WasmAroonOscillator, WasmVortex, WasmMassIndex. - Wiki: Indicator-AroonOscillator/Vortex/MassIndex.md plus rows in Indicators-Overview.md and entries in Home.md. cargo fmt + clippy (core/wickra/data/wasm/node) clean; 320 core tests, 25 data tests and 45 doctests green.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
//! Aroon Oscillator.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::Aroon;
|
||||
|
||||
/// Aroon Oscillator — the single-line difference `AroonUp − AroonDown`.
|
||||
///
|
||||
/// The [`Aroon`] indicator reports two `[0, 100]` lines; the Aroon Oscillator
|
||||
/// collapses them into one value in `[−100, 100]`:
|
||||
///
|
||||
/// ```text
|
||||
/// AroonOscillator = AroonUp − AroonDown
|
||||
/// ```
|
||||
///
|
||||
/// Strongly positive means the most recent high is much fresher than the most
|
||||
/// recent low (an up-trend); strongly negative is the mirror image. Readings
|
||||
/// near zero mean neither extreme is recent — a range.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AroonOscillator};
|
||||
///
|
||||
/// let mut indicator = AroonOscillator::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + i as f64;
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(100.0)); // pure uptrend
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AroonOscillator {
|
||||
aroon: Aroon,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AroonOscillator {
|
||||
/// Construct a new Aroon Oscillator with the given period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
aroon: Aroon::new(period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.aroon.period()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AroonOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let osc = self.aroon.update(candle).map(|o| o.up - o.down)?;
|
||||
self.last = Some(osc);
|
||||
Some(osc)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.aroon.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.aroon.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AroonOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(AroonOscillator::new(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_yields_plus_100() {
|
||||
// Every bar a fresh high, no fresh low: AroonUp = 100, AroonDown = 0.
|
||||
let mut osc = AroonOscillator::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let p = 100.0 + i as f64;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 100.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_downtrend_yields_minus_100() {
|
||||
let mut osc = AroonOscillator::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let p = 100.0 - i as f64;
|
||||
candle(p + 1.0, p - 1.0, p, i)
|
||||
})
|
||||
.collect();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, -100.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_stays_within_minus_100_and_100() {
|
||||
let mut osc = AroonOscillator::new(14).unwrap();
|
||||
let candles: Vec<Candle> = (0..200)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.25).sin() * 12.0;
|
||||
candle(mid + 2.0, mid - 2.0, mid, i)
|
||||
})
|
||||
.collect();
|
||||
for v in osc.batch(&candles).into_iter().flatten() {
|
||||
assert!((-100.0..=100.0).contains(&v), "out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_period_matches_aroon() {
|
||||
let osc = AroonOscillator::new(7).unwrap();
|
||||
assert_eq!(osc.warmup_period(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut osc = AroonOscillator::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(100.0 + i as f64, 90.0, 95.0, i))
|
||||
.collect();
|
||||
osc.batch(&candles);
|
||||
assert!(osc.is_ready());
|
||||
osc.reset();
|
||||
assert!(!osc.is_ready());
|
||||
assert_eq!(osc.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
|
||||
candle(mid + 2.0, mid - 2.0, mid, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = AroonOscillator::new(14).unwrap().batch(&candles);
|
||||
let mut b = AroonOscillator::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Mass Index.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
use super::Ema;
|
||||
|
||||
/// Mass Index — Donald Dorsey's range-expansion indicator.
|
||||
///
|
||||
/// The Mass Index watches the high–low range, not direction. It smooths the
|
||||
/// range with an EMA, smooths that again, takes the ratio of the two, and sums
|
||||
/// the ratio over a window:
|
||||
///
|
||||
/// ```text
|
||||
/// range_t = high_t − low_t
|
||||
/// ratio_t = EMA(range, ema_period) / EMA(EMA(range, ema_period), ema_period)
|
||||
/// MassIndex = Σ ratio over sum_period
|
||||
/// ```
|
||||
///
|
||||
/// When the range widens, the single EMA pulls ahead of the double EMA, the
|
||||
/// ratio rises above `1`, and the sum climbs. Dorsey's "reversal bulge" is the
|
||||
/// Mass Index rising above `27` and then falling back below `26.5` — a sign
|
||||
/// that a range expansion is about to resolve into a trend reversal. With the
|
||||
/// conventional `(ema_period = 9, sum_period = 25)` a flat-range market sits at
|
||||
/// `25`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, MassIndex};
|
||||
///
|
||||
/// let mut indicator = MassIndex::new(9, 25).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + i as f64;
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MassIndex {
|
||||
ema_period: usize,
|
||||
sum_period: usize,
|
||||
ema1: Ema,
|
||||
ema2: Ema,
|
||||
/// Rolling window of the last `sum_period` EMA ratios.
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl MassIndex {
|
||||
/// Construct a new Mass Index with the EMA smoothing period and the sum
|
||||
/// window length.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either period is `0`.
|
||||
pub fn new(ema_period: usize, sum_period: usize) -> Result<Self> {
|
||||
if ema_period == 0 || sum_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
ema_period,
|
||||
sum_period,
|
||||
ema1: Ema::new(ema_period)?,
|
||||
ema2: Ema::new(ema_period)?,
|
||||
window: VecDeque::with_capacity(sum_period),
|
||||
sum: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `(ema_period, sum_period)` pair.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.ema_period, self.sum_period)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for MassIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let range = candle.high - candle.low;
|
||||
let single = self.ema1.update(range)?;
|
||||
let double = self.ema2.update(single)?;
|
||||
let ratio = if double == 0.0 {
|
||||
// A zero-range market: no expansion, neutral ratio.
|
||||
1.0
|
||||
} else {
|
||||
single / double
|
||||
};
|
||||
if self.window.len() == self.sum_period {
|
||||
self.sum -= self.window.pop_front().expect("window is non-empty");
|
||||
}
|
||||
self.window.push_back(ratio);
|
||||
self.sum += ratio;
|
||||
if self.window.len() < self.sum_period {
|
||||
return None;
|
||||
}
|
||||
self.last = Some(self.sum);
|
||||
Some(self.sum)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ema1.reset();
|
||||
self.ema2.reset();
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// ema1 seeds at `ema_period`, ema2 at `2·ema_period − 1`, then the sum
|
||||
// window needs `sum_period` ratios.
|
||||
2 * self.ema_period + self.sum_period - 2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"MassIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
/// A candle with a fixed high–low range `span` centred on `mid`.
|
||||
fn candle(mid: f64, span: f64, ts: i64) -> Candle {
|
||||
Candle::new(mid, mid + span / 2.0, mid - span / 2.0, mid, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(MassIndex::new(0, 25), Err(Error::PeriodZero)));
|
||||
assert!(matches!(MassIndex::new(9, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_period_formula() {
|
||||
let mi = MassIndex::new(9, 25).unwrap();
|
||||
assert_eq!(mi.warmup_period(), 2 * 9 + 25 - 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut mi = MassIndex::new(3, 4).unwrap();
|
||||
let warmup = mi.warmup_period(); // 2*3 + 4 - 2 = 8
|
||||
assert_eq!(warmup, 8);
|
||||
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
|
||||
let out = mi.batch(&candles);
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_range_sums_to_sum_period() {
|
||||
// A constant high–low range makes both EMAs converge to the same
|
||||
// value, so every ratio is 1 and the Mass Index equals `sum_period`.
|
||||
let mut mi = MassIndex::new(3, 4).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
|
||||
for v in mi.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 4.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_market_sums_to_sum_period() {
|
||||
let mut mi = MassIndex::new(3, 4).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0, 0.0, i)).collect();
|
||||
for v in mi.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 4.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut mi = MassIndex::new(3, 4).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
|
||||
mi.batch(&candles);
|
||||
assert!(mi.is_ready());
|
||||
mi.reset();
|
||||
assert!(!mi.is_ready());
|
||||
assert_eq!(mi.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let span = 2.0 + (i as f64 * 0.3).sin().abs() * 3.0;
|
||||
candle(100.0 + (i as f64 * 0.2).cos() * 5.0, span, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = MassIndex::new(9, 25).unwrap().batch(&candles);
|
||||
let mut b = MassIndex::new(9, 25).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
mod adx;
|
||||
mod aroon;
|
||||
mod aroon_oscillator;
|
||||
mod atr;
|
||||
mod awesome_oscillator;
|
||||
mod bollinger;
|
||||
@@ -20,6 +21,7 @@ mod hma;
|
||||
mod kama;
|
||||
mod keltner;
|
||||
mod macd;
|
||||
mod mass_index;
|
||||
mod mfi;
|
||||
mod mom;
|
||||
mod obv;
|
||||
@@ -38,6 +40,7 @@ mod trima;
|
||||
mod trix;
|
||||
mod tsi;
|
||||
mod ultimate_oscillator;
|
||||
mod vortex;
|
||||
mod vwap;
|
||||
mod vwma;
|
||||
mod williams_r;
|
||||
@@ -46,6 +49,7 @@ mod zlema;
|
||||
|
||||
pub use adx::{Adx, AdxOutput};
|
||||
pub use aroon::{Aroon, AroonOutput};
|
||||
pub use aroon_oscillator::AroonOscillator;
|
||||
pub use atr::Atr;
|
||||
pub use awesome_oscillator::AwesomeOscillator;
|
||||
pub use bollinger::{BollingerBands, BollingerOutput};
|
||||
@@ -60,6 +64,7 @@ pub use hma::Hma;
|
||||
pub use kama::Kama;
|
||||
pub use keltner::{Keltner, KeltnerOutput};
|
||||
pub use macd::{MacdIndicator, MacdOutput};
|
||||
pub use mass_index::MassIndex;
|
||||
pub use mfi::Mfi;
|
||||
pub use mom::Mom;
|
||||
pub use obv::Obv;
|
||||
@@ -78,6 +83,7 @@ pub use trima::Trima;
|
||||
pub use trix::Trix;
|
||||
pub use tsi::Tsi;
|
||||
pub use ultimate_oscillator::UltimateOscillator;
|
||||
pub use vortex::{Vortex, VortexOutput};
|
||||
pub use vwap::{RollingVwap, Vwap};
|
||||
pub use vwma::Vwma;
|
||||
pub use williams_r::WilliamsR;
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Vortex Indicator.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Vortex Indicator output: the two directional movement lines.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct VortexOutput {
|
||||
/// `VI+` — strength of upward (positive) vortex movement.
|
||||
pub plus: f64,
|
||||
/// `VI−` — strength of downward (negative) vortex movement.
|
||||
pub minus: f64,
|
||||
}
|
||||
|
||||
/// Vortex Indicator — Botes & Siepman's pair of oscillators (`VI+`, `VI−`) that
|
||||
/// capture the relationship between two consecutive bars.
|
||||
///
|
||||
/// Two "vortex movements" measure how far price travelled against the opposite
|
||||
/// extreme of the previous bar; each is normalised by the summed true range:
|
||||
///
|
||||
/// ```text
|
||||
/// VM+_t = |high_t − low_{t−1}|
|
||||
/// VM−_t = |low_t − high_{t−1}|
|
||||
/// VI+ = Σ VM+ over n / Σ TR over n
|
||||
/// VI− = Σ VM− over n / Σ TR over n
|
||||
/// ```
|
||||
///
|
||||
/// `VI+` crossing above `VI−` is a bullish signal, the reverse a bearish one;
|
||||
/// the wider the gap, the stronger the trend. A fully flat window (zero true
|
||||
/// range) reports `(0, 0)`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Vortex};
|
||||
///
|
||||
/// let mut indicator = Vortex::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// let base = 100.0 + i as f64;
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Vortex {
|
||||
period: usize,
|
||||
prev: Option<Candle>,
|
||||
/// Rolling window of `(VM+, VM−, TR)` triples.
|
||||
window: VecDeque<(f64, f64, f64)>,
|
||||
sum_vm_plus: f64,
|
||||
sum_vm_minus: f64,
|
||||
sum_tr: f64,
|
||||
last: Option<VortexOutput>,
|
||||
}
|
||||
|
||||
impl Vortex {
|
||||
/// Construct a new Vortex Indicator with the given period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_vm_plus: 0.0,
|
||||
sum_vm_minus: 0.0,
|
||||
sum_tr: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<VortexOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Vortex {
|
||||
type Input = Candle;
|
||||
type Output = VortexOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<VortexOutput> {
|
||||
let Some(prev) = self.prev else {
|
||||
// The first bar has no predecessor to measure against.
|
||||
self.prev = Some(candle);
|
||||
return None;
|
||||
};
|
||||
let vm_plus = (candle.high - prev.low).abs();
|
||||
let vm_minus = (candle.low - prev.high).abs();
|
||||
let tr = candle.true_range(Some(prev.close));
|
||||
self.prev = Some(candle);
|
||||
|
||||
if self.window.len() == self.period {
|
||||
let (old_p, old_m, old_tr) = self.window.pop_front().expect("window is non-empty");
|
||||
self.sum_vm_plus -= old_p;
|
||||
self.sum_vm_minus -= old_m;
|
||||
self.sum_tr -= old_tr;
|
||||
}
|
||||
self.window.push_back((vm_plus, vm_minus, tr));
|
||||
self.sum_vm_plus += vm_plus;
|
||||
self.sum_vm_minus += vm_minus;
|
||||
self.sum_tr += tr;
|
||||
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let out = if self.sum_tr == 0.0 {
|
||||
// A perfectly flat window has no range to normalise against.
|
||||
VortexOutput {
|
||||
plus: 0.0,
|
||||
minus: 0.0,
|
||||
}
|
||||
} else {
|
||||
VortexOutput {
|
||||
plus: self.sum_vm_plus / self.sum_tr,
|
||||
minus: self.sum_vm_minus / self.sum_tr,
|
||||
}
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.window.clear();
|
||||
self.sum_vm_plus = 0.0;
|
||||
self.sum_vm_minus = 0.0;
|
||||
self.sum_tr = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The first VM/TR triple needs a previous bar, then the window fills.
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Vortex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_period() {
|
||||
assert!(matches!(Vortex::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_values() {
|
||||
// Vortex(2) over three explicit candles (high, low, close):
|
||||
// c1 = (10, 8, 9), c2 = (12, 9, 11), c3 = (13, 11, 12).
|
||||
// bar 2: VM+ = |12-8| = 4, VM- = |9-10| = 1, TR = 3.
|
||||
// bar 3: VM+ = |13-9| = 4, VM- = |11-12| = 1, TR = 2.
|
||||
// window sums: VM+ = 8, VM- = 2, TR = 5 -> VI+ = 1.6, VI- = 0.4.
|
||||
let candles = [
|
||||
candle(9.0, 10.0, 8.0, 9.0, 0),
|
||||
candle(10.0, 12.0, 9.0, 11.0, 1),
|
||||
candle(12.0, 13.0, 11.0, 12.0, 2),
|
||||
];
|
||||
let mut v = Vortex::new(2).unwrap();
|
||||
let out = v.batch(&candles);
|
||||
assert_eq!(v.warmup_period(), 3);
|
||||
assert_eq!(out[0], None);
|
||||
assert_eq!(out[1], None);
|
||||
let o = out[2].unwrap();
|
||||
assert_relative_eq!(o.plus, 1.6, epsilon = 1e-12);
|
||||
assert_relative_eq!(o.minus, 0.4, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfectly_flat_market_yields_zero() {
|
||||
let mut v = Vortex::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect();
|
||||
for o in v.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(o.plus, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(o.minus, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_are_non_negative() {
|
||||
let mut v = Vortex::new(14).unwrap();
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0;
|
||||
candle(mid, mid + 3.0, mid - 3.0, mid + 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
for o in v.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.plus >= 0.0 && o.minus >= 0.0, "negative VI: {o:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut v = Vortex::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(100.0, 102.0, 98.0, 101.0, i))
|
||||
.collect();
|
||||
v.batch(&candles);
|
||||
assert!(v.is_ready());
|
||||
v.reset();
|
||||
assert!(!v.is_ready());
|
||||
assert_eq!(v.update(candles[0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let mid = 100.0 + (i as f64 * 0.35).sin() * 9.0;
|
||||
candle(mid, mid + 2.5, mid - 2.5, mid + 0.5, i)
|
||||
})
|
||||
.collect();
|
||||
let batch = Vortex::new(14).unwrap().batch(&candles);
|
||||
let mut b = Vortex::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user