F13a: add Accelerator Oscillator, Balance of Power, Choppiness Index and Vertical Horizontal Filter

First half of the eight indicators that fill out the new family taxonomy.

- Rust core: accelerator_oscillator.rs (AcceleratorOscillator — AO minus a
  short SMA of itself), balance_of_power.rs (BalanceOfPower — per-bar
  (close-open)/(high-low)), choppiness_index.rs (ChoppinessIndex — summed
  true range over the high-low span, log-scaled) and
  vertical_horizontal_filter.rs (VerticalHorizontalFilter — net move over
  total move). Each with a full Indicator impl, runnable doctest and
  reference / property / warmup / reset / batch==streaming tests.
- Python / Node / WASM: classes wired through all three bindings
  (BalanceOfPower carries an explicit open column; VHF rides the scalar
  macros) plus .pyi stubs and __init__.py / __all__ entries.
- Wiki: four new Indicator-*.md pages.

The eight-family taxonomy restructure (Overview / Home / README / folder
layout) lands in F13c once F13b's four indicators are in.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 481 core tests,
25 data tests and 70 doctests green.
This commit is contained in:
kingchenc
2026-05-22 20:57:52 +02:00
parent 27f37f5347
commit e452d35a27
16 changed files with 2001 additions and 11 deletions
@@ -0,0 +1,194 @@
//! Accelerator Oscillator (Bill Williams).
use crate::error::Result;
use crate::indicators::awesome_oscillator::AwesomeOscillator;
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Accelerator Oscillator — Bill Williams' gauge of *momentum's acceleration*.
///
/// ```text
/// AO = SMA(median, fast) SMA(median, slow) (the Awesome Oscillator)
/// AC = AO SMA(AO, signal)
/// ```
///
/// Where the [`AwesomeOscillator`](crate::AwesomeOscillator) tracks momentum,
/// the Accelerator tracks the *change* in momentum: it is the AO minus a short
/// moving average of itself. Because acceleration leads speed, `AC` tends to
/// turn before the `AO` does. Bill Williams' classic configuration is the
/// `(5, 34)` AO with a `5`-period signal average.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AcceleratorOscillator};
///
/// let mut indicator = AcceleratorOscillator::classic();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AcceleratorOscillator {
ao: AwesomeOscillator,
signal: Sma,
ao_fast: usize,
ao_slow: usize,
signal_period: usize,
}
impl AcceleratorOscillator {
/// Construct an Accelerator Oscillator with explicit AO and signal periods.
///
/// # Errors
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) for a zero
/// period and [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if the
/// AO `fast` period is not strictly below `slow`.
pub fn new(ao_fast: usize, ao_slow: usize, signal_period: usize) -> Result<Self> {
Ok(Self {
ao: AwesomeOscillator::new(ao_fast, ao_slow)?,
signal: Sma::new(signal_period)?,
ao_fast,
ao_slow,
signal_period,
})
}
/// Bill Williams' classic configuration: `AO(5, 34)` with a `5`-period signal.
pub fn classic() -> Self {
Self::new(5, 34, 5).expect("classic Accelerator Oscillator params are valid")
}
/// Configured `(ao_fast, ao_slow, signal_period)`.
pub const fn params(&self) -> (usize, usize, usize) {
(self.ao_fast, self.ao_slow, self.signal_period)
}
}
impl Indicator for AcceleratorOscillator {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let ao = self.ao.update(candle)?;
let signal = self.signal.update(ao)?;
Some(ao - signal)
}
fn reset(&mut self) {
self.ao.reset();
self.signal.reset();
}
fn warmup_period(&self) -> usize {
// The AO emits at candle `ao_slow`; the signal SMA then needs
// `signal_period` AO values.
self.ao_slow + self.signal_period - 1
}
fn is_ready(&self) -> bool {
self.signal.is_ready()
}
fn name(&self) -> &'static str {
"AcceleratorOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn constant_series_yields_zero() {
// A flat market gives AO = 0, so its signal average and AC are 0 too.
let candles: Vec<Candle> = (0..80).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ac = AcceleratorOscillator::classic();
for v in ac.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn matches_independent_ao_and_signal() {
let candles: Vec<Candle> = (0..90)
.map(|i| {
let m = 100.0 + (i as f64 * 0.2).sin() * 6.0;
c(m + 1.5, m - 1.5, m + 0.3, i)
})
.collect();
let mut ac = AcceleratorOscillator::classic();
let mut ao = AwesomeOscillator::classic();
let mut signal = Sma::new(5).unwrap();
for (i, candle) in candles.iter().enumerate() {
let got = ac.update(*candle);
match ao.update(*candle) {
Some(ao_val) => match signal.update(ao_val) {
Some(sig) => {
assert_relative_eq!(got.unwrap(), ao_val - sig, epsilon = 1e-9);
}
None => assert!(got.is_none(), "i={i}"),
},
None => assert!(got.is_none(), "i={i}"),
}
}
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ac = AcceleratorOscillator::classic();
let out = ac.batch(&candles);
assert_eq!(ac.warmup_period(), 38);
for (i, v) in out.iter().enumerate().take(37) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[37].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_invalid_params() {
assert!(AcceleratorOscillator::new(0, 34, 5).is_err());
assert!(AcceleratorOscillator::new(5, 34, 0).is_err());
assert!(AcceleratorOscillator::new(34, 5, 5).is_err());
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..60).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ac = AcceleratorOscillator::classic();
ac.batch(&candles);
assert!(ac.is_ready());
ac.reset();
assert!(!ac.is_ready());
assert_eq!(ac.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..90)
.map(|i| {
let m = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(m + 1.5, m - 1.5, m + 0.5, i)
})
.collect();
let mut a = AcceleratorOscillator::classic();
let mut b = AcceleratorOscillator::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,168 @@
//! Balance of Power.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Balance of Power — where the close settled within the bar's range relative
/// to the open.
///
/// ```text
/// BOP = (close open) / (high low)
/// ```
///
/// The result lives in `[1, +1]`: `+1` is a bar that opened on its low and
/// closed on its high (buyers in full control), `1` the mirror image. It is
/// a stateless per-bar reading — a quick gauge of intrabar conviction. A
/// zero-range bar carries no information and yields `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, BalanceOfPower};
///
/// let mut indicator = BalanceOfPower::new();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct BalanceOfPower {
has_emitted: bool,
}
impl BalanceOfPower {
/// Construct a new Balance of Power transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for BalanceOfPower {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
let bop = if range == 0.0 {
// A zero-range bar carries no directional information.
0.0
} else {
(candle.close - candle.open) / range
};
Some(bop)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"BalanceOfPower"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value() {
// (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
let mut bop = BalanceOfPower::new();
assert_relative_eq!(
bop.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(),
0.5,
epsilon = 1e-12
);
}
#[test]
fn close_on_high_after_open_on_low_is_plus_one() {
let mut bop = BalanceOfPower::new();
// open == low, close == high -> BOP = +1.
assert_relative_eq!(
bop.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(),
1.0,
epsilon = 1e-12
);
}
#[test]
fn stays_within_unit_range() {
let candles: Vec<Candle> = (0..100)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.2).sin() * 8.0;
let close = mid + (i as f64 * 0.5).cos() * 2.0;
candle(mid, mid + 3.0, mid - 3.0, close, i)
})
.collect();
let mut bop = BalanceOfPower::new();
for v in bop.batch(&candles).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "BOP {v} outside [-1, 1]");
}
}
#[test]
fn zero_range_bar_yields_zero() {
let mut bop = BalanceOfPower::new();
assert_relative_eq!(
bop.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn emits_from_first_candle() {
let mut bop = BalanceOfPower::new();
assert_eq!(bop.warmup_period(), 1);
assert!(!bop.is_ready());
assert!(bop.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(bop.is_ready());
}
#[test]
fn reset_clears_state() {
let mut bop = BalanceOfPower::new();
bop.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(bop.is_ready());
bop.reset();
assert!(!bop.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + i as f64;
candle(base, base + 2.0, base - 2.0, base + 1.0, i)
})
.collect();
let mut a = BalanceOfPower::new();
let mut b = BalanceOfPower::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,220 @@
//! Choppiness Index.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Choppiness Index — is the market trending or just chopping sideways?
///
/// ```text
/// CI = 100 · log10( Σ(TR, n) / (highest_high(n) lowest_low(n)) ) / log10(n)
/// ```
///
/// The ratio compares the *distance price actually travelled* (the summed true
/// range) with the *net ground it covered* (the high-low span of the window).
/// A clean trend travels almost exactly its span, so the ratio is near `1` and
/// `CI` near `0`; a choppy market criss-crosses far more than its span, so the
/// ratio is large and `CI` climbs toward `100`. The conventional reading is
/// `CI > 61.8` ranging, `CI < 38.2` trending. A perfectly flat window yields
/// `100` by convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ChoppinessIndex};
///
/// let mut indicator = ChoppinessIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ChoppinessIndex {
period: usize,
log_n: f64,
prev_close: Option<f64>,
tr_window: VecDeque<f64>,
tr_sum: f64,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
}
impl ChoppinessIndex {
/// Construct a new Choppiness Index over `period` bars.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — the `log10(period)`
/// denominator is zero for `period == 1` and undefined for `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "choppiness index needs period >= 2",
});
}
Ok(Self {
period,
log_n: (period as f64).log10(),
prev_close: None,
tr_window: VecDeque::with_capacity(period),
tr_sum: 0.0,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ChoppinessIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let tr = candle.true_range(self.prev_close);
self.prev_close = Some(candle.close);
if self.tr_window.len() == self.period {
self.tr_sum -= self.tr_window.pop_front().expect("non-empty");
self.highs.pop_front();
self.lows.pop_front();
}
self.tr_window.push_back(tr);
self.tr_sum += tr;
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
if self.tr_window.len() < self.period {
return None;
}
let highest = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lowest = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
let span = highest - lowest;
if span == 0.0 {
// A perfectly flat window: maximal choppiness by convention.
return Some(100.0);
}
Some(100.0 * (self.tr_sum / span).log10() / self.log_n)
}
fn reset(&mut self) {
self.prev_close = None;
self.tr_window.clear();
self.tr_sum = 0.0;
self.highs.clear();
self.lows.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.tr_window.len() == self.period
}
fn name(&self) -> &'static str {
"ChoppinessIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new((high + low) / 2.0, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value_equal_range_bars() {
// Two H=11 L=9 C=10 bars: TR = 2 each, ΣTR = 4; span = 11 - 9 = 2.
// CI = 100 · log10(4 / 2) / log10(2) = 100.
let mut ci = ChoppinessIndex::new(2).unwrap();
let out = ci.batch(&[c(11.0, 9.0, 10.0, 0), c(11.0, 9.0, 10.0, 1)]);
assert!(out[0].is_none());
assert_relative_eq!(out[1].unwrap(), 100.0, epsilon = 1e-9);
}
#[test]
fn flat_window_yields_hundred() {
let candles: Vec<Candle> = (0..20).map(|i| c(10.0, 10.0, 10.0, i)).collect();
let mut ci = ChoppinessIndex::new(14).unwrap();
for v in ci.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 100.0, epsilon = 1e-9);
}
}
#[test]
fn steady_trend_reads_low() {
// A clean one-directional march travels close to its span -> low CI.
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + i as f64;
c(base + 1.0, base - 1.0, base, i)
})
.collect();
let mut ci = ChoppinessIndex::new(14).unwrap();
for v in ci.batch(&candles).into_iter().flatten() {
assert!(v < 50.0, "a steady trend should read below 50, got {v}");
assert!(v >= 0.0, "CI must be non-negative, got {v}");
}
}
#[test]
fn first_emission_matches_warmup_period() {
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ci = ChoppinessIndex::new(8).unwrap();
let out = ci.batch(&candles);
assert_eq!(ci.warmup_period(), 8);
for (i, v) in out.iter().enumerate().take(7) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[7].is_some(), "first value lands at warmup_period - 1");
}
#[test]
fn rejects_period_below_two() {
assert!(ChoppinessIndex::new(0).is_err());
assert!(ChoppinessIndex::new(1).is_err());
assert!(ChoppinessIndex::new(2).is_ok());
}
#[test]
fn reset_clears_state() {
let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
let mut ci = ChoppinessIndex::new(14).unwrap();
ci.batch(&candles);
assert!(ci.is_ready());
ci.reset();
assert!(!ci.is_ready());
assert_eq!(ci.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
c(mid + 1.5, mid - 1.5, mid + 0.5, i)
})
.collect();
let mut a = ChoppinessIndex::new(14).unwrap();
let mut b = ChoppinessIndex::new(14).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+8
View File
@@ -4,6 +4,7 @@
//! volume) but every public name is also re-exported flat from this module and
//! from the crate root for convenience.
mod accelerator_oscillator;
mod adl;
mod adx;
mod aroon;
@@ -11,12 +12,14 @@ mod aroon_oscillator;
mod atr;
mod atr_trailing_stop;
mod awesome_oscillator;
mod balance_of_power;
mod bollinger;
mod bollinger_bandwidth;
mod cci;
mod chaikin_oscillator;
mod chande_kroll_stop;
mod chandelier_exit;
mod choppiness_index;
mod cmf;
mod cmo;
mod coppock;
@@ -59,6 +62,7 @@ mod tsi;
mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
mod vertical_horizontal_filter;
mod vortex;
mod vpt;
mod vwap;
@@ -68,6 +72,7 @@ mod williams_r;
mod wma;
mod zlema;
pub use accelerator_oscillator::AcceleratorOscillator;
pub use adl::Adl;
pub use adx::{Adx, AdxOutput};
pub use aroon::{Aroon, AroonOutput};
@@ -75,12 +80,14 @@ pub use aroon_oscillator::AroonOscillator;
pub use atr::Atr;
pub use atr_trailing_stop::AtrTrailingStop;
pub use awesome_oscillator::AwesomeOscillator;
pub use balance_of_power::BalanceOfPower;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use cci::Cci;
pub use chaikin_oscillator::ChaikinOscillator;
pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput};
pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput};
pub use choppiness_index::ChoppinessIndex;
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coppock::Coppock;
@@ -123,6 +130,7 @@ pub use tsi::Tsi;
pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vortex::{Vortex, VortexOutput};
pub use vpt::VolumePriceTrend;
pub use vwap::{RollingVwap, Vwap};
@@ -0,0 +1,202 @@
//! Vertical Horizontal Filter.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Vertical Horizontal Filter — Adam White's trend-versus-range gauge.
///
/// ```text
/// VHF = (highest_close(n) lowest_close(n)) / Σ|close close_prev|(n)
/// ```
///
/// The numerator is the *net* distance price covered over the window; the
/// denominator is the *total* distance it walked. Their ratio lives in
/// `[0, 1]`: a clean trend walks almost only in its net direction, so `VHF`
/// approaches `1`; a choppy market doubles back constantly, inflating the
/// denominator and pushing `VHF` toward `0`. It answers the same question as
/// the [`ChoppinessIndex`](crate::ChoppinessIndex) on an inverted scale.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, VerticalHorizontalFilter};
///
/// let mut indicator = VerticalHorizontalFilter::new(28).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VerticalHorizontalFilter {
period: usize,
closes: VecDeque<f64>,
prev_close: Option<f64>,
diffs: VecDeque<f64>,
diff_sum: f64,
}
impl VerticalHorizontalFilter {
/// Construct a new Vertical Horizontal Filter over `period` closes.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
closes: VecDeque::with_capacity(period),
prev_close: None,
diffs: VecDeque::with_capacity(period),
diff_sum: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for VerticalHorizontalFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.closes.len() == self.period {
self.closes.pop_front();
}
self.closes.push_back(value);
if let Some(prev) = self.prev_close {
let diff = (value - prev).abs();
if self.diffs.len() == self.period {
self.diff_sum -= self.diffs.pop_front().expect("non-empty");
}
self.diffs.push_back(diff);
self.diff_sum += diff;
}
self.prev_close = Some(value);
if self.closes.len() < self.period || self.diffs.len() < self.period {
return None;
}
let highest = self
.closes
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let lowest = self.closes.iter().copied().fold(f64::INFINITY, f64::min);
if self.diff_sum == 0.0 {
// A flat window walked nowhere — no trend to filter.
return Some(0.0);
}
Some((highest - lowest) / self.diff_sum)
}
fn reset(&mut self) {
self.closes.clear();
self.prev_close = None;
self.diffs.clear();
self.diff_sum = 0.0;
}
fn warmup_period(&self) -> usize {
// `period` closes fill the high/low window; the `period`-th diff needs
// one extra input because the first input has nothing to diff against.
self.period + 1
}
fn is_ready(&self) -> bool {
self.diffs.len() == self.period
}
fn name(&self) -> &'static str {
"VerticalHorizontalFilter"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn reference_values_pure_uptrend() {
// Closes 1,2,…: every diff is 1 (Σ = period), the n-close span is
// period 1, so VHF = (period 1) / period. For period 5: 4/5 = 0.8.
let mut vhf = VerticalHorizontalFilter::new(5).unwrap();
let out = vhf.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
for (i, v) in out.iter().enumerate().take(5) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert_relative_eq!(out[5].unwrap(), 0.8, epsilon = 1e-12);
assert_eq!(vhf.warmup_period(), 6);
}
#[test]
fn choppy_series_reads_low() {
// A market that oscillates between two prices covers a tiny net span
// while walking a long way -> VHF near zero.
let prices: Vec<f64> = (0..40)
.map(|i| if i % 2 == 0 { 10.0 } else { 11.0 })
.collect();
let mut vhf = VerticalHorizontalFilter::new(10).unwrap();
for v in vhf.batch(&prices).into_iter().flatten() {
assert!(v < 0.2, "a choppy series should read low, got {v}");
}
}
#[test]
fn flat_series_yields_zero() {
let mut vhf = VerticalHorizontalFilter::new(8).unwrap();
for v in vhf.batch(&[50.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn stays_within_unit_range() {
let prices: Vec<f64> = (0..120)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut vhf = VerticalHorizontalFilter::new(28).unwrap();
for v in vhf.batch(&prices).into_iter().flatten() {
assert!((0.0..=1.0).contains(&v), "VHF {v} outside [0, 1]");
}
}
#[test]
fn rejects_zero_period() {
assert!(VerticalHorizontalFilter::new(0).is_err());
}
#[test]
fn reset_clears_state() {
let mut vhf = VerticalHorizontalFilter::new(8).unwrap();
vhf.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]);
assert!(vhf.is_ready());
vhf.reset();
assert!(!vhf.is_ready());
assert_eq!(vhf.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut a = VerticalHorizontalFilter::new(28).unwrap();
let mut b = VerticalHorizontalFilter::new(28).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+10 -10
View File
@@ -44,16 +44,16 @@ pub mod indicators;
pub use error::{Error, Result};
pub use indicators::{
Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, AtrTrailingStop,
AwesomeOscillator, BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow,
ChaikinOscillator, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema,
ForceIndex, HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, LinRegSlope,
LinearRegression, MacdIndicator, MacdOutput, MassIndex, MedianPrice, Mfi, Mom, Natr, Obv,
PercentB, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma, StdDev, StochRsi, Stochastic,
StochasticOutput, SuperTrend, SuperTrendOutput, Tema, Trima, Trix, Tsi, TypicalPrice,
UlcerIndex, UltimateOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma,
WeightedClose, WilliamsR, Wma, Zlema, T3,
AcceleratorOscillator, Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr,
AtrTrailingStop, AwesomeOscillator, BalanceOfPower, BollingerBands, BollingerBandwidth,
BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChandeKrollStop,
ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, Cmo, Coppock,
Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema, ForceIndex, HistoricalVolatility,
Hma, Kama, Keltner, KeltnerOutput, LinRegSlope, LinearRegression, MacdIndicator, MacdOutput,
MassIndex, MedianPrice, Mfi, Mom, Natr, Obv, PercentB, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi,
Sma, Smma, StdDev, StochRsi, Stochastic, StochasticOutput, SuperTrend, SuperTrendOutput, Tema,
Trima, Trix, Tsi, TypicalPrice, UlcerIndex, UltimateOscillator, VerticalHorizontalFilter,
VolumePriceTrend, Vortex, VortexOutput, Vwap, Vwma, WeightedClose, WilliamsR, Wma, Zlema, T3,
};
pub use ohlcv::{Candle, Tick};
pub use traits::{BatchExt, Chain, Indicator};