From abd2d80f8dd89fafc14c6e8d5ea566a76f53ea34 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Fri, 22 May 2026 17:10:52 +0200 Subject: [PATCH] F1: add SMMA and TRIMA moving averages (core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the indicator-family expansion (see the F section of todo-detailed.md). Family F1 — Simple & Weighted MAs — gains two members alongside the existing Sma/Ema/Wma: - Smma — Wilder's smoothed moving average (RMA): SMA-seeded, then the (prev*(n-1)+x)/n recurrence. The average underlying RSI and ATR. - Trima — triangular moving average: two stacked SMAs (n1/n2 split by parity) that triangular-weight the window. Genuine stacking — the outer SMA consumes the inner SMA's output. Both implement the full Indicator trait with reference-value, warmup, reset, batch==streaming and non-finite-input tests, a runnable doctest, and are re-exported from the crate root. 208 core tests + 30 doctests pass; clippy and fmt clean. --- crates/wickra-core/src/indicators/mod.rs | 4 + crates/wickra-core/src/indicators/smma.rs | 180 +++++++++++++++++++++ crates/wickra-core/src/indicators/trima.rs | 176 ++++++++++++++++++++ crates/wickra-core/src/lib.rs | 4 +- 4 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 crates/wickra-core/src/indicators/smma.rs create mode 100644 crates/wickra-core/src/indicators/trima.rs diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index e22152ae..62ab9f78 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -23,8 +23,10 @@ mod psar; mod roc; mod rsi; mod sma; +mod smma; mod stochastic; mod tema; +mod trima; mod trix; mod vwap; mod williams_r; @@ -49,8 +51,10 @@ pub use psar::Psar; pub use roc::Roc; pub use rsi::Rsi; pub use sma::Sma; +pub use smma::Smma; pub use stochastic::{Stochastic, StochasticOutput}; pub use tema::Tema; +pub use trima::Trima; pub use trix::Trix; pub use vwap::{RollingVwap, Vwap}; pub use williams_r::WilliamsR; diff --git a/crates/wickra-core/src/indicators/smma.rs b/crates/wickra-core/src/indicators/smma.rs new file mode 100644 index 00000000..69370c25 --- /dev/null +++ b/crates/wickra-core/src/indicators/smma.rs @@ -0,0 +1,180 @@ +//! Smoothed Moving Average (Wilder's RMA). + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Smoothed Moving Average — Wilder's running moving average, also known as +/// RMA. +/// +/// Seeded with the simple average of the first `period` inputs, then advanced +/// by `SMMA_t = (SMMA_{t-1} * (period - 1) + price_t) / period`. This is an +/// exponential average with a slow `1 / period` smoothing factor and is the +/// average underlying Wilder's RSI and ATR. The first output lands after +/// exactly `period` inputs. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Smma}; +/// +/// let mut indicator = Smma::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 Smma { + period: usize, + /// Inputs collected while seeding (before the first value is produced). + seed: VecDeque, + seed_sum: f64, + current: Option, +} + +impl Smma { + /// Construct a new SMMA with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + seed: VecDeque::with_capacity(period), + seed_sum: 0.0, + current: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Smma { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored, leaving state untouched. + return self.current; + } + if let Some(prev) = self.current { + let period = self.period as f64; + self.current = Some((prev * (period - 1.0) + input) / period); + } else { + self.seed.push_back(input); + self.seed_sum += input; + if self.seed.len() == self.period { + self.current = Some(self.seed_sum / self.period as f64); + } + } + self.current + } + + fn reset(&mut self) { + self.seed.clear(); + self.seed_sum = 0.0; + self.current = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "SMMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Smma::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn warmup_then_recurrence() { + // SMMA(3): seed = SMA(1,2,3) = 2.0; then (prev*2 + x) / 3. + let mut smma = Smma::new(3).unwrap(); + assert_eq!(smma.update(1.0), None); + assert_eq!(smma.update(2.0), None); + assert_eq!(smma.update(3.0), Some(2.0)); + assert_relative_eq!( + smma.update(4.0).unwrap(), + (2.0 * 2.0 + 4.0) / 3.0, + epsilon = 1e-12 + ); + assert_relative_eq!( + smma.update(5.0).unwrap(), + ((2.0 * 2.0 + 4.0) / 3.0 * 2.0 + 5.0) / 3.0, + epsilon = 1e-12 + ); + } + + #[test] + fn period_one_is_pass_through() { + let mut smma = Smma::new(1).unwrap(); + assert_eq!(smma.update(5.0), Some(5.0)); + assert_eq!(smma.update(10.0), Some(10.0)); + } + + #[test] + fn constant_series_yields_the_constant() { + let mut smma = Smma::new(5).unwrap(); + let out = smma.batch(&[7.0; 20]); + for x in out.iter().skip(4) { + assert_relative_eq!(x.unwrap(), 7.0, epsilon = 1e-12); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut smma = Smma::new(3).unwrap(); + smma.batch(&[1.0, 2.0, 3.0]); + assert_eq!(smma.update(f64::NAN), Some(2.0)); + assert_eq!(smma.update(f64::INFINITY), Some(2.0)); + } + + #[test] + fn reset_clears_state() { + let mut smma = Smma::new(3).unwrap(); + smma.batch(&[1.0, 2.0, 3.0, 4.0]); + assert!(smma.is_ready()); + smma.reset(); + assert!(!smma.is_ready()); + assert_eq!(smma.update(10.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=30).map(f64::from).collect(); + let batch = Smma::new(7).unwrap().batch(&prices); + let mut b = Smma::new(7).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/trima.rs b/crates/wickra-core/src/indicators/trima.rs new file mode 100644 index 00000000..81b144c1 --- /dev/null +++ b/crates/wickra-core/src/indicators/trima.rs @@ -0,0 +1,176 @@ +//! Triangular Moving Average. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +use super::Sma; + +/// Triangular Moving Average — a simple moving average applied twice, which +/// triangular-weights the window so the middle bars carry the most weight and +/// the edges the least. +/// +/// For period `n` the two stacked SMAs use lengths `n1` and `n2`: +/// an odd `n` uses `n1 = n2 = (n + 1) / 2`; an even `n` uses `n1 = n / 2` and +/// `n2 = n / 2 + 1`. Either way the first output lands after exactly `n` +/// inputs. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Trima}; +/// +/// let mut indicator = Trima::new(5).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 Trima { + period: usize, + inner: Sma, + outer: Sma, +} + +impl Trima { + /// Construct a new TRIMA with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let (n1, n2) = if period % 2 == 1 { + (period.div_ceil(2), period.div_ceil(2)) + } else { + (period / 2, period / 2 + 1) + }; + Ok(Self { + period, + inner: Sma::new(n1)?, + outer: Sma::new(n2)?, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub fn value(&self) -> Option { + self.outer.value() + } +} + +impl Indicator for Trima { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; do not double-feed the inner SMA's + // stale value into the outer SMA. + return self.outer.value(); + } + // Genuine stacking: the outer SMA consumes the inner SMA's output. + match self.inner.update(input) { + Some(v) => self.outer.update(v), + None => None, + } + } + + fn reset(&mut self) { + self.inner.reset(); + self.outer.reset(); + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.outer.is_ready() + } + + fn name(&self) -> &'static str { + "TRIMA" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Trima::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn odd_period_reference_values() { + // TRIMA(5) is SMA(3) of SMA(3). + // SMA(3) of 1..=7 -> [_,_,2,3,4,5,6]; SMA(3) of that -> [_,_,_,_,3,4,5]. + let mut trima = Trima::new(5).unwrap(); + let out = trima.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); + assert_eq!(out[0], None); + assert_eq!(out[3], None); + assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12); + assert_relative_eq!(out[5].unwrap(), 4.0, epsilon = 1e-12); + assert_relative_eq!(out[6].unwrap(), 5.0, epsilon = 1e-12); + } + + #[test] + fn first_emission_at_warmup_period() { + // Even period: TRIMA(6) -> SMA(3) of SMA(4); first value at input 6. + let mut trima = Trima::new(6).unwrap(); + let out = trima.batch(&(1..=10).map(f64::from).collect::>()); + assert_eq!(trima.warmup_period(), 6); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn constant_series_yields_the_constant() { + let mut trima = Trima::new(7).unwrap(); + let out = trima.batch(&[42.0; 20]); + for x in out.iter().skip(6) { + assert_relative_eq!(x.unwrap(), 42.0, epsilon = 1e-12); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut trima = Trima::new(5).unwrap(); + let ready = trima.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let last = ready[4]; + assert!(last.is_some()); + assert_eq!(trima.update(f64::NAN), last); + } + + #[test] + fn reset_clears_state() { + let mut trima = Trima::new(5).unwrap(); + trima.batch(&(1..=10).map(f64::from).collect::>()); + assert!(trima.is_ready()); + trima.reset(); + assert!(!trima.is_ready()); + assert_eq!(trima.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=40).map(f64::from).collect(); + let batch = Trima::new(8).unwrap().batch(&prices); + let mut b = Trima::new(8).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 9ccdef7f..b06960aa 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -46,8 +46,8 @@ pub use error::{Error, Result}; pub use indicators::{ Adx, AdxOutput, Aroon, AroonOutput, Atr, AwesomeOscillator, BollingerBands, BollingerOutput, Cci, Dema, Donchian, DonchianOutput, Ema, Hma, Kama, Keltner, KeltnerOutput, MacdIndicator, - MacdOutput, Mfi, Obv, Psar, Roc, RollingVwap, Rsi, Sma, Stochastic, StochasticOutput, Tema, - Trix, Vwap, WilliamsR, Wma, + MacdOutput, Mfi, Obv, Psar, Roc, RollingVwap, Rsi, Sma, Smma, Stochastic, StochasticOutput, + Tema, Trima, Trix, Vwap, WilliamsR, Wma, }; pub use ohlcv::{Candle, Tick}; pub use traits::{BatchExt, Chain, Indicator};