feat(family-12): add 13 Statistik/Regression indicators (#51)

* feat(family-12): add 13 Statistik/Regression indicators

Brings the Price Statistics family to 20 indicators (7 → 20) and the
total catalogue to 84 (71 → 84). Every indicator ships in the Rust
core plus Python, Node, and WASM bindings with full streaming ↔ batch
parity, fuzz coverage, and benches.

Scalar (f64 → f64):
- Variance, CoefficientOfVariation: rolling population variance and
  its dimensionless ratio with the mean. O(1) updates.
- Skewness, Kurtosis: rolling Pearson skewness and excess kurtosis,
  derived from running sums of x, x², x³, x⁴ via the binomial
  identities — also O(1) per bar.
- StandardError, DetrendedStdDev: standard error of estimate (n − 2)
  and population StdDev (n) of OLS residuals, sharing the LinReg
  O(1) sliding sums.
- RSquared: coefficient of determination of the rolling OLS fit; the
  trend-quality filter, clamped to [0, 1].
- MedianAbsoluteDeviation: robust dispersion estimator; O(period log
  period) per emission via two in-place sorts of a reusable scratch
  buffer.
- Autocorrelation(period, lag): rolling lag-k Pearson autocorrelation.
- HurstExponent(period, chunks): R/S-analysis trend-persistence
  estimator clamped to [0, 1].

Pair indicators (Input = (f64, f64)):
- PearsonCorrelation: rolling cross-series Pearson, O(1).
- Beta: rolling OLS slope of asset vs. benchmark (CAPM).
- SpearmanCorrelation: rolling rank correlation with mid-rank tie
  handling; O(period log period).

Touchpoints:
- crates/wickra-core: 13 new indicator modules + mod.rs / lib.rs
  re-exports.
- bindings/python: pyclasses + add_class registration + __init__.py
  import & __all__ updates. The pair indicators expose
  update(x, y) and batch(x, y) over two equally-sized numpy arrays.
- bindings/node: scalar indicators via node_scalar_indicator! macro;
  pair indicators via new node_pair_indicator! macro; explicit
  structs for Autocorrelation and HurstExponent (two-arg ctors).
  index.js extended with the new exports.
- bindings/wasm: scalar wrappers via wasm_scalar_indicator!; pair
  wrappers via new wasm_pair_indicator! macro.
- fuzz: every scalar drove through the generic helper; pair
  indicators stress-tested by pairing adjacent samples of the fuzz
  input.
- Python tests (test_new_indicators.py): added to SCALAR
  parametrisation, plus algebraic reference values
  (variance of [2,4,6] = 8/3, MAD ignoring outlier = 0, monotone
  non-linear Spearman = 1, two-to-one Beta = 2, etc.) and a
  streaming-vs-batch test for the pair indicators.
- Node tests (indicators.test.js): extended the scalar factories
  map and added a pair-indicator section with the same algebraic
  reference values.
- crates/wickra/benches: bench_scalar entries for all 10 single-
  input new indicators.
- README: counter 71 → 84; Price Statistics family-table row
  expanded with the 13 new indicators.
- CHANGELOG: Unreleased section documents the family addition.

Wiki drafts (ghost-ignored, manual sync to wickra.wiki at release
time): indicator-ideas/families/wiki/family-12-statistik-regression/
contains 13 deep-dive pages plus _Sidebar / Indicators-Overview /
Warmup-Periods / Home fragments for the curator merge.

cargo check --workspace --all-features: clean.

* fix(family-12): remove unreachable defensive guards in hurst_exponent

The three guards (m < 2 continue, end > buf.len() break, denom == 0.0
return) are by-construction unreachable given the constructor invariant
period >= 2 * chunks: m = period / k for k in 1..=chunks always
satisfies m >= 2 and end = (c+1) * m <= k * m <= period = buf.len(),
and m_1 = period and m_2 = period / 2 are always distinct so the slope
denominator is strictly positive. Removing them brings codecov/patch
back to 100%.
This commit is contained in:
kingchenc
2026-05-25 23:42:05 +02:00
committed by GitHub
parent 5aa0949bce
commit 05fcdd9a5e
26 changed files with 4303 additions and 42 deletions
@@ -0,0 +1,221 @@
//! Rolling lag-`k` autocorrelation.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling lag-`lag` autocorrelation of the last `period` inputs.
///
/// Over the trailing window the Pearson correlation between the series and
/// itself shifted by `lag` is computed:
///
/// ```text
/// y_i for i = 0..period 1
/// ACF(lag) = Σ ( (y_i ȳ) · (y_{i + lag} ȳ) ) / Σ ( y_i ȳ )²
/// ```
///
/// `+1` means a perfectly repeating pattern at the given lag; `1` means a
/// perfect alternation. Values near `0` mean the series at `t` and `t
/// lag` carry no linear relationship — a clean white-noise proxy. The
/// classic application is detecting periodicity (a peak in `|ACF(lag)|`
/// flags a cycle of that length) or testing whether returns are
/// uncorrelated (a key efficient-markets diagnostic).
///
/// `period` must be strictly greater than `lag` so that at least two
/// `(y, y_lagged)` pairs exist. A flat window has zero variance; the
/// indicator returns `0` rather than dividing by zero.
///
/// # Example
///
/// ```
/// use wickra_core::{Autocorrelation, Indicator};
///
/// let mut indicator = Autocorrelation::new(20, 1).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Autocorrelation {
period: usize,
lag: usize,
window: VecDeque<f64>,
}
impl Autocorrelation {
/// Construct a new rolling lag-`lag` autocorrelation over `period` inputs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `lag == 0` or `lag >= period`.
pub fn new(period: usize, lag: usize) -> Result<Self> {
if lag == 0 {
return Err(Error::InvalidPeriod {
message: "autocorrelation lag must be >= 1",
});
}
if period <= lag {
return Err(Error::InvalidPeriod {
message: "autocorrelation needs period > lag",
});
}
Ok(Self {
period,
lag,
window: VecDeque::with_capacity(period),
})
}
/// Configured window period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured lag.
pub const fn lag(&self) -> usize {
self.lag
}
}
impl Indicator for Autocorrelation {
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;
}
// ACF over the current window with a single inner pass. The window is
// small relative to a typical input stream so the O(period) per-bar
// cost is bounded by the user-chosen `period`; the constant factor
// is dominated by two adds and one multiply per element.
let n = self.period as f64;
let mean = self.window.iter().sum::<f64>() / n;
let mut denom = 0.0;
let mut numer = 0.0;
// The window is a deque; index via slices for cache-friendly access.
let (front, back) = self.window.as_slices();
let get = |i: usize| -> f64 {
if i < front.len() {
front[i]
} else {
back[i - front.len()]
}
};
for i in 0..self.period {
let d = get(i) - mean;
denom += d * d;
}
let lag = self.lag;
for i in 0..(self.period - lag) {
numer += (get(i) - mean) * (get(i + lag) - mean);
}
if denom == 0.0 {
return Some(0.0);
}
Some(numer / denom)
}
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 {
"Autocorrelation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_lag() {
assert!(Autocorrelation::new(10, 0).is_err());
}
#[test]
fn rejects_lag_geq_period() {
assert!(Autocorrelation::new(5, 5).is_err());
assert!(Autocorrelation::new(5, 10).is_err());
}
#[test]
fn accessors_and_metadata() {
let a = Autocorrelation::new(14, 2).unwrap();
assert_eq!(a.period(), 14);
assert_eq!(a.lag(), 2);
assert_eq!(a.warmup_period(), 14);
assert_eq!(a.name(), "Autocorrelation");
}
#[test]
fn constant_series_yields_zero() {
let mut a = Autocorrelation::new(10, 1).unwrap();
for v in a.batch(&[42.0; 30]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn alternating_series_lag_one_is_strongly_negative() {
// [1, 1, 1, 1, …] alternates each step.
let prices: Vec<f64> = (0..20)
.map(|i| if i % 2 == 0 { -1.0 } else { 1.0 })
.collect();
let mut a = Autocorrelation::new(10, 1).unwrap();
let last = a.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last < -0.5,
"alternating series should be strongly negative, got {last}"
);
}
#[test]
fn repeating_series_is_strongly_positive_at_period() {
// A series that repeats every 4 steps must have ACF(4) ≈ +1.
let pattern = [1.0, 2.0, 3.0, 4.0];
let prices: Vec<f64> = (0..32).map(|i| pattern[i % 4]).collect();
let mut a = Autocorrelation::new(16, 4).unwrap();
let last = a.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last > 0.5,
"period-4 repeat should ACF(4) > 0.5, got {last}"
);
}
#[test]
fn reset_clears_state() {
let mut a = Autocorrelation::new(5, 1).unwrap();
a.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(a.is_ready());
a.reset();
assert!(!a.is_ready());
assert_eq!(a.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.3).sin()).collect();
let batch = Autocorrelation::new(14, 2).unwrap().batch(&prices);
let mut b = Autocorrelation::new(14, 2).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+228
View File
@@ -0,0 +1,228 @@
//! Rolling Beta — sensitivity of an asset to a benchmark.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Beta of an `asset` series relative to a `benchmark` series.
///
/// Each `update` receives one `(asset, benchmark)` pair. Over the trailing
/// window of `period` pairs:
///
/// ```text
/// cov_ab = (1/n) · Σ a·b ā·b̄
/// var_b = (1/n) · Σ b² b̄²
/// Beta = cov_ab / var_b
/// ```
///
/// Beta measures how much the asset moves for a unit move in the
/// benchmark. A reading of `1.0` means the two move together one-for-one;
/// `2.0` means the asset typically doubles the benchmark's moves;
/// `0.5` means it moves only half as much; `0.0` means moves are
/// uncorrelated; negative Betas signal a hedge. It is the slope of the
/// OLS regression of the asset on the benchmark and the foundation of the
/// CAPM. Unlike [`crate::PearsonCorrelation`], Beta is *not* unit-free —
/// it carries the ratio of standard deviations.
///
/// Each `update` is O(1): four running sums (`Σa`, `Σb`, `Σb²`, `Σa·b`)
/// are maintained as the window slides. A flat benchmark window has zero
/// variance and Beta is undefined; the indicator returns `0` in that
/// case rather than producing `NaN`.
///
/// Conventionally Beta is computed on **returns** (typically log-returns)
/// rather than raw prices; feed the indicator pre-computed returns if
/// that is your convention. The pure rolling OLS slope is the same
/// either way.
///
/// # Example
///
/// ```
/// use wickra_core::{Beta, Indicator};
///
/// let mut indicator = Beta::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// // Asset doubles every benchmark move.
/// last = indicator.update((2.0 * f64::from(i), f64::from(i)));
/// }
/// assert!((last.unwrap() - 2.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct Beta {
period: usize,
window: VecDeque<(f64, f64)>,
sum_a: f64,
sum_b: f64,
sum_bb: f64,
sum_ab: f64,
}
impl Beta {
/// Construct a new rolling Beta.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "beta needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_a: 0.0,
sum_b: 0.0,
sum_bb: 0.0,
sum_ab: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Beta {
/// `(asset, benchmark)` pair.
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
self.sum_b -= ob;
self.sum_bb -= ob * ob;
self.sum_ab -= oa * ob;
}
self.window.push_back((a, b));
self.sum_a += a;
self.sum_b += b;
self.sum_bb += b * b;
self.sum_ab += a * b;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_a = self.sum_a / n;
let mean_b = self.sum_b / n;
let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
let cov = self.sum_ab / n - mean_a * mean_b;
if var_b == 0.0 {
// A flat benchmark has no defined beta.
return Some(0.0);
}
Some(cov / var_b)
}
fn reset(&mut self) {
self.window.clear();
self.sum_a = 0.0;
self.sum_b = 0.0;
self.sum_bb = 0.0;
self.sum_ab = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Beta"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(Beta::new(0).is_err());
assert!(Beta::new(1).is_err());
assert!(Beta::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let b = Beta::new(14).unwrap();
assert_eq!(b.period(), 14);
assert_eq!(b.warmup_period(), 14);
assert_eq!(b.name(), "Beta");
}
#[test]
fn perfect_two_to_one_relationship() {
let pairs: Vec<(f64, f64)> = (0..10)
.map(|i| (2.0 * f64::from(i), f64::from(i)))
.collect();
let last = Beta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 2.0, epsilon = 1e-9);
}
#[test]
fn perfect_negative_one() {
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (-f64::from(i), f64::from(i))).collect();
let last = Beta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
}
#[test]
fn constant_benchmark_yields_zero() {
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect();
let last = Beta::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut b = Beta::new(5).unwrap();
b.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]);
assert!(b.is_ready());
b.reset();
assert!(!b.is_ready());
assert_eq!(b.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|i| {
let t = f64::from(i);
(t.sin() * 2.0 + 0.3 * t.cos(), t.sin())
})
.collect();
let batch = Beta::new(14).unwrap().batch(&pairs);
let mut b = Beta::new(14).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,184 @@
//! Rolling Coefficient of Variation (`StdDev / Mean`).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Coefficient of Variation — the rolling population standard deviation
/// divided by the rolling mean.
///
/// ```text
/// mean = (1/n) · Σ price
/// sd = √( (1/n) · Σ price² mean² )
/// CV = sd / mean
/// ```
///
/// CV is a dimensionless dispersion measure: it scales `StdDev` by the price
/// level so two assets at very different price magnitudes can be compared
/// directly. A higher CV means more relative variability for the same
/// average price.
///
/// When the rolling mean is exactly zero the ratio is undefined; the
/// indicator returns `0.0` in that degenerate case rather than producing a
/// `NaN`/infinity.
///
/// # Example
///
/// ```
/// use wickra_core::{CoefficientOfVariation, Indicator};
///
/// let mut indicator = CoefficientOfVariation::new(20).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 CoefficientOfVariation {
period: usize,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
}
impl CoefficientOfVariation {
/// Construct a new rolling CV with the given period.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for CoefficientOfVariation {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.window.push_back(value);
self.sum += value;
self.sum_sq += value * value;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
let variance = (self.sum_sq / n - mean * mean).max(0.0);
let sd = variance.sqrt();
if mean == 0.0 {
// Undefined ratio: return 0 instead of NaN/inf so downstream
// consumers can keep arithmetic going on flat or zeroed series.
return Some(0.0);
}
Some(sd / mean)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"CoefficientOfVariation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
CoefficientOfVariation::new(0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let cv = CoefficientOfVariation::new(14).unwrap();
assert_eq!(cv.period(), 14);
assert_eq!(cv.warmup_period(), 14);
assert_eq!(cv.name(), "CoefficientOfVariation");
}
#[test]
fn reference_value() {
// CV(3) of [2, 4, 6]: mean = 4, variance = 8/3, sd = √(8/3); CV = sd / 4.
let mut cv = CoefficientOfVariation::new(3).unwrap();
let out = cv.batch(&[2.0, 4.0, 6.0]);
assert_eq!(out[0], None);
let expected = (8.0_f64 / 3.0).sqrt() / 4.0;
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut cv = CoefficientOfVariation::new(5).unwrap();
for o in cv.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(o, 0.0, epsilon = 1e-12);
}
}
#[test]
fn zero_mean_returns_zero() {
// [-1, 0, 1] has mean 0; the CV is defined to be 0 rather than NaN.
let mut cv = CoefficientOfVariation::new(3).unwrap();
let out = cv.batch(&[-1.0, 0.0, 1.0]);
assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut cv = CoefficientOfVariation::new(5).unwrap();
cv.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(cv.is_ready());
cv.reset();
assert!(!cv.is_ready());
assert_eq!(cv.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
let batch = CoefficientOfVariation::new(14).unwrap().batch(&prices);
let mut b = CoefficientOfVariation::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,221 @@
//! Population standard deviation of residuals from a rolling OLS detrend.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Detrended (residual) standard deviation over the last `period` inputs.
///
/// Over the trailing window indexed `x = 0, 1, …, period 1` the OLS line
/// `y = a + b·x` is fitted and the residual sum of squares is then divided
/// by `n` (population convention):
///
/// ```text
/// slope = (n·Σxy Σx·Σy) / (n·Σxx (Σx)²)
/// SS_total = Σy² n·ȳ²
/// RSS = SS_total slope² · ( denom / n )
/// DetrendedStdDev = √( RSS / n )
/// ```
///
/// Unlike [`crate::StdDev`], which measures dispersion around the rolling
/// **mean**, `DetrendedStdDev` measures dispersion around the rolling
/// **linear trend** — the portion of the price action that is *not*
/// explained by the local slope. On a strongly trending series this is
/// much smaller than `StdDev`; on a sideways, mean-reverting series the
/// two converge.
///
/// The divisor is `n` (population), matching the convention of
/// [`crate::StdDev`]; use [`crate::StandardError`] when you want the
/// textbook standard error of estimate with `n 2` residual degrees of
/// freedom.
///
/// Each `update` is O(1) via the same rolling sums as
/// [`crate::LinearRegression`], plus a running `Σy²`. Floating-point
/// cancellation noise in the residual is clamped to zero before the square
/// root.
///
/// # Example
///
/// ```
/// use wickra_core::{DetrendedStdDev, Indicator};
///
/// let mut indicator = DetrendedStdDev::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.3).sin());
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DetrendedStdDev {
period: usize,
window: VecDeque<f64>,
sum_x: f64,
/// `n·Σxx (Σx)²` — OLS denominator, constant in `period`.
denom: f64,
sum_y: f64,
sum_xy: f64,
sum_y_sq: f64,
}
impl DetrendedStdDev {
/// Construct a new rolling detrended standard deviation.
///
/// # 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: "detrended stddev 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,
sum_y_sq: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for DetrendedStdDev {
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;
self.sum_y_sq -= y0 * y0;
}
let k = self.window.len() as f64;
self.window.push_back(value);
self.sum_y += value;
self.sum_xy += k * value;
self.sum_y_sq += value * 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 mean_y = self.sum_y / n;
let ss_total = self.sum_y_sq - n * mean_y * mean_y;
let s_xx = self.denom / n;
let rss = (ss_total - slope * slope * s_xx).max(0.0);
Some((rss / n).sqrt())
}
fn reset(&mut self) {
self.window.clear();
self.sum_y = 0.0;
self.sum_xy = 0.0;
self.sum_y_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"DetrendedStdDev"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(DetrendedStdDev::new(0).is_err());
assert!(DetrendedStdDev::new(1).is_err());
assert!(DetrendedStdDev::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let d = DetrendedStdDev::new(14).unwrap();
assert_eq!(d.period(), 14);
assert_eq!(d.warmup_period(), 14);
assert_eq!(d.name(), "DetrendedStdDev");
}
#[test]
fn perfect_line_has_zero_residual() {
// Residuals are zero on a perfectly linear series.
let prices: Vec<f64> = (0..30).map(|i| 2.0 * f64::from(i) + 5.0).collect();
let mut d = DetrendedStdDev::new(10).unwrap();
for v in d.batch(&prices).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn constant_series_yields_zero() {
let mut d = DetrendedStdDev::new(5).unwrap();
for v in d.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn never_exceeds_stddev() {
// The detrended residual is the projection of (y - ȳ) orthogonal to
// the trend axis, so its norm cannot exceed the raw stddev. Equality
// holds iff the OLS slope is exactly zero.
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 4.0)
.collect();
let mut d = DetrendedStdDev::new(14).unwrap();
let mut sd = crate::StdDev::new(14).unwrap();
for &p in &prices {
let (dv, sv) = (d.update(p), sd.update(p));
assert_eq!(dv.is_some(), sv.is_some());
if let (Some(dv), Some(sv)) = (dv, sv) {
assert!(dv <= sv + 1e-9, "detrended {dv} should be <= stddev {sv}");
}
}
}
#[test]
fn reset_clears_state() {
let mut d = DetrendedStdDev::new(5).unwrap();
d.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
assert_eq!(d.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 10.0)
.collect();
let batch = DetrendedStdDev::new(14).unwrap().batch(&prices);
let mut b = DetrendedStdDev::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,299 @@
//! Rolling Hurst Exponent via simplified R/S analysis.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Hurst Exponent of the last `period` values, estimated by rescaled-range
/// (R/S) analysis.
///
/// The classic Hurst-Mandelbrot estimator forms log-log pairs of `(n,
/// R(n)/S(n))` for several window lengths `n` and reports the slope of the
/// least-squares fit. Wickra uses a streaming-friendly variant that
/// partitions the trailing window into `chunks` of equal size,
/// computes `(R/S)` for each chunk length, and fits a log-log line to the
/// resulting points:
///
/// ```text
/// for each chunk size m ∈ {n/2, n/3, …, n/chunks}:
/// mean_m = (1/m) · Σ x_i over the chunk
/// dev_m_i = (Σ_{j ≤ i} (x_j mean_m)) // cumulative deviation
/// R_m = max(dev_m) min(dev_m)
/// S_m = population_stddev(chunk)
/// pair = (log m, log(R_m / S_m))
/// H = slope of OLS line through the (log m, log(R/S)) points
/// ```
///
/// The interpretation is unchanged from the textbook:
///
/// - `H ≈ 0.5` → random walk; recent moves carry no information about
/// future direction (the efficient-markets baseline).
/// - `H > 0.5` → persistent / trending; up moves are likelier to be
/// followed by more up moves.
/// - `H < 0.5` → anti-persistent / mean-reverting; up moves tend to
/// reverse.
///
/// Use it as a regime filter: trend-following strategies prefer
/// `H > 0.55`; mean-reversion prefers `H < 0.45`. The output is clamped
/// to `[0, 1]` to absorb degenerate fits on very small windows.
///
/// `period` must be at least `2 · chunks` so every chunk has at least two
/// points (otherwise its stddev is zero). A perfectly flat window has all
/// `R/S = 0` and the indicator returns `0.5` (random-walk baseline) to
/// avoid divide-by-zero / log-zero failures.
///
/// Each `update` is O(period); the window is stored in a deque and the
/// chunked R/S computation runs once per emission, not per input.
///
/// # Example
///
/// ```
/// use wickra_core::{HurstExponent, Indicator};
///
/// let mut indicator = HurstExponent::new(100, 4).unwrap();
/// let mut last = None;
/// for i in 0..200 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct HurstExponent {
period: usize,
chunks: usize,
window: VecDeque<f64>,
}
impl HurstExponent {
/// Construct a new Hurst Exponent over a window of `period` inputs,
/// fitted across `chunks` log-log points.
///
/// `chunks` controls the number of R/S pairs that go into the slope
/// fit; the typical value is `4` (the original Hurst paper used 5 — 9
/// points; smaller windows constrain the choice).
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `chunks < 2` or
/// `period < 2 · chunks`.
pub fn new(period: usize, chunks: usize) -> Result<Self> {
if chunks < 2 {
return Err(Error::InvalidPeriod {
message: "Hurst chunks must be >= 2",
});
}
if period < 2 * chunks {
return Err(Error::InvalidPeriod {
message: "Hurst period must be >= 2 * chunks",
});
}
Ok(Self {
period,
chunks,
window: VecDeque::with_capacity(period),
})
}
/// Configured window period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured chunk count.
pub const fn chunks(&self) -> usize {
self.chunks
}
}
/// R/S over a single chunk; returns `None` if the chunk has zero dispersion
/// (its stddev is zero, so the ratio is undefined).
fn rescaled_range(chunk: &[f64]) -> Option<f64> {
let n = chunk.len() as f64;
let mean = chunk.iter().sum::<f64>() / n;
let mut cum = 0.0;
let mut hi = f64::NEG_INFINITY;
let mut lo = f64::INFINITY;
let mut sum_sq = 0.0;
for &x in chunk {
let d = x - mean;
cum += d;
if cum > hi {
hi = cum;
}
if cum < lo {
lo = cum;
}
sum_sq += d * d;
}
let r = hi - lo;
let s = (sum_sq / n).sqrt();
if s == 0.0 || r == 0.0 {
return None;
}
Some(r / s)
}
impl Indicator for HurstExponent {
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;
}
// Materialise the window contiguously so chunk slicing is trivial.
let buf: Vec<f64> = self.window.iter().copied().collect();
// Build (log m, log(R/S)) points. The chunk size sweeps from period
// (one big chunk) down to period / chunks (chunks small chunks).
let mut sum_x = 0.0;
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
let mut sum_xx = 0.0;
let mut count = 0usize;
for k in 1..=self.chunks {
// k chunks each of size m; ignore the integer-division leftover
// bars at the end of the window. The `period >= 2 * chunks`
// constructor invariant guarantees m >= 2 for every k in range.
let m = self.period / k;
// Average R/S across the k chunks of size m to reduce noise.
let mut acc = 0.0;
let mut chunks_used = 0;
for c in 0..k {
let start = c * m;
let end = start + m;
if let Some(rs) = rescaled_range(&buf[start..end]) {
acc += rs;
chunks_used += 1;
}
}
if chunks_used == 0 {
continue;
}
let avg_rs = acc / f64::from(chunks_used);
let x = (m as f64).ln();
let y = avg_rs.ln();
sum_x += x;
sum_y += y;
sum_xy += x * y;
sum_xx += x * x;
count += 1;
}
if count < 2 {
// A perfectly flat window yields no usable R/S point; the
// canonical fallback for R/S on white noise is H = 0.5.
return Some(0.5);
}
// With chunks >= 2 and period >= 2 * chunks, m_1 = period and
// m_2 = period / 2 are always distinct, so the variance of the
// log-m values is strictly positive and `denom > 0`.
let n = count as f64;
let denom = n * sum_xx - sum_x * sum_x;
let slope = (n * sum_xy - sum_x * sum_y) / denom;
Some(slope.clamp(0.0, 1.0))
}
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 {
"HurstExponent"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_parameters() {
assert!(HurstExponent::new(10, 0).is_err());
assert!(HurstExponent::new(10, 1).is_err());
assert!(HurstExponent::new(3, 2).is_err());
assert!(HurstExponent::new(4, 2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let h = HurstExponent::new(100, 4).unwrap();
assert_eq!(h.period(), 100);
assert_eq!(h.chunks(), 4);
assert_eq!(h.warmup_period(), 100);
assert_eq!(h.name(), "HurstExponent");
}
#[test]
fn constant_series_is_one_half() {
let mut h = HurstExponent::new(40, 4).unwrap();
for v in h.batch(&[42.0; 80]).into_iter().flatten() {
assert_relative_eq!(v, 0.5, epsilon = 1e-12);
}
}
#[test]
fn output_stays_in_zero_one_range() {
let prices: Vec<f64> = (0..400)
.map(|i| {
100.0
+ (f64::from(i) * 0.05).sin() * 8.0
+ (f64::from(i) * 0.21).cos() * 3.0
+ f64::from(i) * 0.1
})
.collect();
let mut h = HurstExponent::new(100, 4).unwrap();
for v in h.batch(&prices).into_iter().flatten() {
assert!((0.0..=1.0).contains(&v), "Hurst out of range: {v}");
}
}
#[test]
fn trending_series_above_half() {
// A clean monotonic ramp is the textbook persistent series; the R/S
// pairs must lie above the random-walk baseline.
let prices: Vec<f64> = (0..200).map(f64::from).collect();
let mut h = HurstExponent::new(100, 4).unwrap();
let last = h.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last > 0.5,
"trending series should have H > 0.5, got {last}"
);
}
#[test]
fn reset_clears_state() {
let mut h = HurstExponent::new(20, 4).unwrap();
for i in 0..20 {
h.update(f64::from(i));
}
assert!(h.is_ready());
h.reset();
assert!(!h.is_ready());
assert_eq!(h.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 5.0)
.collect();
let batch = HurstExponent::new(50, 4).unwrap().batch(&prices);
let mut b = HurstExponent::new(50, 4).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,203 @@
//! Rolling excess kurtosis (Pearson's fourth standardised central moment 3).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling **excess** kurtosis of the last `period` values.
///
/// ```text
/// mean = (1/n) · Σ x
/// m2 = (1/n) · Σ (x mean)²
/// m4 = (1/n) · Σ (x mean)⁴
/// Kurtosis = m4 / m2² 3
/// ```
///
/// The unshifted kurtosis `m4 / m2²` equals `3` for the normal distribution;
/// subtracting `3` gives **excess** kurtosis so that `0` is the Gaussian
/// baseline. Positive readings flag fat tails (heavy outliers compared to
/// normal); negative readings flag light tails (more concentrated than
/// normal). This is the population definition with divisor `n`. A window
/// with zero dispersion yields `0`.
///
/// Each `update` is O(1): four running sums (`Σ x`, `Σ x²`, `Σ x³`, `Σ x⁴`)
/// are maintained as the window slides; the central moments are derived
/// from them via the binomial-expansion identities, so no inner loop runs
/// per bar.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Kurtosis};
///
/// let mut indicator = Kurtosis::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Kurtosis {
period: usize,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
sum_cu: f64,
sum_qu: f64,
}
impl Kurtosis {
/// Construct a new rolling excess kurtosis with the given period.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 4`.
pub fn new(period: usize) -> Result<Self> {
if period < 4 {
return Err(Error::InvalidPeriod {
message: "kurtosis needs period >= 4",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
sum_cu: 0.0,
sum_qu: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Kurtosis {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
let sq = old * old;
self.sum -= old;
self.sum_sq -= sq;
self.sum_cu -= old * sq;
self.sum_qu -= sq * sq;
}
self.window.push_back(value);
let sq = value * value;
self.sum += value;
self.sum_sq += sq;
self.sum_cu += value * sq;
self.sum_qu += sq * sq;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
let m2 = (self.sum_sq / n - mean * mean).max(0.0);
if m2 == 0.0 {
// Flat window: kurtosis is undefined, return 0 (Gaussian baseline).
return Some(0.0);
}
// m4 = E[x⁴] 4·mean·E[x³] + 6·mean²·E[x²] 3·mean⁴.
let mean_sq = mean * mean;
let m4 = self.sum_qu / n - 4.0 * mean * (self.sum_cu / n)
+ 6.0 * mean_sq * (self.sum_sq / n)
- 3.0 * mean_sq * mean_sq;
Some(m4 / (m2 * m2) - 3.0)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
self.sum_cu = 0.0;
self.sum_qu = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Kurtosis"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_four() {
assert!(Kurtosis::new(0).is_err());
assert!(Kurtosis::new(3).is_err());
assert!(Kurtosis::new(4).is_ok());
}
#[test]
fn accessors_and_metadata() {
let k = Kurtosis::new(14).unwrap();
assert_eq!(k.period(), 14);
assert_eq!(k.warmup_period(), 14);
assert_eq!(k.name(), "Kurtosis");
}
#[test]
fn two_point_distribution_is_negative_two() {
// A {a, b, a, b} window has m4/m2² = 1, so excess kurtosis = 2.
// This is the theoretical minimum for any real distribution.
let mut k = Kurtosis::new(4).unwrap();
let out = k.batch(&[-1.0, 1.0, -1.0, 1.0]);
assert_relative_eq!(out[3].unwrap(), -2.0, epsilon = 1e-9);
}
#[test]
fn constant_series_yields_zero() {
let mut k = Kurtosis::new(5).unwrap();
for v in k.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn outlier_window_is_leptokurtic() {
// A single large outlier amid otherwise-flat samples has positive
// excess kurtosis (a heavy tail).
let mut k = Kurtosis::new(5).unwrap();
let out = k.batch(&[0.0, 0.0, 0.0, 0.0, 100.0]);
assert!(out[4].unwrap() > 0.0);
}
#[test]
fn reset_clears_state() {
let mut k = Kurtosis::new(5).unwrap();
k.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(k.is_ready());
k.reset();
assert!(!k.is_ready());
assert_eq!(k.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let batch = Kurtosis::new(14).unwrap().batch(&prices);
let mut b = Kurtosis::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,201 @@
//! Rolling Median Absolute Deviation (MAD), a robust dispersion estimator.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Median Absolute Deviation of the last `period` values.
///
/// ```text
/// med = median(window)
/// MAD = median( |x_i med| for x_i in window )
/// ```
///
/// MAD is the median analogue of the standard deviation: it is a robust
/// dispersion measure that ignores extreme outliers (a single huge spike
/// barely moves the result) and is widely used as a sturdier alternative
/// to `StdDev` for risk reporting on heavy-tailed return distributions.
/// Multiplying MAD by `1.4826` produces a consistent estimator of the
/// underlying Gaussian standard deviation (the "robust σ"); Wickra returns
/// the raw MAD so the caller chooses whether to scale.
///
/// Each `update` is O(period log period): the window is kept as a deque
/// and copied into a small scratch buffer that is sorted twice (once to
/// pick the median, once to pick the median of absolute deviations). The
/// rolling structure makes the constant factor low; for the typical
/// period range (10100) this is dwarfed by the streaming overhead.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MedianAbsoluteDeviation};
///
/// let mut indicator = MedianAbsoluteDeviation::new(20).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 MedianAbsoluteDeviation {
period: usize,
window: VecDeque<f64>,
/// Reusable scratch buffer to avoid allocating per `update`.
scratch: Vec<f64>,
}
impl MedianAbsoluteDeviation {
/// Construct a new rolling MAD with the given period.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
/// Sort a slice of `f64` in-place using total ordering (NaN-safe).
fn sort_finite(buf: &mut [f64]) {
buf.sort_by(f64::total_cmp);
}
/// Median of a sorted, non-empty slice.
fn median_sorted(sorted: &[f64]) -> f64 {
let n = sorted.len();
let mid = n / 2;
if n % 2 == 0 {
(sorted[mid - 1] + sorted[mid]) * 0.5
} else {
sorted[mid]
}
}
impl Indicator for MedianAbsoluteDeviation {
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;
}
// Copy into scratch and sort to find the window median.
self.scratch.clear();
self.scratch.extend(self.window.iter().copied());
sort_finite(&mut self.scratch);
let med = median_sorted(&self.scratch);
// Replace with absolute deviations and sort again.
for x in &mut self.scratch {
*x = (*x - med).abs();
}
sort_finite(&mut self.scratch);
Some(median_sorted(&self.scratch))
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"MedianAbsoluteDeviation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
MedianAbsoluteDeviation::new(0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let m = MedianAbsoluteDeviation::new(14).unwrap();
assert_eq!(m.period(), 14);
assert_eq!(m.warmup_period(), 14);
assert_eq!(m.name(), "MedianAbsoluteDeviation");
}
#[test]
fn reference_value() {
// [1, 1, 2, 2, 4, 6, 9]: median = 2, deviations [1,1,0,0,2,4,7],
// sorted [0,0,1,1,2,4,7] → median = 1.
let mut m = MedianAbsoluteDeviation::new(7).unwrap();
let out = m.batch(&[1.0, 1.0, 2.0, 2.0, 4.0, 6.0, 9.0]);
assert_relative_eq!(out[6].unwrap(), 1.0, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut m = MedianAbsoluteDeviation::new(5).unwrap();
for v in m.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn ignores_single_extreme_outlier() {
// A window of 9 equal values plus 1 huge outlier still has MAD = 0,
// because more than half the window agrees on the median and the
// deviations majority are zero.
let mut m = MedianAbsoluteDeviation::new(10).unwrap();
let mut prices = vec![5.0; 9];
prices.push(1_000.0);
let last = m.batch(&prices).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut m = MedianAbsoluteDeviation::new(5).unwrap();
m.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
assert_eq!(m.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let batch = MedianAbsoluteDeviation::new(14).unwrap().batch(&prices);
let mut b = MedianAbsoluteDeviation::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+26
View File
@@ -20,9 +20,11 @@ mod aroon_oscillator;
mod atr;
mod atr_bands;
mod atr_trailing_stop;
mod autocorrelation;
mod awesome_oscillator;
mod awesome_oscillator_histogram;
mod balance_of_power;
mod beta;
mod bollinger;
mod bollinger_bandwidth;
mod camarilla_pivots;
@@ -37,6 +39,7 @@ mod choppiness_index;
mod classic_pivots;
mod cmf;
mod cmo;
mod coefficient_of_variation;
mod connors_rsi;
mod coppock;
mod cybernetic_cycle;
@@ -45,6 +48,7 @@ mod decycler_oscillator;
mod dema;
mod demand_index;
mod demark_pivots;
mod detrended_std_dev;
mod donchian;
mod donchian_stop;
mod double_bollinger;
@@ -68,6 +72,7 @@ mod hilo_activator;
mod historical_volatility;
mod hma;
mod hurst_channel;
mod hurst_exponent;
mod ichimoku;
mod inertia;
mod instantaneous_trendline;
@@ -76,6 +81,7 @@ mod jma;
mod kama;
mod keltner;
mod kst;
mod kurtosis;
mod kvo;
mod laguerre_rsi;
mod linreg;
@@ -88,6 +94,7 @@ mod mama;
mod market_facilitation_index;
mod mass_index;
mod mcginley_dynamic;
mod median_absolute_deviation;
mod median_price;
mod mfi;
mod mom;
@@ -95,6 +102,7 @@ mod natr;
mod nvi;
mod obv;
mod parkinson;
mod pearson_correlation;
mod percent_b;
mod percentage_trailing_stop;
mod pgo;
@@ -102,6 +110,7 @@ mod pmo;
mod ppo;
mod psar;
mod pvi;
mod r_squared;
mod renko_trailing_stop;
mod roc;
mod rogers_satchell;
@@ -111,9 +120,12 @@ mod rvi;
mod rvi_volatility;
mod rwi;
mod sine_wave;
mod skewness;
mod sma;
mod smi;
mod smma;
mod spearman_correlation;
mod standard_error;
mod standard_error_bands;
mod starc_bands;
mod stc;
@@ -147,6 +159,7 @@ mod ttm_squeeze;
mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
mod variance;
mod vertical_horizontal_filter;
mod vidya;
mod volty_stop;
@@ -186,9 +199,11 @@ pub use aroon_oscillator::AroonOscillator;
pub use atr::Atr;
pub use atr_bands::{AtrBands, AtrBandsOutput};
pub use atr_trailing_stop::AtrTrailingStop;
pub use autocorrelation::Autocorrelation;
pub use awesome_oscillator::AwesomeOscillator;
pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
pub use balance_of_power::BalanceOfPower;
pub use beta::Beta;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
@@ -203,6 +218,7 @@ pub use choppiness_index::ChoppinessIndex;
pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput};
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coefficient_of_variation::CoefficientOfVariation;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use cybernetic_cycle::CyberneticCycle;
@@ -211,6 +227,7 @@ pub use decycler_oscillator::DecyclerOscillator;
pub use dema::Dema;
pub use demand_index::DemandIndex;
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
pub use detrended_std_dev::DetrendedStdDev;
pub use donchian::{Donchian, DonchianOutput};
pub use donchian_stop::{DonchianStop, DonchianStopOutput};
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
@@ -234,6 +251,7 @@ pub use hilo_activator::HiLoActivator;
pub use historical_volatility::HistoricalVolatility;
pub use hma::Hma;
pub use hurst_channel::{HurstChannel, HurstChannelOutput};
pub use hurst_exponent::HurstExponent;
pub use ichimoku::{Ichimoku, IchimokuOutput};
pub use inertia::Inertia;
pub use instantaneous_trendline::InstantaneousTrendline;
@@ -242,6 +260,7 @@ pub use jma::Jma;
pub use kama::Kama;
pub use keltner::{Keltner, KeltnerOutput};
pub use kst::{Kst, KstOutput};
pub use kurtosis::Kurtosis;
pub use kvo::Kvo;
pub use laguerre_rsi::LaguerreRsi;
pub use linreg::LinearRegression;
@@ -254,6 +273,7 @@ pub use mama::{Mama, MamaOutput};
pub use market_facilitation_index::MarketFacilitationIndex;
pub use mass_index::MassIndex;
pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_price::MedianPrice;
pub use mfi::Mfi;
pub use mom::Mom;
@@ -261,6 +281,7 @@ pub use natr::Natr;
pub use nvi::Nvi;
pub use obv::Obv;
pub use parkinson::ParkinsonVolatility;
pub use pearson_correlation::PearsonCorrelation;
pub use percent_b::PercentB;
pub use percentage_trailing_stop::PercentageTrailingStop;
pub use pgo::Pgo;
@@ -268,6 +289,7 @@ pub use pmo::Pmo;
pub use ppo::Ppo;
pub use psar::Psar;
pub use pvi::Pvi;
pub use r_squared::RSquared;
pub use renko_trailing_stop::RenkoTrailingStop;
pub use roc::Roc;
pub use rogers_satchell::RogersSatchellVolatility;
@@ -277,9 +299,12 @@ pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sine_wave::SineWave;
pub use skewness::Skewness;
pub use sma::Sma;
pub use smi::Smi;
pub use smma::Smma;
pub use spearman_correlation::SpearmanCorrelation;
pub use standard_error::StandardError;
pub use standard_error_bands::{StandardErrorBands, StandardErrorBandsOutput};
pub use starc_bands::{StarcBands, StarcBandsOutput};
pub use stc::Stc;
@@ -313,6 +338,7 @@ pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
pub use variance::Variance;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vidya::Vidya;
pub use volty_stop::VoltyStop;
@@ -0,0 +1,246 @@
//! Rolling Pearson correlation between two synchronised series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Pearson correlation between two synchronised series.
///
/// Each `update` receives one `(x, y)` pair (e.g. the latest close of the
/// asset and of the benchmark). Over the trailing window of `period`
/// pairs:
///
/// ```text
/// cov_xy = (1/n) · Σ x·y x̄·ȳ
/// var_x = (1/n) · Σ x² x̄²
/// var_y = (1/n) · Σ y² ȳ²
/// Pearson = cov_xy / √(var_x · var_y)
/// ```
///
/// Output is in `[1, +1]`. `+1` means a perfect positive linear
/// relationship; `1` is a perfect inverse one; `0` means no linear
/// relationship. It is the same statistic `SciPy` / `NumPy` report as
/// `pearsonr` and the standardised relative of [`crate::Beta`] — Beta
/// scales Pearson by the ratio of standard deviations.
///
/// Each `update` is O(1): five running sums (`Σx`, `Σy`, `Σx²`, `Σy²`,
/// `Σxy`) are maintained as the window slides. A flat series in either
/// channel gives an undefined ratio; the indicator returns `0` in that
/// case rather than producing `NaN`. The output is clamped to `[1, +1]`
/// to absorb tiny floating-point overshoots near the boundaries.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, PearsonCorrelation};
///
/// let mut indicator = PearsonCorrelation::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update((f64::from(i), 2.0 * f64::from(i) + 1.0));
/// }
/// // A perfectly linear pair → +1.
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct PearsonCorrelation {
period: usize,
window: VecDeque<(f64, f64)>,
sum_x: f64,
sum_y: f64,
sum_xx: f64,
sum_yy: f64,
sum_xy: f64,
}
impl PearsonCorrelation {
/// Construct a new rolling Pearson correlation.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — correlation is
/// undefined for fewer than two pairs.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "pearson correlation needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_x: 0.0,
sum_y: 0.0,
sum_xx: 0.0,
sum_yy: 0.0,
sum_xy: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for PearsonCorrelation {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (x, y) = input;
if self.window.len() == self.period {
let (ox, oy) = self.window.pop_front().expect("non-empty");
self.sum_x -= ox;
self.sum_y -= oy;
self.sum_xx -= ox * ox;
self.sum_yy -= oy * oy;
self.sum_xy -= ox * oy;
}
self.window.push_back((x, y));
self.sum_x += x;
self.sum_y += y;
self.sum_xx += x * x;
self.sum_yy += y * y;
self.sum_xy += x * y;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_x = self.sum_x / n;
let mean_y = self.sum_y / n;
let var_x = (self.sum_xx / n - mean_x * mean_x).max(0.0);
let var_y = (self.sum_yy / n - mean_y * mean_y).max(0.0);
let cov = self.sum_xy / n - mean_x * mean_y;
let denom = (var_x * var_y).sqrt();
if denom == 0.0 {
// At least one channel is flat: correlation is undefined.
return Some(0.0);
}
Some((cov / denom).clamp(-1.0, 1.0))
}
fn reset(&mut self) {
self.window.clear();
self.sum_x = 0.0;
self.sum_y = 0.0;
self.sum_xx = 0.0;
self.sum_yy = 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 {
"PearsonCorrelation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(PearsonCorrelation::new(0).is_err());
assert!(PearsonCorrelation::new(1).is_err());
assert!(PearsonCorrelation::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let p = PearsonCorrelation::new(14).unwrap();
assert_eq!(p.period(), 14);
assert_eq!(p.warmup_period(), 14);
assert_eq!(p.name(), "PearsonCorrelation");
}
#[test]
fn perfect_positive_is_one() {
let pairs: Vec<(f64, f64)> = (0..10)
.map(|i| (f64::from(i), 3.0 * f64::from(i) + 1.0))
.collect();
let last = PearsonCorrelation::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
}
#[test]
fn perfect_negative_is_minus_one() {
let pairs: Vec<(f64, f64)> = (0..10)
.map(|i| (f64::from(i), -2.0 * f64::from(i) + 5.0))
.collect();
let last = PearsonCorrelation::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
}
#[test]
fn constant_channel_yields_zero() {
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect();
let last = PearsonCorrelation::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn output_in_minus_one_to_one_range() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|i| {
let t = f64::from(i);
(100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0)
})
.collect();
let mut p = PearsonCorrelation::new(20).unwrap();
for v in p.batch(&pairs).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v));
}
}
#[test]
fn reset_clears_state() {
let mut p = PearsonCorrelation::new(5).unwrap();
p.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]);
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|i| {
let t = f64::from(i);
(t.sin(), (t * 0.5).cos())
})
.collect();
let batch = PearsonCorrelation::new(14).unwrap().batch(&pairs);
let mut b = PearsonCorrelation::new(14).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,216 @@
//! Coefficient of determination R² for the rolling OLS fit.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// R² (coefficient of determination) of the rolling least-squares fit.
///
/// Over the trailing window indexed `x = 0, 1, …, period 1` the OLS line
/// `y = a + b·x` is fitted and the ratio of variance explained by the line
/// to total variance is reported:
///
/// ```text
/// slope = (n·Σxy Σx·Σy) / (n·Σxx (Σx)²)
/// SS_total = Σy² n·ȳ²
/// SS_explained = slope² · ( denom / n )
/// R² = SS_explained / SS_total if SS_total > 0
/// = 1 otherwise (flat window)
/// ```
///
/// A reading of `1.0` means the window lies on a straight line — perfect
/// linear fit. `0.0` means the slope is irrelevant; the trend explains none
/// of the variance. Mid-range values quantify how trending the recent price
/// action is, independent of the slope's sign or magnitude. Use it as a
/// trend-quality filter: a strategy that needs a clear trend can require
/// `R² > 0.7`, while a mean-reversion strategy can prefer `R² < 0.3`.
///
/// A flat window has `SS_total = 0`; the line is also flat and the fit is
/// trivially perfect, so the indicator returns `1.0` rather than dividing
/// by zero.
///
/// Each `update` is O(1) via the same rolling sums as
/// [`crate::LinearRegression`], plus a running `Σy²`. The output is
/// clamped to `[0, 1]` to absorb tiny floating-point cancellation.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RSquared};
///
/// let mut indicator = RSquared::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct RSquared {
period: usize,
window: VecDeque<f64>,
sum_x: f64,
/// `n·Σxx (Σx)²` — OLS denominator, constant in `period`.
denom: f64,
sum_y: f64,
sum_xy: f64,
sum_y_sq: f64,
}
impl RSquared {
/// Construct a new rolling R² 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: "R² 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,
sum_y_sq: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for RSquared {
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;
self.sum_y_sq -= y0 * y0;
}
let k = self.window.len() as f64;
self.window.push_back(value);
self.sum_y += value;
self.sum_xy += k * value;
self.sum_y_sq += value * 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 mean_y = self.sum_y / n;
let ss_total = (self.sum_y_sq - n * mean_y * mean_y).max(0.0);
let s_xx = self.denom / n;
let ss_explained = slope * slope * s_xx;
if ss_total <= 0.0 {
// Flat window: the fit is trivially perfect.
return Some(1.0);
}
Some((ss_explained / ss_total).clamp(0.0, 1.0))
}
fn reset(&mut self) {
self.window.clear();
self.sum_y = 0.0;
self.sum_xy = 0.0;
self.sum_y_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"RSquared"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(RSquared::new(0).is_err());
assert!(RSquared::new(1).is_err());
assert!(RSquared::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let r = RSquared::new(14).unwrap();
assert_eq!(r.period(), 14);
assert_eq!(r.warmup_period(), 14);
assert_eq!(r.name(), "RSquared");
}
#[test]
fn perfect_line_is_one() {
let prices: Vec<f64> = (0..30).map(|i| 2.0 * f64::from(i) + 5.0).collect();
let mut r = RSquared::new(10).unwrap();
for v in r.batch(&prices).into_iter().flatten() {
assert_relative_eq!(v, 1.0, epsilon = 1e-9);
}
}
#[test]
fn constant_series_is_one() {
// SS_total is zero; the indicator must return 1 instead of NaN.
let mut r = RSquared::new(5).unwrap();
for v in r.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 1.0, epsilon = 1e-12);
}
}
#[test]
fn output_stays_in_zero_one_range() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0 + (f64::from(i) * 0.07).cos() * 12.0)
.collect();
let mut r = RSquared::new(20).unwrap();
for v in r.batch(&prices).into_iter().flatten() {
assert!((0.0..=1.0).contains(&v), "R² out of range: {v}");
}
}
#[test]
fn reset_clears_state() {
let mut r = RSquared::new(5).unwrap();
r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let batch = RSquared::new(14).unwrap().batch(&prices);
let mut b = RSquared::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,202 @@
//! Rolling Pearson skewness (third standardised central moment).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Pearson skewness of the last `period` values.
///
/// ```text
/// mean = (1/n) · Σ x
/// m2 = (1/n) · Σ (x mean)² // population variance
/// m3 = (1/n) · Σ (x mean)³ // third central moment
/// Skew = m3 / m2^(3/2)
/// ```
///
/// Positive skewness means the right tail (large positive deviations from
/// the mean) is heavier than the left; negative skewness flags the
/// opposite. A symmetric distribution has skewness `0`. This is the
/// population (Pearson) definition with divisor `n`; many statistics
/// packages report the bias-corrected sample skewness instead. The window
/// is required to have at least three points so the moments are
/// well-defined. A window with zero dispersion yields `0`.
///
/// Each `update` is O(1): three running sums (`Σ x`, `Σ x²`, `Σ x³`) are
/// maintained as the window slides; the central moments are then derived
/// from them via the binomial-expansion identities, so no inner loop runs
/// per bar.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Skewness};
///
/// let mut indicator = Skewness::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Skewness {
period: usize,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
sum_cu: f64,
}
impl Skewness {
/// Construct a new rolling skewness with the given period.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 3`.
pub fn new(period: usize) -> Result<Self> {
if period < 3 {
return Err(Error::InvalidPeriod {
message: "skewness needs period >= 3",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
sum_cu: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Skewness {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
self.sum_sq -= old * old;
self.sum_cu -= old * old * old;
}
self.window.push_back(value);
self.sum += value;
self.sum_sq += value * value;
self.sum_cu += value * value * value;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
// m2 = E[x²] E[x]²
let m2 = (self.sum_sq / n - mean * mean).max(0.0);
// m3 = E[x³] 3·mean·E[x²] + 2·mean³ (binomial expansion).
let m3 = self.sum_cu / n - 3.0 * mean * (self.sum_sq / n) + 2.0 * mean * mean * mean;
if m2 == 0.0 {
// A window with no dispersion has no defined shape; return 0.
return Some(0.0);
}
Some(m3 / m2.powf(1.5))
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
self.sum_cu = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Skewness"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_three() {
assert!(Skewness::new(0).is_err());
assert!(Skewness::new(1).is_err());
assert!(Skewness::new(2).is_err());
assert!(Skewness::new(3).is_ok());
}
#[test]
fn accessors_and_metadata() {
let s = Skewness::new(14).unwrap();
assert_eq!(s.period(), 14);
assert_eq!(s.warmup_period(), 14);
assert_eq!(s.name(), "Skewness");
}
#[test]
fn symmetric_window_is_zero() {
// Symmetric around its mean — skewness must be (numerically) zero.
let mut s = Skewness::new(5).unwrap();
let out = s.batch(&[-2.0, -1.0, 0.0, 1.0, 2.0]);
assert_relative_eq!(out[4].unwrap(), 0.0, epsilon = 1e-9);
}
#[test]
fn constant_series_yields_zero() {
let mut s = Skewness::new(5).unwrap();
for v in s.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn right_tail_is_positive() {
// One large positive outlier creates a right-skewed window.
let mut s = Skewness::new(5).unwrap();
let out = s.batch(&[0.0, 0.0, 0.0, 0.0, 10.0]);
assert!(out[4].unwrap() > 0.0);
}
#[test]
fn left_tail_is_negative() {
// Mirror image — one large negative outlier gives left skew.
let mut s = Skewness::new(5).unwrap();
let out = s.batch(&[10.0, 10.0, 10.0, 10.0, 0.0]);
assert!(out[4].unwrap() < 0.0);
}
#[test]
fn reset_clears_state() {
let mut s = Skewness::new(5).unwrap();
s.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 7.0)
.collect();
let batch = Skewness::new(14).unwrap().batch(&prices);
let mut b = Skewness::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,314 @@
//! Rolling Spearman rank correlation between two synchronised series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling Spearman rank correlation between two synchronised series.
///
/// Each `update` receives one `(x, y)` pair. Over the trailing window of
/// `period` pairs, the values in each channel are replaced by their ranks
/// (mid-ranks for ties), and the Pearson correlation of those ranks is
/// reported:
///
/// ```text
/// rx = rank(x_i) with mid-rank tie handling
/// ry = rank(y_i) with mid-rank tie handling
/// Spearman = Pearson( rx, ry )
/// ```
///
/// Spearman is the non-linear, **monotone** analogue of
/// [`crate::PearsonCorrelation`]: `+1` means the two series move in the
/// same direction (any monotone relationship, not just linear); `1`
/// means they move in opposite directions; `0` means no monotone
/// relationship. Because ranks throw away magnitude, Spearman is robust
/// to outliers and to non-linear (but monotone) transformations — the
/// canonical example is two assets that move together but with very
/// different volatility profiles.
///
/// Each `update` is O(period²) in the naïve implementation; Wickra uses
/// an O(period log period) sort-and-pair approach: the window is copied
/// into a scratch buffer, sorted twice (once per channel) to derive the
/// ranks, then Pearson is computed on the rank arrays via the same O(n)
/// rolling sums as [`crate::PearsonCorrelation`].
///
/// A window in which one channel is constant has no rank dispersion and
/// the correlation is undefined; the indicator returns `0` rather than
/// `NaN`. The output is clamped to `[1, +1]` to absorb tiny
/// floating-point overshoots.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SpearmanCorrelation};
///
/// let mut indicator = SpearmanCorrelation::new(10).unwrap();
/// let mut last = None;
/// for i in 1..20 {
/// // Strictly monotone — Spearman should be +1.
/// last = indicator.update((f64::from(i), (f64::from(i)).powi(3)));
/// }
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct SpearmanCorrelation {
period: usize,
window: VecDeque<(f64, f64)>,
/// Reusable scratch buffer for ranking; pairs of `(value, original_index)`.
scratch: Vec<(f64, usize)>,
/// Reusable rank buffers, indexed by original position in the window.
rx: Vec<f64>,
ry: Vec<f64>,
}
impl SpearmanCorrelation {
/// Construct a new rolling Spearman correlation.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "spearman correlation needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
rx: vec![0.0; period],
ry: vec![0.0; period],
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
/// Fill `ranks_out[original_index] = rank` for the supplied `values`,
/// using mid-ranks for ties. `scratch` is reused so no allocation
/// happens per call after the first.
fn rank_into(
values: impl Iterator<Item = f64>,
ranks_out: &mut [f64],
scratch: &mut Vec<(f64, usize)>,
) {
scratch.clear();
for (i, v) in values.enumerate() {
scratch.push((v, i));
}
scratch.sort_by(|a, b| a.0.total_cmp(&b.0));
let n = scratch.len();
let mut i = 0;
while i < n {
let mut j = i + 1;
while j < n && scratch[j].0 == scratch[i].0 {
j += 1;
}
// Mid-rank of positions [i, j-1] in 1-indexed terms:
// (i + 1 + j) / 2.
let mid = (i as f64 + 1.0 + j as f64) / 2.0;
for k in i..j {
ranks_out[scratch[k].1] = mid;
}
i = j;
}
}
impl Indicator for SpearmanCorrelation {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
// Rank each channel.
rank_into(
self.window.iter().map(|p| p.0),
&mut self.rx,
&mut self.scratch,
);
rank_into(
self.window.iter().map(|p| p.1),
&mut self.ry,
&mut self.scratch,
);
// Pearson over the rank arrays. Closed forms are not used here
// because tie handling produces mid-ranks; the generic Pearson keeps
// the code uniform.
let n = self.period as f64;
let mut sum_x = 0.0;
let mut sum_y = 0.0;
let mut sum_xx = 0.0;
let mut sum_yy = 0.0;
let mut sum_xy = 0.0;
for i in 0..self.period {
let x = self.rx[i];
let y = self.ry[i];
sum_x += x;
sum_y += y;
sum_xx += x * x;
sum_yy += y * y;
sum_xy += x * y;
}
let mean_x = sum_x / n;
let mean_y = sum_y / n;
let var_x = (sum_xx / n - mean_x * mean_x).max(0.0);
let var_y = (sum_yy / n - mean_y * mean_y).max(0.0);
let cov = sum_xy / n - mean_x * mean_y;
let denom = (var_x * var_y).sqrt();
if denom == 0.0 {
return Some(0.0);
}
Some((cov / denom).clamp(-1.0, 1.0))
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
self.rx.iter_mut().for_each(|r| *r = 0.0);
self.ry.iter_mut().for_each(|r| *r = 0.0);
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"SpearmanCorrelation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(SpearmanCorrelation::new(0).is_err());
assert!(SpearmanCorrelation::new(1).is_err());
assert!(SpearmanCorrelation::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let s = SpearmanCorrelation::new(14).unwrap();
assert_eq!(s.period(), 14);
assert_eq!(s.warmup_period(), 14);
assert_eq!(s.name(), "SpearmanCorrelation");
}
#[test]
fn perfect_monotone_relationship_is_one() {
// y = x³ is strictly monotone but very non-linear; Pearson would
// not return exactly 1 but Spearman must.
let pairs: Vec<(f64, f64)> = (1..=10)
.map(|i| (f64::from(i), (f64::from(i)).powi(3)))
.collect();
let last = SpearmanCorrelation::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
}
#[test]
fn perfect_inverse_is_minus_one() {
let pairs: Vec<(f64, f64)> = (1..=10)
.map(|i| (f64::from(i), 1.0 / (f64::from(i))))
.collect();
let last = SpearmanCorrelation::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
}
#[test]
fn constant_channel_yields_zero() {
let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect();
let last = SpearmanCorrelation::new(5)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn output_in_minus_one_to_one_range() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|i| {
let t = f64::from(i);
(100.0 + t.sin() * 5.0, 50.0 + (t * 0.7).cos() * 3.0)
})
.collect();
let mut s = SpearmanCorrelation::new(20).unwrap();
for v in s.batch(&pairs).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v));
}
}
#[test]
fn handles_ties_via_mid_ranks() {
// x has a tie at the top; Spearman must still produce a sensible
// value (it equals Pearson of the rank arrays).
let pairs = [(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (3.0, 4.0)];
let last = SpearmanCorrelation::new(4)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
// Ranks: rx = [1, 2, 3.5, 3.5]; ry = [1, 2, 3, 4]. Pearson of those
// is a positive number less than 1 because of the tie in rx.
assert!(last > 0.0 && last < 1.0);
}
#[test]
fn reset_clears_state() {
let mut s = SpearmanCorrelation::new(5).unwrap();
s.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|i| {
let t = f64::from(i);
(t.sin() + (t * 0.1).cos(), (t * 0.3).cos())
})
.collect();
let batch = SpearmanCorrelation::new(14).unwrap().batch(&pairs);
let mut b = SpearmanCorrelation::new(14).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,244 @@
//! Standard Error of the rolling least-squares regression.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Standard Error of the regression line fit over the last `period` inputs.
///
/// Over the trailing window indexed `x = 0, 1, …, period 1` the OLS line
/// `y = a + b·x` is fitted, then:
///
/// ```text
/// slope = (n·Σxy Σx·Σy) / (n·Σxx (Σx)²)
/// SS_total = Σy² n·ȳ² // total sum of squares
/// RSS = SS_total slope² · S_xx // residual sum of squares
/// StdErr = √( RSS / (n 2) ) // n 2 residual d.o.f.
/// ```
///
/// where `S_xx = (n·Σxx (Σx)²) / n` is the centred sum of squares of the
/// design.
///
/// This is the textbook **standard error of estimate** of OLS: it measures
/// the typical distance between the observed prices and the fitted line,
/// using the residual degrees of freedom `n 2`. It is the spread that
/// drives [`crate::Bollinger`]-style bands around a regression instead of
/// around an SMA — when the price hugs its trend, `StdErr` is small.
///
/// Each `update` is O(1): the `Σx` and `Σxx` terms depend only on `period`
/// and are precomputed once, while `Σy`, `Σxy`, and `Σy²` are maintained
/// incrementally as the window slides. Tiny floating-point cancellation
/// noise that could drive the residual sum of squares slightly negative is
/// clamped to zero before the square root.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, StandardError};
///
/// let mut indicator = StandardError::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.5).sin());
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct StandardError {
period: usize,
window: VecDeque<f64>,
sum_x: f64,
/// `n·Σxx (Σx)²` — OLS denominator, constant in `period`.
denom: f64,
sum_y: f64,
sum_xy: f64,
sum_y_sq: f64,
}
impl StandardError {
/// Construct a new rolling standard error of regression.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 3` — the residual
/// degrees of freedom `n 2` would be non-positive.
pub fn new(period: usize) -> Result<Self> {
if period < 3 {
return Err(Error::InvalidPeriod {
message: "standard error needs period >= 3",
});
}
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,
sum_y_sq: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for StandardError {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
// Slide: pop oldest, shift indices, then push the new value at index n 1.
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
self.sum_y -= y0;
self.sum_y_sq -= y0 * y0;
}
let k = self.window.len() as f64;
self.window.push_back(value);
self.sum_y += value;
self.sum_xy += k * value;
self.sum_y_sq += value * 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 mean_y = self.sum_y / n;
let ss_total = self.sum_y_sq - n * mean_y * mean_y;
// S_xx = denom / n
let s_xx = self.denom / n;
let rss = (ss_total - slope * slope * s_xx).max(0.0);
Some((rss / (n - 2.0)).sqrt())
}
fn reset(&mut self) {
self.window.clear();
self.sum_y = 0.0;
self.sum_xy = 0.0;
self.sum_y_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"StandardError"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_three() {
assert!(StandardError::new(0).is_err());
assert!(StandardError::new(2).is_err());
assert!(StandardError::new(3).is_ok());
}
#[test]
fn accessors_and_metadata() {
let se = StandardError::new(14).unwrap();
assert_eq!(se.period(), 14);
assert_eq!(se.warmup_period(), 14);
assert_eq!(se.name(), "StandardError");
}
#[test]
fn perfect_line_has_zero_error() {
// Residuals from a perfectly linear fit are zero, so SE = 0.
let prices: Vec<f64> = (0..30).map(|i| 2.0 * f64::from(i) + 5.0).collect();
let mut se = StandardError::new(10).unwrap();
for v in se.batch(&prices).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn constant_series_yields_zero() {
let mut se = StandardError::new(5).unwrap();
for v in se.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
}
}
#[test]
fn matches_naive_definition() {
// Compare the O(1) update against a fresh-from-scratch OLS refit each bar.
fn naive(window: &[f64]) -> f64 {
let n = window.len() as f64;
let mean_y = window.iter().sum::<f64>() / n;
let mut sum_xy = 0.0;
let mut sum_x = 0.0;
let mut sum_xx = 0.0;
for (i, &y) in window.iter().enumerate() {
let x = i as f64;
sum_xy += x * y;
sum_x += x;
sum_xx += x * x;
}
let mean_x = sum_x / n;
let s_xx = sum_xx - n * mean_x * mean_x;
let slope = (sum_xy - n * mean_x * mean_y) / s_xx;
let intercept = mean_y - slope * mean_x;
let rss: f64 = window
.iter()
.enumerate()
.map(|(i, &y)| {
let r = y - (intercept + slope * i as f64);
r * r
})
.sum();
(rss / (n - 2.0)).sqrt()
}
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + f64::from(i) * 0.5 + (f64::from(i) * 0.7).sin() * 3.0)
.collect();
let period = 14;
let got = StandardError::new(period).unwrap().batch(&prices);
for (i, g) in got.iter().enumerate() {
if let Some(v) = g {
let expected = naive(&prices[i + 1 - period..=i]);
assert_relative_eq!(*v, expected, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut se = StandardError::new(5).unwrap();
se.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(se.is_ready());
se.reset();
assert!(!se.is_ready());
assert_eq!(se.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 10.0)
.collect();
let batch = StandardError::new(14).unwrap().batch(&prices);
let mut b = StandardError::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,192 @@
//! Rolling population variance.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling population variance over the last `period` values.
///
/// ```text
/// mean = (1/n) · Σ price
/// Variance = (1/n) · Σ price² mean²
/// ```
///
/// Variance is the squared standard deviation. It is the second central
/// moment of the rolling distribution and the natural input to risk
/// calculations that expect squared returns (e.g. portfolio variance,
/// covariance matrices). Use [`crate::StdDev`] when you need the
/// scale-preserving square root instead.
///
/// Floating-point cancellation can drive the running expression slightly
/// negative on perfectly constant inputs; the result is clamped to zero
/// before being returned so it stays a valid variance.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Variance};
///
/// let mut indicator = Variance::new(20).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 Variance {
period: usize,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
}
impl Variance {
/// Construct a new rolling variance with the given period.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Variance {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.window.push_back(value);
self.sum += value;
self.sum_sq += value * value;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
Some((self.sum_sq / n - mean * mean).max(0.0))
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Variance"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Variance::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let v = Variance::new(14).unwrap();
assert_eq!(v.period(), 14);
assert_eq!(v.warmup_period(), 14);
assert_eq!(v.name(), "Variance");
}
#[test]
fn reference_value() {
// Variance(3) of [2, 4, 6]: mean = 4, variance = (4 + 0 + 4) / 3 = 8/3.
let mut v = Variance::new(3).unwrap();
let out = v.batch(&[2.0, 4.0, 6.0]);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_relative_eq!(out[2].unwrap(), 8.0 / 3.0, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut v = Variance::new(5).unwrap();
for o in v.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(o, 0.0, epsilon = 1e-12);
}
}
#[test]
fn first_value_on_period_th_input() {
let mut v = Variance::new(5).unwrap();
let out = v.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
for (i, x) in out.iter().enumerate().take(4) {
assert!(x.is_none(), "index {i} must be None during warmup");
}
assert!(out[4].is_some());
}
#[test]
fn reset_clears_state() {
let mut v = Variance::new(5).unwrap();
v.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(v.is_ready());
v.reset();
assert!(!v.is_ready());
assert_eq!(v.update(1.0), None);
}
#[test]
fn equals_stddev_squared() {
// The rolling Variance must equal the rolling population StdDev squared.
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 7.0)
.collect();
let mut var = Variance::new(14).unwrap();
let mut sd = crate::StdDev::new(14).unwrap();
for &p in &prices {
let (v, s) = (var.update(p), sd.update(p));
assert_eq!(v.is_some(), s.is_some());
if let (Some(v), Some(s)) = (v, s) {
assert_relative_eq!(v, s * s, epsilon = 1e-9);
}
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 50.0 + (f64::from(i) * 0.3).cos() * 10.0)
.collect();
let batch = Variance::new(14).unwrap().batch(&prices);
let mut b = Variance::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+17 -15
View File
@@ -46,32 +46,34 @@ pub use error::{Error, Result};
pub use indicators::{
AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator, AdaptiveCycle,
Adl, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, AnchoredVwap, Apo, Aroon,
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop,
AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BollingerBands,
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, Autocorrelation,
AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Beta, BollingerBands,
BollingerBandwidth, BollingerOutput, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity,
Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop,
ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots,
ClassicPivotsOutput, Cmo, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator,
Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, Donchian, DonchianOutput, DonchianStop,
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement,
EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Evwma, Fama, FibonacciPivots,
FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput,
Frama, GarmanKlassVolatility, HeikinAshi, HeikinAshiOutput, HiLoActivator,
HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, Ichimoku,
ClassicPivotsOutput, Cmo, CoefficientOfVariation, ConnorsRsi, Coppock, CyberneticCycle,
Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput,
DetrendedStdDev, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
DoubleBollingerOutput, Dpo, EaseOfMovement, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput,
FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama,
GarmanKlassVolatility, HeikinAshi, HeikinAshiOutput, HiLoActivator, HilbertDominantCycle,
HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku,
IchimokuOutput, Inertia, InstantaneousTrendline, InverseFisherTransform, Jma, Kama, Keltner,
KeltnerOutput, Kst, KstOutput, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel,
KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel,
LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput,
MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, MassIndex,
McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Nvi, Obv, ParkinsonVolatility, PercentB,
PercentageTrailingStop, Pgo, Pmo, Ppo, Psar, Pvi, RenkoTrailingStop, Roc,
RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput,
SineWave, Sma, Smi, Smma, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Mom, Natr, Nvi, Obv,
ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo, Pmo, Ppo, Psar,
Pvi, RSquared, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter,
Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SineWave, Skewness, Sma, Smi, Smma,
SpearmanCorrelation, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic, StochasticOutput,
SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, TdDifferential,
TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei,
TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, Tii, Trima,
Trix, TrueRange, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TypicalPrice, UlcerIndex,
UltimateOscillator, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator,
UltimateOscillator, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator,
VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma,
Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput,
WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore,
+39 -13
View File
@@ -20,19 +20,21 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Through
use std::hint::black_box;
use wickra::{
AccelerationBands, AdOscillator, AdaptiveCycle, Adxr, Alma, AnchoredVwap, Atr, AtrBands,
BatchExt, BollingerBands, Camarilla, Candle, CenterOfGravity, ClassicPivots, CyberneticCycle,
Decycler, DecyclerOscillator, DemandIndex, DemarkPivots, DonchianStop, DoubleBollinger,
EhlersStochastic, Ema, EmpiricalModeDecomposition, Fama, FibonacciPivots, FisherTransform,
FractalChaosBands, Frama, GarmanKlassVolatility, HeikinAshi, HiLoActivator,
HilbertDominantCycle, HurstChannel, Ichimoku, Indicator, InstantaneousTrendline,
InverseFisherTransform, Jma, Kst, Kvo, LinRegChannel, MaEnvelope, MacdIndicator, Mama,
MarketFacilitationIndex, McGinleyDynamic, Nvi, Obv, ParkinsonVolatility,
PercentageTrailingStop, Pgo, Pvi, RenkoTrailingStop, RogersSatchellVolatility, RoofingFilter,
Rsi, Rvi, RviVolatility, Rwi, SineWave, Sma, StandardErrorBands, StarcBands, StepTrailingStop,
Stochastic, SuperSmoother, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen,
TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, Tii, Tsv, TtmSqueeze,
Vidya, VoltyStop, VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, WilliamsFractals, Wma,
WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag,
Autocorrelation, BatchExt, BollingerBands, Camarilla, Candle, CenterOfGravity, ClassicPivots,
CoefficientOfVariation, CyberneticCycle, Decycler, DecyclerOscillator, DemandIndex,
DemarkPivots, DetrendedStdDev, DonchianStop, DoubleBollinger, EhlersStochastic, Ema,
EmpiricalModeDecomposition, Fama, FibonacciPivots, FisherTransform, FractalChaosBands, Frama,
GarmanKlassVolatility, HeikinAshi, HiLoActivator, HilbertDominantCycle, HurstChannel,
HurstExponent, Ichimoku, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, Kst,
Kurtosis, Kvo, LinRegChannel, MaEnvelope, MacdIndicator, Mama, MarketFacilitationIndex,
McGinleyDynamic, MedianAbsoluteDeviation, Nvi, Obv, ParkinsonVolatility,
PercentageTrailingStop, Pgo, Pvi, RSquared, RenkoTrailingStop, RogersSatchellVolatility,
RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, SineWave, Skewness, Sma, StandardError,
StandardErrorBands, StarcBands, StepTrailingStop, Stochastic, SuperSmoother, TdCombo,
TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei,
TdRiskLevel, TdSequential, TdSetup, Tii, Tsv, TtmSqueeze, Variance, Vidya, VoltyStop,
VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, WilliamsFractals, Wma, WoodiePivots,
YangZhangVolatility, YoyoExit, ZigZag,
};
use wickra_data::csv::CandleReader;
@@ -361,6 +363,30 @@ fn benches(c: &mut Criterion) {
bench_scalar_multi(c, "double_bollinger", &closes, || {
DoubleBollinger::new(20, 1.0, 2.0).unwrap()
});
// --- Family 12: Statistik / Regression ---
bench_scalar(c, "variance", &closes, || Variance::new(20).unwrap());
bench_scalar(c, "coefficient_of_variation", &closes, || {
CoefficientOfVariation::new(20).unwrap()
});
bench_scalar(c, "skewness", &closes, || Skewness::new(20).unwrap());
bench_scalar(c, "kurtosis", &closes, || Kurtosis::new(20).unwrap());
bench_scalar(c, "standard_error", &closes, || {
StandardError::new(14).unwrap()
});
bench_scalar(c, "detrended_std_dev", &closes, || {
DetrendedStdDev::new(14).unwrap()
});
bench_scalar(c, "r_squared", &closes, || RSquared::new(14).unwrap());
bench_scalar(c, "median_absolute_deviation", &closes, || {
MedianAbsoluteDeviation::new(20).unwrap()
});
bench_scalar(c, "autocorrelation", &closes, || {
Autocorrelation::new(20, 1).unwrap()
});
bench_scalar(c, "hurst_exponent", &closes, || {
HurstExponent::new(100, 4).unwrap()
});
}
/// Variant of `bench_scalar` for scalar-input indicators whose output is *not*