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:
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user