feat: TA-Lib parity — 19 standalone indicators (DM components, price transforms, ROC/LinReg/MACD/SAR variants, Hilbert outputs) (#148)

Closes the remaining TA-Lib function-name gap by shipping each missing or
bundled-only function as a real, standalone, fully-covered indicator. 19 new
indicators across 5 families; mod-count 295 -> 314.

### Trend & Directional — Directional Movement components
- `PlusDm` (`PLUS_DM`), `MinusDm` (`MINUS_DM`) — Wilder-smoothed ±DM.
- `PlusDi` (`PLUS_DI`), `MinusDi` (`MINUS_DI`) — `100·smoothed(±DM)/ATR`.
- `Dx` (`DX`) — `100·|+DI−−DI|/(+DI+−DI)`.

### Price Statistics
- `AvgPrice` (`AVGPRICE`) — `(O+H+L+C)/4`.
- `MidPoint` (`MIDPOINT`) — `(max+min)/2` of a scalar series over N.
- `MidPrice` (`MIDPRICE`) — `(highestHigh+lowestLow)/2` over N.
- `LinRegIntercept` (`LINEARREG_INTERCEPT`) — OLS intercept.
- `Tsf` (`TSF`) — time series forecast `a + b·period`.

### Momentum Oscillators
- `Rocp` (`ROCP`), `Rocr` (`ROCR`), `Rocr100` (`ROCR100`) — ROC ratio forms.

### Trailing Stops
- `SarExt` (`SAREXT`) — Parabolic SAR with start value, reversal offset,
  separate long/short acceleration, signed output.

### Trend & Directional — MACD variants
- `MacdFix` (`MACDFIX`) — MACD fixed 12/26.
- `MacdExt` (`MACDEXT`) — MACD with a selectable moving-average type per line
  (new public `MaType` enum: SMA/EMA/WMA/DEMA/TEMA/TRIMA).

### Ehlers / Cycle (DSP) — Hilbert transform outputs
- `HtPhasor` (`HT_PHASOR`) — in-phase / quadrature components.
- `HtDcPhase` (`HT_DCPHASE`) — dominant-cycle phase (degrees).
- `HtTrendMode` (`HT_TRENDMODE`) — trend (1) vs cycle (0) classification.

Each indicator ships the full chain: core + every-branch unit tests, Python /
Node / WASM bindings, fuzz coverage, README counter + family rows, CHANGELOG.
`cargo test`, doctests, `clippy -D warnings`, `npm test` and pytest all green
locally; mod-count == lib-block == README counter (314), FAMILIES total 309.
This commit is contained in:
kingchenc
2026-06-03 02:26:38 +02:00
committed by GitHub
parent 9a98e9bf55
commit 9eb46f144a
36 changed files with 6869 additions and 51 deletions
+1 -1
View File
@@ -91,7 +91,7 @@ impl Adx {
}
}
fn directional_movement(prev: &Candle, current: &Candle) -> (f64, f64) {
pub(crate) fn directional_movement(prev: &Candle, current: &Candle) -> (f64, f64) {
let up = current.high - prev.high;
let down = prev.low - current.low;
let plus_dm = if up > down && up > 0.0 { up } else { 0.0 };
@@ -0,0 +1,92 @@
//! Average Price (AVGPRICE).
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Average Price (`AVGPRICE`) — the bar's `(open + high + low + close) / 4`.
///
/// A per-bar price aggregate that, unlike [`TypicalPrice`](crate::TypicalPrice)
/// and [`WeightedClose`](crate::WeightedClose), folds in the open as well as the
/// high, low and close. As a stateless transform it emits a value from the very
/// first candle.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AvgPrice};
///
/// let mut indicator = AvgPrice::new();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct AvgPrice {
has_emitted: bool,
}
impl AvgPrice {
/// Construct a new Average Price transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for AvgPrice {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
Some(candle.avg_price())
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AVGPRICE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn averages_the_four_prices() {
// (open + high + low + close) / 4 = (10 + 14 + 6 + 12) / 4 = 10.5.
let candle = Candle::new(10.0, 14.0, 6.0, 12.0, 1.0, 0).unwrap();
let mut ap = AvgPrice::new();
assert!(!ap.is_ready());
assert_relative_eq!(ap.update(candle).unwrap(), 10.5, epsilon = 1e-12);
assert!(ap.is_ready());
}
#[test]
fn accessors_and_reset() {
let mut ap = AvgPrice::new();
assert_eq!(ap.name(), "AVGPRICE");
assert_eq!(ap.warmup_period(), 1);
let candle = Candle::new(10.0, 14.0, 6.0, 12.0, 1.0, 0).unwrap();
let _ = ap.update(candle);
assert!(ap.is_ready());
ap.reset();
assert!(!ap.is_ready());
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Directional Movement Index (DX), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Directional Movement Index (`DX`).
///
/// `DX = 100 · |+DI DI| / (+DI + DI)`, the un-smoothed precursor to
/// [`Adx`](crate::Adx) (which is the Wilder average of `DX`). Both directional
/// indicators are derived from Wilder-smoothed `+DM`, `DM` and true range over
/// `period` bars, so the first value is emitted after `period + 1` candles.
///
/// `DX` ranges over `[0, 100]`: high when one side of the directional system
/// clearly dominates (a strong trend) and near zero when `+DI` and `DI` are
/// balanced (a range). When both directional indicators are zero — a perfectly
/// flat market — the index returns `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Dx};
///
/// let mut indicator = Dx::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Dx {
period: usize,
prev: Option<Candle>,
plus_dm_seed: f64,
minus_dm_seed: f64,
tr_seed: f64,
seed_count: usize,
plus_dm_smooth: Option<f64>,
minus_dm_smooth: Option<f64>,
tr_smooth: Option<f64>,
}
impl Dx {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
plus_dm_seed: 0.0,
minus_dm_seed: 0.0,
tr_seed: 0.0,
seed_count: 0,
plus_dm_smooth: None,
minus_dm_smooth: None,
tr_smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Dx {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (plus_dm, minus_dm) = directional_movement(&prev, &candle);
let tr = candle.true_range(Some(prev.close));
let n = self.period as f64;
let (plus_v, minus_v, tr_v) = if let (Some(p), Some(m), Some(t)) =
(self.plus_dm_smooth, self.minus_dm_smooth, self.tr_smooth)
{
let p_new = p - p / n + plus_dm;
let m_new = m - m / n + minus_dm;
let t_new = t - t / n + tr;
self.plus_dm_smooth = Some(p_new);
self.minus_dm_smooth = Some(m_new);
self.tr_smooth = Some(t_new);
(p_new, m_new, t_new)
} else {
self.plus_dm_seed += plus_dm;
self.minus_dm_seed += minus_dm;
self.tr_seed += tr;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.plus_dm_smooth = Some(self.plus_dm_seed);
self.minus_dm_smooth = Some(self.minus_dm_seed);
self.tr_smooth = Some(self.tr_seed);
(self.plus_dm_seed, self.minus_dm_seed, self.tr_seed)
};
let (plus_di, minus_di) = if tr_v == 0.0 {
(0.0, 0.0)
} else {
(100.0 * plus_v / tr_v, 100.0 * minus_v / tr_v)
};
let di_sum = plus_di + minus_di;
let dx = if di_sum == 0.0 {
0.0
} else {
100.0 * (plus_di - minus_di).abs() / di_sum
};
Some(dx)
}
fn reset(&mut self) {
self.prev = None;
self.plus_dm_seed = 0.0;
self.minus_dm_seed = 0.0;
self.tr_seed = 0.0;
self.seed_count = 0;
self.plus_dm_smooth = None;
self.minus_dm_smooth = None;
self.tr_smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.tr_smooth.is_some()
}
fn name(&self) -> &'static str {
"DX"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(Dx::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let dx = Dx::new(7).unwrap();
assert_eq!(dx.period(), 7);
assert_eq!(dx.name(), "DX");
assert_eq!(dx.warmup_period(), 7);
assert!(!dx.is_ready());
}
#[test]
fn strong_trend_drives_dx_high() {
// A clean uptrend has one-sided directional movement, so DX is large.
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut dx = Dx::new(3).unwrap();
let out: Vec<Option<f64>> = dx.batch(&candles);
assert_eq!(out[0], None);
assert!(out[3].is_some());
let last = out.into_iter().flatten().last().unwrap();
assert!(last > 50.0 && last <= 100.0);
assert!(dx.is_ready());
}
#[test]
fn flat_market_returns_zero() {
// Both directional indicators collapse to zero -> DX is zero.
let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
let mut dx = Dx::new(3).unwrap();
let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn balanced_directional_movement_is_low() {
// Alternating up and down bars of equal magnitude keep +DI and -DI close,
// so DX stays well below a trending reading.
let candles: Vec<Candle> = (0..30)
.map(|i| {
let base = if i % 2 == 0 { 100.0 } else { 101.0 };
c(base + 1.0, base - 1.0, base)
})
.collect();
let mut dx = Dx::new(5).unwrap();
let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
assert!((0.0..=100.0).contains(&last));
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..6)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut dx = Dx::new(3).unwrap();
let _ = dx.batch(&candles);
assert!(dx.is_ready());
dx.reset();
assert!(!dx.is_ready());
assert_eq!(dx.update(candles[0]), None);
}
}
@@ -0,0 +1,278 @@
//! Ehlers Hilbert Transform Dominant Cycle Phase (`HT_DCPHASE`).
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// Ehlers' Hilbert Transform Dominant Cycle Phase (`HT_DCPHASE`).
///
/// Runs the same adaptive Hilbert-transform engine as
/// [`HilbertDominantCycle`](crate::HilbertDominantCycle) to recover the dominant
/// cycle period, then measures the **phase angle** of that cycle (in degrees) by
/// correlating the smoothed price over one dominant-cycle window against a unit
/// phasor. The phase advances roughly linearly through a clean cycle and stalls
/// in a trend, which is the basis of Ehlers' trend-versus-cycle detection.
///
/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
/// `HT_DCPHASE`. The first value is emitted after ~50 inputs, once the engine's
/// moving-average chain has filled.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HtDcPhase};
///
/// let mut ht = HtDcPhase::new();
/// let mut last = None;
/// for i in 0..120 {
/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HtDcPhase {
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
// Longer history of the 4-bar smoothed price, used to integrate the phase
// over one dominant-cycle window (up to 50 bars).
smooth_price: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
prev_smooth_period: f64,
count: usize,
last_value: Option<f64>,
}
impl HtDcPhase {
/// Construct a new Hilbert transform dominant-cycle phase estimator.
pub fn new() -> Self {
Self::default()
}
/// Current dominant-cycle phase (degrees) if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for HtDcPhase {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 7 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
Self::push_front(&mut self.smooth_price, smooth, 50);
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
if self.count < 50 {
return None;
}
// Integrate the smoothed price over one dominant-cycle window against a
// unit phasor to recover the instantaneous dominant-cycle phase.
let smooth_period = self.prev_smooth_period;
let dc_period = (smooth_period + 0.5) as usize;
let dc_period = dc_period.clamp(1, self.smooth_price.len());
let mut real_part = 0.0;
let mut imag_part = 0.0;
for i in 0..dc_period {
let angle = (i as f64) * 2.0 * PI / (dc_period as f64);
let sp = self.smooth_price[i];
real_part += angle.sin() * sp;
imag_part += angle.cos() * sp;
}
let mut dc_phase = if imag_part.abs() > 0.001 {
(real_part / imag_part).atan().to_degrees()
} else if real_part < 0.0 {
-90.0
} else {
90.0
};
dc_phase += 90.0;
// Compensate the group delay of the 4-bar weighted smoother.
dc_phase += 360.0 / smooth_period;
if imag_part < 0.0 {
dc_phase += 180.0;
}
if dc_phase > 315.0 {
dc_phase -= 360.0;
}
self.last_value = Some(dc_phase);
Some(dc_phase)
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.smooth_price.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.prev_smooth_period = 0.0;
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
50
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"HT_DCPHASE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn sine_prices(n: usize) -> Vec<f64> {
(0..n)
.map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0)
.collect()
}
#[test]
fn accessors_and_metadata() {
let ht = HtDcPhase::new();
assert_eq!(ht.warmup_period(), 50);
assert_eq!(ht.name(), "HT_DCPHASE");
assert!(!ht.is_ready());
}
#[test]
fn emits_after_warmup_within_phase_band() {
let mut ht = HtDcPhase::new();
let out: Vec<Option<f64>> = ht.batch(&sine_prices(200));
assert_eq!(out[0], None);
assert!(ht.is_ready());
for v in out.into_iter().flatten() {
assert!(v.is_finite(), "phase must be finite");
assert!((-360.0..=360.0).contains(&v), "phase {v} outside band");
}
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HtDcPhase::new();
let _ = ht.batch(&sine_prices(120));
let before = ht.value();
assert_eq!(ht.update(f64::NAN), before);
}
#[test]
fn batch_equals_streaming() {
let prices = sine_prices(200);
let mut a = HtDcPhase::new();
let mut b = HtDcPhase::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut ht = HtDcPhase::new();
let _ = ht.batch(&sine_prices(120));
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert_eq!(ht.update(100.0), None);
}
}
@@ -0,0 +1,240 @@
//! Ehlers Hilbert Transform Phasor components (`HT_PHASOR`).
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// In-phase and quadrature components of the Hilbert transform phasor.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HtPhasorOutput {
/// In-phase component (`I1`).
pub inphase: f64,
/// Quadrature component (`Q1`).
pub quadrature: f64,
}
/// Ehlers' Hilbert Transform Phasor (`HT_PHASOR`).
///
/// Runs the same adaptive Hilbert-transform engine as
/// [`HilbertDominantCycle`](crate::HilbertDominantCycle) but reports the raw
/// in-phase (`I1`) and quadrature (`Q1`) components of the analytic signal rather
/// than the recovered cycle period. The two components are 90° out of phase, so
/// their ratio tracks the instantaneous phase of the dominant cycle.
///
/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
/// `HT_PHASOR`. The first value is emitted once the transform's tap buffers fill.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HtPhasor};
///
/// let mut ht = HtPhasor::new();
/// let mut last = None;
/// for i in 0..120 {
/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HtPhasor {
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
ready: bool,
}
impl HtPhasor {
/// Construct a new Hilbert transform phasor.
pub fn new() -> Self {
Self::default()
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for HtPhasor {
type Input = f64;
type Output = HtPhasorOutput;
fn update(&mut self, input: f64) -> Option<HtPhasorOutput> {
if !input.is_finite() {
return None;
}
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 7 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
// Continue the dominant-cycle period adaptation so the next bar's `adj`
// coefficient tracks the cycle, exactly as TA-Lib's HT_PHASOR does.
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
self.ready = true;
Some(HtPhasorOutput {
inphase: i1,
quadrature: q1,
})
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.ready = false;
}
fn warmup_period(&self) -> usize {
19
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"HT_PHASOR"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn sine_prices(n: usize) -> Vec<f64> {
(0..n)
.map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0)
.collect()
}
#[test]
fn accessors_and_metadata() {
let ht = HtPhasor::new();
assert_eq!(ht.warmup_period(), 19);
assert_eq!(ht.name(), "HT_PHASOR");
assert!(!ht.is_ready());
}
#[test]
fn emits_after_warmup_and_stays_finite() {
let mut ht = HtPhasor::new();
let out: Vec<Option<HtPhasorOutput>> = ht.batch(&sine_prices(120));
assert_eq!(out[0], None);
let first = out.iter().position(Option::is_some).expect("emits");
assert!(first <= 19, "first phasor at index {first}");
for o in out.into_iter().flatten() {
assert!(o.inphase.is_finite() && o.quadrature.is_finite());
}
assert!(ht.is_ready());
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HtPhasor::new();
let _ = ht.batch(&sine_prices(120));
// A non-finite input is skipped and produces no value.
assert_eq!(ht.update(f64::NAN), None);
}
#[test]
fn batch_equals_streaming() {
let prices = sine_prices(150);
let mut a = HtPhasor::new();
let mut b = HtPhasor::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut ht = HtPhasor::new();
let _ = ht.batch(&sine_prices(120));
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert_eq!(ht.update(100.0), None);
}
}
@@ -0,0 +1,354 @@
//! Ehlers Hilbert Transform Trend vs Cycle Mode (`HT_TRENDMODE`).
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// Ehlers' Hilbert Transform Trend Mode (`HT_TRENDMODE`).
///
/// Runs the same adaptive Hilbert-transform engine as
/// [`HilbertDominantCycle`](crate::HilbertDominantCycle), derives the dominant
/// cycle phase, its sine / lead-sine, and an instantaneous trendline, then
/// classifies the market into **trend mode (`1`)** or **cycle mode (`0`)**:
///
/// - it is a *cycle* shortly after the sine and lead-sine cross, while the phase
/// advances at roughly the dominant-cycle rate;
/// - it is a *trend* otherwise, and is forced to trend whenever price separates
/// from the trendline by more than 1.5%.
///
/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
/// `HT_TRENDMODE`. The output is `1.0` or `0.0`; the first value is emitted after
/// ~50 inputs once the engine's moving-average chain has filled.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HtTrendMode};
///
/// let mut ht = HtTrendMode::new();
/// let mut last = None;
/// for i in 0..120 {
/// last = ht.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HtTrendMode {
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
smooth_price: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
prev_smooth_period: f64,
// Trend-mode state.
prev_dc_phase: f64,
prev_sine: f64,
prev_lead_sine: f64,
days_in_trend: f64,
it1: f64,
it2: f64,
it3: f64,
count: usize,
last_value: Option<f64>,
}
impl HtTrendMode {
/// Construct a new Hilbert transform trend-mode classifier.
pub fn new() -> Self {
Self::default()
}
/// Current trend-mode flag (`1.0` trend, `0.0` cycle) if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for HtTrendMode {
type Input = f64;
type Output = f64;
#[allow(clippy::too_many_lines)]
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 7 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
Self::push_front(&mut self.smooth_price, smooth, 50);
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
let smooth_period = self.prev_smooth_period;
let dc_period = ((smooth_period + 0.5) as usize).clamp(1, self.smooth_price.len());
// Dominant-cycle phase over one cycle window.
let mut real_part = 0.0;
let mut imag_part = 0.0;
for i in 0..dc_period {
let angle = (i as f64) * 2.0 * PI / (dc_period as f64);
let sp = self.smooth_price[i];
real_part += angle.sin() * sp;
imag_part += angle.cos() * sp;
}
let mut dc_phase = if imag_part.abs() > 0.001 {
(real_part / imag_part).atan().to_degrees()
} else if real_part < 0.0 {
-90.0
} else {
90.0
};
dc_phase += 90.0;
dc_phase += 360.0 / smooth_period;
if imag_part < 0.0 {
dc_phase += 180.0;
}
if dc_phase > 315.0 {
dc_phase -= 360.0;
}
let sine = (dc_phase * PI / 180.0).sin();
let lead_sine = ((dc_phase + 45.0) * PI / 180.0).sin();
// Instantaneous trendline: average smoothed price over the cycle window,
// then a 4-3-2-1 weighted smoothing of that running average.
let mut trend_sum = 0.0;
for i in 0..dc_period {
trend_sum += self.smooth_price[i];
}
trend_sum /= dc_period as f64;
let trendline = (4.0 * trend_sum + 3.0 * self.it1 + 2.0 * self.it2 + self.it3) / 10.0;
self.it3 = self.it2;
self.it2 = self.it1;
self.it1 = trend_sum;
// Trend / cycle decision (assume trend, override to cycle).
let mut trend = 1.0_f64;
// A crossing of sine and lead-sine restarts the cycle clock.
if (sine > lead_sine && self.prev_sine <= self.prev_lead_sine)
|| (sine < lead_sine && self.prev_sine >= self.prev_lead_sine)
{
self.days_in_trend = 0.0;
trend = 0.0;
}
self.days_in_trend += 1.0;
if self.days_in_trend < 0.5 * smooth_period {
trend = 0.0;
}
// Cycle mode while the phase advances at roughly the dominant-cycle rate.
let delta_phase = dc_phase - self.prev_dc_phase;
if smooth_period != 0.0
&& delta_phase > 0.67 * 360.0 / smooth_period
&& delta_phase < 1.5 * 360.0 / smooth_period
{
trend = 0.0;
}
// Force trend mode when price separates from the trendline.
if trendline != 0.0 && ((smooth - trendline) / trendline).abs() >= 0.015 {
trend = 1.0;
}
self.prev_dc_phase = dc_phase;
self.prev_sine = sine;
self.prev_lead_sine = lead_sine;
if self.count < 50 {
return None;
}
self.last_value = Some(trend);
Some(trend)
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.smooth_price.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.prev_smooth_period = 0.0;
self.prev_dc_phase = 0.0;
self.prev_sine = 0.0;
self.prev_lead_sine = 0.0;
self.days_in_trend = 0.0;
self.it1 = 0.0;
self.it2 = 0.0;
self.it3 = 0.0;
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
50
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"HT_TRENDMODE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
/// A trending ramp followed by a clean cycle, so both modes are exercised.
fn mixed_prices() -> Vec<f64> {
let mut v = Vec::new();
for i in 0..150 {
v.push(100.0 + f64::from(i) * 0.8);
}
for i in 0..200 {
v.push(220.0 + (f64::from(i) * 0.45).sin() * 12.0);
}
v
}
#[test]
fn accessors_and_metadata() {
let ht = HtTrendMode::new();
assert_eq!(ht.warmup_period(), 50);
assert_eq!(ht.name(), "HT_TRENDMODE");
assert!(!ht.is_ready());
assert!(ht.value().is_none());
}
#[test]
fn emits_binary_flag_and_visits_both_modes() {
let mut ht = HtTrendMode::new();
let out: Vec<Option<f64>> = ht.batch(&mixed_prices());
assert_eq!(out[0], None);
assert!(ht.is_ready());
let mut saw_trend = false;
let mut saw_cycle = false;
for v in out.into_iter().flatten() {
assert!(v == 0.0 || v == 1.0, "trend mode must be binary, got {v}");
if v == 1.0 {
saw_trend = true;
} else {
saw_cycle = true;
}
}
assert!(saw_trend, "ramp segment should report trend mode");
assert!(saw_cycle, "cycle segment should report cycle mode");
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HtTrendMode::new();
let _ = ht.batch(&mixed_prices());
let before = ht.value();
assert_eq!(ht.update(f64::NAN), before);
}
#[test]
fn batch_equals_streaming() {
let prices = mixed_prices();
let mut a = HtTrendMode::new();
let mut b = HtTrendMode::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut ht = HtTrendMode::new();
let _ = ht.batch(&mixed_prices());
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert_eq!(ht.update(100.0), None);
}
}
@@ -0,0 +1,170 @@
//! Linear Regression Intercept (`LINEARREG_INTERCEPT`).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Linear Regression Intercept (`LINEARREG_INTERCEPT`): the intercept `a` of the
/// rolling least-squares fit `y = a + b·x` over the last `period` inputs, indexed
/// `x = 0, 1, …, period 1`.
///
/// ```text
/// b (slope) = (n·Σxy Σx·Σy) / (n·Σxx (Σx)²)
/// a (intercept) = (Σy b·Σx) / n
/// ```
///
/// Where [`LinearRegression`](crate::LinearRegression) reports the fitted line at
/// the most recent bar (`a + b·(period 1)`), this reports its value at the
/// *start* of the window (`x = 0`). Each update is O(1), maintaining the same
/// closed-form sliding-window sums as `LinearRegression`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, LinRegIntercept};
///
/// let mut indicator = LinRegIntercept::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct LinRegIntercept {
period: usize,
window: VecDeque<f64>,
sum_x: f64,
denom: f64,
sum_y: f64,
sum_xy: f64,
}
impl LinRegIntercept {
/// Construct a new rolling linear-regression intercept over `period` inputs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is
/// undefined for fewer than two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "linear regression intercept needs period >= 2",
});
}
let n = period as f64;
let sum_x = n * (n - 1.0) / 2.0;
let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_x,
denom: n * sum_xx - sum_x * sum_x,
sum_y: 0.0,
sum_xy: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for LinRegIntercept {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
self.sum_y -= y0;
}
let k = self.window.len() as f64;
self.window.push_back(value);
self.sum_y += value;
self.sum_xy += k * value;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom;
let intercept = (self.sum_y - slope * self.sum_x) / n;
Some(intercept)
}
fn reset(&mut self) {
self.window.clear();
self.sum_y = 0.0;
self.sum_xy = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"LINEARREG_INTERCEPT"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_short_period() {
assert!(matches!(
LinRegIntercept::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_report_config() {
let lr = LinRegIntercept::new(5).unwrap();
assert_eq!(lr.period(), 5);
assert_eq!(lr.name(), "LINEARREG_INTERCEPT");
assert_eq!(lr.warmup_period(), 5);
assert!(!lr.is_ready());
}
#[test]
fn reference_value() {
// period 3 over [1, 2, 9]: fit y = 0 + 4x, intercept = 0.
let mut lr = LinRegIntercept::new(3).unwrap();
let out: Vec<Option<f64>> = lr.batch(&[1.0, 2.0, 9.0]);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-9);
assert!(lr.is_ready());
}
#[test]
fn slides_and_tracks_a_shifted_line() {
// After sliding to window [2, 9, 4]... intercept stays finite and the
// fit is exact for a clean line [10, 12, 14]: y = 10 + 2x, intercept 10.
let mut lr = LinRegIntercept::new(3).unwrap();
let out: Vec<Option<f64>> = lr.batch(&[1.0, 10.0, 12.0, 14.0]);
assert_relative_eq!(out[3].unwrap(), 10.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut lr = LinRegIntercept::new(3).unwrap();
let _ = lr.batch(&[1.0, 2.0, 9.0]);
assert!(lr.is_ready());
lr.reset();
assert!(!lr.is_ready());
assert_eq!(lr.update(1.0), None);
}
}
@@ -0,0 +1,286 @@
//! MACD with selectable moving-average types (MACDEXT).
use crate::error::{Error, Result};
use crate::indicators::dema::Dema;
use crate::indicators::ema::Ema;
use crate::indicators::macd::MacdOutput;
use crate::indicators::sma::Sma;
use crate::indicators::tema::Tema;
use crate::indicators::trima::Trima;
use crate::indicators::wma::Wma;
use crate::traits::Indicator;
/// Moving-average type selector for [`MacdExt`] and other multi-MA indicators.
///
/// The variants map to TA-Lib's `MA_Type` codes `0..=5` — the period-only
/// moving averages. (TA-Lib's KAMA / MAMA / T3 take additional shape parameters
/// and are not selectable here.)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaType {
/// Simple moving average (TA-Lib code `0`).
Sma,
/// Exponential moving average (TA-Lib code `1`).
Ema,
/// Weighted moving average (TA-Lib code `2`).
Wma,
/// Double exponential moving average (TA-Lib code `3`).
Dema,
/// Triple exponential moving average (TA-Lib code `4`).
Tema,
/// Triangular moving average (TA-Lib code `5`).
Trima,
}
impl MaType {
/// Map a TA-Lib `MA_Type` integer code (`0..=5`) to a [`MaType`].
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] for codes outside `0..=5` (the period-only
/// moving averages); codes `6..=8` (KAMA / MAMA / T3) are not supported.
pub fn from_code(code: u32) -> Result<Self> {
match code {
0 => Ok(Self::Sma),
1 => Ok(Self::Ema),
2 => Ok(Self::Wma),
3 => Ok(Self::Dema),
4 => Ok(Self::Tema),
5 => Ok(Self::Trima),
_ => Err(Error::InvalidPeriod {
message: "unsupported moving-average type code (expected 0..=5)",
}),
}
}
}
/// A concrete period-only moving average instance, dispatched by [`MaType`].
#[derive(Debug, Clone)]
enum Ma {
Sma(Sma),
Ema(Ema),
Wma(Wma),
Dema(Dema),
Tema(Tema),
Trima(Trima),
}
impl Ma {
fn new(kind: MaType, period: usize) -> Result<Self> {
Ok(match kind {
MaType::Sma => Self::Sma(Sma::new(period)?),
MaType::Ema => Self::Ema(Ema::new(period)?),
MaType::Wma => Self::Wma(Wma::new(period)?),
MaType::Dema => Self::Dema(Dema::new(period)?),
MaType::Tema => Self::Tema(Tema::new(period)?),
MaType::Trima => Self::Trima(Trima::new(period)?),
})
}
fn update(&mut self, value: f64) -> Option<f64> {
match self {
Self::Sma(m) => m.update(value),
Self::Ema(m) => m.update(value),
Self::Wma(m) => m.update(value),
Self::Dema(m) => m.update(value),
Self::Tema(m) => m.update(value),
Self::Trima(m) => m.update(value),
}
}
fn reset(&mut self) {
match self {
Self::Sma(m) => m.reset(),
Self::Ema(m) => m.reset(),
Self::Wma(m) => m.reset(),
Self::Dema(m) => m.reset(),
Self::Tema(m) => m.reset(),
Self::Trima(m) => m.reset(),
}
}
fn warmup_period(&self) -> usize {
match self {
Self::Sma(m) => m.warmup_period(),
Self::Ema(m) => m.warmup_period(),
Self::Wma(m) => m.warmup_period(),
Self::Dema(m) => m.warmup_period(),
Self::Tema(m) => m.warmup_period(),
Self::Trima(m) => m.warmup_period(),
}
}
}
/// MACD Extended (`MACDEXT`): MACD with an independently selectable
/// [`MaType`] for each of the fast, slow and signal lines.
///
/// Classic [`MacdIndicator`](crate::MacdIndicator) hard-wires the exponential
/// moving average everywhere; `MACDEXT` lets each line use any period-only
/// moving average. The MACD line is `fast_ma(price) slow_ma(price)`, the signal
/// line is `signal_ma(macd)`, and the histogram is `macd signal`. The first
/// full [`MacdOutput`] is emitted once the slow and signal averages are both warm.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MacdExt, MaType};
///
/// let mut indicator =
/// MacdExt::new(12, MaType::Ema, 26, MaType::Ema, 9, MaType::Sma).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 MacdExt {
fast: Ma,
slow: Ma,
signal: Ma,
has_emitted: bool,
}
impl MacdExt {
/// Construct a MACDEXT with per-line periods and moving-average types.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if any period is zero and
/// [`Error::InvalidPeriod`] if `fast >= slow`, propagating any moving-average
/// construction error.
pub fn new(
fast: usize,
fast_type: MaType,
slow: usize,
slow_type: MaType,
signal: usize,
signal_type: MaType,
) -> Result<Self> {
if fast == 0 || slow == 0 || signal == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "fast period must be < slow period",
});
}
Ok(Self {
fast: Ma::new(fast_type, fast)?,
slow: Ma::new(slow_type, slow)?,
signal: Ma::new(signal_type, signal)?,
has_emitted: false,
})
}
}
impl Indicator for MacdExt {
type Input = f64;
type Output = MacdOutput;
fn update(&mut self, value: f64) -> Option<MacdOutput> {
let fast_v = self.fast.update(value);
let slow_v = self.slow.update(value);
let (Some(fast_v), Some(slow_v)) = (fast_v, slow_v) else {
return None;
};
let macd = fast_v - slow_v;
let signal = self.signal.update(macd)?;
self.has_emitted = true;
Some(MacdOutput {
macd,
signal,
histogram: macd - signal,
})
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.signal.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
self.slow.warmup_period() + self.signal.warmup_period()
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"MACDEXT"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
const TYPES: [MaType; 6] = [
MaType::Sma,
MaType::Ema,
MaType::Wma,
MaType::Dema,
MaType::Tema,
MaType::Trima,
];
#[test]
fn from_code_maps_all_supported_types() {
assert_eq!(MaType::from_code(0).unwrap(), MaType::Sma);
assert_eq!(MaType::from_code(1).unwrap(), MaType::Ema);
assert_eq!(MaType::from_code(2).unwrap(), MaType::Wma);
assert_eq!(MaType::from_code(3).unwrap(), MaType::Dema);
assert_eq!(MaType::from_code(4).unwrap(), MaType::Tema);
assert_eq!(MaType::from_code(5).unwrap(), MaType::Trima);
assert!(MaType::from_code(6).is_err());
}
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
MacdExt::new(0, MaType::Ema, 26, MaType::Ema, 9, MaType::Ema),
Err(Error::PeriodZero)
));
assert!(matches!(
MacdExt::new(26, MaType::Ema, 12, MaType::Ema, 9, MaType::Ema),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let m = MacdExt::new(12, MaType::Ema, 26, MaType::Sma, 9, MaType::Sma).unwrap();
assert_eq!(m.name(), "MACDEXT");
assert!(!m.is_ready());
assert!(m.warmup_period() >= 26);
}
#[test]
fn every_ma_type_produces_a_consistent_histogram() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 6.0)
.collect();
for &t in &TYPES {
let mut m = MacdExt::new(5, t, 10, t, 4, t).unwrap();
let out: Vec<Option<MacdOutput>> = m.batch(&prices);
assert!(out.iter().any(Option::is_some), "{t:?} never emitted");
for o in out.into_iter().flatten() {
assert!((o.histogram - (o.macd - o.signal)).abs() < 1e-9);
}
// Exercise the warmup accessor for this variant's inner averages.
assert!(m.warmup_period() >= 10);
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
}
}
#[test]
fn mixed_ma_types_per_line() {
let prices: Vec<f64> = (0..120).map(|i| 100.0 + f64::from(i)).collect();
let mut m = MacdExt::new(12, MaType::Wma, 26, MaType::Dema, 9, MaType::Trima).unwrap();
let last = m.batch(&prices).into_iter().flatten().last();
assert!(last.is_some());
}
}
@@ -0,0 +1,117 @@
//! MACD with fixed 12/26 periods (MACDFIX).
use crate::error::Result;
use crate::indicators::macd::{MacdIndicator, MacdOutput};
use crate::traits::Indicator;
/// MACD Fix (`MACDFIX`): the classic MACD with the fast and slow EMAs fixed at
/// 12 and 26, leaving only the signal period configurable.
///
/// This is TA-Lib's `MACDFIX` — identical output to
/// [`MacdIndicator::new(12, 26, signal)`](crate::MacdIndicator), packaged as a
/// single-parameter constructor for the common case. The output is the usual
/// [`MacdOutput`] triple `{ macd, signal, histogram }`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MacdFix};
///
/// let mut indicator = MacdFix::new(9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MacdFix {
inner: MacdIndicator,
}
impl MacdFix {
/// Construct a MACDFIX with fast = 12, slow = 26 and the given signal period.
///
/// # Errors
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `signal == 0`.
pub fn new(signal: usize) -> Result<Self> {
Ok(Self {
inner: MacdIndicator::new(12, 26, signal)?,
})
}
/// Configured signal period.
pub fn signal_period(&self) -> usize {
self.inner.periods().2
}
}
impl Indicator for MacdFix {
type Input = f64;
type Output = MacdOutput;
fn update(&mut self, value: f64) -> Option<MacdOutput> {
self.inner.update(value)
}
fn reset(&mut self) {
self.inner.reset();
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn name(&self) -> &'static str {
"MACDFIX"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_zero_signal() {
assert!(MacdFix::new(0).is_err());
}
#[test]
fn accessors_report_config() {
let m = MacdFix::new(9).unwrap();
assert_eq!(m.signal_period(), 9);
assert_eq!(m.name(), "MACDFIX");
assert!(!m.is_ready());
assert_eq!(
m.warmup_period(),
MacdIndicator::new(12, 26, 9).unwrap().warmup_period()
);
}
#[test]
fn matches_macd_with_fixed_periods() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let fix: Vec<Option<MacdOutput>> = MacdFix::new(9).unwrap().batch(&prices);
let classic: Vec<Option<MacdOutput>> =
MacdIndicator::new(12, 26, 9).unwrap().batch(&prices);
assert_eq!(fix, classic);
assert!(fix.iter().any(Option::is_some));
}
#[test]
fn reset_clears_state() {
let prices: Vec<f64> = (0..80).map(|i| 100.0 + f64::from(i)).collect();
let mut m = MacdFix::new(9).unwrap();
let _ = m.batch(&prices);
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
}
}
@@ -0,0 +1,144 @@
//! Midpoint (MIDPOINT) over a rolling window of a scalar series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Midpoint (`MIDPOINT`): the average of the highest and lowest value of the
/// input series over the last `period` points.
///
/// ```text
/// MIDPOINT = (highest(value, period) + lowest(value, period)) / 2
/// ```
///
/// Where [`MidPrice`](crate::MidPrice) takes the window extremes from a candle's
/// high/low, `MIDPOINT` works on a single scalar stream (typically the close),
/// taking the max and min of that stream over the window. The first value is
/// emitted once `period` points have been seen.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MidPoint};
///
/// let mut indicator = MidPoint::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MidPoint {
period: usize,
window: VecDeque<f64>,
}
impl MidPoint {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for MidPoint {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
let highest = self
.window
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let lowest = self.window.iter().copied().fold(f64::INFINITY, f64::min);
Some(f64::midpoint(highest, lowest))
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"MIDPOINT"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(MidPoint::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let mp = MidPoint::new(7).unwrap();
assert_eq!(mp.period(), 7);
assert_eq!(mp.name(), "MIDPOINT");
assert_eq!(mp.warmup_period(), 7);
assert!(!mp.is_ready());
}
#[test]
fn averages_window_min_and_max() {
// Window {8, 12, 10}: highest 12, lowest 8 -> 10.
let mut mp = MidPoint::new(3).unwrap();
let out: Vec<Option<f64>> = mp.batch(&[8.0, 12.0, 10.0]);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_relative_eq!(out[2].unwrap(), 10.0, epsilon = 1e-12);
assert!(mp.is_ready());
}
#[test]
fn window_slides_and_drops_old_values() {
// After the 30 spike leaves the window, the midpoint falls back.
let mut mp = MidPoint::new(3).unwrap();
let out: Vec<Option<f64>> = mp.batch(&[30.0, 8.0, 12.0, 10.0]);
// Last window {8, 12, 10}: (12 + 8) / 2 = 10.
assert_relative_eq!(out[3].unwrap(), 10.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut mp = MidPoint::new(3).unwrap();
let _ = mp.batch(&[8.0, 12.0, 10.0]);
assert!(mp.is_ready());
mp.reset();
assert!(!mp.is_ready());
assert_eq!(mp.update(8.0), None);
}
}
@@ -0,0 +1,165 @@
//! Midpoint Price (MIDPRICE) over a rolling window of high/low extremes.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Midpoint Price (`MIDPRICE`): the average of the highest high and the lowest
/// low over the last `period` candles.
///
/// ```text
/// MIDPRICE = (highest(high, period) + lowest(low, period)) / 2
/// ```
///
/// Unlike [`MedianPrice`](crate::MedianPrice), which averages a single bar's own
/// high and low, `MIDPRICE` averages the *window* extremes — it is numerically
/// the centre line of [`Donchian`](crate::Donchian) channels, exposed as a
/// standalone scalar for TA-Lib parity. The first value is emitted once `period`
/// candles have been seen.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MidPrice};
///
/// let mut indicator = MidPrice::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MidPrice {
period: usize,
candles: VecDeque<Candle>,
}
impl MidPrice {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
candles: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for MidPrice {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if self.candles.len() == self.period {
self.candles.pop_front();
}
self.candles.push_back(candle);
if self.candles.len() < self.period {
return None;
}
let highest = self
.candles
.iter()
.map(|c| c.high)
.fold(f64::NEG_INFINITY, f64::max);
let lowest = self
.candles
.iter()
.map(|c| c.low)
.fold(f64::INFINITY, f64::min);
Some(f64::midpoint(highest, lowest))
}
fn reset(&mut self) {
self.candles.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.candles.len() == self.period
}
fn name(&self) -> &'static str {
"MIDPRICE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(MidPrice::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let mp = MidPrice::new(7).unwrap();
assert_eq!(mp.period(), 7);
assert_eq!(mp.name(), "MIDPRICE");
assert_eq!(mp.warmup_period(), 7);
assert!(!mp.is_ready());
}
#[test]
fn averages_window_extremes() {
// Window highs {12, 14, 16}, lows {8, 9, 10}: highest 16, lowest 8 -> 12.
let candles = [c(12.0, 8.0, 10.0), c(14.0, 9.0, 11.0), c(16.0, 10.0, 12.0)];
let mut mp = MidPrice::new(3).unwrap();
let out: Vec<Option<f64>> = mp.batch(&candles);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_relative_eq!(out[2].unwrap(), 12.0, epsilon = 1e-12);
assert!(mp.is_ready());
}
#[test]
fn window_slides_and_drops_old_extremes() {
// After the spike leaves the window the midpoint falls back.
let candles = [
c(30.0, 10.0, 20.0),
c(12.0, 8.0, 10.0),
c(14.0, 9.0, 11.0),
c(16.0, 10.0, 12.0),
];
let mut mp = MidPrice::new(3).unwrap();
let out: Vec<Option<f64>> = mp.batch(&candles);
// Last window {12,14,16}/{8,9,10}: (16 + 8) / 2 = 12.
assert_relative_eq!(out[3].unwrap(), 12.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let candles = [c(12.0, 8.0, 10.0), c(14.0, 9.0, 11.0), c(16.0, 10.0, 12.0)];
let mut mp = MidPrice::new(3).unwrap();
let _ = mp.batch(&candles);
assert!(mp.is_ready());
mp.reset();
assert!(!mp.is_ready());
assert_eq!(mp.update(candles[0]), None);
}
}
@@ -0,0 +1,199 @@
//! Minus Directional Indicator (-DI), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Minus Directional Indicator (`MINUS_DI`).
///
/// `-DI = 100 · smoothed(-DM) / smoothed(TR)`, where both the minus directional
/// movement and the true range are Wilder-smoothed over `period` bars. It is the
/// bearish half of the directional system that drives [`Adx`](crate::Adx);
/// readings above [`PlusDi`](crate::PlusDi) mark a down-trending regime.
///
/// The first `period` raw values seed the two running sums; from then on each
/// applies the Wilder recursion `smoothed smoothed / period + raw`. Because a
/// bar's directional movement and true range both need the previous bar, the
/// first value is emitted after `period + 1` candles. When the smoothed true
/// range is zero (a perfectly flat market) the indicator returns `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MinusDi};
///
/// let mut indicator = MinusDi::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 - f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base - 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MinusDi {
period: usize,
prev: Option<Candle>,
dm_seed: f64,
tr_seed: f64,
seed_count: usize,
dm_smooth: Option<f64>,
tr_smooth: Option<f64>,
}
impl MinusDi {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
dm_seed: 0.0,
tr_seed: 0.0,
seed_count: 0,
dm_smooth: None,
tr_smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for MinusDi {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (_, minus_dm) = directional_movement(&prev, &candle);
let tr = candle.true_range(Some(prev.close));
let n = self.period as f64;
let (dm_v, tr_v) = if let (Some(d), Some(t)) = (self.dm_smooth, self.tr_smooth) {
let d_new = d - d / n + minus_dm;
let t_new = t - t / n + tr;
self.dm_smooth = Some(d_new);
self.tr_smooth = Some(t_new);
(d_new, t_new)
} else {
self.dm_seed += minus_dm;
self.tr_seed += tr;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.dm_smooth = Some(self.dm_seed);
self.tr_smooth = Some(self.tr_seed);
(self.dm_seed, self.tr_seed)
};
let di = if tr_v == 0.0 {
0.0
} else {
100.0 * dm_v / tr_v
};
Some(di)
}
fn reset(&mut self) {
self.prev = None;
self.dm_seed = 0.0;
self.tr_seed = 0.0;
self.seed_count = 0;
self.dm_smooth = None;
self.tr_smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.dm_smooth.is_some()
}
fn name(&self) -> &'static str {
"MINUS_DI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(MinusDi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let di = MinusDi::new(7).unwrap();
assert_eq!(di.period(), 7);
assert_eq!(di.name(), "MINUS_DI");
assert_eq!(di.warmup_period(), 7);
assert!(!di.is_ready());
}
#[test]
fn downtrend_drives_minus_di_high() {
// Strict downtrend: -DM dominates, so -DI is large and bounded by 100.
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 140.0 - f64::from(i) * 2.0;
c(base + 0.5, base - 1.0, base - 0.5)
})
.collect();
let mut di = MinusDi::new(3).unwrap();
let out: Vec<Option<f64>> = di.batch(&candles);
assert_eq!(out[0], None);
assert!(out[3].is_some());
let last = out.into_iter().flatten().last().unwrap();
assert!(last > 0.0 && last <= 100.0);
assert!(di.is_ready());
}
#[test]
fn flat_market_returns_zero() {
let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
let mut di = MinusDi::new(3).unwrap();
let last = di.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..6)
.map(|i| {
let base = 140.0 - f64::from(i) * 2.0;
c(base + 0.5, base - 1.0, base - 0.5)
})
.collect();
let mut di = MinusDi::new(3).unwrap();
let _ = di.batch(&candles);
assert!(di.is_ready());
di.reset();
assert!(!di.is_ready());
assert_eq!(di.update(candles[0]), None);
}
}
@@ -0,0 +1,195 @@
//! Minus Directional Movement (-DM), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Minus Directional Movement (`MINUS_DM`).
///
/// The raw minus directional movement of a bar is `max(low_prev low, 0)` when
/// the down-move exceeds the up-move `high high_prev`, and `0` otherwise. This
/// indicator returns the Wilder-smoothed running total of that raw `-DM` over
/// `period` bars, the same accumulation that feeds [`Adx`](crate::Adx) and
/// [`MinusDi`](crate::MinusDi).
///
/// The first `period` raw values seed the sum; from then on each update applies
/// the Wilder recursion `smoothed smoothed / period + raw`. Because a bar's
/// directional movement needs the previous bar, the first value is emitted after
/// `period + 1` candles.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MinusDm};
///
/// let mut indicator = MinusDm::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 - f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base - 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MinusDm {
period: usize,
prev: Option<Candle>,
seed: f64,
seed_count: usize,
smooth: Option<f64>,
}
impl MinusDm {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
seed: 0.0,
seed_count: 0,
smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for MinusDm {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (_, minus_dm) = directional_movement(&prev, &candle);
let n = self.period as f64;
if let Some(s) = self.smooth {
let s_new = s - s / n + minus_dm;
self.smooth = Some(s_new);
return Some(s_new);
}
self.seed += minus_dm;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.smooth = Some(self.seed);
Some(self.seed)
}
fn reset(&mut self) {
self.prev = None;
self.seed = 0.0;
self.seed_count = 0;
self.smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.smooth.is_some()
}
fn name(&self) -> &'static str {
"MINUS_DM"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Candle with explicit high/low; open and close are pinned to `cl`.
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(MinusDm::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let dm = MinusDm::new(7).unwrap();
assert_eq!(dm.period(), 7);
assert_eq!(dm.name(), "MINUS_DM");
assert_eq!(dm.warmup_period(), 7);
assert!(!dm.is_ready());
}
#[test]
fn seeds_then_smooths_a_constant_minus_dm() {
// Low falls by 1 each bar (down = +1); high falls by 0.5 each bar, so the
// up-move is negative and -DM equals the down-move (1.0) on every bar.
let candles: Vec<Candle> = (0..5)
.map(|i| {
c(
20.0 - 0.5 * f64::from(i),
18.0 - f64::from(i),
19.0 - f64::from(i),
)
})
.collect();
let mut dm = MinusDm::new(3).unwrap();
let out: Vec<Option<f64>> = dm.batch(&candles);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_eq!(out[2], None);
// Seed = sum of three unit -DM values.
assert_relative_eq!(out[3].unwrap(), 3.0, epsilon = 1e-12);
// Wilder step: 3 - 3/3 + 1 = 3.
assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
assert!(dm.is_ready());
}
#[test]
fn up_moves_contribute_zero() {
// Strict uptrend: lows rise, so every raw -DM is zero.
let candles: Vec<Candle> = (0..6)
.map(|i| c(20.0 + f64::from(i), 5.0 + f64::from(i), 12.0 + f64::from(i)))
.collect();
let mut dm = MinusDm::new(3).unwrap();
let last = dm.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..5)
.map(|i| {
c(
20.0 - 0.5 * f64::from(i),
18.0 - f64::from(i),
19.0 - f64::from(i),
)
})
.collect();
let mut dm = MinusDm::new(3).unwrap();
let _ = dm.batch(&candles);
assert!(dm.is_ready());
dm.reset();
assert!(!dm.is_ready());
assert_eq!(dm.update(candles[0]), None);
}
}
+58 -1
View File
@@ -26,6 +26,7 @@ mod atr_bands;
mod atr_trailing_stop;
mod autocorrelation;
mod average_drawdown;
mod avg_price;
mod awesome_oscillator;
mod awesome_oscillator_histogram;
mod balance_of_power;
@@ -74,6 +75,7 @@ mod downside_gap_three_methods;
mod dpo;
mod dragonfly_doji;
mod drawdown_duration;
mod dx;
mod ease_of_movement;
mod effective_spread;
mod ehlers_stochastic;
@@ -111,6 +113,9 @@ mod hilo_activator;
mod historical_volatility;
mod hma;
mod homing_pigeon;
mod ht_dcphase;
mod ht_phasor;
mod ht_trendmode;
mod hurst_channel;
mod hurst_exponent;
mod ichimoku;
@@ -139,6 +144,7 @@ mod lead_lag_cross_correlation;
mod linreg;
mod linreg_angle;
mod linreg_channel;
mod linreg_intercept;
mod linreg_slope;
mod liquidation_features;
mod long_legged_doji;
@@ -146,6 +152,8 @@ mod long_line;
mod long_short_ratio;
mod ma_envelope;
mod macd;
mod macd_ext;
mod macd_fix;
mod mama;
mod market_facilitation_index;
mod marubozu;
@@ -158,6 +166,10 @@ mod median_absolute_deviation;
mod median_price;
mod mfi;
mod microprice;
mod mid_point;
mod mid_price;
mod minus_di;
mod minus_dm;
mod mom;
mod morning_doji_star;
mod morning_evening_star;
@@ -183,6 +195,8 @@ mod percent_b;
mod percentage_trailing_stop;
mod pgo;
mod piercing_dark_cloud;
mod plus_di;
mod plus_dm;
mod pmo;
mod point_and_figure_bars;
mod ppo;
@@ -199,12 +213,16 @@ mod renko_trailing_stop;
mod rickshaw_man;
mod rising_three_methods;
mod roc;
mod rocp;
mod rocr;
mod rocr100;
mod rogers_satchell;
mod roofing_filter;
mod rsi;
mod rvi;
mod rvi_volatility;
mod rwi;
mod sar_ext;
mod separating_lines;
mod sharpe_ratio;
mod shooting_star;
@@ -261,6 +279,7 @@ mod treynor_ratio;
mod trima;
mod trix;
mod true_range;
mod tsf;
mod tsi;
mod tsv;
mod ttm_squeeze;
@@ -321,6 +340,7 @@ pub use atr_bands::{AtrBands, AtrBandsOutput};
pub use atr_trailing_stop::AtrTrailingStop;
pub use autocorrelation::Autocorrelation;
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;
@@ -369,6 +389,7 @@ pub use downside_gap_three_methods::DownsideGapThreeMethods;
pub use dpo::Dpo;
pub use dragonfly_doji::DragonflyDoji;
pub use drawdown_duration::DrawdownDuration;
pub use dx::Dx;
pub use ease_of_movement::EaseOfMovement;
pub use effective_spread::EffectiveSpread;
pub use ehlers_stochastic::EhlersStochastic;
@@ -406,6 +427,9 @@ pub use hilo_activator::HiLoActivator;
pub use historical_volatility::HistoricalVolatility;
pub use hma::Hma;
pub use homing_pigeon::HomingPigeon;
pub use ht_dcphase::HtDcPhase;
pub use ht_phasor::{HtPhasor, HtPhasorOutput};
pub use ht_trendmode::HtTrendMode;
pub use hurst_channel::{HurstChannel, HurstChannelOutput};
pub use hurst_exponent::HurstExponent;
pub use ichimoku::{Ichimoku, IchimokuOutput};
@@ -434,6 +458,7 @@ pub use lead_lag_cross_correlation::{LeadLagCrossCorrelation, LeadLagCrossCorrel
pub use linreg::LinearRegression;
pub use linreg_angle::LinRegAngle;
pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
pub use linreg_intercept::LinRegIntercept;
pub use linreg_slope::LinRegSlope;
pub use liquidation_features::{LiquidationFeatures, LiquidationFeaturesOutput};
pub use long_legged_doji::LongLeggedDoji;
@@ -441,6 +466,8 @@ pub use long_line::LongLine;
pub use long_short_ratio::LongShortRatio;
pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
pub use macd::{MacdIndicator, MacdOutput};
pub use macd_ext::{MaType, MacdExt};
pub use macd_fix::MacdFix;
pub use mama::{Mama, MamaOutput};
pub use market_facilitation_index::MarketFacilitationIndex;
pub use marubozu::Marubozu;
@@ -453,6 +480,10 @@ pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_price::MedianPrice;
pub use mfi::Mfi;
pub use microprice::Microprice;
pub use mid_point::MidPoint;
pub use mid_price::MidPrice;
pub use minus_di::MinusDi;
pub use minus_dm::MinusDm;
pub use mom::Mom;
pub use morning_doji_star::MorningDojiStar;
pub use morning_evening_star::MorningEveningStar;
@@ -478,6 +509,8 @@ pub use percent_b::PercentB;
pub use percentage_trailing_stop::PercentageTrailingStop;
pub use pgo::Pgo;
pub use piercing_dark_cloud::PiercingDarkCloud;
pub use plus_di::PlusDi;
pub use plus_dm::PlusDm;
pub use pmo::Pmo;
pub use point_and_figure_bars::{PnfColumn, PointAndFigureBars};
pub use ppo::Ppo;
@@ -494,12 +527,16 @@ pub use renko_trailing_stop::RenkoTrailingStop;
pub use rickshaw_man::RickshawMan;
pub use rising_three_methods::RisingThreeMethods;
pub use roc::Roc;
pub use rocp::Rocp;
pub use rocr::Rocr;
pub use rocr100::Rocr100;
pub use rogers_satchell::RogersSatchellVolatility;
pub use roofing_filter::RoofingFilter;
pub use rsi::Rsi;
pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sar_ext::SarExt;
pub use separating_lines::SeparatingLines;
pub use sharpe_ratio::SharpeRatio;
pub use shooting_star::ShootingStar;
@@ -556,6 +593,7 @@ pub use treynor_ratio::TreynorRatio;
pub use trima::Trima;
pub use trix::Trix;
pub use true_range::TrueRange;
pub use tsf::Tsf;
pub use tsi::Tsi;
pub use tsv::Tsv;
pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
@@ -649,12 +687,17 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"LaguerreRsi",
"ConnorsRsi",
"Inertia",
"Rocp",
"Rocr",
"Rocr100",
],
),
(
"Trend & Directional",
&[
"MacdIndicator",
"MacdFix",
"MacdExt",
"Adx",
"Adxr",
"Aroon",
@@ -667,6 +710,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"MassIndex",
"ChoppinessIndex",
"VerticalHorizontalFilter",
"PlusDm",
"MinusDm",
"PlusDi",
"MinusDi",
"Dx",
],
),
(
@@ -738,6 +786,7 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"PercentageTrailingStop",
"StepTrailingStop",
"RenkoTrailingStop",
"SarExt",
],
),
(
@@ -787,6 +836,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"PearsonCorrelation",
"Beta",
"SpearmanCorrelation",
"MidPrice",
"MidPoint",
"AvgPrice",
"LinRegIntercept",
"Tsf",
],
),
(
@@ -798,6 +852,9 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"InverseFisherTransform",
"SuperSmoother",
"HilbertDominantCycle",
"HtDcPhase",
"HtPhasor",
"HtTrendMode",
"SineWave",
"Decycler",
"DecyclerOscillator",
@@ -1004,6 +1061,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, 290, "FAMILIES total drifted from indicator count");
assert_eq!(total, 309, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,201 @@
//! Plus Directional Indicator (+DI), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Plus Directional Indicator (`PLUS_DI`).
///
/// `+DI = 100 · smoothed(+DM) / smoothed(TR)`, where both the plus directional
/// movement and the true range are Wilder-smoothed over `period` bars. It is the
/// bullish half of the directional system that drives [`Adx`](crate::Adx);
/// readings above [`MinusDi`](crate::MinusDi) mark an up-trending regime.
///
/// The first `period` raw values seed the two running sums; from then on each
/// applies the Wilder recursion `smoothed smoothed / period + raw`. Because a
/// bar's directional movement and true range both need the previous bar, the
/// first value is emitted after `period + 1` candles. When the smoothed true
/// range is zero (a perfectly flat market) the indicator returns `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, PlusDi};
///
/// let mut indicator = PlusDi::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct PlusDi {
period: usize,
prev: Option<Candle>,
dm_seed: f64,
tr_seed: f64,
seed_count: usize,
dm_smooth: Option<f64>,
tr_smooth: Option<f64>,
}
impl PlusDi {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
dm_seed: 0.0,
tr_seed: 0.0,
seed_count: 0,
dm_smooth: None,
tr_smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for PlusDi {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (plus_dm, _) = directional_movement(&prev, &candle);
let tr = candle.true_range(Some(prev.close));
let n = self.period as f64;
let (dm_v, tr_v) = if let (Some(d), Some(t)) = (self.dm_smooth, self.tr_smooth) {
let d_new = d - d / n + plus_dm;
let t_new = t - t / n + tr;
self.dm_smooth = Some(d_new);
self.tr_smooth = Some(t_new);
(d_new, t_new)
} else {
self.dm_seed += plus_dm;
self.tr_seed += tr;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.dm_smooth = Some(self.dm_seed);
self.tr_smooth = Some(self.tr_seed);
(self.dm_seed, self.tr_seed)
};
let di = if tr_v == 0.0 {
0.0
} else {
100.0 * dm_v / tr_v
};
Some(di)
}
fn reset(&mut self) {
self.prev = None;
self.dm_seed = 0.0;
self.tr_seed = 0.0;
self.seed_count = 0;
self.dm_smooth = None;
self.tr_smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.dm_smooth.is_some()
}
fn name(&self) -> &'static str {
"PLUS_DI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(PlusDi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let di = PlusDi::new(7).unwrap();
assert_eq!(di.period(), 7);
assert_eq!(di.name(), "PLUS_DI");
assert_eq!(di.warmup_period(), 7);
assert!(!di.is_ready());
}
#[test]
fn uptrend_drives_plus_di_high() {
// Strict uptrend: +DM dominates, so +DI is large and bounded by 100.
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut di = PlusDi::new(3).unwrap();
let out: Vec<Option<f64>> = di.batch(&candles);
assert_eq!(out[0], None);
// Seeds after `period` directional moves (candle index `period`).
assert!(out[3].is_some());
let last = out.into_iter().flatten().last().unwrap();
assert!(last > 0.0 && last <= 100.0);
assert!(di.is_ready());
}
#[test]
fn flat_market_returns_zero() {
// No range and no movement: smoothed true range is zero -> +DI is zero.
let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
let mut di = PlusDi::new(3).unwrap();
let last = di.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..6)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut di = PlusDi::new(3).unwrap();
let _ = di.batch(&candles);
assert!(di.is_ready());
di.reset();
assert!(!di.is_ready());
assert_eq!(di.update(candles[0]), None);
}
}
@@ -0,0 +1,197 @@
//! Plus Directional Movement (+DM), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Plus Directional Movement (`PLUS_DM`).
///
/// The raw plus directional movement of a bar is `max(high high_prev, 0)` when
/// the up-move exceeds the down-move `low_prev low`, and `0` otherwise. This
/// indicator returns the Wilder-smoothed running total of that raw `+DM` over
/// `period` bars, the same accumulation that feeds [`Adx`](crate::Adx) and
/// [`PlusDi`](crate::PlusDi).
///
/// The first `period` raw values seed the sum; from then on each update applies
/// the Wilder recursion `smoothed smoothed / period + raw`. Because a bar's
/// directional movement needs the previous bar, the first value is emitted after
/// `period + 1` candles.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, PlusDm};
///
/// let mut indicator = PlusDm::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct PlusDm {
period: usize,
prev: Option<Candle>,
seed: f64,
seed_count: usize,
smooth: Option<f64>,
}
impl PlusDm {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
seed: 0.0,
seed_count: 0,
smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for PlusDm {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (plus_dm, _) = directional_movement(&prev, &candle);
let n = self.period as f64;
if let Some(s) = self.smooth {
let s_new = s - s / n + plus_dm;
self.smooth = Some(s_new);
return Some(s_new);
}
self.seed += plus_dm;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.smooth = Some(self.seed);
Some(self.seed)
}
fn reset(&mut self) {
self.prev = None;
self.seed = 0.0;
self.seed_count = 0;
self.smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.smooth.is_some()
}
fn name(&self) -> &'static str {
"PLUS_DM"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Candle with explicit high/low; open and close are pinned to `cl`.
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(PlusDm::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let dm = PlusDm::new(7).unwrap();
assert_eq!(dm.period(), 7);
assert_eq!(dm.name(), "PLUS_DM");
assert_eq!(dm.warmup_period(), 7);
assert!(!dm.is_ready());
}
#[test]
fn seeds_then_smooths_a_constant_plus_dm() {
// High rises by 1 each bar (up = +1); low rises by 0.5 each bar, so the
// down-move is negative and +DM equals the up-move (1.0) on every bar.
let candles: Vec<Candle> = (0..5)
.map(|i| {
c(
11.0 + f64::from(i),
9.0 + 0.5 * f64::from(i),
10.0 + f64::from(i),
)
})
.collect();
let mut dm = PlusDm::new(3).unwrap();
let out: Vec<Option<f64>> = dm.batch(&candles);
// First candle only sets the previous bar; bars 2-3 seed the sum.
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_eq!(out[2], None);
// Seed = sum of three unit +DM values.
assert_relative_eq!(out[3].unwrap(), 3.0, epsilon = 1e-12);
// Wilder step: 3 - 3/3 + 1 = 3.
assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
assert!(dm.is_ready());
}
#[test]
fn down_moves_contribute_zero() {
// Strict downtrend: highs fall, so every raw +DM is zero and the smoothed
// total stays at zero.
let candles: Vec<Candle> = (0..6)
.map(|i| c(20.0 - f64::from(i), 5.0 - f64::from(i), 12.0 - f64::from(i)))
.collect();
let mut dm = PlusDm::new(3).unwrap();
let last = dm.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..5)
.map(|i| {
c(
11.0 + f64::from(i),
9.0 + 0.5 * f64::from(i),
10.0 + f64::from(i),
)
})
.collect();
let mut dm = PlusDm::new(3).unwrap();
let _ = dm.batch(&candles);
assert!(dm.is_ready());
dm.reset();
assert!(!dm.is_ready());
assert_eq!(dm.update(candles[0]), None);
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Rate of Change Percentage (ROCP).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rate of Change Percentage (`ROCP`): `(close - close[period]) / close[period]`.
///
/// The same momentum measure as [`Roc`](crate::Roc) but expressed as a raw
/// fraction rather than a percentage — `Roc` is exactly `100 · ROCP`. Where the
/// reference price is zero the result is reported as `0`.
///
/// Non-finite inputs are ignored and leave the window untouched; the last
/// computed value is returned instead, matching the SMA / EMA convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rocp};
///
/// let mut indicator = Rocp::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rocp {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl Rocp {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period + 1),
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Rocp {
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 + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period + 1 {
return None;
}
let prev = *self.window.front().expect("non-empty");
let rocp = if prev == 0.0 {
0.0
} else {
(input - prev) / prev
};
self.last = Some(rocp);
Some(rocp)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period + 1
}
fn name(&self) -> &'static str {
"ROCP"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Rocp::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let r = Rocp::new(3).unwrap();
assert_eq!(r.period(), 3);
assert_eq!(r.name(), "ROCP");
assert_eq!(r.warmup_period(), 4);
assert!(!r.is_ready());
}
#[test]
fn known_value_is_a_fraction() {
// period 1 over [10, 11]: (11 - 10) / 10 = 0.1.
let mut r = Rocp::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[10.0, 11.0]);
assert_eq!(out[0], None);
assert_relative_eq!(out[1].unwrap(), 0.1, epsilon = 1e-12);
assert!(r.is_ready());
}
#[test]
fn constant_series_yields_zero() {
let mut r = Rocp::new(3).unwrap();
for v in r.batch(&[10.0_f64; 12]).iter().skip(4).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn zero_reference_price_reports_zero() {
// period 1 over [0, 5]: reference price is zero -> guarded to 0.
let mut r = Rocp::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[0.0, 5.0]);
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn non_finite_input_holds_last() {
let mut r = Rocp::new(1).unwrap();
assert_eq!(r.update(10.0), None);
let v = r.update(11.0).unwrap();
assert_eq!(r.update(f64::NAN), Some(v));
}
#[test]
fn reset_clears_state() {
let mut r = Rocp::new(1).unwrap();
let _ = r.batch(&[10.0, 11.0]);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.update(10.0), None);
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Rate of Change Ratio (ROCR).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rate of Change Ratio (`ROCR`): `close / close[period]`.
///
/// The momentum ratio relative to the price `period` bars ago: `1.0` means no
/// change, `> 1` an advance, `< 1` a decline. It is [`Rocp`](crate::Rocp) plus
/// one. Where the reference price is zero the result is reported as `0`.
///
/// Non-finite inputs are ignored and leave the window untouched; the last
/// computed value is returned instead, matching the SMA / EMA convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rocr};
///
/// let mut indicator = Rocr::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rocr {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl Rocr {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period + 1),
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Rocr {
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 + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period + 1 {
return None;
}
let prev = *self.window.front().expect("non-empty");
let rocr = if prev == 0.0 { 0.0 } else { input / prev };
self.last = Some(rocr);
Some(rocr)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period + 1
}
fn name(&self) -> &'static str {
"ROCR"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Rocr::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let r = Rocr::new(3).unwrap();
assert_eq!(r.period(), 3);
assert_eq!(r.name(), "ROCR");
assert_eq!(r.warmup_period(), 4);
assert!(!r.is_ready());
}
#[test]
fn known_value_is_a_ratio() {
// period 1 over [10, 11]: 11 / 10 = 1.1.
let mut r = Rocr::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[10.0, 11.0]);
assert_eq!(out[0], None);
assert_relative_eq!(out[1].unwrap(), 1.1, epsilon = 1e-12);
assert!(r.is_ready());
}
#[test]
fn constant_series_yields_one() {
let mut r = Rocr::new(3).unwrap();
for v in r.batch(&[10.0_f64; 12]).iter().skip(4).flatten() {
assert_relative_eq!(*v, 1.0, epsilon = 1e-12);
}
}
#[test]
fn zero_reference_price_reports_zero() {
let mut r = Rocr::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[0.0, 5.0]);
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn non_finite_input_holds_last() {
let mut r = Rocr::new(1).unwrap();
assert_eq!(r.update(10.0), None);
let v = r.update(11.0).unwrap();
assert_eq!(r.update(f64::INFINITY), Some(v));
}
#[test]
fn reset_clears_state() {
let mut r = Rocr::new(1).unwrap();
let _ = r.batch(&[10.0, 11.0]);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.update(10.0), None);
}
}
@@ -0,0 +1,161 @@
//! Rate of Change Ratio scaled by 100 (ROCR100).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rate of Change Ratio × 100 (`ROCR100`): `close / close[period] · 100`.
///
/// The same ratio as [`Rocr`](crate::Rocr) rescaled so that an unchanged price
/// reads `100` rather than `1`: `> 100` is an advance, `< 100` a decline. Where
/// the reference price is zero the result is reported as `0`.
///
/// Non-finite inputs are ignored and leave the window untouched; the last
/// computed value is returned instead, matching the SMA / EMA convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rocr100};
///
/// let mut indicator = Rocr100::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rocr100 {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl Rocr100 {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period + 1),
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Rocr100 {
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 + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period + 1 {
return None;
}
let prev = *self.window.front().expect("non-empty");
let rocr = if prev == 0.0 {
0.0
} else {
input / prev * 100.0
};
self.last = Some(rocr);
Some(rocr)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period + 1
}
fn name(&self) -> &'static str {
"ROCR100"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Rocr100::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let r = Rocr100::new(3).unwrap();
assert_eq!(r.period(), 3);
assert_eq!(r.name(), "ROCR100");
assert_eq!(r.warmup_period(), 4);
assert!(!r.is_ready());
}
#[test]
fn known_value_is_a_scaled_ratio() {
// period 1 over [10, 11]: 11 / 10 * 100 = 110.
let mut r = Rocr100::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[10.0, 11.0]);
assert_eq!(out[0], None);
assert_relative_eq!(out[1].unwrap(), 110.0, epsilon = 1e-12);
assert!(r.is_ready());
}
#[test]
fn constant_series_yields_hundred() {
let mut r = Rocr100::new(3).unwrap();
for v in r.batch(&[10.0_f64; 12]).iter().skip(4).flatten() {
assert_relative_eq!(*v, 100.0, epsilon = 1e-12);
}
}
#[test]
fn zero_reference_price_reports_zero() {
let mut r = Rocr100::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[0.0, 5.0]);
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn non_finite_input_holds_last() {
let mut r = Rocr100::new(1).unwrap();
assert_eq!(r.update(10.0), None);
let v = r.update(11.0).unwrap();
assert_eq!(r.update(f64::NEG_INFINITY), Some(v));
}
#[test]
fn reset_clears_state() {
let mut r = Rocr100::new(1).unwrap();
let _ = r.batch(&[10.0, 11.0]);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.update(10.0), None);
}
}
@@ -0,0 +1,414 @@
//! Parabolic SAR Extended (SAREXT).
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Trend {
Up,
Down,
}
/// One direction's acceleration-factor schedule (initial, step, maximum).
#[derive(Debug, Clone, Copy)]
struct Accel {
init: f64,
step: f64,
max: f64,
}
impl Accel {
fn validate(self) -> Result<Self> {
if !(self.init.is_finite() && self.step.is_finite() && self.max.is_finite()) {
return Err(Error::NonPositiveMultiplier);
}
if self.init <= 0.0 || self.step <= 0.0 || self.max <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
if self.init > self.max {
return Err(Error::InvalidPeriod {
message: "acceleration init must be <= max",
});
}
Ok(self)
}
}
/// Parabolic SAR Extended (`SAREXT`): Wilder's Parabolic SAR with TA-Lib's
/// extended controls.
///
/// Beyond [`Psar`](crate::Psar) it adds:
/// - **`start_value`** — the initial SAR. `0` auto-seeds (long, like `Psar`);
/// a positive value starts a long phase at that SAR, a negative value starts a
/// short phase at its absolute value.
/// - **`offset_on_reverse`** — a fractional offset applied to the new SAR on each
/// reversal, pushing it further from price (`0` disables it).
/// - **separate long / short acceleration** — independent `(init, step, max)`
/// schedules for rising and falling phases.
///
/// The output is **signed**: a positive value during a long phase (SAR below
/// price) and a negative value during a short phase (SAR above price), so the
/// sign alone encodes the current trade direction.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SarExt};
///
/// let mut indicator =
/// SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SarExt {
start_value: f64,
offset_on_reverse: f64,
long: Accel,
short: Accel,
initialised: bool,
has_emitted: bool,
prev_high: f64,
prev_low: f64,
trend: Trend,
sar: f64,
ep: f64,
af: f64,
}
impl SarExt {
/// Construct an extended Parabolic SAR.
///
/// Parameters mirror TA-Lib's `SAREXT`: `start_value`, `offset_on_reverse`,
/// then the long `(init, step, max)` and short `(init, step, max)`
/// acceleration schedules.
///
/// # Errors
/// Returns [`Error::NonPositiveMultiplier`] if any acceleration term is
/// non-positive or non-finite, [`Error::InvalidPeriod`] if an `init` exceeds
/// its `max`, and [`Error::NonPositiveMultiplier`] if `start_value` or
/// `offset_on_reverse` is non-finite or `offset_on_reverse` is negative.
#[allow(clippy::too_many_arguments)]
pub fn new(
start_value: f64,
offset_on_reverse: f64,
accel_init_long: f64,
accel_long: f64,
accel_max_long: f64,
accel_init_short: f64,
accel_short: f64,
accel_max_short: f64,
) -> Result<Self> {
if !start_value.is_finite() || !offset_on_reverse.is_finite() || offset_on_reverse < 0.0 {
return Err(Error::NonPositiveMultiplier);
}
let long = Accel {
init: accel_init_long,
step: accel_long,
max: accel_max_long,
}
.validate()?;
let short = Accel {
init: accel_init_short,
step: accel_short,
max: accel_max_short,
}
.validate()?;
Ok(Self {
start_value,
offset_on_reverse,
long,
short,
initialised: false,
has_emitted: false,
prev_high: f64::NAN,
prev_low: f64::NAN,
trend: Trend::Up,
sar: f64::NAN,
ep: f64::NAN,
af: long.init,
})
}
/// Wilder's defaults with no start value or reversal offset and symmetric
/// `(0.02, 0.02, 0.20)` acceleration in both directions.
pub fn classic() -> Self {
Self::new(0.0, 0.0, 0.02, 0.02, 0.20, 0.02, 0.02, 0.20)
.expect("classic SAREXT params are valid")
}
fn signed(&self, sar: f64) -> f64 {
match self.trend {
Trend::Up => sar,
Trend::Down => -sar,
}
}
}
impl Indicator for SarExt {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if !self.initialised {
self.prev_high = candle.high;
self.prev_low = candle.low;
if self.start_value > 0.0 {
self.trend = Trend::Up;
self.sar = self.start_value;
self.ep = candle.high;
self.af = self.long.init;
} else if self.start_value < 0.0 {
self.trend = Trend::Down;
self.sar = -self.start_value;
self.ep = candle.low;
self.af = self.short.init;
} else {
self.trend = Trend::Up;
self.sar = candle.low;
self.ep = candle.high;
self.af = self.long.init;
}
self.initialised = true;
return None;
}
let mut new_sar = self.sar + self.af * (self.ep - self.sar);
let prev_h = self.prev_high;
let prev_l = self.prev_low;
new_sar = match self.trend {
Trend::Up => new_sar.min(prev_l).min(candle.low),
Trend::Down => new_sar.max(prev_h).max(candle.high),
};
let mut output_sar = new_sar;
let reversed = match self.trend {
Trend::Up => candle.low <= new_sar,
Trend::Down => candle.high >= new_sar,
};
if reversed {
output_sar = self.ep;
self.trend = match self.trend {
Trend::Up => Trend::Down,
Trend::Down => Trend::Up,
};
match self.trend {
Trend::Up => {
output_sar -= output_sar.abs() * self.offset_on_reverse;
self.ep = candle.high;
self.af = self.long.init;
}
Trend::Down => {
output_sar += output_sar.abs() * self.offset_on_reverse;
self.ep = candle.low;
self.af = self.short.init;
}
}
} else {
match self.trend {
Trend::Up => {
if candle.high > self.ep {
self.ep = candle.high;
self.af = (self.af + self.long.step).min(self.long.max);
}
}
Trend::Down => {
if candle.low < self.ep {
self.ep = candle.low;
self.af = (self.af + self.short.step).min(self.short.max);
}
}
}
}
self.sar = output_sar;
self.prev_high = candle.high;
self.prev_low = candle.low;
self.has_emitted = true;
Some(self.signed(output_sar))
}
fn reset(&mut self) {
self.initialised = false;
self.has_emitted = false;
self.prev_high = f64::NAN;
self.prev_low = f64::NAN;
self.trend = Trend::Up;
self.sar = f64::NAN;
self.ep = f64::NAN;
self.af = self.long.init;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"SAREXT"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
fn classic() -> SarExt {
SarExt::classic()
}
#[test]
fn rejects_invalid_params() {
// Non-positive / non-finite acceleration terms.
assert!(SarExt::new(0.0, 0.0, 0.0, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());
assert!(SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.0, 0.02, 0.2).is_err());
assert!(SarExt::new(0.0, 0.0, 0.30, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());
// Bad start value / offset.
assert!(SarExt::new(f64::NAN, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());
assert!(SarExt::new(0.0, -1.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());
}
#[test]
fn accessors_and_metadata() {
let s = classic();
assert_eq!(s.warmup_period(), 2);
assert_eq!(s.name(), "SAREXT");
assert!(!s.is_ready());
}
#[test]
fn seed_returns_none_then_emits() {
let mut s = classic();
assert_eq!(s.update(c(11.0, 9.0, 10.0)), None);
assert!(!s.is_ready());
assert!(s.update(c(12.0, 10.0, 11.0)).is_some());
assert!(s.is_ready());
}
#[test]
fn uptrend_is_positive_and_below_lows() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 0.5, base - 0.5, base)
})
.collect();
let mut s = classic();
let ok = s
.batch(&candles)
.iter()
.enumerate()
.all(|(i, v)| v.is_none_or(|x| x > 0.0 && x <= candles[i].low + 1e-9));
assert!(ok, "long-phase SAREXT must be positive and below the low");
}
#[test]
fn downtrend_is_negative_and_above_highs() {
let candles: Vec<Candle> = (0..40)
.rev()
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 0.5, base - 0.5, base)
})
.collect();
let mut s = classic();
let ok = s
.batch(&candles)
.iter()
.enumerate()
.skip(5)
.all(|(i, v)| v.is_none_or(|x| x < 0.0 && -x >= candles[i].high - 1e-9));
assert!(ok, "short-phase SAREXT must be negative and above the high");
}
#[test]
fn positive_start_value_begins_long() {
// start_value > 0 seeds a long phase: first emitted value is positive.
let mut s = SarExt::new(95.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).unwrap();
assert_eq!(s.update(c(101.0, 99.0, 100.0)), None);
let v = s.update(c(102.0, 100.0, 101.0)).unwrap();
assert!(v > 0.0);
}
#[test]
fn negative_start_value_begins_short() {
// start_value < 0 seeds a short phase: first emitted value is negative.
let mut s = SarExt::new(-105.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).unwrap();
assert_eq!(s.update(c(101.0, 99.0, 100.0)), None);
let v = s.update(c(100.0, 98.0, 99.0)).unwrap();
assert!(v < 0.0);
}
#[test]
fn offset_on_reverse_pushes_sar_further() {
// A V-shaped path forces a reversal; with an offset the reversal SAR is
// pushed further from price than without one.
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = if i < 6 {
100.0 - f64::from(i) * 2.0
} else {
88.0 + f64::from(i - 6) * 2.0
};
c(base + 1.0, base - 1.0, base)
})
.collect();
let plain = SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2)
.unwrap()
.batch(&candles);
let offset = SarExt::new(0.0, 0.1, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2)
.unwrap()
.batch(&candles);
// The two configurations must diverge once a reversal with offset fires.
assert_ne!(plain, offset);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut a = classic();
let mut b = classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_allows_clean_reuse() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 0.5, base - 0.5, base)
})
.collect();
let mut s = classic();
let first = s.batch(&candles);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(first, s.batch(&candles));
}
}
+169
View File
@@ -0,0 +1,169 @@
//! Time Series Forecast (TSF).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Time Series Forecast (`TSF`): the rolling least-squares line projected one bar
/// past the window.
///
/// Over the last `period` inputs, indexed `x = 0, 1, …, period 1`, it fits
/// `y = a + b·x` by ordinary least squares and reports the line's value at
/// `x = period` (one step beyond the most recent point):
///
/// ```text
/// b (slope) = (n·Σxy Σx·Σy) / (n·Σxx (Σx)²)
/// a (intercept) = (Σy b·Σx) / n
/// TSF = a + b·period
/// ```
///
/// Where [`LinearRegression`](crate::LinearRegression) evaluates the fit at the
/// current bar (`a + b·(period 1)`), `TSF` advances it one further bar, giving a
/// trend-following one-step-ahead forecast. Each update is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Tsf};
///
/// let mut indicator = Tsf::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Tsf {
period: usize,
window: VecDeque<f64>,
sum_x: f64,
denom: f64,
sum_y: f64,
sum_xy: f64,
}
impl Tsf {
/// Construct a new rolling time-series forecast over `period` inputs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is
/// undefined for fewer than two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "time series forecast needs period >= 2",
});
}
let n = period as f64;
let sum_x = n * (n - 1.0) / 2.0;
let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_x,
denom: n * sum_xx - sum_x * sum_x,
sum_y: 0.0,
sum_xy: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Tsf {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
self.sum_y -= y0;
}
let k = self.window.len() as f64;
self.window.push_back(value);
self.sum_y += value;
self.sum_xy += k * value;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom;
let intercept = (self.sum_y - slope * self.sum_x) / n;
Some(intercept + slope * n)
}
fn reset(&mut self) {
self.window.clear();
self.sum_y = 0.0;
self.sum_xy = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"TSF"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_short_period() {
assert!(matches!(Tsf::new(1), Err(Error::InvalidPeriod { .. })));
}
#[test]
fn accessors_report_config() {
let tsf = Tsf::new(5).unwrap();
assert_eq!(tsf.period(), 5);
assert_eq!(tsf.name(), "TSF");
assert_eq!(tsf.warmup_period(), 5);
assert!(!tsf.is_ready());
}
#[test]
fn reference_value() {
// period 3 over [1, 2, 9]: fit y = 0 + 4x, forecast at x = 3 is 12.
let mut tsf = Tsf::new(3).unwrap();
let out: Vec<Option<f64>> = tsf.batch(&[1.0, 2.0, 9.0]);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert_relative_eq!(out[2].unwrap(), 12.0, epsilon = 1e-9);
assert!(tsf.is_ready());
}
#[test]
fn forecasts_a_clean_line_one_step_ahead() {
// Window [10, 12, 14]: y = 10 + 2x, forecast at x = 3 is 16.
let mut tsf = Tsf::new(3).unwrap();
let out: Vec<Option<f64>> = tsf.batch(&[1.0, 10.0, 12.0, 14.0]);
assert_relative_eq!(out[3].unwrap(), 16.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut tsf = Tsf::new(3).unwrap();
let _ = tsf.batch(&[1.0, 2.0, 9.0]);
assert!(tsf.is_ready());
tsf.reset();
assert!(!tsf.is_ready());
assert_eq!(tsf.update(1.0), None);
}
}