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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 (10–100) 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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*
|
||||
|
||||
Reference in New Issue
Block a user