Add B10 Ehlers / Cycle deepening (10 indicators) (#199)
Deepens the **Ehlers / Cycle (DSP)** family (B10) with ten indicators (452 -> 462): - **HighpassFilter**, **Reflex**, **Trendflex**, **CorrelationTrendIndicator**, **AdaptiveRsi**, **UniversalOscillator** — scalar (f64) Ehlers filters/oscillators. - **AdaptiveCci** — efficiency-ratio-adaptive CCI on typical price (Candle input). - **BandpassFilter**, **EvenBetterSinewave**, **AutocorrelationPeriodogram** — multi-arg scalar (hand-written bindings; the wasm variadic scalar macro covers wasm). Verified locally: 3755 core lib + 420 doc tests, clippy clean, 537 node tests, 881 pytest, counter 462.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
//! Adaptive CCI — a CCI whose centre line adapts to the efficiency ratio.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Adaptive CCI — Lambert's Commodity Channel Index whose centre line is an
|
||||
/// **efficiency-ratio-adaptive** moving average of typical price instead of a
|
||||
/// plain SMA, so it leads in trends and stays calm in chop.
|
||||
///
|
||||
/// ```text
|
||||
/// TP = (high + low + close) / 3
|
||||
/// ER = |TP_t − TP_oldest| / Σ |ΔTP| over the window (0..1)
|
||||
/// sc = ( ER·(2/3 − 2/31) + 2/31 )²
|
||||
/// mean += sc·(TP_t − mean) (adaptive centre, seeded with SMA)
|
||||
/// MD = mean(|TP_i − mean|) over the window (mean deviation)
|
||||
/// CCI = (TP_t − mean) / (0.015 · MD)
|
||||
/// ```
|
||||
///
|
||||
/// The classic [`Cci`](crate::Cci) centres typical price on its simple moving
|
||||
/// average; the lag of that SMA delays the oscillator in fast moves. Replacing it
|
||||
/// with a KAMA-style adaptive average — driven by Kaufman's efficiency ratio —
|
||||
/// lets the centre line accelerate toward price in a clean trend (so the CCI
|
||||
/// reaches its `±100` bands sooner) and slow down in noise (fewer false pokes).
|
||||
/// The `0.015` scaling keeps Lambert's convention that roughly 70–80% of readings
|
||||
/// fall in `[−100, +100]`.
|
||||
///
|
||||
/// The output is unbounded around `0`; a flat window (zero mean deviation) returns
|
||||
/// `0`. The first value lands after `period` inputs; each `update` is O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AdaptiveCci};
|
||||
///
|
||||
/// let mut indicator = AdaptiveCci::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdaptiveCci {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
mean: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AdaptiveCci {
|
||||
/// Construct an adaptive CCI with the given `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::InvalidPeriod`] if `period < 2` (the efficiency ratio needs a
|
||||
/// path of at least one step).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "adaptive CCI needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
mean: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdaptiveCci {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let tp = candle.typical_price();
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(tp);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
|
||||
// Efficiency ratio over the window.
|
||||
let oldest = self.window[0];
|
||||
let direction = (tp - oldest).abs();
|
||||
let mut path = 0.0;
|
||||
for pair in self.window.iter().collect::<Vec<_>>().windows(2) {
|
||||
path += (pair[1] - pair[0]).abs();
|
||||
}
|
||||
let er = if path > 0.0 {
|
||||
(direction / path).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let fast = 2.0 / 3.0;
|
||||
let slow = 2.0 / 31.0;
|
||||
let sc = (er * (fast - slow) + slow).powi(2);
|
||||
|
||||
let mean = match self.mean {
|
||||
None => self.window.iter().sum::<f64>() / n,
|
||||
Some(prev) => prev + sc * (tp - prev),
|
||||
};
|
||||
self.mean = Some(mean);
|
||||
|
||||
let md = self.window.iter().map(|&v| (v - mean).abs()).sum::<f64>() / n;
|
||||
let cci = if md > 0.0 {
|
||||
(tp - mean) / (0.015 * md)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(cci);
|
||||
Some(cci)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.mean = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdaptiveCci"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(tp: f64) -> Candle {
|
||||
// open=high=low=close=tp -> typical price == tp.
|
||||
Candle::new_unchecked(tp, tp, tp, tp, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_period() {
|
||||
assert!(matches!(AdaptiveCci::new(0), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
AdaptiveCci::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let c = AdaptiveCci::new(20).unwrap();
|
||||
assert_eq!(c.period(), 20);
|
||||
assert_eq!(c.warmup_period(), 20);
|
||||
assert_eq!(c.name(), "AdaptiveCci");
|
||||
assert!(!c.is_ready());
|
||||
assert_eq!(c.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut c = AdaptiveCci::new(4).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(100.0 + f64::from(i))).collect();
|
||||
let out = c.batch(&candles);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_is_positive() {
|
||||
let mut c = AdaptiveCci::new(10).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + f64::from(i))).collect();
|
||||
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last > 0.0, "uptrend should give positive CCI, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_is_negative() {
|
||||
let mut c = AdaptiveCci::new(10).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(200.0 - f64::from(i))).collect();
|
||||
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last < 0.0, "downtrend should give negative CCI, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_zero() {
|
||||
let mut c = AdaptiveCci::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..10).map(|_| candle(100.0)).collect();
|
||||
for v in c.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut c = AdaptiveCci::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + f64::from(i))).collect();
|
||||
c.batch(&candles);
|
||||
assert!(c.is_ready());
|
||||
c.reset();
|
||||
assert!(!c.is_ready());
|
||||
assert_eq!(c.value(), None);
|
||||
assert_eq!(c.update(candle(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
|
||||
.collect();
|
||||
let batch = AdaptiveCci::new(20).unwrap().batch(&candles);
|
||||
let mut b = AdaptiveCci::new(20).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Adaptive RSI — an RSI whose up/down averaging adapts to the efficiency ratio.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Adaptive RSI — Wilder's RSI in which the smoothing of the average gain and
|
||||
/// average loss **adapts to trendiness** via Kaufman's efficiency ratio, so the
|
||||
/// oscillator reacts fast in a clean move and smooths through chop.
|
||||
///
|
||||
/// ```text
|
||||
/// ER = |price_t − price_{t−period}| / Σ |Δprice| over the window (efficiency ratio, 0..1)
|
||||
/// sc = ( ER·(2/3 − 2/31) + 2/31 )² (KAMA smoothing constant)
|
||||
/// avg_gain += sc·(gain − avg_gain), avg_loss += sc·(loss − avg_loss)
|
||||
/// RSI = 100 · avg_gain / (avg_gain + avg_loss)
|
||||
/// ```
|
||||
///
|
||||
/// A fixed-period [`Rsi`](crate::Rsi) is a compromise: short periods whip in
|
||||
/// ranges, long ones lag in trends. This adaptive form borrows Kaufman's
|
||||
/// efficiency ratio (`directional move / total path`) to set the smoothing each
|
||||
/// bar — near `1` (a clean trend) the averages track gains and losses almost
|
||||
/// immediately; near `0` (noise) they barely move, filtering the chop. The result
|
||||
/// is an RSI that is responsive when it should be and quiet when it should be. It
|
||||
/// is the efficiency-ratio cousin of Ehlers' cycle-adaptive RSI, which instead
|
||||
/// sets the lookback from the measured dominant cycle.
|
||||
///
|
||||
/// Output is bounded in `[0, 100]`; a flat market returns the neutral `50`. The
|
||||
/// first value lands after `period + 1` inputs. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, AdaptiveRsi};
|
||||
///
|
||||
/// let mut indicator = AdaptiveRsi::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdaptiveRsi {
|
||||
period: usize,
|
||||
prices: VecDeque<f64>,
|
||||
abs_changes: VecDeque<f64>,
|
||||
abs_sum: f64,
|
||||
prev: Option<f64>,
|
||||
seed_gain: f64,
|
||||
seed_loss: f64,
|
||||
seed_count: usize,
|
||||
avg_gain: Option<f64>,
|
||||
avg_loss: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AdaptiveRsi {
|
||||
/// Construct an adaptive RSI with the given efficiency-ratio `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,
|
||||
prices: VecDeque::with_capacity(period + 1),
|
||||
abs_changes: VecDeque::with_capacity(period),
|
||||
abs_sum: 0.0,
|
||||
prev: None,
|
||||
seed_gain: 0.0,
|
||||
seed_loss: 0.0,
|
||||
seed_count: 0,
|
||||
avg_gain: None,
|
||||
avg_loss: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured efficiency-ratio period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
|
||||
let denom = avg_gain + avg_loss;
|
||||
if denom == 0.0 {
|
||||
50.0
|
||||
} else {
|
||||
100.0 * (avg_gain / denom)
|
||||
}
|
||||
}
|
||||
|
||||
fn efficiency_ratio(&self, price: f64) -> f64 {
|
||||
let oldest = *self.prices.front().expect("window non-empty");
|
||||
let direction = (price - oldest).abs();
|
||||
if self.abs_sum == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(direction / self.abs_sum).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdaptiveRsi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(price);
|
||||
self.prices.push_back(price);
|
||||
return None;
|
||||
};
|
||||
let change = price - prev;
|
||||
self.prev = Some(price);
|
||||
let gain = if change > 0.0 { change } else { 0.0 };
|
||||
let loss = if change < 0.0 { -change } else { 0.0 };
|
||||
|
||||
// Maintain the price window (period + 1) and the |Δ| window (period).
|
||||
self.prices.push_back(price);
|
||||
if self.prices.len() > self.period + 1 {
|
||||
self.prices.pop_front();
|
||||
}
|
||||
if self.abs_changes.len() == self.period {
|
||||
self.abs_sum -= self.abs_changes.pop_front().expect("non-empty");
|
||||
}
|
||||
self.abs_changes.push_back(change.abs());
|
||||
self.abs_sum += change.abs();
|
||||
|
||||
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
|
||||
let er = self.efficiency_ratio(price);
|
||||
let fast = 2.0 / 3.0;
|
||||
let slow = 2.0 / 31.0;
|
||||
let sc = (er * (fast - slow) + slow).powi(2);
|
||||
let new_ag = ag + sc * (gain - ag);
|
||||
let new_al = al + sc * (loss - al);
|
||||
self.avg_gain = Some(new_ag);
|
||||
self.avg_loss = Some(new_al);
|
||||
let v = Self::rsi_from_avgs(new_ag, new_al);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
self.seed_gain += gain;
|
||||
self.seed_loss += loss;
|
||||
self.seed_count += 1;
|
||||
if self.seed_count == self.period {
|
||||
let ag = self.seed_gain / self.period as f64;
|
||||
let al = self.seed_loss / self.period as f64;
|
||||
self.avg_gain = Some(ag);
|
||||
self.avg_loss = Some(al);
|
||||
let v = Self::rsi_from_avgs(ag, al);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prices.clear();
|
||||
self.abs_changes.clear();
|
||||
self.abs_sum = 0.0;
|
||||
self.prev = None;
|
||||
self.seed_gain = 0.0;
|
||||
self.seed_loss = 0.0;
|
||||
self.seed_count = 0;
|
||||
self.avg_gain = None;
|
||||
self.avg_loss = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdaptiveRsi"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(AdaptiveRsi::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let r = AdaptiveRsi::new(14).unwrap();
|
||||
assert_eq!(r.period(), 14);
|
||||
assert_eq!(r.warmup_period(), 15);
|
||||
assert_eq!(r.name(), "AdaptiveRsi");
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
let out = r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_one_hundred() {
|
||||
let mut r = AdaptiveRsi::new(5).unwrap();
|
||||
let last = r
|
||||
.batch(&(1..=40).map(f64::from).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
let last = r.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut r = AdaptiveRsi::new(14).unwrap();
|
||||
for v in r
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((0.0..=100.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
let ready = r
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(r.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
r.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(r.is_ready());
|
||||
r.reset();
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
assert_eq!(r.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = AdaptiveRsi::new(14).unwrap().batch(&xs);
|
||||
let mut b = AdaptiveRsi::new(14).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Ehlers Autocorrelation Periodogram — estimates the dominant market cycle.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::roofing_filter::RoofingFilter;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Number of bars averaged into each lagged correlation (Ehlers' `AvgLength`).
|
||||
const AVG_LENGTH: usize = 3;
|
||||
|
||||
/// Ehlers' **Autocorrelation Periodogram** — measures the **dominant cycle
|
||||
/// period** of the market by correlating a roofing-filtered price with lagged
|
||||
/// copies of itself and reading off the spectral peak.
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 8):
|
||||
///
|
||||
/// ```text
|
||||
/// Filt = RoofingFilter(price) (detrend + denoise)
|
||||
/// Corr[lag] = Pearson( Filt[0..AvgLength], Filt[lag..lag+AvgLength] ) for lag = 0..max_period
|
||||
/// for each candidate period:
|
||||
/// power[period] = (Σ Corr[N]·cos(2πN/period))² + (Σ Corr[N]·sin(2πN/period))²
|
||||
/// R[period] = 0.2·power[period] + 0.8·R[period]_{t−1} (EMA across time)
|
||||
/// normalise by a decaying max, then
|
||||
/// DominantCycle = centre-of-gravity of periods whose normalised power ≥ 0.5
|
||||
/// ```
|
||||
///
|
||||
/// The autocorrelation function emphasises whatever cycle is actually present and
|
||||
/// suppresses noise; transforming it into a periodogram and taking the
|
||||
/// power-weighted centre of gravity gives a smooth, robust estimate of the
|
||||
/// dominant cycle length. That cycle is the key input for every *adaptive*
|
||||
/// indicator (adaptive RSI/CCI/stochastic) — set their lookback from it. The
|
||||
/// output is a period in bars within `[min_period, max_period]`.
|
||||
///
|
||||
/// The first value lands after `max_period + AvgLength` inputs. Each `update` is
|
||||
/// O(`max_period²`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, AutocorrelationPeriodogram};
|
||||
/// use std::f64::consts::TAU;
|
||||
///
|
||||
/// let mut indicator = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = indicator.update(100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutocorrelationPeriodogram {
|
||||
min_period: usize,
|
||||
max_period: usize,
|
||||
roof: RoofingFilter,
|
||||
buffer: VecDeque<f64>,
|
||||
r: Vec<f64>,
|
||||
max_pwr: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AutocorrelationPeriodogram {
|
||||
/// Construct an autocorrelation periodogram searching cycles in
|
||||
/// `[min_period, max_period]`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either period is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if `min_period < AvgLength + 1` or
|
||||
/// `max_period <= min_period`.
|
||||
pub fn new(min_period: usize, max_period: usize) -> Result<Self> {
|
||||
if min_period == 0 || max_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if min_period < AVG_LENGTH + 1 || max_period <= min_period {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "autocorrelation periodogram needs AvgLength < min_period < max_period",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
min_period,
|
||||
max_period,
|
||||
roof: RoofingFilter::new(10, max_period)?,
|
||||
buffer: VecDeque::with_capacity(max_period + AVG_LENGTH),
|
||||
r: vec![0.0; max_period + 1],
|
||||
max_pwr: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(min_period, max_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.min_period, self.max_period)
|
||||
}
|
||||
|
||||
/// Current dominant-cycle estimate if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
/// Pearson correlation of the `AvgLength`-deep slices offset by `lag`.
|
||||
/// `buffer` is newest-last; `filt(k)` is the value `k` bars back.
|
||||
fn correlation(&self, lag: usize) -> f64 {
|
||||
let len = self.buffer.len();
|
||||
let filt = |k: usize| self.buffer[len - 1 - k];
|
||||
let m = AVG_LENGTH as f64;
|
||||
let (mut sx, mut sy, mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
for count in 0..AVG_LENGTH {
|
||||
let x = filt(count);
|
||||
let y = filt(lag + count);
|
||||
sx += x;
|
||||
sy += y;
|
||||
sxx += x * x;
|
||||
syy += y * y;
|
||||
sxy += x * y;
|
||||
}
|
||||
let denom = (m * sxx - sx * sx) * (m * syy - sy * sy);
|
||||
if denom > 0.0 {
|
||||
(m * sxy - sx * sy) / denom.sqrt()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AutocorrelationPeriodogram {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let filt = self.roof.update(price)?;
|
||||
if self.buffer.len() == self.max_period + AVG_LENGTH {
|
||||
self.buffer.pop_front();
|
||||
}
|
||||
self.buffer.push_back(filt);
|
||||
if self.buffer.len() < self.max_period + AVG_LENGTH {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Autocorrelation across lags.
|
||||
let mut corr = vec![0.0; self.max_period + 1];
|
||||
for (lag, c) in corr.iter_mut().enumerate() {
|
||||
*c = self.correlation(lag);
|
||||
}
|
||||
|
||||
// Periodogram: spectral power for each candidate period, EMA'd over time.
|
||||
self.max_pwr *= 0.995;
|
||||
for period in self.min_period..=self.max_period {
|
||||
let mut cosine = 0.0;
|
||||
let mut sine = 0.0;
|
||||
for (n, &cn) in corr
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(self.max_period + 1)
|
||||
.skip(AVG_LENGTH)
|
||||
{
|
||||
let angle = TAU * n as f64 / period as f64;
|
||||
cosine += cn * angle.cos();
|
||||
sine += cn * angle.sin();
|
||||
}
|
||||
let power = cosine * cosine + sine * sine;
|
||||
self.r[period] = 0.2 * power + 0.8 * self.r[period];
|
||||
if self.r[period] > self.max_pwr {
|
||||
self.max_pwr = self.r[period];
|
||||
}
|
||||
}
|
||||
|
||||
// Power-weighted centre of gravity of the strong periods.
|
||||
let mut spx = 0.0;
|
||||
let mut sp = 0.0;
|
||||
for period in self.min_period..=self.max_period {
|
||||
let pwr = if self.max_pwr > 0.0 {
|
||||
self.r[period] / self.max_pwr
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if pwr >= 0.5 {
|
||||
spx += period as f64 * pwr;
|
||||
sp += pwr;
|
||||
}
|
||||
}
|
||||
let dominant = if sp > 0.0 {
|
||||
(spx / sp).clamp(self.min_period as f64, self.max_period as f64)
|
||||
} else {
|
||||
self.min_period as f64
|
||||
};
|
||||
self.last = Some(dominant);
|
||||
Some(dominant)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.roof.reset();
|
||||
self.buffer.clear();
|
||||
self.r.iter_mut().for_each(|x| *x = 0.0);
|
||||
self.max_pwr = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.max_period + AVG_LENGTH
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AutocorrelationPeriodogram"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_periods() {
|
||||
assert!(matches!(
|
||||
AutocorrelationPeriodogram::new(0, 48),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
AutocorrelationPeriodogram::new(3, 48),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
AutocorrelationPeriodogram::new(48, 10),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
assert_eq!(p.periods(), (10, 48));
|
||||
assert_eq!(p.warmup_period(), 51);
|
||||
assert_eq!(p.name(), "AutocorrelationPeriodogram");
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut p = AutocorrelationPeriodogram::new(8, 20).unwrap();
|
||||
let xs: Vec<f64> = (0..40)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 12.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out = p.batch(&xs);
|
||||
let warmup = p.warmup_period(); // 23
|
||||
assert_eq!(warmup, 23);
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_within_period_band() {
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
for v in p.batch(&xs).into_iter().flatten() {
|
||||
assert!((10.0..=48.0).contains(&v), "cycle out of band: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_injected_cycle() {
|
||||
// A clean 20-bar sine: the dominant cycle estimate should settle near 20.
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
let xs: Vec<f64> = (0..600)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let last = p.batch(&xs).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
(last - 20.0).abs() < 6.0,
|
||||
"expected ~20-bar cycle, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
p.batch(
|
||||
&(0..80)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let before = p.value();
|
||||
assert_eq!(p.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
p.batch(
|
||||
&(0..120)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let batch = AutocorrelationPeriodogram::new(10, 48).unwrap().batch(&xs);
|
||||
let mut b = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_input_falls_back_to_min_period() {
|
||||
// Constant input has zero variance, so every lag correlation is
|
||||
// degenerate (denom <= 0), the max power is zero and no period clears
|
||||
// the 0.5 threshold -> the dominant cycle defaults to `min_period`.
|
||||
let flat = [100.0_f64; 200];
|
||||
let last = AutocorrelationPeriodogram::new(10, 48)
|
||||
.unwrap()
|
||||
.batch(&flat)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 10.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Ehlers Bandpass Filter — isolates the cyclic component around a target period.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Bandpass Filter — a two-pole resonator that passes the cyclic content
|
||||
/// around a target `period` and rejects both the trend (low frequencies) and the
|
||||
/// noise (high frequencies).
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
|
||||
///
|
||||
/// ```text
|
||||
/// beta = cos(2π / period)
|
||||
/// gamma = 1 / cos(4π · bandwidth / period)
|
||||
/// alpha = gamma − sqrt(gamma² − 1)
|
||||
/// BP_t = 0.5·(1 − alpha)·(price_t − price_{t−2})
|
||||
/// + beta·(1 + alpha)·BP_{t−1} − alpha·BP_{t−2}
|
||||
/// ```
|
||||
///
|
||||
/// `bandwidth` (a fraction, typically `0.3`) sets how wide a band of periods is
|
||||
/// admitted: narrow bandwidth gives a sharp, ringing resonator tuned tightly to
|
||||
/// `period`; wide bandwidth lets more of the spectrum through. The output is a
|
||||
/// zero-mean oscillator — it swings symmetrically around `0`, peaking when the
|
||||
/// dominant cycle aligns with `period`. It is the building block for cycle-phase
|
||||
/// and cycle-amplitude work.
|
||||
///
|
||||
/// The recursion needs two prior prices and two prior outputs; until then it emits
|
||||
/// `0` (Ehlers' initial condition), so `warmup_period` is `1` and a value is
|
||||
/// produced every bar. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, BandpassFilter};
|
||||
///
|
||||
/// let mut indicator = BandpassFilter::new(20, 0.3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BandpassFilter {
|
||||
period: usize,
|
||||
bandwidth: f64,
|
||||
beta: f64,
|
||||
alpha: f64,
|
||||
prev_price_1: Option<f64>,
|
||||
prev_price_2: Option<f64>,
|
||||
bp1: f64,
|
||||
bp2: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl BandpassFilter {
|
||||
/// Construct a bandpass filter tuned to `period` with the given `bandwidth`
|
||||
/// fraction.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::InvalidParameter`] if `bandwidth` is not finite or outside
|
||||
/// `(0, 1)`.
|
||||
pub fn new(period: usize, bandwidth: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !bandwidth.is_finite() || bandwidth <= 0.0 || bandwidth >= 1.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "bandpass bandwidth must be in (0, 1)",
|
||||
});
|
||||
}
|
||||
let period_f = period as f64;
|
||||
let beta = (2.0 * PI / period_f).cos();
|
||||
let gamma = 1.0 / (4.0 * PI * bandwidth / period_f).cos();
|
||||
let alpha = gamma - (gamma * gamma - 1.0).sqrt();
|
||||
Ok(Self {
|
||||
period,
|
||||
bandwidth,
|
||||
beta,
|
||||
alpha,
|
||||
prev_price_1: None,
|
||||
prev_price_2: None,
|
||||
bp1: 0.0,
|
||||
bp2: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, bandwidth)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.period, self.bandwidth)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BandpassFilter {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let bp = match self.prev_price_2 {
|
||||
Some(p2) => {
|
||||
0.5 * (1.0 - self.alpha) * (price - p2) + self.beta * (1.0 + self.alpha) * self.bp1
|
||||
- self.alpha * self.bp2
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
self.prev_price_2 = self.prev_price_1;
|
||||
self.prev_price_1 = Some(price);
|
||||
self.bp2 = self.bp1;
|
||||
self.bp1 = bp;
|
||||
self.last = Some(bp);
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price_1 = None;
|
||||
self.prev_price_2 = None;
|
||||
self.bp1 = 0.0;
|
||||
self.bp2 = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BandpassFilter"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
BandpassFilter::new(0, 0.3),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
BandpassFilter::new(20, 0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
BandpassFilter::new(20, 1.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
assert_eq!(bp.params(), (20, 0.3));
|
||||
assert_eq!(bp.warmup_period(), 1);
|
||||
assert_eq!(bp.name(), "BandpassFilter");
|
||||
assert!(!bp.is_ready());
|
||||
assert_eq!(bp.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bars_are_zero() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
assert_eq!(bp.update(100.0), Some(0.0));
|
||||
assert_eq!(bp.update(101.0), Some(0.0));
|
||||
// From the third bar the recursion is active.
|
||||
assert!(bp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_stays_zero() {
|
||||
// A trend-free flat input has no cyclic content -> output stays 0.
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
for v in bp.batch(&[50.0; 200]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_input_oscillates_around_zero() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (2.0 * PI * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out: Vec<f64> = bp.batch(&xs).into_iter().flatten().skip(100).collect();
|
||||
let mean = out.iter().sum::<f64>() / out.len() as f64;
|
||||
assert!(
|
||||
mean.abs() < 1.0,
|
||||
"bandpass output should be ~zero mean, got {mean}"
|
||||
);
|
||||
assert!(out.iter().any(|&v| v > 0.5));
|
||||
assert!(out.iter().any(|&v| v < -0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
let before = bp.value();
|
||||
assert_eq!(bp.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(bp.is_ready());
|
||||
bp.reset();
|
||||
assert!(!bp.is_ready());
|
||||
assert_eq!(bp.update(100.0), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = BandpassFilter::new(20, 0.3).unwrap().batch(&xs);
|
||||
let mut b = BandpassFilter::new(20, 0.3).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Ehlers Correlation Trend Indicator (CTI) — Pearson correlation of price vs. time.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' **Correlation Trend Indicator** (CTI) — the Pearson correlation
|
||||
/// coefficient between price and a perfectly straight ramp over the lookback.
|
||||
///
|
||||
/// ```text
|
||||
/// CTI = corr( price over the window , [0, 1, …, period−1] )
|
||||
/// ```
|
||||
///
|
||||
/// John Ehlers' CTI asks "how closely does recent price track a straight line?"
|
||||
/// by correlating the windowed price against the time index itself. A reading near
|
||||
/// `+1` means price is rising in a near-perfect line (strong uptrend); near `−1`
|
||||
/// means a clean downtrend; near `0` means no linear trend (a range or choppy
|
||||
/// market). Because correlation is scale- and offset-invariant, the slope's
|
||||
/// steepness does not matter — only how *linear* the move is — which makes CTI an
|
||||
/// unusually clean trend/range classifier. It differs from
|
||||
/// [`Autocorrelation`](crate::Autocorrelation), which correlates price with a
|
||||
/// *lagged copy of itself* rather than with time.
|
||||
///
|
||||
/// The output is in `[−1, +1]`; a flat window (zero price variance) returns `0`.
|
||||
/// The first value lands after `period` inputs; each `update` recomputes the
|
||||
/// correlation over the window in O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, CorrelationTrendIndicator};
|
||||
///
|
||||
/// let mut indicator = CorrelationTrendIndicator::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i)); // a clean uptrend
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CorrelationTrendIndicator {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl CorrelationTrendIndicator {
|
||||
/// Construct a CTI over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs two
|
||||
/// points).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "CTI needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured lookback period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let n = self.period as f64;
|
||||
let mut sum_x = 0.0;
|
||||
let mut sum_xx = 0.0;
|
||||
let mut sum_xt = 0.0;
|
||||
for (i, &x) in self.window.iter().enumerate() {
|
||||
let t = i as f64;
|
||||
sum_x += x;
|
||||
sum_xx += x * x;
|
||||
sum_xt += x * t;
|
||||
}
|
||||
// Time index 0..n-1 has closed-form sums.
|
||||
let sum_t = n * (n - 1.0) / 2.0;
|
||||
let sum_tt = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
|
||||
let cov = n * sum_xt - sum_x * sum_t;
|
||||
let var_x = n * sum_xx - sum_x * sum_x;
|
||||
let var_t = n * sum_tt - sum_t * sum_t;
|
||||
let denom = (var_x * var_t).sqrt();
|
||||
if denom == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(cov / denom).clamp(-1.0, 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CorrelationTrendIndicator {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let out = self.compute();
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CorrelationTrendIndicator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
CorrelationTrendIndicator::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(CorrelationTrendIndicator::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let cti = CorrelationTrendIndicator::new(20).unwrap();
|
||||
assert_eq!(cti.period(), 20);
|
||||
assert_eq!(cti.warmup_period(), 20);
|
||||
assert_eq!(cti.name(), "CorrelationTrendIndicator");
|
||||
assert!(!cti.is_ready());
|
||||
assert_eq!(cti.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
|
||||
let out = cti.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_uptrend_is_one() {
|
||||
let mut cti = CorrelationTrendIndicator::new(10).unwrap();
|
||||
let last = cti
|
||||
.batch(&(0..40).map(f64::from).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_downtrend_is_minus_one() {
|
||||
let mut cti = CorrelationTrendIndicator::new(10).unwrap();
|
||||
let last = cti
|
||||
.batch(&(0..40).map(|i| 100.0 - f64::from(i)).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_zero() {
|
||||
let mut cti = CorrelationTrendIndicator::new(8).unwrap();
|
||||
let last = cti.batch(&[7.0; 16]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut cti = CorrelationTrendIndicator::new(20).unwrap();
|
||||
for v in cti
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((-1.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
|
||||
let ready = cti
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(cti.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
|
||||
cti.batch(&[1.0, 2.0, 3.0, 4.0]);
|
||||
assert!(cti.is_ready());
|
||||
cti.reset();
|
||||
assert!(!cti.is_ready());
|
||||
assert_eq!(cti.value(), None);
|
||||
assert_eq!(cti.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = CorrelationTrendIndicator::new(20).unwrap().batch(&xs);
|
||||
let mut b = CorrelationTrendIndicator::new(20).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Ehlers Even Better Sinewave (EBSW) — a normalised cycle oscillator in [-1, 1].
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::super_smoother::SuperSmoother;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' **Even Better Sinewave** (EBSW) — a self-normalising cycle oscillator
|
||||
/// that swings cleanly in `[−1, +1]` regardless of price amplitude.
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 12):
|
||||
///
|
||||
/// ```text
|
||||
/// alpha1 = (1 − sin(2π/hp_period)) / cos(2π/hp_period)
|
||||
/// HP_t = 0.5·(1 + alpha1)·(price_t − price_{t−1}) + alpha1·HP_{t−1} (one-pole highpass)
|
||||
/// Filt = SuperSmoother(HP, ssf_length)
|
||||
/// Wave = (Filt_t + Filt_{t−1} + Filt_{t−2}) / 3
|
||||
/// Pwr = (Filt_t² + Filt_{t−1}² + Filt_{t−2}²) / 3
|
||||
/// EBSW = Wave / sqrt(Pwr)
|
||||
/// ```
|
||||
///
|
||||
/// The price is first highpass-filtered to remove the trend, then SuperSmoothed to
|
||||
/// remove noise, leaving the dominant cycle. Dividing a 3-bar average of that
|
||||
/// cycle by its RMS power normalises the amplitude, so the output reads like a
|
||||
/// clean sine wave bounded in `[−1, +1]` whatever the instrument. Unlike the
|
||||
/// classic [`SineWave`](crate::SineWave) (which derives in-phase/quadrature
|
||||
/// components from the Hilbert transform and can whip in trends), the EBSW stays
|
||||
/// well-behaved and is read directly: crossing up through `0`/`−0.9` is a buy
|
||||
/// cue, crossing down through `0`/`+0.9` a sell cue.
|
||||
///
|
||||
/// The first value lands once three SuperSmoothed samples exist
|
||||
/// (`warmup_period == 3`). Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, EvenBetterSinewave};
|
||||
///
|
||||
/// let mut indicator = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvenBetterSinewave {
|
||||
hp_period: usize,
|
||||
ssf_length: usize,
|
||||
alpha1: f64,
|
||||
smoother: SuperSmoother,
|
||||
prev_price: Option<f64>,
|
||||
hp: f64,
|
||||
filt1: Option<f64>,
|
||||
filt2: Option<f64>,
|
||||
filt3: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl EvenBetterSinewave {
|
||||
/// Construct an EBSW with the given highpass `hp_period` and SuperSmoother
|
||||
/// `ssf_length`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either argument is `0`.
|
||||
pub fn new(hp_period: usize, ssf_length: usize) -> Result<Self> {
|
||||
if hp_period == 0 || ssf_length == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
let w = 2.0 * PI / hp_period as f64;
|
||||
let alpha1 = (1.0 - w.sin()) / w.cos();
|
||||
Ok(Self {
|
||||
hp_period,
|
||||
ssf_length,
|
||||
alpha1,
|
||||
smoother: SuperSmoother::new(ssf_length)?,
|
||||
prev_price: None,
|
||||
hp: 0.0,
|
||||
filt1: None,
|
||||
filt2: None,
|
||||
filt3: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(hp_period, ssf_length)`.
|
||||
pub const fn params(&self) -> (usize, usize) {
|
||||
(self.hp_period, self.ssf_length)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for EvenBetterSinewave {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let hp = match self.prev_price {
|
||||
Some(prev) => 0.5 * (1.0 + self.alpha1) * (price - prev) + self.alpha1 * self.hp,
|
||||
None => 0.0,
|
||||
};
|
||||
self.prev_price = Some(price);
|
||||
self.hp = hp;
|
||||
let filt = self.smoother.update(hp)?;
|
||||
// Shift the three-deep filter buffer.
|
||||
self.filt3 = self.filt2;
|
||||
self.filt2 = self.filt1;
|
||||
self.filt1 = Some(filt);
|
||||
let (Some(f1), Some(f2), Some(f3)) = (self.filt1, self.filt2, self.filt3) else {
|
||||
return None;
|
||||
};
|
||||
let wave = (f1 + f2 + f3) / 3.0;
|
||||
let pwr = (f1 * f1 + f2 * f2 + f3 * f3) / 3.0;
|
||||
let ebsw = if pwr > 0.0 {
|
||||
(wave / pwr.sqrt()).clamp(-1.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(ebsw);
|
||||
Some(ebsw)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.smoother.reset();
|
||||
self.prev_price = None;
|
||||
self.hp = 0.0;
|
||||
self.filt1 = None;
|
||||
self.filt2 = None;
|
||||
self.filt3 = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"EvenBetterSinewave"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_params() {
|
||||
assert!(matches!(
|
||||
EvenBetterSinewave::new(0, 10),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
EvenBetterSinewave::new(40, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
assert_eq!(e.params(), (40, 10));
|
||||
assert_eq!(e.warmup_period(), 3);
|
||||
assert_eq!(e.name(), "EvenBetterSinewave");
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
let xs: Vec<f64> = (0..12)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 3.0)
|
||||
.collect();
|
||||
let out = e.batch(&xs);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
|
||||
.collect();
|
||||
for v in e.batch(&xs).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v), "EBSW out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_input_swings_both_signs() {
|
||||
let mut e = EvenBetterSinewave::new(30, 8).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out: Vec<f64> = e.batch(&xs).into_iter().flatten().skip(100).collect();
|
||||
assert!(out.iter().any(|&v| v > 0.5));
|
||||
assert!(out.iter().any(|&v| v < -0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
e.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let before = e.value();
|
||||
assert_eq!(e.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
e.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = EvenBetterSinewave::new(40, 10).unwrap().batch(&xs);
|
||||
let mut b = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_input_yields_zero_power() {
|
||||
// A constant series drives the highpass/smoother outputs to zero, so the
|
||||
// signal power is zero and the oscillator reports 0.0 (the `pwr == 0` arm).
|
||||
let flat = [100.0_f64; 200];
|
||||
let last = EvenBetterSinewave::new(40, 10)
|
||||
.unwrap()
|
||||
.batch(&flat)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Ehlers two-pole Highpass Filter — removes the trend, keeps the cycles.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' two-pole Highpass Filter — strips the low-frequency trend from a price
|
||||
/// series, leaving the higher-frequency cyclic and noise content.
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
|
||||
///
|
||||
/// ```text
|
||||
/// a = 0.707 · 2π / period
|
||||
/// alpha1 = (cos(a) + sin(a) − 1) / cos(a)
|
||||
/// HP_t = (1 − alpha1/2)² · (price_t − 2·price_{t−1} + price_{t−2})
|
||||
/// + 2·(1 − alpha1)·HP_{t−1} − (1 − alpha1)²·HP_{t−2}
|
||||
/// ```
|
||||
///
|
||||
/// A highpass filter is the complement of a smoother: where a lowpass keeps the
|
||||
/// trend, the highpass keeps everything *faster* than the cutoff `period`. The
|
||||
/// two-pole design gives a steep roll-off so frequencies below the cutoff are
|
||||
/// firmly removed, detrending the series into a zero-mean wave. This differs from
|
||||
/// the [`Decycler`](crate::Decycler), which is `price − highpass` (the *trend* that
|
||||
/// remains); the highpass is the cyclic part that the decycler discards.
|
||||
///
|
||||
/// The recursion needs two prior prices and two prior outputs; until then it emits
|
||||
/// `0`, so `warmup_period` is `1`. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, HighpassFilter};
|
||||
///
|
||||
/// let mut indicator = HighpassFilter::new(48).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.5).sin() * 3.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HighpassFilter {
|
||||
period: usize,
|
||||
alpha1: f64,
|
||||
prev_price_1: Option<f64>,
|
||||
prev_price_2: Option<f64>,
|
||||
hp1: f64,
|
||||
hp2: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl HighpassFilter {
|
||||
/// Construct a two-pole highpass filter with the given cutoff `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
let a = 0.707 * 2.0 * PI / period as f64;
|
||||
let alpha1 = (a.cos() + a.sin() - 1.0) / a.cos();
|
||||
Ok(Self {
|
||||
period,
|
||||
alpha1,
|
||||
prev_price_1: None,
|
||||
prev_price_2: None,
|
||||
hp1: 0.0,
|
||||
hp2: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured cutoff period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HighpassFilter {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let hp = match (self.prev_price_1, self.prev_price_2) {
|
||||
(Some(p1), Some(p2)) => {
|
||||
let one_minus = 1.0 - self.alpha1;
|
||||
let half = 1.0 - self.alpha1 / 2.0;
|
||||
half * half * (price - 2.0 * p1 + p2) + 2.0 * one_minus * self.hp1
|
||||
- one_minus * one_minus * self.hp2
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
self.prev_price_2 = self.prev_price_1;
|
||||
self.prev_price_1 = Some(price);
|
||||
self.hp2 = self.hp1;
|
||||
self.hp1 = hp;
|
||||
self.last = Some(hp);
|
||||
Some(hp)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price_1 = None;
|
||||
self.prev_price_2 = None;
|
||||
self.hp1 = 0.0;
|
||||
self.hp2 = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HighpassFilter"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(HighpassFilter::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let hp = HighpassFilter::new(48).unwrap();
|
||||
assert_eq!(hp.period(), 48);
|
||||
assert_eq!(hp.warmup_period(), 1);
|
||||
assert_eq!(hp.name(), "HighpassFilter");
|
||||
assert!(!hp.is_ready());
|
||||
assert_eq!(hp.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bars_are_zero() {
|
||||
let mut hp = HighpassFilter::new(48).unwrap();
|
||||
assert_eq!(hp.update(100.0), Some(0.0));
|
||||
assert_eq!(hp.update(101.0), Some(0.0));
|
||||
assert!(hp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_stays_zero() {
|
||||
let mut hp = HighpassFilter::new(48).unwrap();
|
||||
for v in hp.batch(&[50.0; 200]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_trend_is_attenuated() {
|
||||
// A straight ramp is low-frequency -> the highpass should drive its
|
||||
// output small after warmup (the trend is removed).
|
||||
let mut hp = HighpassFilter::new(20).unwrap();
|
||||
let out: Vec<f64> = hp
|
||||
.batch(&(0..400).map(f64::from).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.skip(200)
|
||||
.collect();
|
||||
for v in out {
|
||||
assert!(v.abs() < 5.0, "trend should be attenuated, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut hp = HighpassFilter::new(48).unwrap();
|
||||
hp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
let before = hp.value();
|
||||
assert_eq!(hp.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut hp = HighpassFilter::new(48).unwrap();
|
||||
hp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(hp.is_ready());
|
||||
hp.reset();
|
||||
assert!(!hp.is_ready());
|
||||
assert_eq!(hp.update(100.0), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + f64::from(i) + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = HighpassFilter::new(48).unwrap().batch(&xs);
|
||||
let mut b = HighpassFilter::new(48).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,10 @@ mod acceleration_bands;
|
||||
mod accelerator_oscillator;
|
||||
mod ad_oscillator;
|
||||
mod ad_volume_line;
|
||||
mod adaptive_cci;
|
||||
mod adaptive_cycle;
|
||||
mod adaptive_laguerre_filter;
|
||||
mod adaptive_rsi;
|
||||
mod adl;
|
||||
mod advance_block;
|
||||
mod advance_decline;
|
||||
@@ -39,12 +41,14 @@ mod atr_ratchet;
|
||||
mod atr_trailing_stop;
|
||||
mod auto_fib;
|
||||
mod autocorrelation;
|
||||
mod autocorrelation_periodogram;
|
||||
mod average_daily_range;
|
||||
mod average_drawdown;
|
||||
mod avg_price;
|
||||
mod awesome_oscillator;
|
||||
mod awesome_oscillator_histogram;
|
||||
mod balance_of_power;
|
||||
mod bandpass_filter;
|
||||
mod bat;
|
||||
mod belt_hold;
|
||||
mod beta;
|
||||
@@ -81,6 +85,7 @@ mod concealing_baby_swallow;
|
||||
mod conditional_value_at_risk;
|
||||
mod connors_rsi;
|
||||
mod coppock;
|
||||
mod correlation_trend_indicator;
|
||||
mod counterattack;
|
||||
mod crab;
|
||||
mod cumulative_volume_index;
|
||||
@@ -121,6 +126,7 @@ mod elder_safezone;
|
||||
mod ema;
|
||||
mod empirical_mode_decomposition;
|
||||
mod engulfing;
|
||||
mod even_better_sinewave;
|
||||
mod evening_doji_star;
|
||||
mod evwma;
|
||||
mod ewma_volatility;
|
||||
@@ -166,6 +172,7 @@ mod heikin_ashi;
|
||||
mod high_low_index;
|
||||
mod high_low_range;
|
||||
mod high_wave;
|
||||
mod highpass_filter;
|
||||
mod hikkake;
|
||||
mod hikkake_modified;
|
||||
mod hilbert_dominant_cycle;
|
||||
@@ -300,6 +307,7 @@ mod realized_spread;
|
||||
mod realized_volatility;
|
||||
mod recovery_factor;
|
||||
mod rectangle_range;
|
||||
mod reflex;
|
||||
mod regime_label;
|
||||
mod relative_strength_ab;
|
||||
mod renko_bars;
|
||||
@@ -397,6 +405,7 @@ mod trade_imbalance;
|
||||
mod trade_volume_index;
|
||||
mod trend_label;
|
||||
mod trend_strength_index;
|
||||
mod trendflex;
|
||||
mod treynor_ratio;
|
||||
mod triangle;
|
||||
mod trima;
|
||||
@@ -418,6 +427,7 @@ mod typical_price;
|
||||
mod ulcer_index;
|
||||
mod ultimate_oscillator;
|
||||
mod unique_three_river;
|
||||
mod universal_oscillator;
|
||||
mod up_down_volume_ratio;
|
||||
mod upside_gap_three_methods;
|
||||
mod upside_gap_two_crows;
|
||||
@@ -468,8 +478,10 @@ pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput};
|
||||
pub use accelerator_oscillator::AcceleratorOscillator;
|
||||
pub use ad_oscillator::AdOscillator;
|
||||
pub use ad_volume_line::AdVolumeLine;
|
||||
pub use adaptive_cci::AdaptiveCci;
|
||||
pub use adaptive_cycle::AdaptiveCycle;
|
||||
pub use adaptive_laguerre_filter::AdaptiveLaguerreFilter;
|
||||
pub use adaptive_rsi::AdaptiveRsi;
|
||||
pub use adl::Adl;
|
||||
pub use advance_block::AdvanceBlock;
|
||||
pub use advance_decline::AdvanceDecline;
|
||||
@@ -491,12 +503,14 @@ pub use atr_ratchet::{AtrRatchet, AtrRatchetOutput};
|
||||
pub use atr_trailing_stop::AtrTrailingStop;
|
||||
pub use auto_fib::{AutoFib, AutoFibOutput};
|
||||
pub use autocorrelation::Autocorrelation;
|
||||
pub use autocorrelation_periodogram::AutocorrelationPeriodogram;
|
||||
pub use average_daily_range::AverageDailyRange;
|
||||
pub use average_drawdown::AverageDrawdown;
|
||||
pub use avg_price::AvgPrice;
|
||||
pub use awesome_oscillator::AwesomeOscillator;
|
||||
pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
|
||||
pub use balance_of_power::BalanceOfPower;
|
||||
pub use bandpass_filter::BandpassFilter;
|
||||
pub use bat::Bat;
|
||||
pub use belt_hold::BeltHold;
|
||||
pub use beta::Beta;
|
||||
@@ -533,6 +547,7 @@ pub use concealing_baby_swallow::ConcealingBabySwallow;
|
||||
pub use conditional_value_at_risk::ConditionalValueAtRisk;
|
||||
pub use connors_rsi::ConnorsRsi;
|
||||
pub use coppock::Coppock;
|
||||
pub use correlation_trend_indicator::CorrelationTrendIndicator;
|
||||
pub use counterattack::Counterattack;
|
||||
pub use crab::Crab;
|
||||
pub use cumulative_volume_index::CumulativeVolumeIndex;
|
||||
@@ -573,6 +588,7 @@ pub use elder_safezone::{ElderSafeZone, ElderSafeZoneOutput};
|
||||
pub use ema::Ema;
|
||||
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
|
||||
pub use engulfing::Engulfing;
|
||||
pub use even_better_sinewave::EvenBetterSinewave;
|
||||
pub use evening_doji_star::EveningDojiStar;
|
||||
pub use evwma::Evwma;
|
||||
pub use ewma_volatility::EwmaVolatility;
|
||||
@@ -618,6 +634,7 @@ pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
|
||||
pub use high_low_index::HighLowIndex;
|
||||
pub use high_low_range::HighLowRange;
|
||||
pub use high_wave::HighWave;
|
||||
pub use highpass_filter::HighpassFilter;
|
||||
pub use hikkake::Hikkake;
|
||||
pub use hikkake_modified::HikkakeModified;
|
||||
pub use hilbert_dominant_cycle::HilbertDominantCycle;
|
||||
@@ -752,6 +769,7 @@ pub use realized_spread::RealizedSpread;
|
||||
pub use realized_volatility::RealizedVolatility;
|
||||
pub use recovery_factor::RecoveryFactor;
|
||||
pub use rectangle_range::RectangleRange;
|
||||
pub use reflex::Reflex;
|
||||
pub use regime_label::RegimeLabel;
|
||||
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
|
||||
pub use renko_bars::{RenkoBars, RenkoBrick};
|
||||
@@ -849,6 +867,7 @@ pub use trade_imbalance::TradeImbalance;
|
||||
pub use trade_volume_index::TradeVolumeIndex;
|
||||
pub use trend_label::TrendLabel;
|
||||
pub use trend_strength_index::TrendStrengthIndex;
|
||||
pub use trendflex::Trendflex;
|
||||
pub use treynor_ratio::TreynorRatio;
|
||||
pub use triangle::Triangle;
|
||||
pub use trima::Trima;
|
||||
@@ -870,6 +889,7 @@ pub use typical_price::TypicalPrice;
|
||||
pub use ulcer_index::UlcerIndex;
|
||||
pub use ultimate_oscillator::UltimateOscillator;
|
||||
pub use unique_three_river::UniqueThreeRiver;
|
||||
pub use universal_oscillator::UniversalOscillator;
|
||||
pub use up_down_volume_ratio::UpDownVolumeRatio;
|
||||
pub use upside_gap_three_methods::UpsideGapThreeMethods;
|
||||
pub use upside_gap_two_crows::UpsideGapTwoCrows;
|
||||
@@ -1230,6 +1250,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"EmpiricalModeDecomposition",
|
||||
"EhlersStochastic",
|
||||
"InstantaneousTrendline",
|
||||
"HighpassFilter",
|
||||
"Reflex",
|
||||
"Trendflex",
|
||||
"CorrelationTrendIndicator",
|
||||
"AdaptiveRsi",
|
||||
"UniversalOscillator",
|
||||
"AdaptiveCci",
|
||||
"BandpassFilter",
|
||||
"EvenBetterSinewave",
|
||||
"AutocorrelationPeriodogram",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1510,6 +1540,6 @@ mod family_tests {
|
||||
// the actual indicator count is the early-warning signal that an
|
||||
// indicator was added without being assigned a family.
|
||||
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
|
||||
assert_eq!(total, 452, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 462, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Ehlers Reflex — a zero-lag cycle oscillator built on a SuperSmoother prefilter.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::super_smoother::SuperSmoother;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' **Reflex** — a near-zero-lag oscillator that measures how far the
|
||||
/// smoothed price has deviated from the straight line connecting its endpoints
|
||||
/// over the lookback.
|
||||
///
|
||||
/// From John Ehlers, "Reflex: A New Zero-Lag Indicator" (*Stocks & Commodities*,
|
||||
/// Feb 2020):
|
||||
///
|
||||
/// ```text
|
||||
/// Filt = SuperSmoother(price, period)
|
||||
/// slope = (Filt[period] − Filt[0]) / period (line over the window)
|
||||
/// sum = mean over i=1..period of ( Filt[0] + i·slope − Filt[i] )
|
||||
/// ms = 0.04·sum² + 0.96·ms[−1] (adaptive normaliser)
|
||||
/// Reflex = sum / sqrt(ms) (0 if ms == 0)
|
||||
/// ```
|
||||
///
|
||||
/// Reflex fits a straight line across the SuperSmoothed price over `period` bars
|
||||
/// and averages the deviation of the curve from that line. Because the line uses
|
||||
/// both endpoints, the measure has almost no lag — it crosses zero essentially at
|
||||
/// the cycle turns. The adaptive mean-square normaliser rescales the output to a
|
||||
/// roughly `±3` range regardless of price, so the same thresholds work on any
|
||||
/// instrument. Its sibling [`Trendflex`](crate::Trendflex) uses the deviation from
|
||||
/// the *current* value instead of the line, making it trend- rather than
|
||||
/// cycle-sensitive.
|
||||
///
|
||||
/// The first value lands after `period + 1` SuperSmoothed samples. Each `update`
|
||||
/// is O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Reflex};
|
||||
///
|
||||
/// let mut indicator = Reflex::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Reflex {
|
||||
period: usize,
|
||||
smoother: SuperSmoother,
|
||||
filt: VecDeque<f64>,
|
||||
ms: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Reflex {
|
||||
/// Construct a Reflex with the given lookback `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,
|
||||
smoother: SuperSmoother::new(period)?,
|
||||
filt: VecDeque::with_capacity(period + 1),
|
||||
ms: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured lookback period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Reflex {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let filt = self.smoother.update(price)?;
|
||||
if self.filt.len() == self.period + 1 {
|
||||
self.filt.pop_front();
|
||||
}
|
||||
self.filt.push_back(filt);
|
||||
if self.filt.len() < self.period + 1 {
|
||||
return None;
|
||||
}
|
||||
// Newest at index `period`, oldest (period bars ago) at index 0.
|
||||
let newest = self.filt[self.period];
|
||||
let oldest = self.filt[0];
|
||||
let slope = (oldest - newest) / self.period as f64;
|
||||
let mut sum = 0.0;
|
||||
for i in 1..=self.period {
|
||||
sum += (newest + i as f64 * slope) - self.filt[self.period - i];
|
||||
}
|
||||
sum /= self.period as f64;
|
||||
self.ms = 0.04 * sum * sum + 0.96 * self.ms;
|
||||
let reflex = if self.ms > 0.0 {
|
||||
sum / self.ms.sqrt()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(reflex);
|
||||
Some(reflex)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.smoother.reset();
|
||||
self.filt.clear();
|
||||
self.ms = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Reflex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Reflex::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let r = Reflex::new(20).unwrap();
|
||||
assert_eq!(r.period(), 20);
|
||||
assert_eq!(r.warmup_period(), 21);
|
||||
assert_eq!(r.name(), "Reflex");
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut r = Reflex::new(5).unwrap();
|
||||
let xs: Vec<f64> = (0..12)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 3.0)
|
||||
.collect();
|
||||
let out = r.batch(&xs);
|
||||
for v in out.iter().take(5) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_is_zero() {
|
||||
// A flat price is exactly its own straight line -> zero deviation -> 0.
|
||||
let mut r = Reflex::new(10).unwrap();
|
||||
for v in r.batch(&[50.0; 100]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_input_oscillates_around_zero() {
|
||||
let mut r = Reflex::new(20).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out: Vec<f64> = r.batch(&xs).into_iter().flatten().skip(100).collect();
|
||||
assert!(out.iter().any(|&v| v > 0.5));
|
||||
assert!(out.iter().any(|&v| v < -0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut r = Reflex::new(10).unwrap();
|
||||
r.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let before = r.value();
|
||||
assert_eq!(r.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut r = Reflex::new(10).unwrap();
|
||||
r.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(r.is_ready());
|
||||
r.reset();
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = Reflex::new(20).unwrap().batch(&xs);
|
||||
let mut b = Reflex::new(20).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Ehlers Trendflex — a trend-sensitive sibling of Reflex.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::super_smoother::SuperSmoother;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' **Trendflex** — the trend-sensitive companion to
|
||||
/// [`Reflex`](crate::Reflex): it averages how far the SuperSmoothed price sits
|
||||
/// above or below its values over the lookback, then self-normalises.
|
||||
///
|
||||
/// From John Ehlers, "Reflex: A New Zero-Lag Indicator" (*Stocks & Commodities*,
|
||||
/// Feb 2020):
|
||||
///
|
||||
/// ```text
|
||||
/// Filt = SuperSmoother(price, period)
|
||||
/// sum = mean over i=1..period of ( Filt[0] − Filt[i] )
|
||||
/// ms = 0.04·sum² + 0.96·ms[−1] (adaptive normaliser)
|
||||
/// Trendflex = sum / sqrt(ms) (0 if ms == 0)
|
||||
/// ```
|
||||
///
|
||||
/// Where Reflex measures deviation from the straight *line* across the window
|
||||
/// (cycle sensitive, near zero lag), Trendflex measures deviation from the
|
||||
/// window's *values* (trend sensitive). It stays pinned to one side of zero
|
||||
/// during a trend and oscillates through zero in a range, so it doubles as a
|
||||
/// trend/range gauge. The adaptive mean-square normaliser keeps the output near a
|
||||
/// `±3` band on any instrument.
|
||||
///
|
||||
/// The first value lands after `period + 1` SuperSmoothed samples. Each `update`
|
||||
/// is O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Trendflex};
|
||||
///
|
||||
/// let mut indicator = Trendflex::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Trendflex {
|
||||
period: usize,
|
||||
smoother: SuperSmoother,
|
||||
filt: VecDeque<f64>,
|
||||
ms: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl Trendflex {
|
||||
/// Construct a Trendflex with the given lookback `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,
|
||||
smoother: SuperSmoother::new(period)?,
|
||||
filt: VecDeque::with_capacity(period + 1),
|
||||
ms: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured lookback period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Trendflex {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let filt = self.smoother.update(price)?;
|
||||
if self.filt.len() == self.period + 1 {
|
||||
self.filt.pop_front();
|
||||
}
|
||||
self.filt.push_back(filt);
|
||||
if self.filt.len() < self.period + 1 {
|
||||
return None;
|
||||
}
|
||||
let newest = self.filt[self.period];
|
||||
let mut sum = 0.0;
|
||||
for i in 1..=self.period {
|
||||
sum += newest - self.filt[self.period - i];
|
||||
}
|
||||
sum /= self.period as f64;
|
||||
self.ms = 0.04 * sum * sum + 0.96 * self.ms;
|
||||
let trendflex = if self.ms > 0.0 {
|
||||
sum / self.ms.sqrt()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(trendflex);
|
||||
Some(trendflex)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.smoother.reset();
|
||||
self.filt.clear();
|
||||
self.ms = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Trendflex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Trendflex::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = Trendflex::new(20).unwrap();
|
||||
assert_eq!(t.period(), 20);
|
||||
assert_eq!(t.warmup_period(), 21);
|
||||
assert_eq!(t.name(), "Trendflex");
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut t = Trendflex::new(5).unwrap();
|
||||
let xs: Vec<f64> = (0..12).map(f64::from).collect();
|
||||
let out = t.batch(&xs);
|
||||
for v in out.iter().take(5) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_is_zero() {
|
||||
let mut t = Trendflex::new(10).unwrap();
|
||||
for v in t.batch(&[50.0; 100]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_is_positive() {
|
||||
// A steady rise keeps the current filtered value above its past values.
|
||||
let mut t = Trendflex::new(10).unwrap();
|
||||
let out: Vec<f64> = t
|
||||
.batch(&(0..200).map(f64::from).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.skip(100)
|
||||
.collect();
|
||||
for v in out {
|
||||
assert!(v > 0.0, "uptrend should be positive, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_is_negative() {
|
||||
let mut t = Trendflex::new(10).unwrap();
|
||||
let out: Vec<f64> = t
|
||||
.batch(&(0..200).map(|i| 200.0 - f64::from(i)).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.skip(100)
|
||||
.collect();
|
||||
for v in out {
|
||||
assert!(v < 0.0, "downtrend should be negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut t = Trendflex::new(10).unwrap();
|
||||
t.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
let before = t.value();
|
||||
assert_eq!(t.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = Trendflex::new(10).unwrap();
|
||||
t.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
assert_eq!(t.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = Trendflex::new(20).unwrap().batch(&xs);
|
||||
let mut b = Trendflex::new(20).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Ehlers Universal Oscillator — whitened, SuperSmoothed, AGC-normalised cycle.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::super_smoother::SuperSmoother;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' **Universal Oscillator** — a cycle oscillator that whitens the price
|
||||
/// series, SuperSmooths it, then normalises with an automatic gain control (AGC)
|
||||
/// to swing in `[−1, +1]`.
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
|
||||
///
|
||||
/// ```text
|
||||
/// WhiteNoise = (price_t − price_{t−2}) / 2 (flat-spectrum prewhitening)
|
||||
/// Filt = SuperSmoother(WhiteNoise, period)
|
||||
/// Peak = max(|Filt|, 0.991 · Peak_{t−1}) (decaying peak / AGC)
|
||||
/// Universal = Filt / Peak (0 if Peak == 0)
|
||||
/// ```
|
||||
///
|
||||
/// "Whitening" the input (a two-bar difference) flattens its power spectrum so the
|
||||
/// SuperSmoother responds equally to all cycles rather than being dominated by the
|
||||
/// trend. The automatic gain control divides by a slowly-decaying running peak, so
|
||||
/// the output is amplitude-normalised to `[−1, +1]` and behaves consistently
|
||||
/// across instruments and volatility regimes — hence "universal". Read it like any
|
||||
/// bounded oscillator: turns near the rails flag cycle extremes, zero-crossings
|
||||
/// flag cycle direction changes.
|
||||
///
|
||||
/// The first value lands once a two-bar difference exists (`warmup_period == 3`).
|
||||
/// Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, UniversalOscillator};
|
||||
///
|
||||
/// let mut indicator = UniversalOscillator::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UniversalOscillator {
|
||||
period: usize,
|
||||
smoother: SuperSmoother,
|
||||
prev_price_1: Option<f64>,
|
||||
prev_price_2: Option<f64>,
|
||||
peak: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl UniversalOscillator {
|
||||
/// Construct a Universal Oscillator with the given SuperSmoother `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,
|
||||
smoother: SuperSmoother::new(period)?,
|
||||
prev_price_1: None,
|
||||
prev_price_2: None,
|
||||
peak: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for UniversalOscillator {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let Some(p2) = self.prev_price_2 else {
|
||||
self.prev_price_2 = self.prev_price_1;
|
||||
self.prev_price_1 = Some(price);
|
||||
return None;
|
||||
};
|
||||
let white_noise = (price - p2) / 2.0;
|
||||
if !white_noise.is_finite() {
|
||||
// `price - p2` can overflow to +/-inf even when both are finite;
|
||||
// skip the bar rather than feeding a non-finite value downstream.
|
||||
self.prev_price_2 = self.prev_price_1;
|
||||
self.prev_price_1 = Some(price);
|
||||
return self.last;
|
||||
}
|
||||
let filt = self
|
||||
.smoother
|
||||
.update(white_noise)
|
||||
.expect("supersmoother emits");
|
||||
self.peak = filt.abs().max(0.991 * self.peak);
|
||||
let universal = if self.peak > 0.0 {
|
||||
(filt / self.peak).clamp(-1.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.prev_price_2 = self.prev_price_1;
|
||||
self.prev_price_1 = Some(price);
|
||||
self.last = Some(universal);
|
||||
Some(universal)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.smoother.reset();
|
||||
self.prev_price_1 = None;
|
||||
self.prev_price_2 = None;
|
||||
self.peak = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"UniversalOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
UniversalOscillator::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let u = UniversalOscillator::new(20).unwrap();
|
||||
assert_eq!(u.period(), 20);
|
||||
assert_eq!(u.warmup_period(), 3);
|
||||
assert_eq!(u.name(), "UniversalOscillator");
|
||||
assert!(!u.is_ready());
|
||||
assert_eq!(u.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
let out = u.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_is_zero() {
|
||||
// A flat input whitens to zero -> output 0.
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
for v in u.batch(&[50.0; 200]).into_iter().flatten() {
|
||||
assert!(v.abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
for v in u.batch(&xs).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v), "out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_input_swings_both_signs() {
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out: Vec<f64> = u.batch(&xs).into_iter().flatten().skip(100).collect();
|
||||
assert!(out.iter().any(|&v| v > 0.5));
|
||||
assert!(out.iter().any(|&v| v < -0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
u.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let before = u.value();
|
||||
assert_eq!(u.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
u.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(u.is_ready());
|
||||
u.reset();
|
||||
assert!(!u.is_ready());
|
||||
assert_eq!(u.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = UniversalOscillator::new(20).unwrap().batch(&xs);
|
||||
let mut b = UniversalOscillator::new(20).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_white_noise_is_skipped() {
|
||||
// `price - p2` can overflow to infinity even when both prices are
|
||||
// finite; the non-finite white-noise term must be skipped, not fed to
|
||||
// the smoother (which would otherwise yield `None` on the first bar).
|
||||
let mut u = UniversalOscillator::new(20).unwrap();
|
||||
assert_eq!(u.update(-1e308), None);
|
||||
assert_eq!(u.update(0.0), None);
|
||||
// (1e308 - (-1e308)) overflows to +inf -> white_noise non-finite.
|
||||
assert_eq!(u.update(1e308), None);
|
||||
}
|
||||
}
|
||||
@@ -57,12 +57,13 @@ pub use derivatives::DerivativesTick;
|
||||
pub use error::{Error, Result};
|
||||
pub use indicators::{
|
||||
AbandonedBaby, Abcd, AbsoluteBreadthIndex, AccelerationBands, AccelerationBandsOutput,
|
||||
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, AdaptiveLaguerreFilter, Adl,
|
||||
AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator,
|
||||
AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi, AnchoredVwap, Apo, Aroon,
|
||||
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput,
|
||||
AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown,
|
||||
AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
|
||||
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCci, AdaptiveCycle,
|
||||
AdaptiveLaguerreFilter, AdaptiveRsi, Adl, AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio,
|
||||
Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi,
|
||||
AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput,
|
||||
AtrRatchet, AtrRatchetOutput, AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation,
|
||||
AutocorrelationPeriodogram, AverageDailyRange, AverageDrawdown, AvgPrice, AwesomeOscillator,
|
||||
AwesomeOscillatorHistogram, BalanceOfPower, BandpassFilter, Bat, BeltHold, Beta,
|
||||
BetaNeutralSpread, BetterVolume, BipowerVariation, BodySizePct, BollingerBands,
|
||||
BollingerBandwidth, BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway,
|
||||
BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput,
|
||||
@@ -70,29 +71,30 @@ pub use indicators::{
|
||||
ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex,
|
||||
ClassicPivots, ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation,
|
||||
Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi,
|
||||
Coppock, Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle,
|
||||
CyberneticCycle, Cypher, DayOfWeekProfile, DayOfWeekProfileOutput, Decycler,
|
||||
DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope,
|
||||
DerivativeOscillator, DetrendedStdDev, DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian,
|
||||
DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput,
|
||||
DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx,
|
||||
DynamicMomentumIndex, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse,
|
||||
ElderRay, ElderRayOutput, ElderSafeZone, ElderSafeZoneOutput, Ema, EmpiricalModeDecomposition,
|
||||
Engulfing, EveningDojiStar, Evwma, EwmaVolatility, Expectancy, FallingThreeMethods, Fama,
|
||||
FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput,
|
||||
FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput,
|
||||
FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots,
|
||||
FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint, FootprintOutput,
|
||||
ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate,
|
||||
FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, Garch11,
|
||||
GarmanKlassVolatility, Gartley, GatorOscillator, GatorOscillatorOutput, GeneralizedDema,
|
||||
GeometricMa, GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer,
|
||||
HangingMan, Harami, HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator,
|
||||
HighLowIndex, HighLowRange, HighWave, Hikkake, HikkakeModified, HilbertDominantCycle,
|
||||
HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput,
|
||||
HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput,
|
||||
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
|
||||
InstantaneousTrendline, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile,
|
||||
Coppock, CorrelationTrendIndicator, Counterattack, Crab, CumulativeVolumeDelta,
|
||||
CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher, DayOfWeekProfile,
|
||||
DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots,
|
||||
DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev, DisparityIndex,
|
||||
DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput,
|
||||
DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom, DownsideGapThreeMethods, Dpo,
|
||||
DragonflyDoji, DrawdownDuration, Dx, DynamicMomentumIndex, EaseOfMovement, EffectiveSpread,
|
||||
EhlersStochastic, Ehma, ElderImpulse, ElderRay, ElderRayOutput, ElderSafeZone,
|
||||
ElderSafeZoneOutput, Ema, EmpiricalModeDecomposition, Engulfing, EvenBetterSinewave,
|
||||
EveningDojiStar, Evwma, EwmaVolatility, Expectancy, FallingThreeMethods, Fama, FibArcs,
|
||||
FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput, FibExtension,
|
||||
FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput, FibRetracement,
|
||||
FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots, FibonacciPivotsOutput,
|
||||
FisherRsi, FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex,
|
||||
FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean,
|
||||
FundingRateZScore, GainLossRatio, GapSideBySideWhite, Garch11, GarmanKlassVolatility, Gartley,
|
||||
GatorOscillator, GatorOscillatorOutput, GeneralizedDema, GeometricMa, GoldenPocket,
|
||||
GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami,
|
||||
HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange,
|
||||
HighWave, HighpassFilter, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility,
|
||||
Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
|
||||
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
|
||||
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
|
||||
IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile,
|
||||
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, JarqueBera, Jma,
|
||||
JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop,
|
||||
KaseDevStopOutput, KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion,
|
||||
@@ -115,37 +117,38 @@ pub use indicators::{
|
||||
PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, ProjectionBands,
|
||||
ProjectionBandsOutput, ProjectionOscillator, Psar, Pvi, Qqe, QqeOutput, Qstick, QuartileBands,
|
||||
QuartileBandsOutput, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
|
||||
RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput,
|
||||
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100,
|
||||
RogersSatchellVolatility, RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr,
|
||||
RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi,
|
||||
Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SampleEntropy, SarExt, SeasonalZScore,
|
||||
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
|
||||
SessionVwap, ShannonEntropy, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume,
|
||||
SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation,
|
||||
SpinningTop, SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput,
|
||||
SpreadHurst, StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput,
|
||||
StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi,
|
||||
Stochastic, StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
|
||||
TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential,
|
||||
TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei,
|
||||
TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema,
|
||||
TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside,
|
||||
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop,
|
||||
TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
|
||||
TradeImbalance, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle,
|
||||
Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze,
|
||||
TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice,
|
||||
UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods,
|
||||
UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio,
|
||||
VerticalHorizontalFilter, Vidya, VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility,
|
||||
VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator,
|
||||
VolumePriceTrend, VolumeProfile, VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd,
|
||||
VolumeWeightedMacdOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
|
||||
VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm, WaveTrend, WaveTrendOutput, Wedge,
|
||||
WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma,
|
||||
WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
|
||||
ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
RecoveryFactor, RectangleRange, Reflex, RegimeLabel, RelativeStrengthAB,
|
||||
RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi,
|
||||
Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure, RollingCorrelation,
|
||||
RollingCovariance, RollingIqr, RollingMinMaxScaler, RollingPercentileRank, RollingQuantile,
|
||||
RollingVwap, RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SampleEntropy,
|
||||
SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange,
|
||||
SessionRangeOutput, SessionVwap, ShannonEntropy, Shark, SharpeRatio, ShootingStar, ShortLine,
|
||||
SignedVolume, SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio,
|
||||
SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient, SpreadBollingerBands,
|
||||
SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, StandardErrorBands,
|
||||
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
|
||||
StickSandwich, StochRsi, Stochastic, StochasticCci, StochasticOutput, SuperSmoother,
|
||||
SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown,
|
||||
TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
|
||||
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
|
||||
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
|
||||
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex,
|
||||
Tii, TimeBasedStop, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile,
|
||||
TpoProfileOutput, TradeImbalance, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, Trendflex,
|
||||
TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi,
|
||||
Tsv, TtmSqueeze, TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows,
|
||||
TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UniversalOscillator,
|
||||
UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput,
|
||||
ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya, VolatilityCone,
|
||||
VolatilityConeOutput, VolatilityOfVolatility, VolatilityRatio, VoltyStop, VolumeByTimeProfile,
|
||||
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
|
||||
VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd, VolumeWeightedMacdOutput, Vortex,
|
||||
VortexOutput, Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm,
|
||||
WaveTrend, WaveTrendOutput, Wedge, WeightedClose, WickRatio, WilliamsFractals,
|
||||
WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots, WoodiePivotsOutput,
|
||||
YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput,
|
||||
Zlema, FAMILIES, T3,
|
||||
};
|
||||
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
|
||||
// line so the indicator-count tooling (which scans the braced block above and
|
||||
|
||||
Reference in New Issue
Block a user